generated from nhcarrigan/template
feat: migrate from github
This commit is contained in:
commit
7437deab71
45
.eslintrc.json
Normal file
45
.eslintrc.json
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"extends": "@nhcarrigan",
|
||||||
|
"rules": {
|
||||||
|
"camelcase": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"allow": [
|
||||||
|
"serverId_userId",
|
||||||
|
"userId_serverId",
|
||||||
|
"pull_request",
|
||||||
|
"issue_number",
|
||||||
|
"issue_comment",
|
||||||
|
"serverId_level_roleId",
|
||||||
|
"serverId_roleId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["src/server/github/*.ts"],
|
||||||
|
"rules": {
|
||||||
|
"camelcase": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"allow": ["icon_url"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"jsdoc/require-jsdoc": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["src/modules/subcommands/config/*.ts"],
|
||||||
|
"rules": {
|
||||||
|
"jsdoc/require-param": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["src/modules/data/*.ts"],
|
||||||
|
"rules": {
|
||||||
|
"require-atomic-updates": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
8
.gitattributes
vendored
Normal file
8
.gitattributes
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# Auto detect text files and perform LF normalization
|
||||||
|
* text eol=LF
|
||||||
|
*.ts text
|
||||||
|
*.spec.ts text
|
||||||
|
|
||||||
|
# Ignore binary files >:(
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
/node_modules/
|
||||||
|
/prod/
|
||||||
|
.env
|
15
.knip.jsonc
Normal file
15
.knip.jsonc
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://unpkg.com/knip@2/schema.json",
|
||||||
|
"entry": [
|
||||||
|
"src/index.ts",
|
||||||
|
/**
|
||||||
|
* Because the commands are dynamically generated, knip can't follow them.
|
||||||
|
* Treat them as entry files to allow for dependency and import/export validation.
|
||||||
|
*/
|
||||||
|
"src/commands/*.ts",
|
||||||
|
"src/contexts/*.ts"
|
||||||
|
],
|
||||||
|
"project": ["src/**/*.ts"],
|
||||||
|
"ignore": [],
|
||||||
|
"ignoreDependencies": []
|
||||||
|
}
|
1
.prettierrc.json
Normal file
1
.prettierrc.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
"@nhcarrigan/prettier-config"
|
3
CODE_OF_CONDUCT.md
Normal file
3
CODE_OF_CONDUCT.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Code of Conduct
|
||||||
|
|
||||||
|
Our Code of Conduct can be found here: https://docs.nhcarrigan.com/#/coc
|
3
CONTRIBUTING.md
Normal file
3
CONTRIBUTING.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Our contributing guidelines can be found here: https://docs.nhcarrigan.com/#/contributing
|
5
LICENSE.md
Normal file
5
LICENSE.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# License
|
||||||
|
|
||||||
|
This software is licensed under our [global software license](https://docs.nhcarrigan.com/#/license).
|
||||||
|
|
||||||
|
Copyright held by Naomi Carrigan.
|
3
PRIVACY.md
Normal file
3
PRIVACY.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Privacy Policy
|
||||||
|
|
||||||
|
Our privacy policy can be found here: https://docs.nhcarrigan.com/#/privacy
|
3
SECURITY.md
Normal file
3
SECURITY.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
Our security policy can be found here: https://docs.nhcarrigan.com/#/security
|
3
TERMS.md
Normal file
3
TERMS.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Terms of Service
|
||||||
|
|
||||||
|
Our Terms of Service can be found here: https://docs.nhcarrigan.com/#/terms
|
47
package.json
Normal file
47
package.json
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "naomis-moderation-bot",
|
||||||
|
"version": "1.2.0",
|
||||||
|
"description": "A public paid moderation bot for Discord.",
|
||||||
|
"main": "prod/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"audit": "knip",
|
||||||
|
"prebuild": "rm -rf prod && prisma generate",
|
||||||
|
"build": "tsc",
|
||||||
|
"lint": "eslint src --max-warnings 0 && prettier src --check",
|
||||||
|
"start": "node -r dotenv/config prod/index.js",
|
||||||
|
"test": "echo 'no tests yet'"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/nhcarrigan/mod-bot.git"
|
||||||
|
},
|
||||||
|
"author": "Naomi Carrigan",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/nhcarrigan/mod-bot/issues"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20",
|
||||||
|
"pnpm": "8"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/nhcarrigan/mod-bot#readme",
|
||||||
|
"devDependencies": {
|
||||||
|
"@nhcarrigan/eslint-config": "3.2.0",
|
||||||
|
"@nhcarrigan/prettier-config": "3.2.0",
|
||||||
|
"@nhcarrigan/typescript-config": "3.0.0",
|
||||||
|
"@types/express": "4.17.21",
|
||||||
|
"eslint": "8.57.0",
|
||||||
|
"knip": "5.15.0",
|
||||||
|
"prettier": "3.2.5",
|
||||||
|
"prisma": "5.13.0",
|
||||||
|
"typescript": "5.4.5"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@octokit/rest": "20.1.1",
|
||||||
|
"@prisma/client": "5.13.0",
|
||||||
|
"discord.js": "14.15.2",
|
||||||
|
"dotenv": "16.4.5",
|
||||||
|
"express": "4.19.2",
|
||||||
|
"node-html-to-image": "4.0.0",
|
||||||
|
"winston": "3.13.0"
|
||||||
|
}
|
||||||
|
}
|
4117
pnpm-lock.yaml
generated
Normal file
4117
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
81
prisma/schema.prisma
Normal file
81
prisma/schema.prisma
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "mongodb"
|
||||||
|
url = env("MONGO_URI")
|
||||||
|
}
|
||||||
|
|
||||||
|
model cases {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
serverId String
|
||||||
|
userId String
|
||||||
|
number Int
|
||||||
|
action String
|
||||||
|
reason String
|
||||||
|
evidence String[]
|
||||||
|
timestamp String
|
||||||
|
moderator String
|
||||||
|
|
||||||
|
@@unique([serverId, number])
|
||||||
|
@@index([serverId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model levelRoles {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
serverId String
|
||||||
|
level Int
|
||||||
|
roleId String
|
||||||
|
|
||||||
|
@@unique([serverId, level, roleId])
|
||||||
|
@@index([serverId], map: "serverId")
|
||||||
|
}
|
||||||
|
|
||||||
|
model levels {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
serverId String
|
||||||
|
userId String
|
||||||
|
points Int @default(0)
|
||||||
|
level Int @default(0)
|
||||||
|
username String
|
||||||
|
avatar String
|
||||||
|
backgroundColour String @default("000000")
|
||||||
|
colour String @default("ffffff")
|
||||||
|
backgroundImage String @default("https://cdn.nhcarrigan.com/banner.png")
|
||||||
|
cooldown DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([serverId, userId], map: "serverId_userId")
|
||||||
|
}
|
||||||
|
|
||||||
|
model configs {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
serverId String
|
||||||
|
inviteLink String @default("")
|
||||||
|
banAppealLink String @default("")
|
||||||
|
modLogChannel String @default("")
|
||||||
|
eventLogChannel String @default("")
|
||||||
|
messageReportChannel String @default("")
|
||||||
|
|
||||||
|
@@unique([serverId], map: "serverId")
|
||||||
|
}
|
||||||
|
|
||||||
|
model entitlements {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
serverId String
|
||||||
|
purchaserId String
|
||||||
|
notes String @default("")
|
||||||
|
|
||||||
|
@@unique([serverId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model roles {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
serverId String
|
||||||
|
roleId String
|
||||||
|
|
||||||
|
@@unique([serverId, roleId], map: "serverId_roleId")
|
||||||
|
}
|
11
sample.env
Normal file
11
sample.env
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
## Global Values
|
||||||
|
BOT_TOKEN=-""
|
||||||
|
MONGO_URI=""
|
||||||
|
DEBUG_HOOK=""
|
||||||
|
NODE_ENV="development"
|
||||||
|
|
||||||
|
## Server Shit
|
||||||
|
GITHUB_WEBHOOK_SECRET=""
|
||||||
|
PATREON_WEBHOOK_SECRET=""
|
||||||
|
KOFI_WEBHOOK_SECRET=""
|
||||||
|
GITHUB_TOKEN=""
|
99
src/commands/ban.ts
Normal file
99
src/commands/ban.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import {
|
||||||
|
GuildMember,
|
||||||
|
PermissionFlagsBits,
|
||||||
|
SlashCommandBuilder
|
||||||
|
} from "discord.js";
|
||||||
|
|
||||||
|
import { Command } from "../interfaces/Command";
|
||||||
|
import { errorHandler } from "../utils/errorHandler";
|
||||||
|
import { isModerator } from "../utils/isModerator";
|
||||||
|
import { processModAction } from "../utils/processModAction";
|
||||||
|
|
||||||
|
export const ban: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("ban")
|
||||||
|
.setDescription("Ban a user from the server.")
|
||||||
|
.addUserOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("user")
|
||||||
|
.setDescription("The user to ban.")
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("reason")
|
||||||
|
.setDescription("The reason for banning.")
|
||||||
|
.setRequired(true)
|
||||||
|
.setMinLength(1)
|
||||||
|
.setMaxLength(400)
|
||||||
|
)
|
||||||
|
.addIntegerOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("prune")
|
||||||
|
.setDescription("Number of days to prune messages.")
|
||||||
|
.setMinValue(0)
|
||||||
|
.setMaxValue(7)
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("evidence")
|
||||||
|
.setDescription(
|
||||||
|
"A link to the evidence for the ban. For multiple links, separate with a space."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
run: async (bot, interaction) => {
|
||||||
|
try {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
const { member, guild } = interaction;
|
||||||
|
|
||||||
|
if (!member || !guild) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "There was an error loading the guild and member data."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!(member as GuildMember).permissions.has(PermissionFlagsBits.BanMembers)
|
||||||
|
) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "You do not have permission to run this command."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = interaction.options.getString("reason", true);
|
||||||
|
const evidence =
|
||||||
|
interaction.options.getString("evidence")?.split(/\s+/) || [];
|
||||||
|
const prune = interaction.options.getInteger("prune") || 0;
|
||||||
|
const user = interaction.options.getUser("user", true);
|
||||||
|
const target =
|
||||||
|
guild.members.cache.get(user.id) ||
|
||||||
|
(await guild.members.fetch(user.id).catch(() => null));
|
||||||
|
|
||||||
|
if (target && isModerator(target)) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "You cannot ban a moderator."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await processModAction(
|
||||||
|
bot,
|
||||||
|
interaction,
|
||||||
|
guild,
|
||||||
|
user,
|
||||||
|
"ban",
|
||||||
|
reason,
|
||||||
|
evidence,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
prune
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "ban command", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
90
src/commands/cases.ts
Normal file
90
src/commands/cases.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { GuildMember, EmbedBuilder, SlashCommandBuilder } from "discord.js";
|
||||||
|
|
||||||
|
import { Command } from "../interfaces/Command";
|
||||||
|
import { customSubstring } from "../utils/customSubstring";
|
||||||
|
import { errorHandler } from "../utils/errorHandler";
|
||||||
|
import { isModerator } from "../utils/isModerator";
|
||||||
|
|
||||||
|
export const cases: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("case")
|
||||||
|
.setDescription("View a specific moderation case.")
|
||||||
|
.addIntegerOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("number")
|
||||||
|
.setDescription("The case number to view.")
|
||||||
|
.setRequired(true)
|
||||||
|
),
|
||||||
|
run: async (bot, interaction) => {
|
||||||
|
try {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
const { member, guild } = interaction;
|
||||||
|
if (!member || !guild) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "There was an error loading the guild and member data."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isModerator(member as GuildMember)) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "You do not have permission to run this command."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = interaction.options.getUser("user", true);
|
||||||
|
const number = interaction.options.getInteger("number", true);
|
||||||
|
|
||||||
|
const requestedCase = await bot.db.cases.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: target.id,
|
||||||
|
serverId: guild.id,
|
||||||
|
number
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!requestedCase) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "That user doesn't seem to have a moderation history yet."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewEmbed = new EmbedBuilder();
|
||||||
|
viewEmbed.setTitle(
|
||||||
|
`Case ${requestedCase.number} - ${requestedCase.action}`
|
||||||
|
);
|
||||||
|
viewEmbed.setAuthor({
|
||||||
|
name: target.tag,
|
||||||
|
iconURL: target.displayAvatarURL()
|
||||||
|
});
|
||||||
|
viewEmbed.setDescription(customSubstring(requestedCase.reason, 4000));
|
||||||
|
viewEmbed.addFields(
|
||||||
|
{
|
||||||
|
name: "Evidence",
|
||||||
|
value:
|
||||||
|
customSubstring(requestedCase.evidence.join("\n"), 2000) ||
|
||||||
|
"No evidence provided"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Date",
|
||||||
|
value: requestedCase.timestamp
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Moderator",
|
||||||
|
value: requestedCase.moderator
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [viewEmbed]
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "cases command", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
136
src/commands/config.ts
Normal file
136
src/commands/config.ts
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import {
|
||||||
|
SlashCommandBuilder,
|
||||||
|
SlashCommandSubcommandBuilder,
|
||||||
|
Guild,
|
||||||
|
GuildMember,
|
||||||
|
PermissionFlagsBits
|
||||||
|
} from "discord.js";
|
||||||
|
|
||||||
|
import { logChannelChoices } from "../config/LogChannelChoices";
|
||||||
|
import { Command } from "../interfaces/Command";
|
||||||
|
import { getConfig } from "../modules/data/getConfig";
|
||||||
|
import { handleAppealLink } from "../modules/subcommands/config/handleAppealLink";
|
||||||
|
import { handleInviteLink } from "../modules/subcommands/config/handleInviteLink";
|
||||||
|
import { handleList } from "../modules/subcommands/config/handleList";
|
||||||
|
import { handleLogging } from "../modules/subcommands/config/handleLogging";
|
||||||
|
import { handleRole } from "../modules/subcommands/config/handleRole";
|
||||||
|
import { errorHandler } from "../utils/errorHandler";
|
||||||
|
|
||||||
|
export const config: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("config")
|
||||||
|
.setDescription("Modify the config settings.")
|
||||||
|
.addSubcommand(
|
||||||
|
new SlashCommandSubcommandBuilder()
|
||||||
|
.setName("list")
|
||||||
|
.setDescription("List your server's current config settings")
|
||||||
|
)
|
||||||
|
.addSubcommand(
|
||||||
|
new SlashCommandSubcommandBuilder()
|
||||||
|
.setName("invite-link")
|
||||||
|
.setDescription(
|
||||||
|
"Set the link to be sent to someone to rejoin the server after they are kicked."
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setRequired(true)
|
||||||
|
.setName("link")
|
||||||
|
.setDescription("The invite link to send.")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(
|
||||||
|
new SlashCommandSubcommandBuilder()
|
||||||
|
.setName("appeal-link")
|
||||||
|
.setDescription(
|
||||||
|
"Set the link to be sent to someone when they are banned to appeal the decision."
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setRequired(true)
|
||||||
|
.setName("link")
|
||||||
|
.setDescription("The appeal link to send.")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(
|
||||||
|
new SlashCommandSubcommandBuilder()
|
||||||
|
.setName("logging")
|
||||||
|
.setDescription("Configure a logging channel.")
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("log-type")
|
||||||
|
.setDescription("The type of log to configure.")
|
||||||
|
.addChoices(...logChannelChoices)
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
.addChannelOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("channel")
|
||||||
|
.setDescription("The channel to log to.")
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(
|
||||||
|
new SlashCommandSubcommandBuilder()
|
||||||
|
.setName("roles")
|
||||||
|
.setDescription("Toggle roles to be self-assignable by users.")
|
||||||
|
.addRoleOption((o) =>
|
||||||
|
o
|
||||||
|
.setName("role")
|
||||||
|
.setDescription("The role to toggle.")
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
run: async (bot, interaction) => {
|
||||||
|
try {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
|
||||||
|
const member = interaction.member as GuildMember;
|
||||||
|
const guild = interaction.guild as Guild;
|
||||||
|
|
||||||
|
if (!member || !guild) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "You must be in a server to use this command."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getConfig(bot, guild.id);
|
||||||
|
|
||||||
|
if (!member.permissions.has(PermissionFlagsBits.ManageGuild)) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "You do not have permission to use this command."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
switch (subcommand) {
|
||||||
|
case "list":
|
||||||
|
await handleList(bot, interaction, config);
|
||||||
|
break;
|
||||||
|
case "logging":
|
||||||
|
await handleLogging(bot, interaction, config);
|
||||||
|
break;
|
||||||
|
case "invite-link":
|
||||||
|
await handleInviteLink(bot, interaction, config);
|
||||||
|
break;
|
||||||
|
case "appeal-link":
|
||||||
|
await handleAppealLink(bot, interaction, config);
|
||||||
|
break;
|
||||||
|
case "roles":
|
||||||
|
await handleRole(bot, interaction, config);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "This is an invalid subcommand. Please contact Naomi."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "config command", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
88
src/commands/help.ts
Normal file
88
src/commands/help.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { execSync } from "child_process";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
EmbedBuilder,
|
||||||
|
SlashCommandBuilder
|
||||||
|
} from "discord.js";
|
||||||
|
|
||||||
|
import { Command } from "../interfaces/Command";
|
||||||
|
import { checkEntitledGuild } from "../utils/checkEntitledGuild";
|
||||||
|
import { errorHandler } from "../utils/errorHandler";
|
||||||
|
|
||||||
|
export const help: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("help")
|
||||||
|
.setDMPermission(false)
|
||||||
|
.setDescription("Get help with the bot."),
|
||||||
|
run: async (bot, interaction) => {
|
||||||
|
try {
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
const version = process.env.npm_package_version;
|
||||||
|
const commit = execSync("git rev-parse HEAD").toString().trim();
|
||||||
|
const subscribed = await checkEntitledGuild(bot, interaction.guild);
|
||||||
|
|
||||||
|
const servers = bot.guilds.cache.size;
|
||||||
|
const members = bot.guilds.cache.reduce(
|
||||||
|
(sum, guild) => sum + guild.memberCount,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder();
|
||||||
|
embed.setTitle("Naomi's Moderation Bot");
|
||||||
|
embed.setDescription(
|
||||||
|
"This is a highly focused moderation bot designed to deliver the best experience when it comes to keeping your community safe and welcoming. To ensure we are able to deliver the features our users require, this bot is only available through a $5/month subscription."
|
||||||
|
);
|
||||||
|
embed.addFields(
|
||||||
|
{
|
||||||
|
name: "Version",
|
||||||
|
value: version ? `v${version}` : "unable to parse version",
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Current Commit",
|
||||||
|
value: `[${commit.slice(
|
||||||
|
0,
|
||||||
|
7
|
||||||
|
)}](https://github.com/nhcarrigan/mod-bot/commit/${commit})`,
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Is this server subscribed?",
|
||||||
|
value: subscribed ? "Yes!" : "No :c",
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Details",
|
||||||
|
value: `Currently protecting ${servers} servers and watching over ${members} users.`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const supportButton = new ButtonBuilder()
|
||||||
|
.setStyle(ButtonStyle.Link)
|
||||||
|
.setURL("https://chat.naomi.lgbt")
|
||||||
|
.setLabel("Join our Support Server");
|
||||||
|
const subscribeButton = new ButtonBuilder()
|
||||||
|
.setStyle(ButtonStyle.Link)
|
||||||
|
.setURL("https://docs.nhcarrigan.com/#/donate")
|
||||||
|
.setLabel("Subscribe for Access");
|
||||||
|
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
supportButton,
|
||||||
|
subscribeButton
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [embed],
|
||||||
|
components: [row]
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "help command", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
198
src/commands/history.ts
Normal file
198
src/commands/history.ts
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import {
|
||||||
|
GuildMember,
|
||||||
|
EmbedBuilder,
|
||||||
|
SlashCommandBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ActionRowBuilder,
|
||||||
|
ComponentType,
|
||||||
|
ButtonStyle
|
||||||
|
} from "discord.js";
|
||||||
|
|
||||||
|
import { Command } from "../interfaces/Command";
|
||||||
|
import { errorHandler } from "../utils/errorHandler";
|
||||||
|
import { getNextIndex, getPreviousIndex } from "../utils/getArrayIndex";
|
||||||
|
import { isModerator } from "../utils/isModerator";
|
||||||
|
|
||||||
|
export const history: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("history")
|
||||||
|
.setDescription("View a user's history.")
|
||||||
|
.addUserOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("user")
|
||||||
|
.setDescription("The user to view the history for.")
|
||||||
|
.setRequired(true)
|
||||||
|
),
|
||||||
|
run: async (bot, interaction) => {
|
||||||
|
try {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
const { member, guild } = interaction;
|
||||||
|
if (!member || !guild) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "There was an error loading the guild and member data."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isModerator(member as GuildMember)) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "You do not have permission to run this command."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = interaction.options.getUser("user", true);
|
||||||
|
|
||||||
|
const cases = await bot.db.cases.findMany({
|
||||||
|
where: {
|
||||||
|
userId: target.id,
|
||||||
|
serverId: guild.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!cases.length) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "That user is squeaky clean!"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const caseNumbers = cases
|
||||||
|
.filter((c) => c.action !== "note")
|
||||||
|
.map((c) => `**#${c.number} - ${c.action}**`);
|
||||||
|
const noteNumbers = cases
|
||||||
|
.filter((c) => c.action === "note")
|
||||||
|
.map((c) => `**#${c.number} - ${c.action}**`);
|
||||||
|
``;
|
||||||
|
|
||||||
|
const historyEmbed = new EmbedBuilder();
|
||||||
|
historyEmbed.setTitle(`${target.tag}'s history`);
|
||||||
|
historyEmbed.addFields(
|
||||||
|
{
|
||||||
|
name: "Bans",
|
||||||
|
value: String(cases.filter((c) => c.action === "ban").length) || "0",
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Unbans",
|
||||||
|
value:
|
||||||
|
String(cases.filter((c) => c.action === "unban").length) || "0",
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Softbans",
|
||||||
|
value:
|
||||||
|
String(cases.filter((c) => c.action === "softban").length) || "0",
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Kicks",
|
||||||
|
value: String(cases.filter((c) => c.action === "kick").length) || "0",
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mutes",
|
||||||
|
value: String(cases.filter((c) => c.action === "mute").length) || "0",
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Unmutes",
|
||||||
|
value:
|
||||||
|
String(cases.filter((c) => c.action === "unmute").length) || "0",
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Warns",
|
||||||
|
value: String(cases.filter((c) => c.action === "warn").length) || "0",
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Notes",
|
||||||
|
value: String(cases.filter((c) => c.action === "note").length) || "0",
|
||||||
|
inline: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const embeds = [historyEmbed];
|
||||||
|
|
||||||
|
if (caseNumbers.length) {
|
||||||
|
const manualEmbed = new EmbedBuilder()
|
||||||
|
.setTitle("Manual Cases")
|
||||||
|
.setDescription(caseNumbers.join(", "));
|
||||||
|
embeds.push(manualEmbed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (noteNumbers.length) {
|
||||||
|
const noteEmbed = new EmbedBuilder()
|
||||||
|
.setTitle("Notes")
|
||||||
|
.setDescription(noteNumbers.join(", "));
|
||||||
|
embeds.push(noteEmbed);
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
const nextButton = new ButtonBuilder()
|
||||||
|
.setCustomId("next")
|
||||||
|
.setStyle(ButtonStyle.Primary)
|
||||||
|
.setLabel(
|
||||||
|
embeds[getNextIndex(embeds, index)]?.data.title || "Unknown embed."
|
||||||
|
)
|
||||||
|
.setEmoji("▶️");
|
||||||
|
const prevButton = new ButtonBuilder()
|
||||||
|
.setCustomId("prev")
|
||||||
|
.setStyle(ButtonStyle.Primary)
|
||||||
|
.setLabel(
|
||||||
|
embeds[getPreviousIndex(embeds, index)]?.data.title ||
|
||||||
|
"Unknown embed."
|
||||||
|
)
|
||||||
|
.setEmoji("◀️");
|
||||||
|
const initialRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
prevButton,
|
||||||
|
nextButton
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await interaction.editReply({
|
||||||
|
embeds: [embeds[index] as EmbedBuilder],
|
||||||
|
components: [initialRow]
|
||||||
|
});
|
||||||
|
|
||||||
|
const collector =
|
||||||
|
response.createMessageComponentCollector<ComponentType.Button>({
|
||||||
|
time: 1000 * 60 * 5
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on("collect", async (i) => {
|
||||||
|
await i.deferUpdate();
|
||||||
|
index =
|
||||||
|
i.customId === "next"
|
||||||
|
? getNextIndex(embeds, index)
|
||||||
|
: getPreviousIndex(embeds, index);
|
||||||
|
prevButton.setLabel(
|
||||||
|
embeds[getPreviousIndex(embeds, index)]?.data.title ||
|
||||||
|
"Unknown embed."
|
||||||
|
);
|
||||||
|
nextButton.setLabel(
|
||||||
|
embeds[getNextIndex(embeds, index)]?.data.title || "Unknown embed."
|
||||||
|
);
|
||||||
|
const newRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
prevButton,
|
||||||
|
nextButton
|
||||||
|
);
|
||||||
|
await i.editReply({
|
||||||
|
embeds: [embeds[index] as EmbedBuilder],
|
||||||
|
components: [newRow]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on("end", async () => {
|
||||||
|
await interaction.editReply({
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "history command", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
145
src/commands/kick.ts
Normal file
145
src/commands/kick.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import {
|
||||||
|
GuildMember,
|
||||||
|
Message,
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
PermissionFlagsBits,
|
||||||
|
ButtonStyle,
|
||||||
|
ComponentType,
|
||||||
|
SlashCommandBuilder
|
||||||
|
} from "discord.js";
|
||||||
|
|
||||||
|
import { Command } from "../interfaces/Command";
|
||||||
|
import { errorHandler } from "../utils/errorHandler";
|
||||||
|
import { isModerator } from "../utils/isModerator";
|
||||||
|
import { processModAction } from "../utils/processModAction";
|
||||||
|
|
||||||
|
export const kick: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("kick")
|
||||||
|
.setDescription("Kick a user from the server.")
|
||||||
|
.addUserOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("user")
|
||||||
|
.setDescription("The user to kick.")
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("reason")
|
||||||
|
.setDescription("The reason for kicking.")
|
||||||
|
.setRequired(true)
|
||||||
|
.setMinLength(1)
|
||||||
|
.setMaxLength(400)
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("evidence")
|
||||||
|
.setDescription(
|
||||||
|
"A link to the evidence for the kick. For multiple links, separate with a space."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
run: async (bot, interaction) => {
|
||||||
|
try {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
const { member, guild } = interaction;
|
||||||
|
|
||||||
|
if (!member || !guild) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "There was an error loading the guild and member data."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!(member as GuildMember).permissions.has(
|
||||||
|
PermissionFlagsBits.KickMembers
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "You do not have permission to run this command."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = interaction.options.getString("reason", true);
|
||||||
|
const evidence =
|
||||||
|
interaction.options.getString("evidence")?.split(/\s+/) || [];
|
||||||
|
const user = interaction.options.getUser("user", true);
|
||||||
|
const target =
|
||||||
|
guild.members.cache.get(user.id) ||
|
||||||
|
(await guild.members.fetch(user.id).catch(() => null));
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `${user.tag} is not in this server and thus cannot be kicked.`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isModerator(target)) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "You cannot kick a moderator."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const yes = new ButtonBuilder()
|
||||||
|
.setCustomId("confirm")
|
||||||
|
.setLabel("Confirm")
|
||||||
|
.setStyle(ButtonStyle.Success);
|
||||||
|
const no = new ButtonBuilder()
|
||||||
|
.setCustomId("cancel")
|
||||||
|
.setLabel("Cancel")
|
||||||
|
.setStyle(ButtonStyle.Danger);
|
||||||
|
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(yes, no);
|
||||||
|
const response = (await interaction.editReply({
|
||||||
|
content: `Are you sure you want to kick <@!${user.id}>?`,
|
||||||
|
components: [row]
|
||||||
|
})) as Message;
|
||||||
|
|
||||||
|
const collector =
|
||||||
|
response.createMessageComponentCollector<ComponentType.Button>({
|
||||||
|
filter: (click) => click.user.id === interaction.user.id,
|
||||||
|
time: 10000,
|
||||||
|
max: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on("end", async (clicks) => {
|
||||||
|
const choice = clicks.first()?.customId;
|
||||||
|
if (!clicks || clicks.size <= 0 || !choice) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "This command has timed out.",
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (choice === "confirm") {
|
||||||
|
await processModAction(
|
||||||
|
bot,
|
||||||
|
interaction,
|
||||||
|
guild,
|
||||||
|
user,
|
||||||
|
"kick",
|
||||||
|
reason,
|
||||||
|
evidence
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (choice === "cancel") {
|
||||||
|
interaction.editReply({
|
||||||
|
content: "Kick cancelled.",
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "kick command", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
156
src/commands/leaderboard.ts
Normal file
156
src/commands/leaderboard.ts
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import {
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
ComponentType,
|
||||||
|
SlashCommandBuilder
|
||||||
|
} from "discord.js";
|
||||||
|
|
||||||
|
import { Command } from "../interfaces/Command";
|
||||||
|
import { generateLeaderboardImage } from "../modules/commands/generateProfileImage";
|
||||||
|
import { errorHandler } from "../utils/errorHandler";
|
||||||
|
|
||||||
|
export const leaderboard: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("leaderboard")
|
||||||
|
.setDescription("See the levels for this community.")
|
||||||
|
.setDMPermission(false),
|
||||||
|
run: async (bot, interaction) => {
|
||||||
|
try {
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
const levels = await bot.db.levels.findMany({
|
||||||
|
where: {
|
||||||
|
serverId: interaction.guild.id
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
points: "desc"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapped = levels.map((user, index) => ({
|
||||||
|
...user,
|
||||||
|
index: index + 1
|
||||||
|
}));
|
||||||
|
|
||||||
|
let page = 1;
|
||||||
|
const lastPage = Math.ceil(mapped.length / 10);
|
||||||
|
|
||||||
|
const pageBack = new ButtonBuilder()
|
||||||
|
.setCustomId("prev")
|
||||||
|
.setDisabled(true)
|
||||||
|
.setLabel("◀")
|
||||||
|
.setStyle(ButtonStyle.Primary);
|
||||||
|
const pageForward = new ButtonBuilder()
|
||||||
|
.setCustomId("next")
|
||||||
|
.setLabel("▶")
|
||||||
|
.setStyle(ButtonStyle.Primary);
|
||||||
|
|
||||||
|
if (page <= 1) {
|
||||||
|
pageBack.setDisabled(true);
|
||||||
|
} else {
|
||||||
|
pageBack.setDisabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page >= lastPage) {
|
||||||
|
pageForward.setDisabled(true);
|
||||||
|
} else {
|
||||||
|
pageForward.setDisabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachment = await generateLeaderboardImage(
|
||||||
|
bot,
|
||||||
|
mapped.slice(page * 10 - 10, page * 10)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!attachment) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "Failed to load leaderboard image.",
|
||||||
|
files: [],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sent = await interaction.editReply({
|
||||||
|
files: [attachment],
|
||||||
|
components: [
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
pageBack,
|
||||||
|
pageForward
|
||||||
|
)
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const clickyClick =
|
||||||
|
sent.createMessageComponentCollector<ComponentType.Button>({
|
||||||
|
time: 300000,
|
||||||
|
filter: (click) => click.user.id === interaction.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
clickyClick.on("collect", async (click) => {
|
||||||
|
await click.deferUpdate();
|
||||||
|
if (click.customId === "prev") {
|
||||||
|
page--;
|
||||||
|
}
|
||||||
|
if (click.customId === "next") {
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page <= 1) {
|
||||||
|
pageBack.setDisabled(true);
|
||||||
|
} else {
|
||||||
|
pageBack.setDisabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page >= lastPage) {
|
||||||
|
pageForward.setDisabled(true);
|
||||||
|
} else {
|
||||||
|
pageForward.setDisabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachment = await generateLeaderboardImage(
|
||||||
|
bot,
|
||||||
|
mapped.slice(page * 10 - 10, page * 10)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!attachment) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "Failed to load leaderboard image.",
|
||||||
|
files: [],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
files: [attachment],
|
||||||
|
components: [
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
pageBack,
|
||||||
|
pageForward
|
||||||
|
)
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
clickyClick.on("end", async () => {
|
||||||
|
pageBack.setDisabled(true);
|
||||||
|
pageForward.setDisabled(true);
|
||||||
|
await interaction.editReply({
|
||||||
|
components: [
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
pageBack,
|
||||||
|
pageForward
|
||||||
|
)
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "leaderboard subcommand", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
106
src/commands/levelRoles.ts
Normal file
106
src/commands/levelRoles.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import {
|
||||||
|
PermissionFlagsBits,
|
||||||
|
SlashCommandBuilder,
|
||||||
|
SlashCommandSubcommandBuilder
|
||||||
|
} from "discord.js";
|
||||||
|
|
||||||
|
import { Command } from "../interfaces/Command";
|
||||||
|
import { errorHandler } from "../utils/errorHandler";
|
||||||
|
|
||||||
|
export const levelRoles: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("level-role")
|
||||||
|
.setDescription("Manage level roles.")
|
||||||
|
.setDMPermission(false)
|
||||||
|
.addSubcommand(
|
||||||
|
new SlashCommandSubcommandBuilder()
|
||||||
|
.setName("create")
|
||||||
|
.setDescription("Create a new level role.")
|
||||||
|
.addRoleOption((o) =>
|
||||||
|
o
|
||||||
|
.setName("role")
|
||||||
|
.setDescription("The role to assign")
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
.addIntegerOption((o) =>
|
||||||
|
o
|
||||||
|
.setName("level")
|
||||||
|
.setDescription("The level at which to assign the role.")
|
||||||
|
.setRequired(true)
|
||||||
|
.setMinValue(1)
|
||||||
|
.setMaxValue(1000)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(
|
||||||
|
new SlashCommandSubcommandBuilder()
|
||||||
|
.setName("delete")
|
||||||
|
.setDescription("Delete a level role.")
|
||||||
|
.addRoleOption((o) =>
|
||||||
|
o
|
||||||
|
.setName("role")
|
||||||
|
.setDescription("The role to remove")
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
.addIntegerOption((o) =>
|
||||||
|
o
|
||||||
|
.setName("level")
|
||||||
|
.setDescription("The level at which the role was being assigned")
|
||||||
|
.setRequired(true)
|
||||||
|
.setMinValue(1)
|
||||||
|
.setMaxValue(1000)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
run: async (bot, interaction) => {
|
||||||
|
try {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
const { member } = interaction;
|
||||||
|
|
||||||
|
if (!member.permissions.has(PermissionFlagsBits.ManageRoles)) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "You do not have permission to run this command."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const role = interaction.options.getRole("role", true);
|
||||||
|
const level = interaction.options.getInteger("level", true);
|
||||||
|
const action = interaction.options.getSubcommand(true);
|
||||||
|
|
||||||
|
let success = false;
|
||||||
|
if (action === "create") {
|
||||||
|
success = !!(await bot.db.levelRoles
|
||||||
|
.create({
|
||||||
|
data: {
|
||||||
|
serverId: interaction.guild.id,
|
||||||
|
roleId: role.id,
|
||||||
|
level
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => null));
|
||||||
|
}
|
||||||
|
if (action === "delete") {
|
||||||
|
success = !!(await bot.db.levelRoles
|
||||||
|
.delete({
|
||||||
|
where: {
|
||||||
|
serverId_level_roleId: {
|
||||||
|
serverId: interaction.guild.id,
|
||||||
|
roleId: role.id,
|
||||||
|
level
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => null));
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
content: success
|
||||||
|
? `Successfully ${action}ed your level ${level} ${role} assignment.`
|
||||||
|
: `Failed to ${action} your level ${level} ${role} assignment.`
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "level roles command", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
98
src/commands/lockdown.ts
Normal file
98
src/commands/lockdown.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import {
|
||||||
|
ChannelType,
|
||||||
|
EmbedBuilder,
|
||||||
|
GuildMember,
|
||||||
|
PermissionFlagsBits,
|
||||||
|
SlashCommandBuilder,
|
||||||
|
TextChannel
|
||||||
|
} from "discord.js";
|
||||||
|
|
||||||
|
import { Command } from "../interfaces/Command";
|
||||||
|
import { getConfig } from "../modules/data/getConfig";
|
||||||
|
import { errorHandler } from "../utils/errorHandler";
|
||||||
|
|
||||||
|
export const lockdown: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("lockdown")
|
||||||
|
.setDescription("Lock down a channel.")
|
||||||
|
.addChannelOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("channel")
|
||||||
|
.setDescription("The channel to lock down.")
|
||||||
|
.setRequired(true)
|
||||||
|
.addChannelTypes(ChannelType.GuildText)
|
||||||
|
),
|
||||||
|
run: async (bot, interaction) => {
|
||||||
|
try {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
const { member, guild } = interaction;
|
||||||
|
|
||||||
|
if (!member || !guild) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "You must be in a guild to use this command."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!(member as GuildMember).permissions.has(
|
||||||
|
PermissionFlagsBits.ManageChannels
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "You do not have permission to run this command."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = interaction.options.getChannel(
|
||||||
|
"channel",
|
||||||
|
true
|
||||||
|
) as TextChannel;
|
||||||
|
|
||||||
|
if (!("send" in channel)) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "You must use this command to target a text based channel."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await channel.permissionOverwrites.edit(
|
||||||
|
guild.id,
|
||||||
|
{
|
||||||
|
SendMessages: false
|
||||||
|
},
|
||||||
|
{ reason: `Lockdown Performed by ${interaction.user.tag}` }
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = await getConfig(bot, guild.id);
|
||||||
|
if (!config.modLogChannel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const logChannel =
|
||||||
|
guild.channels.cache.get(config.modLogChannel) ||
|
||||||
|
(await guild.channels.fetch(config.modLogChannel));
|
||||||
|
|
||||||
|
if (!logChannel || !("send" in logChannel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const embed = new EmbedBuilder();
|
||||||
|
embed.setTitle("Channel Locked Down");
|
||||||
|
embed.setDescription(
|
||||||
|
`The <#${channel.id}> channel has been locked down.`
|
||||||
|
);
|
||||||
|
embed.setAuthor({
|
||||||
|
name: interaction.user.tag,
|
||||||
|
iconURL: interaction.user.displayAvatarURL()
|
||||||
|
});
|
||||||
|
await logChannel.send({ embeds: [embed] });
|
||||||
|
|
||||||
|
await interaction.editReply({ content: "Channel has been locked down!" });
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "lockdown", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
49
src/commands/massBan.ts
Normal file
49
src/commands/massBan.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import {
|
||||||
|
ActionRowBuilder,
|
||||||
|
ModalBuilder,
|
||||||
|
SlashCommandBuilder,
|
||||||
|
TextInputBuilder,
|
||||||
|
TextInputStyle
|
||||||
|
} from "discord.js";
|
||||||
|
|
||||||
|
import { Command } from "../interfaces/Command";
|
||||||
|
import { errorHandler } from "../utils/errorHandler";
|
||||||
|
|
||||||
|
export const massBan: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("massban")
|
||||||
|
.setDescription("Ban a list of user IDs at once."),
|
||||||
|
run: async (bot, interaction) => {
|
||||||
|
try {
|
||||||
|
const textInput = new TextInputBuilder()
|
||||||
|
.setCustomId("mass-ban-ids")
|
||||||
|
.setLabel("Input your list of IDs separated by new lines")
|
||||||
|
.setMaxLength(4000)
|
||||||
|
.setStyle(TextInputStyle.Paragraph)
|
||||||
|
.setRequired(true);
|
||||||
|
const reasonInput = new TextInputBuilder()
|
||||||
|
.setCustomId("reason")
|
||||||
|
.setLabel("Reason for the mass ban?")
|
||||||
|
.setStyle(TextInputStyle.Short)
|
||||||
|
.setRequired(true);
|
||||||
|
const inputRow = new ActionRowBuilder<TextInputBuilder>().addComponents(
|
||||||
|
textInput
|
||||||
|
);
|
||||||
|
const reasonRow = new ActionRowBuilder<TextInputBuilder>().addComponents(
|
||||||
|
reasonInput
|
||||||
|
);
|
||||||
|
const modal = new ModalBuilder()
|
||||||
|
.setCustomId("mass-ban-modal")
|
||||||
|
.setTitle("Mass Ban")
|
||||||
|
.addComponents(inputRow, reasonRow);
|
||||||
|
|
||||||
|
await interaction.showModal(modal);
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "mass ban", err);
|
||||||
|
await interaction.reply({
|
||||||
|
ephemeral: true,
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
122
src/commands/mute.ts
Normal file
122
src/commands/mute.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import {
|
||||||
|
GuildMember,
|
||||||
|
PermissionFlagsBits,
|
||||||
|
SlashCommandBuilder
|
||||||
|
} from "discord.js";
|
||||||
|
|
||||||
|
import { Command } from "../interfaces/Command";
|
||||||
|
import { errorHandler } from "../utils/errorHandler";
|
||||||
|
import { isModerator } from "../utils/isModerator";
|
||||||
|
import { processModAction } from "../utils/processModAction";
|
||||||
|
|
||||||
|
export const mute: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("mute")
|
||||||
|
.setDescription("Mute a member")
|
||||||
|
.addUserOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("user")
|
||||||
|
.setDescription("The user to mute.")
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
.addIntegerOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("duration")
|
||||||
|
.setDescription("The duration of the mute.")
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("duration-unit")
|
||||||
|
.setDescription("The unit for the duration value")
|
||||||
|
.setRequired(true)
|
||||||
|
.addChoices(
|
||||||
|
{ name: "Minutes", value: "minutes" },
|
||||||
|
{ name: "Hours", value: "hours" },
|
||||||
|
{ name: "Days", value: "days" },
|
||||||
|
{ name: "Weeks", value: "weeks" }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("reason")
|
||||||
|
.setDescription("The reason for the mute.")
|
||||||
|
.setRequired(true)
|
||||||
|
.setMinLength(1)
|
||||||
|
.setMaxLength(400)
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("evidence")
|
||||||
|
.setDescription(
|
||||||
|
"A link to the evidence for the mute. For multiple links, separate with a space."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
run: async (bot, interaction) => {
|
||||||
|
try {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
const { member, guild } = interaction;
|
||||||
|
|
||||||
|
if (!member || !guild) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "There was an error loading the guild and member data."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!(member as GuildMember).permissions.has(
|
||||||
|
PermissionFlagsBits.ModerateMembers
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "You do not have permission to run this command."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = interaction.options.getUser("user", true);
|
||||||
|
const target =
|
||||||
|
guild.members.cache.get(user.id) ||
|
||||||
|
(await guild.members.fetch(user.id).catch(() => null));
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "That member appears to have left the server."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isModerator(target)) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "You cannot mute a moderator."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = interaction.options.getInteger("duration", true);
|
||||||
|
const durationUnit = interaction.options.getString("duration-unit", true);
|
||||||
|
|
||||||
|
const reason = interaction.options.getString("reason", true);
|
||||||
|
const evidence =
|
||||||
|
interaction.options.getString("evidence")?.split(/\s+/) || [];
|
||||||
|
|
||||||
|
await processModAction(
|
||||||
|
bot,
|
||||||
|
interaction,
|
||||||
|
guild,
|
||||||
|
user,
|
||||||
|
"mute",
|
||||||
|
reason,
|
||||||
|
evidence,
|
||||||
|
duration,
|
||||||
|
durationUnit
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "mute command", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
67
src/commands/note.ts
Normal file
67
src/commands/note.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { GuildMember, SlashCommandBuilder } from "discord.js";
|
||||||
|
|
||||||
|
import { Command } from "../interfaces/Command";
|
||||||
|
import { errorHandler } from "../utils/errorHandler";
|
||||||
|
import { isModerator } from "../utils/isModerator";
|
||||||
|
import { processModAction } from "../utils/processModAction";
|
||||||
|
|
||||||
|
export const note: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("note")
|
||||||
|
.setDescription("Add a note to a member's record.")
|
||||||
|
.addUserOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("user")
|
||||||
|
.setDescription("The user to add a note to.")
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("reason")
|
||||||
|
.setDescription("The reason for adding this note.")
|
||||||
|
.setRequired(true)
|
||||||
|
.setMinLength(1)
|
||||||
|
.setMaxLength(400)
|
||||||
|
),
|
||||||
|
run: async (bot, interaction) => {
|
||||||
|
try {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
const { member, guild } = interaction;
|
||||||
|
|
||||||
|
if (!member || !guild) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "There was an error loading the guild and member data."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isModerator(member as GuildMember)) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "You do not have permission to run this command."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = interaction.options.getUser("user", true);
|
||||||
|
const reason = interaction.options.getString("reason", true);
|
||||||
|
|
||||||
|
const target =
|
||||||
|
guild.members.cache.get(user.id) ||
|
||||||
|
(await guild.members.fetch(user.id).catch(() => null));
|
||||||
|
|
||||||
|
if (target && isModerator(target)) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "You cannot add a note to a moderator."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await processModAction(bot, interaction, guild, user, "note", reason, []);
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "note command", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
50
src/commands/ping.ts
Normal file
50
src/commands/ping.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { EmbedBuilder, SlashCommandBuilder } from "discord.js";
|
||||||
|
|
||||||
|
import { Command } from "../interfaces/Command";
|
||||||
|
import { errorHandler } from "../utils/errorHandler";
|
||||||
|
|
||||||
|
export const ping: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("ping")
|
||||||
|
.setDescription("Check the response time of the bot."),
|
||||||
|
run: async (bot, interaction) => {
|
||||||
|
try {
|
||||||
|
await interaction.deferReply();
|
||||||
|
const receivedInteraction = Date.now();
|
||||||
|
const { createdTimestamp } = interaction;
|
||||||
|
|
||||||
|
const discordLatency = receivedInteraction - createdTimestamp;
|
||||||
|
const websocketLatency = bot.ws.ping;
|
||||||
|
|
||||||
|
await bot.db.$runCommandRaw({ ping: 1 });
|
||||||
|
const databaseLatency = Date.now() - receivedInteraction;
|
||||||
|
|
||||||
|
const pingEmbed = new EmbedBuilder();
|
||||||
|
pingEmbed.setTitle("Pong!");
|
||||||
|
pingEmbed.setDescription("Here is my latency information!");
|
||||||
|
pingEmbed.addFields(
|
||||||
|
{
|
||||||
|
name: "Interaction Latency",
|
||||||
|
value: `${discordLatency}ms`,
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Websocket Latency",
|
||||||
|
value: `${websocketLatency}ms`,
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Database Latency",
|
||||||
|
value: `${databaseLatency}ms`,
|
||||||
|
inline: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await interaction.editReply({ embeds: [pingEmbed] });
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "ping command", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
129
src/commands/profile.ts
Normal file
129
src/commands/profile.ts
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
|
|
||||||
|
import { Command } from "../interfaces/Command";
|
||||||
|
import {
|
||||||
|
validateColour,
|
||||||
|
validateImage
|
||||||
|
} from "../modules/commands/profileValidation";
|
||||||
|
import { errorHandler } from "../utils/errorHandler";
|
||||||
|
|
||||||
|
export const profile: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("profile")
|
||||||
|
.setDescription("Edit your profile that appears in the leaderboard")
|
||||||
|
.setDMPermission(false)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("avatar")
|
||||||
|
.setDescription(
|
||||||
|
"The avatar to appear on your profile card must be a URL that points to an image."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("background-colour")
|
||||||
|
.setDescription(
|
||||||
|
"The semi-transparent background color for your profile card must be a 6-digit hex value."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("background-image")
|
||||||
|
.setDescription(
|
||||||
|
"The background image for your profile card must be a URL that points to an image."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("colour")
|
||||||
|
.setDescription(
|
||||||
|
"The color for the text on your profile card must be a 6-digit hex value."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
run: async (CamperChan, interaction) => {
|
||||||
|
try {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
|
||||||
|
const responses = ["Your profile settings have been updated!"];
|
||||||
|
const opts = {
|
||||||
|
avatar: interaction.options.getString("avatar"),
|
||||||
|
backgroundColour: interaction.options.getString("background-colour"),
|
||||||
|
backgroundImage: interaction.options.getString("background-image"),
|
||||||
|
colour: interaction.options.getString("colour")
|
||||||
|
};
|
||||||
|
if (opts.avatar) {
|
||||||
|
const isValid = await validateImage(opts.avatar);
|
||||||
|
if (!isValid) {
|
||||||
|
responses.push(`${opts.avatar} is not a valid image URL.`);
|
||||||
|
opts.avatar = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (opts.backgroundImage) {
|
||||||
|
const isValid = await validateImage(opts.backgroundImage);
|
||||||
|
if (!isValid) {
|
||||||
|
responses.push(`${opts.backgroundImage} is not a valid image URL.`);
|
||||||
|
opts.backgroundImage = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (opts.colour) {
|
||||||
|
if (opts.colour.startsWith("#")) {
|
||||||
|
opts.colour = opts.colour.slice(1);
|
||||||
|
}
|
||||||
|
if (!validateColour(opts.colour)) {
|
||||||
|
opts.colour = null;
|
||||||
|
responses.push(
|
||||||
|
`${interaction.options.getString("colour")} is not a valid hex code. Please try again with a 6 character hex code (# is optional).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (opts.backgroundColour) {
|
||||||
|
if (opts.backgroundColour.startsWith("#")) {
|
||||||
|
opts.backgroundColour = opts.backgroundColour.slice(1);
|
||||||
|
}
|
||||||
|
if (!validateColour(opts.backgroundColour)) {
|
||||||
|
opts.backgroundColour = null;
|
||||||
|
responses.push(
|
||||||
|
`${interaction.options.getString("background-colour")} is not a valid hex code. Please try again with a 6 character hex code (# is optional).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = (
|
||||||
|
Object.entries(opts) as [keyof typeof opts, string][]
|
||||||
|
).reduce(
|
||||||
|
(acc, [key, val]) => {
|
||||||
|
if (val) {
|
||||||
|
acc[key] = val;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<keyof typeof opts, string>
|
||||||
|
);
|
||||||
|
|
||||||
|
await CamperChan.db.levels.upsert({
|
||||||
|
where: {
|
||||||
|
serverId_userId: {
|
||||||
|
serverId: interaction.guild.id,
|
||||||
|
userId: interaction.user.id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
...query
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId: interaction.user.id,
|
||||||
|
serverId: interaction.guild.id,
|
||||||
|
username: interaction.user.username,
|
||||||
|
...query
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await interaction.editReply({ content: responses.join("\n") });
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(CamperChan, "user settings command", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
71
src/commands/prune.ts
Normal file
71
src/commands/prune.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
|
|
||||||
|
import { Command } from "../interfaces/Command";
|
||||||
|
import { getConfig } from "../modules/data/getConfig";
|
||||||
|
import { errorHandler } from "../utils/errorHandler";
|
||||||
|
|
||||||
|
export const prune: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("prune")
|
||||||
|
.setDescription("Prune messages from THIS channel.")
|
||||||
|
.addNumberOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("amount")
|
||||||
|
.setDescription("The amount of messages to remove")
|
||||||
|
.setMinValue(1)
|
||||||
|
.setMaxValue(100)
|
||||||
|
.setRequired(true)
|
||||||
|
),
|
||||||
|
run: async (bot, interaction) => {
|
||||||
|
try {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
const { member, guild } = interaction;
|
||||||
|
if (!member || !guild) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "Could not find the member or guild."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!member.permissions.has(PermissionFlagsBits.ManageMessages)) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "You do not have permission to prune messages."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = interaction.channel;
|
||||||
|
const amount = interaction.options.getNumber("amount", true);
|
||||||
|
|
||||||
|
if (!channel) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "Please provide a text channel or thread."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const messages = await channel.messages.fetch({ limit: amount });
|
||||||
|
for (const message of messages.values()) {
|
||||||
|
await message.delete().catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply({ content: "Complete." });
|
||||||
|
const config = await getConfig(bot, interaction.guild.id);
|
||||||
|
if (config.modLogChannel) {
|
||||||
|
const logChannel =
|
||||||
|
interaction.guild.channels.cache.get(config.modLogChannel) ||
|
||||||
|
(await interaction.guild.channels.fetch(config.modLogChannel));
|
||||||
|
|
||||||
|
if (logChannel && "send" in logChannel) {
|
||||||
|
await logChannel.send({
|
||||||
|
content: `Prune run by <@!${interaction.user.id}>. Deleted Messages: ${amount}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "prune interaction", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
52
src/commands/rank.ts
Normal file
52
src/commands/rank.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
|
|
||||||
|
import { Command } from "../interfaces/Command";
|
||||||
|
import { generateProfileImage } from "../modules/commands/generateProfileImage";
|
||||||
|
import { errorHandler } from "../utils/errorHandler";
|
||||||
|
|
||||||
|
export const rank: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setDMPermission(false)
|
||||||
|
.setName("rank")
|
||||||
|
.setDescription("See your level rank in the community."),
|
||||||
|
run: async (bot, interaction) => {
|
||||||
|
try {
|
||||||
|
await interaction.deferReply();
|
||||||
|
const { user } = interaction;
|
||||||
|
|
||||||
|
const target = user.id;
|
||||||
|
|
||||||
|
const record = await bot.db.levels.findUnique({
|
||||||
|
where: {
|
||||||
|
serverId_userId: {
|
||||||
|
userId: target,
|
||||||
|
serverId: interaction.guild.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "Error loading your database record."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = await generateProfileImage(bot, record);
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "There was an error generating your profile. :c"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply({ files: [file] });
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "rank command", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
52
src/commands/role.ts
Normal file
52
src/commands/role.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
|
|
||||||
|
import { Command } from "../interfaces/Command";
|
||||||
|
import { errorHandler } from "../utils/errorHandler";
|
||||||
|
|
||||||
|
export const role: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setDMPermission(false)
|
||||||
|
.setName("role")
|
||||||
|
.setDescription("Give yourself a permitted role, or remove it.")
|
||||||
|
.addRoleOption((o) =>
|
||||||
|
o.setName("role").setDescription("The role to toggle.").setRequired(true)
|
||||||
|
),
|
||||||
|
run: async (bot, interaction) => {
|
||||||
|
try {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
const role = interaction.options.getRole("role", true);
|
||||||
|
const isPermitted = await bot.db.roles
|
||||||
|
.findUnique({
|
||||||
|
where: {
|
||||||
|
serverId_roleId: {
|
||||||
|
serverId: interaction.guild.id,
|
||||||
|
roleId: role.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
if (!isPermitted) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `The <@&${role.id}> role is not self-assignable.`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (interaction.member.roles.cache.has(role.id)) {
|
||||||
|
await interaction.member.roles.remove(role.id);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `The <@&${role.id}> role has been removed.`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await interaction.member.roles.add(role.id);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `The <@&${role.id}> role has been added.`
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "role command", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
102
src/commands/softBan.ts
Normal file
102
src/commands/softBan.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import {
|
||||||
|
GuildMember,
|
||||||
|
PermissionFlagsBits,
|
||||||
|
SlashCommandBuilder
|
||||||
|
} from "discord.js";
|
||||||
|
|
||||||
|
import { Command } from "../interfaces/Command";
|
||||||
|
import { errorHandler } from "../utils/errorHandler";
|
||||||
|
import { isModerator } from "../utils/isModerator";
|
||||||
|
import { processModAction } from "../utils/processModAction";
|
||||||
|
|
||||||
|
export const softBan: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("softban")
|
||||||
|
.setDescription(
|
||||||
|
"Bans a user from the server, cleans up their messages, and removes the ban."
|
||||||
|
)
|
||||||
|
.addUserOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("user")
|
||||||
|
.setDescription("The user to softban.")
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("reason")
|
||||||
|
.setDescription("The reason for softbanning.")
|
||||||
|
.setRequired(true)
|
||||||
|
.setMinLength(1)
|
||||||
|
.setMaxLength(400)
|
||||||
|
)
|
||||||
|
.addIntegerOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("prune")
|
||||||
|
.setDescription("Number of days to prune messages.")
|
||||||
|
.setMinValue(1)
|
||||||
|
.setMaxValue(7)
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("evidence")
|
||||||
|
.setDescription(
|
||||||
|
"A link to the evidence for the ban. For multiple links, separate with a space."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
run: async (bot, interaction) => {
|
||||||
|
try {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
const { member, guild } = interaction;
|
||||||
|
|
||||||
|
if (!member || !guild) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "There was an error loading the guild and member data."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!(member as GuildMember).permissions.has(PermissionFlagsBits.BanMembers)
|
||||||
|
) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "You do not have permission to run this command."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = interaction.options.getString("reason", true);
|
||||||
|
const evidence =
|
||||||
|
interaction.options.getString("evidence")?.split(/\s+/) || [];
|
||||||
|
const prune = interaction.options.getInteger("prune", true);
|
||||||
|
const user = interaction.options.getUser("user", true);
|
||||||
|
const target =
|
||||||
|
guild.members.cache.get(user.id) ||
|
||||||
|
(await guild.members.fetch(user.id).catch(() => null));
|
||||||
|
|
||||||
|
if (target && isModerator(target)) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "You cannot ban a moderator."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await processModAction(
|
||||||
|
bot,
|
||||||
|
interaction,
|
||||||
|
guild,
|
||||||
|
user,
|
||||||
|
"softban",
|
||||||
|
reason,
|
||||||
|
evidence,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
prune
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "softban command", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
87
src/commands/unban.ts
Normal file
87
src/commands/unban.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
GuildMember,
|
||||||
|
PermissionFlagsBits,
|
||||||
|
SlashCommandBuilder
|
||||||
|
} from "discord.js";
|
||||||
|
|
||||||
|
import { Command } from "../interfaces/Command";
|
||||||
|
import { errorHandler } from "../utils/errorHandler";
|
||||||
|
import { processModAction } from "../utils/processModAction";
|
||||||
|
|
||||||
|
export const unban: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("unban")
|
||||||
|
.setDescription("Unban a user from the server.")
|
||||||
|
.addUserOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("user")
|
||||||
|
.setDescription("The user to unban.")
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("reason")
|
||||||
|
.setDescription("The reason for unbanning.")
|
||||||
|
.setRequired(true)
|
||||||
|
.setMinLength(1)
|
||||||
|
.setMaxLength(400)
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("evidence")
|
||||||
|
.setDescription(
|
||||||
|
"A link to the evidence for the unban. For multiple links, separate with a space."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
run: async (bot, interaction) => {
|
||||||
|
try {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
const { member, guild } = interaction;
|
||||||
|
|
||||||
|
if (!member || !guild) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "There was an error loading the guild and member data."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!(member as GuildMember).permissions.has(PermissionFlagsBits.BanMembers)
|
||||||
|
) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "You do not have permission to run this command."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = interaction.options.getString("reason", true);
|
||||||
|
const evidence =
|
||||||
|
interaction.options.getString("evidence")?.split(/\s+/) || [];
|
||||||
|
const user = interaction.options.getUser("user", true);
|
||||||
|
|
||||||
|
const isBanned = await guild.bans.fetch(user.id).catch(() => false);
|
||||||
|
|
||||||
|
if (!isBanned) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `ID ${user.id} is not banned.`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await processModAction(
|
||||||
|
bot,
|
||||||
|
interaction,
|
||||||
|
guild,
|
||||||
|
user,
|
||||||
|
"unban",
|
||||||
|
reason,
|
||||||
|
evidence
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "unban command", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
96
src/commands/unlock.ts
Normal file
96
src/commands/unlock.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import {
|
||||||
|
ChannelType,
|
||||||
|
EmbedBuilder,
|
||||||
|
GuildMember,
|
||||||
|
PermissionFlagsBits,
|
||||||
|
SlashCommandBuilder,
|
||||||
|
TextChannel
|
||||||
|
} from "discord.js";
|
||||||
|
|
||||||
|
import { Command } from "../interfaces/Command";
|
||||||
|
import { getConfig } from "../modules/data/getConfig";
|
||||||
|
import { errorHandler } from "../utils/errorHandler";
|
||||||
|
|
||||||
|
export const unlock: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("unlock")
|
||||||
|
.setDescription("Remove lock down from a channel.")
|
||||||
|
.addChannelOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("channel")
|
||||||
|
.setDescription("The channel to unlock.")
|
||||||
|
.setRequired(true)
|
||||||
|
.addChannelTypes(ChannelType.GuildText)
|
||||||
|
),
|
||||||
|
run: async (bot, interaction) => {
|
||||||
|
try {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
const { member, guild } = interaction;
|
||||||
|
|
||||||
|
if (!member || !guild) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "You must be in a guild to use this command."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!(member as GuildMember).permissions.has(
|
||||||
|
PermissionFlagsBits.ManageChannels
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "You do not have permission to run this command."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = interaction.options.getChannel(
|
||||||
|
"channel",
|
||||||
|
true
|
||||||
|
) as TextChannel;
|
||||||
|
|
||||||
|
if (!("send" in channel)) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "You must use this command to target a text based channel."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await channel.permissionOverwrites.edit(
|
||||||
|
guild.id,
|
||||||
|
{
|
||||||
|
SendMessages: null
|
||||||
|
},
|
||||||
|
{ reason: `Lockdown Removed by ${interaction.user.tag}` }
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = await getConfig(bot, guild.id);
|
||||||
|
if (!config.modLogChannel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const logChannel =
|
||||||
|
guild.channels.cache.get(config.modLogChannel) ||
|
||||||
|
(await guild.channels.fetch(config.modLogChannel));
|
||||||
|
|
||||||
|
if (!logChannel || !("send" in logChannel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const embed = new EmbedBuilder();
|
||||||
|
embed.setTitle("Channel Unlocked");
|
||||||
|
embed.setDescription(`The <#${channel.id}> channel has been unlocked.`);
|
||||||
|
embed.setAuthor({
|
||||||
|
name: interaction.user.tag,
|
||||||
|
iconURL: interaction.user.displayAvatarURL()
|
||||||
|
});
|
||||||
|
await logChannel.send({ embeds: [embed] });
|
||||||
|
|
||||||
|
await interaction.editReply({ content: "Channel has been unlocked!" });
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "unlock command", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
106
src/commands/unmute.ts
Normal file
106
src/commands/unmute.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import {
|
||||||
|
GuildMember,
|
||||||
|
PermissionFlagsBits,
|
||||||
|
SlashCommandBuilder
|
||||||
|
} from "discord.js";
|
||||||
|
|
||||||
|
import { Command } from "../interfaces/Command";
|
||||||
|
import { errorHandler } from "../utils/errorHandler";
|
||||||
|
import { isModerator } from "../utils/isModerator";
|
||||||
|
import { processModAction } from "../utils/processModAction";
|
||||||
|
|
||||||
|
export const unmute: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("unmute")
|
||||||
|
.setDescription("Unmute a member")
|
||||||
|
.addUserOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("user")
|
||||||
|
.setDescription("The user to unmute.")
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("reason")
|
||||||
|
.setDescription("The reason for the unmute.")
|
||||||
|
.setRequired(true)
|
||||||
|
.setMinLength(1)
|
||||||
|
.setMaxLength(400)
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("evidence")
|
||||||
|
.setDescription(
|
||||||
|
"A link to the evidence for the unmute. For multiple links, separate with a space."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
run: async (bot, interaction) => {
|
||||||
|
try {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
const { member, guild } = interaction;
|
||||||
|
|
||||||
|
if (!member || !guild) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "There was an error loading the guild and member data."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!(member as GuildMember).permissions.has(
|
||||||
|
PermissionFlagsBits.ModerateMembers
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "You do not have permission to run this command."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = interaction.options.getUser("user", true);
|
||||||
|
const target =
|
||||||
|
guild.members.cache.get(user.id) ||
|
||||||
|
(await guild.members.fetch(user.id).catch(() => null));
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "That member appears to have left the server."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target.isCommunicationDisabled()) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "That member is not muted."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isModerator(target)) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content:
|
||||||
|
"A moderator should never be muted. How on earth did you achieve this???"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = interaction.options.getString("reason", true);
|
||||||
|
const evidence =
|
||||||
|
interaction.options.getString("evidence")?.split(/\s+/) || [];
|
||||||
|
|
||||||
|
await processModAction(
|
||||||
|
bot,
|
||||||
|
interaction,
|
||||||
|
guild,
|
||||||
|
user,
|
||||||
|
"unmute",
|
||||||
|
reason,
|
||||||
|
evidence
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "unmute command", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
99
src/commands/warn.ts
Normal file
99
src/commands/warn.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import {
|
||||||
|
GuildMember,
|
||||||
|
PermissionFlagsBits,
|
||||||
|
SlashCommandBuilder
|
||||||
|
} from "discord.js";
|
||||||
|
|
||||||
|
import { Command } from "../interfaces/Command";
|
||||||
|
import { errorHandler } from "../utils/errorHandler";
|
||||||
|
import { isModerator } from "../utils/isModerator";
|
||||||
|
import { processModAction } from "../utils/processModAction";
|
||||||
|
|
||||||
|
export const warn: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("warn")
|
||||||
|
.setDescription("Warn a member")
|
||||||
|
.addUserOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("user")
|
||||||
|
.setDescription("The user to warn.")
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("reason")
|
||||||
|
.setDescription("The reason for the warning.")
|
||||||
|
.setRequired(true)
|
||||||
|
.setMinLength(1)
|
||||||
|
.setMaxLength(400)
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("evidence")
|
||||||
|
.setDescription(
|
||||||
|
"A link to the evidence for the warning. For multiple links, separate with a space."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
run: async (bot, interaction) => {
|
||||||
|
try {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
const { member, guild } = interaction;
|
||||||
|
|
||||||
|
if (!member || !guild) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "There was an error loading the guild and member data."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!(member as GuildMember).permissions.has(
|
||||||
|
PermissionFlagsBits.KickMembers
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "You do not have permission to run this command."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = interaction.options.getUser("user", true);
|
||||||
|
const target =
|
||||||
|
guild.members.cache.get(user.id) ||
|
||||||
|
(await guild.members.fetch(user.id).catch(() => null));
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "That member appears to have left the server."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isModerator(target)) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "You cannot warn a moderator."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = interaction.options.getString("reason", true);
|
||||||
|
const evidence =
|
||||||
|
interaction.options.getString("evidence")?.split(/\s+/) || [];
|
||||||
|
|
||||||
|
await processModAction(
|
||||||
|
bot,
|
||||||
|
interaction,
|
||||||
|
guild,
|
||||||
|
user,
|
||||||
|
"warn",
|
||||||
|
reason,
|
||||||
|
evidence
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "warn command", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
10
src/config/DefaultConfig.ts
Normal file
10
src/config/DefaultConfig.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { configs } from "@prisma/client";
|
||||||
|
|
||||||
|
export const defaultConfig: Omit<configs, "id"> = {
|
||||||
|
serverId: "",
|
||||||
|
inviteLink: "",
|
||||||
|
banAppealLink: "",
|
||||||
|
modLogChannel: "",
|
||||||
|
eventLogChannel: "",
|
||||||
|
messageReportChannel: ""
|
||||||
|
};
|
12
src/config/EmbedColours.ts
Normal file
12
src/config/EmbedColours.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Action } from "../interfaces/Action";
|
||||||
|
|
||||||
|
export const EmbedColours: { [K in Action]: number } = {
|
||||||
|
ban: 0xfa000c,
|
||||||
|
softban: 0xfa9900,
|
||||||
|
kick: 0xffee00,
|
||||||
|
warn: 0x2600ff,
|
||||||
|
mute: 0xd900ff,
|
||||||
|
unmute: 0x00ff22,
|
||||||
|
unban: 0x00ff22,
|
||||||
|
note: 0x000001
|
||||||
|
};
|
17
src/config/Github.ts
Normal file
17
src/config/Github.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
export const IgnoredActors = [
|
||||||
|
"renovate[bot]",
|
||||||
|
"codeclimate[bot]",
|
||||||
|
"dependabot[bot]",
|
||||||
|
"lgtm-com[bot]",
|
||||||
|
"deepsource-autofix[bot]",
|
||||||
|
"sonarcloud[bot]",
|
||||||
|
"melody-iuvo"
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ThankYou = `## Thank You
|
||||||
|
|
||||||
|
Thank you for your contribution to our project. We have reviewed your pull request and are happy to accept these changes.
|
||||||
|
|
||||||
|
Please continue to watch for issues labelled \`help wanted\`, as these will be additional opportunities to contribute.
|
||||||
|
|
||||||
|
You can also see all open issues [through our contributor tool](https://contribute.naomi.lgbt). Also, feel free to join our [Discord server](https://chat.naomi.lgbt) to chat with us and get notified when new issues are available!`;
|
10
src/config/IntentOptions.ts
Normal file
10
src/config/IntentOptions.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { GatewayIntentBits } from "discord.js";
|
||||||
|
|
||||||
|
export const IntentOptions = [
|
||||||
|
GatewayIntentBits.Guilds,
|
||||||
|
GatewayIntentBits.GuildMembers,
|
||||||
|
GatewayIntentBits.GuildModeration,
|
||||||
|
GatewayIntentBits.GuildMessages,
|
||||||
|
GatewayIntentBits.MessageContent,
|
||||||
|
GatewayIntentBits.GuildMessageReactions
|
||||||
|
];
|
14
src/config/LevelScale.ts
Normal file
14
src/config/LevelScale.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* This config is an automatically-generated scale for mapping experience
|
||||||
|
* point values to level values.
|
||||||
|
*/
|
||||||
|
const levelScale: number[] = [];
|
||||||
|
|
||||||
|
let j = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i <= 1000; i++) {
|
||||||
|
j += i * 1000;
|
||||||
|
levelScale[i] = j;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default levelScale;
|
15
src/config/LogChannelChoices.ts
Normal file
15
src/config/LogChannelChoices.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { configs } from "@prisma/client";
|
||||||
|
|
||||||
|
export const logChannelChoices: { name: string; value: keyof configs }[] = [
|
||||||
|
{ name: "Moderation Action Log Channel", value: "modLogChannel" },
|
||||||
|
{ name: "Private Event Log Channel", value: "eventLogChannel" },
|
||||||
|
{ name: "Message Reporting Channel", value: "messageReportChannel" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const logChannelChoicesMap: {
|
||||||
|
[key: string]: string;
|
||||||
|
} = {
|
||||||
|
modLogChannel: "moderation actions",
|
||||||
|
eventLogChannel: "gateway events",
|
||||||
|
messageReportChannel: "message reports"
|
||||||
|
};
|
8
src/config/ServerUploadLimits.ts
Normal file
8
src/config/ServerUploadLimits.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { GuildPremiumTier } from "discord.js";
|
||||||
|
|
||||||
|
export const ServerUploadLimits: { [tier in GuildPremiumTier]: number } = {
|
||||||
|
0: 8000000,
|
||||||
|
1: 8000000,
|
||||||
|
2: 50000000,
|
||||||
|
3: 100000000
|
||||||
|
};
|
126
src/contexts/report.ts
Normal file
126
src/contexts/report.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import {
|
||||||
|
Message,
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
EmbedBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
TextInputBuilder,
|
||||||
|
TextInputStyle,
|
||||||
|
ModalBuilder
|
||||||
|
} from "discord.js";
|
||||||
|
|
||||||
|
import { EmbedColours } from "../config/EmbedColours";
|
||||||
|
import { Context } from "../interfaces/Context";
|
||||||
|
import { getConfig } from "../modules/data/getConfig";
|
||||||
|
import { errorHandler } from "../utils/errorHandler";
|
||||||
|
|
||||||
|
export const report: Context = {
|
||||||
|
data: {
|
||||||
|
name: "report",
|
||||||
|
type: 3
|
||||||
|
},
|
||||||
|
run: async (bot, interaction) => {
|
||||||
|
try {
|
||||||
|
if (!interaction.isMessageContextMenuCommand()) {
|
||||||
|
await interaction.reply({
|
||||||
|
content:
|
||||||
|
"This command is improperly configured. Please contact Naomi.",
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const guild = interaction.guild;
|
||||||
|
const message = interaction.options.getMessage(
|
||||||
|
"message",
|
||||||
|
true
|
||||||
|
) as Message;
|
||||||
|
|
||||||
|
if (!guild || !message) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: "Could not find the guild record...",
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getConfig(bot, guild.id);
|
||||||
|
|
||||||
|
if (!config.messageReportChannel) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: "Reporting has not been set up for this server.",
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel =
|
||||||
|
guild.channels.cache.get(config.messageReportChannel) ||
|
||||||
|
(await guild.channels.fetch(config.messageReportChannel));
|
||||||
|
|
||||||
|
if (!channel || !("send" in channel)) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: "Reporting channel not found.",
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle("Message Reported")
|
||||||
|
.setDescription(message.content)
|
||||||
|
.setColor(EmbedColours.ban)
|
||||||
|
.addFields(
|
||||||
|
{
|
||||||
|
name: "Author",
|
||||||
|
value: `<@${message.author.id}>`,
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Reporter",
|
||||||
|
value: `<@${interaction.user.id}>`,
|
||||||
|
inline: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const link = new ButtonBuilder()
|
||||||
|
.setLabel("Message Link")
|
||||||
|
.setStyle(ButtonStyle.Link)
|
||||||
|
.setURL(message.url);
|
||||||
|
const button = new ButtonBuilder()
|
||||||
|
.setLabel("Acknowledge")
|
||||||
|
.setStyle(ButtonStyle.Primary)
|
||||||
|
.setCustomId(`ack-${message.id}`);
|
||||||
|
|
||||||
|
const row = new ActionRowBuilder<ButtonBuilder>().addComponents([
|
||||||
|
link,
|
||||||
|
button
|
||||||
|
]);
|
||||||
|
|
||||||
|
const reportLog = await channel.send({
|
||||||
|
embeds: [embed],
|
||||||
|
components: [row]
|
||||||
|
});
|
||||||
|
|
||||||
|
const reportModal = new ModalBuilder()
|
||||||
|
.setCustomId(`rep-${reportLog.id}`)
|
||||||
|
.setTitle("Report Message");
|
||||||
|
const reasonInput = new TextInputBuilder()
|
||||||
|
.setCustomId("reason")
|
||||||
|
.setLabel("Why are you reporting this message?")
|
||||||
|
.setStyle(TextInputStyle.Paragraph)
|
||||||
|
.setRequired(true);
|
||||||
|
const actionRow = new ActionRowBuilder<TextInputBuilder>().addComponents(
|
||||||
|
reasonInput
|
||||||
|
);
|
||||||
|
reportModal.addComponents(actionRow);
|
||||||
|
|
||||||
|
await interaction.showModal(reportModal);
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "report context command", err);
|
||||||
|
await interaction.reply({
|
||||||
|
ephemeral: true,
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
20
src/database/connectDatabase.ts
Normal file
20
src/database/connectDatabase.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
import { ExtendedClient } from "../interfaces/ExtendedClient";
|
||||||
|
import { errorHandler } from "../utils/errorHandler";
|
||||||
|
import { sendDebugMessage } from "../utils/sendDebugMessage";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connects to the database.
|
||||||
|
*
|
||||||
|
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||||
|
*/
|
||||||
|
export const connectDatabase = async (bot: ExtendedClient) => {
|
||||||
|
try {
|
||||||
|
bot.db = new PrismaClient();
|
||||||
|
await bot.db.$connect();
|
||||||
|
await sendDebugMessage(bot, "Connected to database.");
|
||||||
|
} catch (err) {
|
||||||
|
await errorHandler(bot, "connect database", err);
|
||||||
|
}
|
||||||
|
};
|
129
src/events/_handleEvents.ts
Normal file
129
src/events/_handleEvents.ts
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { ExtendedClient } from "../interfaces/ExtendedClient";
|
||||||
|
import { checkEntitledGuild } from "../utils/checkEntitledGuild";
|
||||||
|
|
||||||
|
import { onDisconnect } from "./client/onDisconnect";
|
||||||
|
import { onReady } from "./client/onReady";
|
||||||
|
import { onAuditLogEntry } from "./guild/onAuditLogEntry";
|
||||||
|
import { onGuildCreate } from "./guild/onGuildCreate";
|
||||||
|
import { onGuildDelete } from "./guild/onGuildDelete";
|
||||||
|
import { onInteraction } from "./interaction/onInteraction";
|
||||||
|
import { onMemberAdd } from "./member/onMemberAdd";
|
||||||
|
import { onMemberRemove } from "./member/onMemberRemove";
|
||||||
|
import { onMemberUpdate } from "./member/onMemberUpdate";
|
||||||
|
import { onMessage } from "./message/onMessage";
|
||||||
|
import { onMessageDelete } from "./message/onMessageDelete";
|
||||||
|
import { onMessageEdit } from "./message/onMessageEdit";
|
||||||
|
import { onThreadCreate } from "./thread/onThreadCreate";
|
||||||
|
import { onThreadDelete } from "./thread/onThreadDelete";
|
||||||
|
import { onThreadUpdate } from "./thread/onThreadUpdate";
|
||||||
|
import { onVoiceUpdate } from "./voice/onVoiceUpdate";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module to mount the Discord event listeners.
|
||||||
|
*
|
||||||
|
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||||
|
*/
|
||||||
|
export const handleEvents = (bot: ExtendedClient) => {
|
||||||
|
/* Client Events */
|
||||||
|
bot.on("ready", async () => await onReady(bot));
|
||||||
|
bot.on("disconnect", () => onDisconnect(bot));
|
||||||
|
|
||||||
|
/* Message Events */
|
||||||
|
bot.on("messageCreate", async (message) => {
|
||||||
|
if (!message.guild || !(await checkEntitledGuild(bot, message.guild))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onMessage(bot, message);
|
||||||
|
});
|
||||||
|
bot.on("messageDelete", async (message) => {
|
||||||
|
if (!message.guild || !(await checkEntitledGuild(bot, message.guild))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onMessageDelete(bot, message);
|
||||||
|
});
|
||||||
|
bot.on("messageUpdate", async (oldMessage, newMessage) => {
|
||||||
|
if (
|
||||||
|
!newMessage.guild ||
|
||||||
|
!(await checkEntitledGuild(bot, newMessage.guild))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onMessageEdit(bot, oldMessage, newMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Interaction Events */
|
||||||
|
bot.on(
|
||||||
|
"interactionCreate",
|
||||||
|
async (interaction) => await onInteraction(bot, interaction)
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Thread Events */
|
||||||
|
bot.on("threadCreate", async (thread) => {
|
||||||
|
if (!thread.guild || !(await checkEntitledGuild(bot, thread.guild))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onThreadCreate(bot, thread);
|
||||||
|
});
|
||||||
|
bot.on("threadDelete", async (thread) => {
|
||||||
|
if (!thread.guild || !(await checkEntitledGuild(bot, thread.guild))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onThreadDelete(bot, thread);
|
||||||
|
});
|
||||||
|
bot.on("threadUpdate", async (oldThread, newThread) => {
|
||||||
|
if (!newThread.guild || !(await checkEntitledGuild(bot, newThread.guild))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onThreadUpdate(bot, oldThread, newThread);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Voice Events */
|
||||||
|
bot.on("voiceStateUpdate", async (oldVoice, newVoice) => {
|
||||||
|
if (!newVoice.guild || !(await checkEntitledGuild(bot, newVoice.guild))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onVoiceUpdate(bot, oldVoice, newVoice);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Member Events */
|
||||||
|
bot.on("guildMemberAdd", async (member) => {
|
||||||
|
if (!member.guild || !(await checkEntitledGuild(bot, member.guild))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onMemberAdd(bot, member);
|
||||||
|
});
|
||||||
|
bot.on("guildMemberRemove", async (member) => {
|
||||||
|
if (!member.guild || !(await checkEntitledGuild(bot, member.guild))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onMemberRemove(bot, member);
|
||||||
|
});
|
||||||
|
bot.on("guildMemberUpdate", async (oldMember, newMember) => {
|
||||||
|
if (!newMember.guild || !(await checkEntitledGuild(bot, newMember.guild))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onMemberUpdate(bot, oldMember, newMember);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Guild Events */
|
||||||
|
bot.on("guildAuditLogEntryCreate", async (log, guild) => {
|
||||||
|
if (!guild || !(await checkEntitledGuild(bot, guild))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onAuditLogEntry(bot, log, guild);
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.on("guildCreate", async (guild) => {
|
||||||
|
if (!guild || !(await checkEntitledGuild(bot, guild))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onGuildCreate(bot, guild);
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.on("guildDelete", async (guild) => {
|
||||||
|
if (!guild) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onGuildDelete(bot, guild);
|
||||||
|
});
|
||||||
|
};
|
16
src/events/client/onDisconnect.ts
Normal file
16
src/events/client/onDisconnect.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { EmbedBuilder } from "discord.js";
|
||||||
|
|
||||||
|
import { ExtendedClient } from "../../interfaces/ExtendedClient";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a message to the debug hook when the bot disconnects.
|
||||||
|
*
|
||||||
|
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||||
|
*/
|
||||||
|
export const onDisconnect = async (bot: ExtendedClient) => {
|
||||||
|
const disconnectEmbed = new EmbedBuilder();
|
||||||
|
disconnectEmbed.setTitle("Disconnected");
|
||||||
|
disconnectEmbed.setDescription("I have been disconnected from Discord.");
|
||||||
|
disconnectEmbed.setTimestamp();
|
||||||
|
await bot.env.debugHook.send({ embeds: [disconnectEmbed] });
|
||||||
|
};
|
13
src/events/client/onReady.ts
Normal file
13
src/events/client/onReady.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { ExtendedClient } from "../../interfaces/ExtendedClient";
|
||||||
|
import { registerCommands } from "../../utils/registerCommands";
|
||||||
|
import { sendDebugMessage } from "../../utils/sendDebugMessage";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the `ready` from Discord.
|
||||||
|
*
|
||||||
|
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||||
|
*/
|
||||||
|
export const onReady = async (bot: ExtendedClient) => {
|
||||||
|
await sendDebugMessage(bot, `Logged in as ${bot.user?.tag}`);
|
||||||
|
await registerCommands(bot);
|
||||||
|
};
|
78
src/events/guild/onAuditLogEntry.ts
Normal file
78
src/events/guild/onAuditLogEntry.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { AuditLogEvent, Guild, GuildAuditLogsEntry, User } from "discord.js";
|
||||||
|
|
||||||
|
import { ExtendedClient } from "../../interfaces/ExtendedClient";
|
||||||
|
import { getModActionFromAuditLog } from "../../modules/events/getModActionFromAuditLog";
|
||||||
|
import { addCase } from "../../utils/addCase";
|
||||||
|
import { errorHandler } from "../../utils/errorHandler";
|
||||||
|
import { sendLogMessage } from "../../utils/sendLogMessage";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles properly logging a manual mod action based on audit logs.
|
||||||
|
*
|
||||||
|
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||||
|
* @param {GuildAuditLogsEntry} log The audit log payload from Discord.
|
||||||
|
* @param {Guild} guild The guild payload from Discord.
|
||||||
|
*/
|
||||||
|
export const onAuditLogEntry = async (
|
||||||
|
bot: ExtendedClient,
|
||||||
|
log: GuildAuditLogsEntry,
|
||||||
|
guild: Guild
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { action, changes, executorId, targetId, target, reason } = log;
|
||||||
|
if (executorId === bot.user?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// if not a mod action we don't care.
|
||||||
|
if (
|
||||||
|
![
|
||||||
|
AuditLogEvent.MemberBanAdd,
|
||||||
|
AuditLogEvent.MemberBanRemove,
|
||||||
|
AuditLogEvent.MemberKick,
|
||||||
|
AuditLogEvent.MemberUpdate
|
||||||
|
].includes(action) ||
|
||||||
|
(action === AuditLogEvent.MemberUpdate &&
|
||||||
|
!changes.find(
|
||||||
|
(change) => change.key === "communication_disabled_until"
|
||||||
|
)) ||
|
||||||
|
!targetId ||
|
||||||
|
!(target instanceof User) ||
|
||||||
|
!executorId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modAction = getModActionFromAuditLog(log);
|
||||||
|
|
||||||
|
if (!modAction) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reasonString = `This was a manual action pulled from the audit log. Please use the bot for accurate reporting.\n\nReason: ${
|
||||||
|
reason || "Unable to parse reason."
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const caseNum = await addCase(
|
||||||
|
bot,
|
||||||
|
guild.id,
|
||||||
|
target.id,
|
||||||
|
reasonString,
|
||||||
|
modAction,
|
||||||
|
executorId,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
await sendLogMessage(
|
||||||
|
bot,
|
||||||
|
guild,
|
||||||
|
target,
|
||||||
|
modAction,
|
||||||
|
reasonString,
|
||||||
|
executorId,
|
||||||
|
[],
|
||||||
|
false,
|
||||||
|
caseNum
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
await errorHandler(bot, "on audit log entry", err);
|
||||||
|
}
|
||||||
|
};
|
18
src/events/guild/onGuildCreate.ts
Normal file
18
src/events/guild/onGuildCreate.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Guild } from "discord.js";
|
||||||
|
|
||||||
|
import { ExtendedClient } from "../../interfaces/ExtendedClient";
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||||
|
* @param {Guild} guild The newly joined Discord guild.
|
||||||
|
*/
|
||||||
|
export const onGuildCreate = async function (
|
||||||
|
bot: ExtendedClient,
|
||||||
|
guild: Guild
|
||||||
|
) {
|
||||||
|
const owner = await guild.fetchOwner();
|
||||||
|
|
||||||
|
await bot.env.debugHook.send({
|
||||||
|
content: `JOINED GUILD: ${guild.name} (${guild.id}) - owned by ${owner?.displayName} (${owner.id})`
|
||||||
|
});
|
||||||
|
};
|
16
src/events/guild/onGuildDelete.ts
Normal file
16
src/events/guild/onGuildDelete.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { Guild } from "discord.js";
|
||||||
|
|
||||||
|
import { ExtendedClient } from "../../interfaces/ExtendedClient";
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||||
|
* @param {Guild} guild The newly left Discord guild.
|
||||||
|
*/
|
||||||
|
export const onGuildDelete = async function (
|
||||||
|
bot: ExtendedClient,
|
||||||
|
guild: Guild
|
||||||
|
) {
|
||||||
|
await bot.env.debugHook.send({
|
||||||
|
content: `LEFT GUILD: ${guild.name} (${guild.id}) `
|
||||||
|
});
|
||||||
|
};
|
71
src/events/interaction/onInteraction.ts
Normal file
71
src/events/interaction/onInteraction.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { Interaction } from "discord.js";
|
||||||
|
|
||||||
|
import { ExtendedClient } from "../../interfaces/ExtendedClient";
|
||||||
|
import { handleCopyIdButton } from "../../modules/buttons/handleCopyIdButton";
|
||||||
|
import { handleReportAcknowledgeButton } from "../../modules/buttons/handleReportAcknowledgeButton";
|
||||||
|
import { handleChatInputCommand } from "../../modules/interactions/handleChatInputCommand";
|
||||||
|
import { handleContextMenuCommand } from "../../modules/interactions/handleContextMenuCommand";
|
||||||
|
import { handleMassBanModal } from "../../modules/modals/handleMassBanModal";
|
||||||
|
import { handleMessageReportModal } from "../../modules/modals/handleMessageReportModal";
|
||||||
|
import { checkEntitledGuild } from "../../utils/checkEntitledGuild";
|
||||||
|
import { errorHandler } from "../../utils/errorHandler";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles interactions.
|
||||||
|
*
|
||||||
|
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||||
|
* @param {Interaction} interaction The interaction payload from Discord.
|
||||||
|
*/
|
||||||
|
export const onInteraction = async (
|
||||||
|
bot: ExtendedClient,
|
||||||
|
interaction: Interaction
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (interaction.isAutocomplete()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!interaction.guild ||
|
||||||
|
(!(await checkEntitledGuild(bot, interaction.guild)) &&
|
||||||
|
(!interaction.isChatInputCommand() ||
|
||||||
|
interaction.commandName !== "help"))
|
||||||
|
) {
|
||||||
|
await interaction.reply(
|
||||||
|
"Your guild does not appear to be subscribed to use our bot. Please reach out to us in our [support server](<https://chat.naomi.lgbt>) if you would like to sign up."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (interaction.isChatInputCommand()) {
|
||||||
|
handleChatInputCommand(bot, interaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.isContextMenuCommand()) {
|
||||||
|
handleContextMenuCommand(bot, interaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.isButton()) {
|
||||||
|
if (interaction.customId.startsWith("ack")) {
|
||||||
|
await handleReportAcknowledgeButton(bot, interaction);
|
||||||
|
}
|
||||||
|
if (interaction.customId.startsWith("copyid")) {
|
||||||
|
await handleCopyIdButton(bot, interaction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.isModalSubmit()) {
|
||||||
|
if (interaction.customId === "mass-ban-modal") {
|
||||||
|
await handleMassBanModal(bot, interaction);
|
||||||
|
}
|
||||||
|
if (interaction.customId.startsWith("rep")) {
|
||||||
|
await handleMessageReportModal(bot, interaction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "on interaction", err);
|
||||||
|
if (!interaction.isAutocomplete()) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
38
src/events/member/onMemberAdd.ts
Normal file
38
src/events/member/onMemberAdd.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { GuildMember } from "discord.js";
|
||||||
|
|
||||||
|
import { ExtendedClient } from "../../interfaces/ExtendedClient";
|
||||||
|
import { getConfig } from "../../modules/data/getConfig";
|
||||||
|
import { errorHandler } from "../../utils/errorHandler";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a log message to the configured log channel when a member
|
||||||
|
* joins the server.
|
||||||
|
*
|
||||||
|
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||||
|
* @param {GuildMember} member The user's Discord instance.
|
||||||
|
*/
|
||||||
|
export const onMemberAdd = async (bot: ExtendedClient, member: GuildMember) => {
|
||||||
|
try {
|
||||||
|
const { user, guild } = member;
|
||||||
|
|
||||||
|
const config = await getConfig(bot, guild.id);
|
||||||
|
|
||||||
|
if (!config.eventLogChannel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel =
|
||||||
|
guild.channels.cache.get(config.eventLogChannel) ||
|
||||||
|
(await guild.channels.fetch(config.eventLogChannel));
|
||||||
|
|
||||||
|
if (!channel || !("send" in channel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await channel.send({
|
||||||
|
content: `${user.tag} (${user.id}) has joined the server. Total Members: ${guild.memberCount}`
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
await errorHandler(bot, "on member add", err);
|
||||||
|
}
|
||||||
|
};
|
45
src/events/member/onMemberRemove.ts
Normal file
45
src/events/member/onMemberRemove.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { GuildMember, PartialGuildMember } from "discord.js";
|
||||||
|
|
||||||
|
import { ExtendedClient } from "../../interfaces/ExtendedClient";
|
||||||
|
import { getConfig } from "../../modules/data/getConfig";
|
||||||
|
import { errorHandler } from "../../utils/errorHandler";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a log message to the configured log channel when a member
|
||||||
|
* leaves the server.
|
||||||
|
*
|
||||||
|
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||||
|
* @param {GuildMember} member The user's Discord instance.
|
||||||
|
*/
|
||||||
|
export const onMemberRemove = async (
|
||||||
|
bot: ExtendedClient,
|
||||||
|
member: GuildMember | PartialGuildMember
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { user, guild } = member;
|
||||||
|
|
||||||
|
const config = await getConfig(bot, guild.id);
|
||||||
|
|
||||||
|
if (!config.eventLogChannel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel =
|
||||||
|
guild.channels.cache.get(config.eventLogChannel) ||
|
||||||
|
(await guild.channels.fetch(config.eventLogChannel));
|
||||||
|
|
||||||
|
if (!channel || !("send" in channel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const joinStamp = member.joinedTimestamp
|
||||||
|
? `<t:${Math.floor(member.joinedTimestamp / 1000)}:F>`
|
||||||
|
: "unknown";
|
||||||
|
|
||||||
|
await channel.send({
|
||||||
|
content: `${user.tag} (${user.id}) has left the server (joined at ${joinStamp}). Total Members: ${guild.memberCount}`
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
await errorHandler(bot, "on member remove", err);
|
||||||
|
}
|
||||||
|
};
|
80
src/events/member/onMemberUpdate.ts
Normal file
80
src/events/member/onMemberUpdate.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { GuildMember, PartialGuildMember } from "discord.js";
|
||||||
|
|
||||||
|
import { ExtendedClient } from "../../interfaces/ExtendedClient";
|
||||||
|
import { getConfig } from "../../modules/data/getConfig";
|
||||||
|
import { errorHandler } from "../../utils/errorHandler";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a log message to the configured log channel when a member's
|
||||||
|
* data is updated.
|
||||||
|
*
|
||||||
|
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||||
|
* @param {GuildMember} oldMember The user's old Discord instance.
|
||||||
|
* @param {GuildMember} newMember The user's new Discord instance.
|
||||||
|
*/
|
||||||
|
export const onMemberUpdate = async (
|
||||||
|
bot: ExtendedClient,
|
||||||
|
oldMember: GuildMember | PartialGuildMember,
|
||||||
|
newMember: GuildMember
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { user, guild } = newMember;
|
||||||
|
|
||||||
|
const config = await getConfig(bot, guild.id);
|
||||||
|
|
||||||
|
if (!config.eventLogChannel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel =
|
||||||
|
guild.channels.cache.get(config.eventLogChannel) ||
|
||||||
|
(await guild.channels.fetch(config.eventLogChannel));
|
||||||
|
|
||||||
|
if (!channel || !("send" in channel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldMember.user.tag !== newMember.user.tag) {
|
||||||
|
await channel.send({
|
||||||
|
content: `${user.tag} (${user.id}) has changed their name from ${oldMember.user.tag} to ${newMember.user.tag}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldMember.nickname !== newMember.nickname) {
|
||||||
|
await channel.send({
|
||||||
|
content: `${user.tag} (${user.id}) has changed their nickname from ${
|
||||||
|
oldMember.nickname || "**none**"
|
||||||
|
} to ${newMember.nickname || "**none**"}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const removedRoles = oldMember.roles.cache.filter(
|
||||||
|
(role) => !newMember.roles.cache.has(role.id)
|
||||||
|
);
|
||||||
|
const addedRoles = newMember.roles.cache.filter(
|
||||||
|
(role) => !oldMember.roles.cache.has(role.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (removedRoles.size > 0) {
|
||||||
|
await channel.send({
|
||||||
|
content: `${user.tag} (${
|
||||||
|
user.id
|
||||||
|
}) has removed the following roles: ${removedRoles.map(
|
||||||
|
(role) => role.name
|
||||||
|
)}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addedRoles.size > 0) {
|
||||||
|
await channel.send({
|
||||||
|
content: `${user.tag} (${
|
||||||
|
user.id
|
||||||
|
}) has added the following roles: ${addedRoles.map(
|
||||||
|
(role) => role.name
|
||||||
|
)}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await errorHandler(bot, "on member update", err);
|
||||||
|
}
|
||||||
|
};
|
141
src/events/message/onMessage.ts
Normal file
141
src/events/message/onMessage.ts
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import { Message } from "discord.js";
|
||||||
|
|
||||||
|
import levelScale from "../../config/LevelScale";
|
||||||
|
import { ExtendedClient } from "../../interfaces/ExtendedClient";
|
||||||
|
import { calculateMuteDuration } from "../../modules/commands/calculateMuteDuration";
|
||||||
|
import { checkSpamDomain } from "../../modules/events/checkSpamDomain";
|
||||||
|
import { addCase } from "../../utils/addCase";
|
||||||
|
import { errorHandler } from "../../utils/errorHandler";
|
||||||
|
import { sendLogMessage } from "../../utils/sendLogMessage";
|
||||||
|
import { sendModDm } from "../../utils/sendModDm";
|
||||||
|
import { triggerModRequest } from "../../utils/triggerModRequest";
|
||||||
|
|
||||||
|
const linkRegex = /https?:\/\/([a-zA-Z0-9_.-]{2,256}\.\w{2,24}\b)/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module to handle the messageCreate event from Discord.
|
||||||
|
*
|
||||||
|
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||||
|
* @param {Message} message The message payload from Discord.
|
||||||
|
*/
|
||||||
|
export const onMessage = async (bot: ExtendedClient, message: Message) => {
|
||||||
|
try {
|
||||||
|
const { guild, member, author, system } = message;
|
||||||
|
if (!guild || !member || system || author.bot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const links = message.content.match(linkRegex);
|
||||||
|
|
||||||
|
if (links) {
|
||||||
|
for (const link of links) {
|
||||||
|
if (await checkSpamDomain(bot, link.replace(/https?:\/\//, ""))) {
|
||||||
|
await message.delete().catch(() => null);
|
||||||
|
const notified = await sendModDm(
|
||||||
|
bot,
|
||||||
|
"mute",
|
||||||
|
author,
|
||||||
|
guild,
|
||||||
|
"Your account appears to be compromised."
|
||||||
|
);
|
||||||
|
const caseNum = await addCase(
|
||||||
|
bot,
|
||||||
|
guild.id,
|
||||||
|
author.id,
|
||||||
|
"Your account appears to be compromised",
|
||||||
|
"mute",
|
||||||
|
"Automoderator",
|
||||||
|
[link]
|
||||||
|
);
|
||||||
|
await sendLogMessage(
|
||||||
|
bot,
|
||||||
|
guild,
|
||||||
|
author,
|
||||||
|
"mute",
|
||||||
|
"Your account appears to be compromised",
|
||||||
|
"Automoderation",
|
||||||
|
[link],
|
||||||
|
notified,
|
||||||
|
caseNum
|
||||||
|
);
|
||||||
|
await triggerModRequest(bot, {
|
||||||
|
userId: author.id,
|
||||||
|
serverId: guild.id,
|
||||||
|
action: "mute",
|
||||||
|
reason: "Your account appears to be compromised",
|
||||||
|
moderator: "Automoderator",
|
||||||
|
duration: calculateMuteDuration(24, "hours"),
|
||||||
|
pruneDays: 0
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bonus = Math.floor(message.content.length / 10);
|
||||||
|
const pointsEarned = Math.floor(Math.random() * (20 + bonus)) + 5;
|
||||||
|
const user = await bot.db.levels.upsert({
|
||||||
|
where: {
|
||||||
|
serverId_userId: {
|
||||||
|
serverId: guild.id,
|
||||||
|
userId: author.id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
serverId: guild.id,
|
||||||
|
userId: author.id,
|
||||||
|
username: author.username,
|
||||||
|
avatar: author.displayAvatarURL(),
|
||||||
|
points: 0,
|
||||||
|
level: 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Date.now() - user.cooldown.getTime() < 60000 || user.level >= 1000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
user.points += pointsEarned;
|
||||||
|
user.cooldown = new Date();
|
||||||
|
let levelUp = false;
|
||||||
|
|
||||||
|
while (user.points > (levelScale[user.level + 1] ?? Infinity)) {
|
||||||
|
user.level++;
|
||||||
|
levelUp = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await bot.db.levels.update({
|
||||||
|
where: {
|
||||||
|
serverId_userId: {
|
||||||
|
serverId: guild.id,
|
||||||
|
userId: author.id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
points: user.points,
|
||||||
|
level: user.level,
|
||||||
|
username: author.username,
|
||||||
|
avatar: author.displayAvatarURL(),
|
||||||
|
cooldown: user.cooldown
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (levelUp) {
|
||||||
|
await message.reply(`Congrats! You're now level ${user.level}!!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelRoles = await bot.db.levelRoles.findMany({
|
||||||
|
where: {
|
||||||
|
serverId: guild.id,
|
||||||
|
level: {
|
||||||
|
lte: user.level
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const record of levelRoles) {
|
||||||
|
await member.roles.add(record.roleId).catch(() => null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await errorHandler(bot, "on message", err);
|
||||||
|
}
|
||||||
|
};
|
73
src/events/message/onMessageDelete.ts
Normal file
73
src/events/message/onMessageDelete.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { Message, PartialMessage } from "discord.js";
|
||||||
|
|
||||||
|
import { ServerUploadLimits } from "../../config/ServerUploadLimits";
|
||||||
|
import { ExtendedClient } from "../../interfaces/ExtendedClient";
|
||||||
|
import { getConfig } from "../../modules/data/getConfig";
|
||||||
|
import { customSubstring } from "../../utils/customSubstring";
|
||||||
|
import { errorHandler } from "../../utils/errorHandler";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a message delete event.
|
||||||
|
*
|
||||||
|
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||||
|
* @param {Message} message The message that was deleted.
|
||||||
|
*/
|
||||||
|
export const onMessageDelete = async (
|
||||||
|
bot: ExtendedClient,
|
||||||
|
message: Message | PartialMessage
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { author, channel, content, guild, embeds, attachments, stickers } =
|
||||||
|
message;
|
||||||
|
|
||||||
|
if (!guild || author?.bot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getConfig(bot, guild.id);
|
||||||
|
|
||||||
|
if (!config.eventLogChannel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logChannel =
|
||||||
|
guild.channels.cache.get(config.eventLogChannel) ||
|
||||||
|
(await guild.channels.fetch(config.eventLogChannel));
|
||||||
|
|
||||||
|
if (!logChannel || !("send" in logChannel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedContent = content || "**No message content found.**";
|
||||||
|
const mappedAttachements = attachments
|
||||||
|
.map((el) => el)
|
||||||
|
.filter((el) => el.size <= ServerUploadLimits[guild.premiumTier]);
|
||||||
|
const mappedStickers = stickers
|
||||||
|
.map((el) => el)
|
||||||
|
.filter((el) => el.available);
|
||||||
|
|
||||||
|
let logContent = `${author?.tag} (${author?.id}) had a message (${message.id}) deleted in <#${channel.id}>:\n\n\`${deletedContent}\``;
|
||||||
|
|
||||||
|
if (message.reference && message.reference.messageId) {
|
||||||
|
logContent += `\n\n**This message was in reply to: https://discord.com/channels/${guild.id}/${message.reference.channelId}/${message.reference.messageId}**`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachments.size && mappedAttachements.length < attachments.size) {
|
||||||
|
logContent += `\n\n**${
|
||||||
|
attachments.size - mappedAttachements.length
|
||||||
|
} attachment(s) were too large to log.**`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await logChannel.send({
|
||||||
|
content: customSubstring(logContent, 2000),
|
||||||
|
files: mappedAttachements,
|
||||||
|
embeds,
|
||||||
|
stickers: mappedStickers,
|
||||||
|
allowedMentions: {
|
||||||
|
parse: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
await errorHandler(bot, "on message delete", err);
|
||||||
|
}
|
||||||
|
};
|
64
src/events/message/onMessageEdit.ts
Normal file
64
src/events/message/onMessageEdit.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { Message, PartialMessage } from "discord.js";
|
||||||
|
|
||||||
|
import { ExtendedClient } from "../../interfaces/ExtendedClient";
|
||||||
|
import { getConfig } from "../../modules/data/getConfig";
|
||||||
|
import { customSubstring } from "../../utils/customSubstring";
|
||||||
|
import { errorHandler } from "../../utils/errorHandler";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a message edit event.
|
||||||
|
*
|
||||||
|
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||||
|
* @param {Message} oldMessage The old message payload.
|
||||||
|
* @param {Message} newMessage The new message payload.
|
||||||
|
*/
|
||||||
|
export const onMessageEdit = async (
|
||||||
|
bot: ExtendedClient,
|
||||||
|
oldMessage: Message | PartialMessage,
|
||||||
|
newMessage: Message | PartialMessage
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { author, channel, guild } = newMessage;
|
||||||
|
|
||||||
|
if (!guild || author?.bot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!oldMessage.content ||
|
||||||
|
!newMessage.content ||
|
||||||
|
oldMessage.content === newMessage.content
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getConfig(bot, guild.id);
|
||||||
|
|
||||||
|
if (!config.eventLogChannel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logChannel =
|
||||||
|
guild.channels.cache.get(config.eventLogChannel) ||
|
||||||
|
(await guild.channels.fetch(config.eventLogChannel));
|
||||||
|
|
||||||
|
if (!logChannel || !("send" in logChannel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await logChannel.send({
|
||||||
|
content: `${author?.tag} (${author?.id}) edited their message in in <#${
|
||||||
|
channel.id
|
||||||
|
}>:\n\n**Old Content:**\n\`${customSubstring(
|
||||||
|
oldMessage.content,
|
||||||
|
1750
|
||||||
|
)}\`\n\n**New content:**\n\`${customSubstring(
|
||||||
|
newMessage.content,
|
||||||
|
1750
|
||||||
|
)}\`\n\n${newMessage.url}`,
|
||||||
|
allowedMentions: { parse: [] }
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
await errorHandler(bot, "on message edit", err);
|
||||||
|
}
|
||||||
|
};
|
42
src/events/thread/onThreadCreate.ts
Normal file
42
src/events/thread/onThreadCreate.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { ThreadChannel } from "discord.js";
|
||||||
|
|
||||||
|
import { ExtendedClient } from "../../interfaces/ExtendedClient";
|
||||||
|
import { getConfig } from "../../modules/data/getConfig";
|
||||||
|
import { errorHandler } from "../../utils/errorHandler";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the creation of a new thread.
|
||||||
|
*
|
||||||
|
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||||
|
* @param {ThreadChannel} thread The thread payload from Discord.
|
||||||
|
*/
|
||||||
|
export const onThreadCreate = async (
|
||||||
|
bot: ExtendedClient,
|
||||||
|
thread: ThreadChannel
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (thread.joinable) {
|
||||||
|
await thread.join();
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getConfig(bot, thread.guild.id);
|
||||||
|
|
||||||
|
if (!config.eventLogChannel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel =
|
||||||
|
thread.guild.channels.cache.get(config.eventLogChannel) ||
|
||||||
|
(await thread.guild.channels.fetch(config.eventLogChannel));
|
||||||
|
|
||||||
|
if (!channel || !("send" in channel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await channel.send({
|
||||||
|
content: `${thread.name} has been created in <#${thread.parentId}>`
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
await errorHandler(bot, "on thread create", err);
|
||||||
|
}
|
||||||
|
};
|
38
src/events/thread/onThreadDelete.ts
Normal file
38
src/events/thread/onThreadDelete.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { ThreadChannel } from "discord.js";
|
||||||
|
|
||||||
|
import { ExtendedClient } from "../../interfaces/ExtendedClient";
|
||||||
|
import { getConfig } from "../../modules/data/getConfig";
|
||||||
|
import { errorHandler } from "../../utils/errorHandler";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the deletion of a thread.
|
||||||
|
*
|
||||||
|
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||||
|
* @param {ThreadChannel} thread The thread payload from Discord.
|
||||||
|
*/
|
||||||
|
export const onThreadDelete = async (
|
||||||
|
bot: ExtendedClient,
|
||||||
|
thread: ThreadChannel
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const config = await getConfig(bot, thread.guild.id);
|
||||||
|
|
||||||
|
if (!config.eventLogChannel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel =
|
||||||
|
thread.guild.channels.cache.get(config.eventLogChannel) ||
|
||||||
|
(await thread.guild.channels.fetch(config.eventLogChannel));
|
||||||
|
|
||||||
|
if (!channel || !("send" in channel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await channel.send({
|
||||||
|
content: `${thread.name} has been deleted from <#${thread.parentId}>`
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
await errorHandler(bot, "on thread create", err);
|
||||||
|
}
|
||||||
|
};
|
54
src/events/thread/onThreadUpdate.ts
Normal file
54
src/events/thread/onThreadUpdate.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { ThreadChannel } from "discord.js";
|
||||||
|
|
||||||
|
import { ExtendedClient } from "../../interfaces/ExtendedClient";
|
||||||
|
import { getConfig } from "../../modules/data/getConfig";
|
||||||
|
import { errorHandler } from "../../utils/errorHandler";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a thread update.
|
||||||
|
*
|
||||||
|
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||||
|
* @param {ThreadChannel} oldThread The old thread payload.
|
||||||
|
* @param {ThreadChannel} newThread The new thread payload.
|
||||||
|
*/
|
||||||
|
export const onThreadUpdate = async (
|
||||||
|
bot: ExtendedClient,
|
||||||
|
oldThread: ThreadChannel,
|
||||||
|
newThread: ThreadChannel
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const config = await getConfig(bot, newThread.guild.id);
|
||||||
|
|
||||||
|
if (!config.eventLogChannel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel =
|
||||||
|
oldThread.guild.channels.cache.get(config.eventLogChannel) ||
|
||||||
|
(await oldThread.guild.channels.fetch(config.eventLogChannel));
|
||||||
|
|
||||||
|
if (!channel || !("send" in channel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!oldThread.archived && newThread.archived) {
|
||||||
|
await channel.send({
|
||||||
|
content: `${newThread.name} has been archived <#${newThread.parentId}>`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldThread.archived && !newThread.archived) {
|
||||||
|
await channel.send({
|
||||||
|
content: `${newThread.name} has been unarchived <#${newThread.parentId}>`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldThread.name !== newThread.name) {
|
||||||
|
await channel.send({
|
||||||
|
content: `${oldThread.name} has been renamed to ${newThread.name} in <#${newThread.parentId}>`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await errorHandler(bot, "on thread update", err);
|
||||||
|
}
|
||||||
|
};
|
82
src/events/voice/onVoiceUpdate.ts
Normal file
82
src/events/voice/onVoiceUpdate.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { VoiceState } from "discord.js";
|
||||||
|
|
||||||
|
import { ExtendedClient } from "../../interfaces/ExtendedClient";
|
||||||
|
import { getConfig } from "../../modules/data/getConfig";
|
||||||
|
import { errorHandler } from "../../utils/errorHandler";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles voice state updates.
|
||||||
|
*
|
||||||
|
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||||
|
* @param {VoiceState} oldVoice The old voice state.
|
||||||
|
* @param {VoiceState} newVoice The new voice state.
|
||||||
|
*/
|
||||||
|
export const onVoiceUpdate = async (
|
||||||
|
bot: ExtendedClient,
|
||||||
|
oldVoice: VoiceState,
|
||||||
|
newVoice: VoiceState
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const config = await getConfig(bot, newVoice.guild.id);
|
||||||
|
|
||||||
|
if (!config.eventLogChannel || !newVoice.member) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel =
|
||||||
|
newVoice.guild.channels.cache.get(config.eventLogChannel) ||
|
||||||
|
(await newVoice.guild.channels.fetch(config.eventLogChannel));
|
||||||
|
|
||||||
|
if (!channel || !("send" in channel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
oldVoice.channelId &&
|
||||||
|
newVoice.channelId &&
|
||||||
|
oldVoice.channelId !== newVoice.channelId
|
||||||
|
) {
|
||||||
|
await channel.send({
|
||||||
|
content: `${newVoice.member.user.tag} (${newVoice.member.id}) has moved from <#!${oldVoice.channelId}> to <#!${newVoice.channelId}>.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldVoice.channelId && !newVoice.channelId) {
|
||||||
|
await channel.send({
|
||||||
|
content: `${newVoice.member.user.tag} (${newVoice.member.id}) has disconnected from <#!${oldVoice.channelId}>.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!oldVoice.channelId && newVoice.channelId) {
|
||||||
|
await channel.send({
|
||||||
|
content: `${newVoice.member.user.tag} (${newVoice.member.id}) has connected to <#!${newVoice.channelId}>.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldVoice.mute && !newVoice.mute) {
|
||||||
|
await channel.send({
|
||||||
|
content: `${newVoice.member.user.tag} (${newVoice.member.id}) has been unmuted.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!oldVoice.mute && newVoice.mute) {
|
||||||
|
await channel.send({
|
||||||
|
content: `${newVoice.member.user.tag} (${newVoice.member.id}) has been muted.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldVoice.deaf && !newVoice.deaf) {
|
||||||
|
await channel.send({
|
||||||
|
content: `${newVoice.member.user.tag} (${newVoice.member.id}) has been undeafened.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!oldVoice.deaf && newVoice.deaf) {
|
||||||
|
await channel.send({
|
||||||
|
content: `${newVoice.member.user.tag} (${newVoice.member.id}) has been deafened.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await errorHandler(bot, "on voice update", err);
|
||||||
|
}
|
||||||
|
};
|
24
src/index.ts
Normal file
24
src/index.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { Client } from "discord.js";
|
||||||
|
|
||||||
|
import { IntentOptions } from "./config/IntentOptions";
|
||||||
|
import { connectDatabase } from "./database/connectDatabase";
|
||||||
|
import { handleEvents } from "./events/_handleEvents";
|
||||||
|
import { ExtendedClient } from "./interfaces/ExtendedClient";
|
||||||
|
import { validateEnv } from "./modules/validateEnv";
|
||||||
|
import { serve } from "./server/serve";
|
||||||
|
import { loadCommands } from "./utils/loadCommands";
|
||||||
|
import { loadContexts } from "./utils/loadContexts";
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const bot = new Client({ intents: IntentOptions }) as ExtendedClient;
|
||||||
|
bot.env = validateEnv();
|
||||||
|
bot.configs = {};
|
||||||
|
bot.commands = await loadCommands(bot);
|
||||||
|
bot.contexts = await loadContexts(bot);
|
||||||
|
|
||||||
|
await connectDatabase(bot);
|
||||||
|
handleEvents(bot);
|
||||||
|
serve(bot);
|
||||||
|
|
||||||
|
await bot.login(bot.env.token);
|
||||||
|
})();
|
30
src/interfaces/Action.ts
Normal file
30
src/interfaces/Action.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
type PastAction =
|
||||||
|
| "warned"
|
||||||
|
| "kicked"
|
||||||
|
| "banned"
|
||||||
|
| "muted"
|
||||||
|
| "unmuted"
|
||||||
|
| "unbanned"
|
||||||
|
| "noted"
|
||||||
|
| "softbanned";
|
||||||
|
|
||||||
|
export type Action =
|
||||||
|
| "warn"
|
||||||
|
| "kick"
|
||||||
|
| "ban"
|
||||||
|
| "mute"
|
||||||
|
| "unmute"
|
||||||
|
| "unban"
|
||||||
|
| "note"
|
||||||
|
| "softban";
|
||||||
|
|
||||||
|
export const ActionToPastTense: { [key in Action]: PastAction } = {
|
||||||
|
warn: "warned",
|
||||||
|
kick: "kicked",
|
||||||
|
ban: "banned",
|
||||||
|
mute: "muted",
|
||||||
|
unmute: "unmuted",
|
||||||
|
unban: "unbanned",
|
||||||
|
note: "noted",
|
||||||
|
softban: "softbanned"
|
||||||
|
};
|
11
src/interfaces/ActionPayload.ts
Normal file
11
src/interfaces/ActionPayload.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Action } from "./Action";
|
||||||
|
|
||||||
|
export interface ActionPayload {
|
||||||
|
userId: string;
|
||||||
|
serverId: string;
|
||||||
|
action: Action;
|
||||||
|
reason: string;
|
||||||
|
moderator: string;
|
||||||
|
duration?: number;
|
||||||
|
pruneDays?: number | undefined;
|
||||||
|
}
|
19
src/interfaces/Command.ts
Normal file
19
src/interfaces/Command.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import {
|
||||||
|
SlashCommandBuilder,
|
||||||
|
SlashCommandOptionsOnlyBuilder,
|
||||||
|
SlashCommandSubcommandsOnlyBuilder
|
||||||
|
} from "discord.js";
|
||||||
|
|
||||||
|
import { ExtendedClient } from "./ExtendedClient";
|
||||||
|
import { GuildCommandInteraction } from "./Interactions";
|
||||||
|
|
||||||
|
export interface Command {
|
||||||
|
data:
|
||||||
|
| SlashCommandOptionsOnlyBuilder
|
||||||
|
| Omit<SlashCommandBuilder, "addSubcommand" | "addSubcommandGroup">
|
||||||
|
| SlashCommandSubcommandsOnlyBuilder;
|
||||||
|
run: (
|
||||||
|
bot: ExtendedClient,
|
||||||
|
interaction: GuildCommandInteraction
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
8
src/interfaces/CommandHandler.ts
Normal file
8
src/interfaces/CommandHandler.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { ExtendedClient } from "./ExtendedClient";
|
||||||
|
import { GuildCommandInteraction } from "./Interactions";
|
||||||
|
|
||||||
|
export type CommandHandler = (
|
||||||
|
bot: ExtendedClient,
|
||||||
|
interaction: GuildCommandInteraction,
|
||||||
|
config: ExtendedClient["configs"][""]
|
||||||
|
) => Promise<void>;
|
13
src/interfaces/Context.ts
Normal file
13
src/interfaces/Context.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { ExtendedClient } from "./ExtendedClient";
|
||||||
|
import { GuildContextInteraction } from "./Interactions";
|
||||||
|
|
||||||
|
export interface Context {
|
||||||
|
data: {
|
||||||
|
name: string;
|
||||||
|
type: 2 | 3;
|
||||||
|
};
|
||||||
|
run: (
|
||||||
|
bot: ExtendedClient,
|
||||||
|
interaction: GuildContextInteraction
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
18
src/interfaces/ExtendedClient.ts
Normal file
18
src/interfaces/ExtendedClient.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { PrismaClient, configs } from "@prisma/client";
|
||||||
|
import { Client, WebhookClient } from "discord.js";
|
||||||
|
|
||||||
|
import { Command } from "./Command";
|
||||||
|
import { Context } from "./Context";
|
||||||
|
|
||||||
|
export interface ExtendedClient extends Client {
|
||||||
|
env: {
|
||||||
|
token: string;
|
||||||
|
debugHook: WebhookClient;
|
||||||
|
mongoUri: string;
|
||||||
|
devMode: boolean;
|
||||||
|
};
|
||||||
|
db: PrismaClient;
|
||||||
|
commands: Command[];
|
||||||
|
contexts: Context[];
|
||||||
|
configs: { [serverId: string]: Omit<configs, "id"> };
|
||||||
|
}
|
190
src/interfaces/GitHubPayloads.ts
Normal file
190
src/interfaces/GitHubPayloads.ts
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* The structure of the NESTED issue data from the GitHub Webhook.
|
||||||
|
*/
|
||||||
|
interface GithubIssuePayload {
|
||||||
|
id: number;
|
||||||
|
node_id: string;
|
||||||
|
url: string;
|
||||||
|
repository_url: string;
|
||||||
|
html_url: string;
|
||||||
|
number: number;
|
||||||
|
state: string;
|
||||||
|
state_reason: string | null;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
user: GithubUserPayload;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
closed_by: GithubUserPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GithubPullRequestPayload {
|
||||||
|
html_url: string;
|
||||||
|
body: string;
|
||||||
|
number: number;
|
||||||
|
merged: boolean;
|
||||||
|
title: string;
|
||||||
|
user: GithubUserPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structure of the repo data, sent on pretty much
|
||||||
|
* every GitHub Webhook payload.
|
||||||
|
*/
|
||||||
|
interface GithubRepoPayload {
|
||||||
|
id: number;
|
||||||
|
node_id: string;
|
||||||
|
name: string;
|
||||||
|
full_name: string;
|
||||||
|
owner: GithubUserPayload;
|
||||||
|
private: boolean;
|
||||||
|
html_url: string;
|
||||||
|
description: string;
|
||||||
|
fork: boolean;
|
||||||
|
url: string;
|
||||||
|
archive_url: string;
|
||||||
|
assignees_url: string;
|
||||||
|
blobs_url: string;
|
||||||
|
branches_url: string;
|
||||||
|
collaborators_url: string;
|
||||||
|
comments_url: string;
|
||||||
|
commits_url: string;
|
||||||
|
compare_url: string;
|
||||||
|
contents_url: string;
|
||||||
|
contributors_url: string;
|
||||||
|
deployments_url: string;
|
||||||
|
downloads_url: string;
|
||||||
|
events_url: string;
|
||||||
|
forks_url: string;
|
||||||
|
git_commits_url: string;
|
||||||
|
git_refs_url: string;
|
||||||
|
git_tags_url: string;
|
||||||
|
git_url: string;
|
||||||
|
issue_comment_url: string;
|
||||||
|
issue_events_url: string;
|
||||||
|
issues_url: string;
|
||||||
|
keys_url: string;
|
||||||
|
labels_url: string;
|
||||||
|
languages_url: string;
|
||||||
|
merges_url: string;
|
||||||
|
milestones_url: string;
|
||||||
|
notifications_url: string;
|
||||||
|
pulls_url: string;
|
||||||
|
releases_url: string;
|
||||||
|
ssh_url: string;
|
||||||
|
stargazers_url: string;
|
||||||
|
statuses_url: string;
|
||||||
|
subscribers_url: string;
|
||||||
|
subscription_url: string;
|
||||||
|
tags_url: string;
|
||||||
|
teams_url: string;
|
||||||
|
trees_url: string;
|
||||||
|
clone_url: string;
|
||||||
|
mirror_url: string;
|
||||||
|
hooks_url: string;
|
||||||
|
svn_url: string;
|
||||||
|
homepage: string;
|
||||||
|
language: string | null;
|
||||||
|
forks: number;
|
||||||
|
forks_count: number;
|
||||||
|
stargazers_count: number;
|
||||||
|
watchers_count: number;
|
||||||
|
watchers: number;
|
||||||
|
size: number;
|
||||||
|
default_branch: string;
|
||||||
|
open_issues_count: number;
|
||||||
|
open_issues: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GithubUserPayload {
|
||||||
|
login: string;
|
||||||
|
id: number;
|
||||||
|
node_id: string;
|
||||||
|
avatar_url: string;
|
||||||
|
gravatar_id: string;
|
||||||
|
url: string;
|
||||||
|
html_url: string;
|
||||||
|
followers_url: string;
|
||||||
|
following_url: string;
|
||||||
|
gists_url: string;
|
||||||
|
starred_url: string;
|
||||||
|
subscriptions_url: string;
|
||||||
|
organizations_url: string;
|
||||||
|
repos_url: string;
|
||||||
|
events_url: string;
|
||||||
|
received_events_url: string;
|
||||||
|
type: string;
|
||||||
|
site_admin: boolean;
|
||||||
|
name: string;
|
||||||
|
company: string;
|
||||||
|
blog: string;
|
||||||
|
location: string;
|
||||||
|
email: string;
|
||||||
|
hireable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The structure of the comment data from the Github Webhook.
|
||||||
|
*/
|
||||||
|
export interface GithubCommentPayload {
|
||||||
|
action: string;
|
||||||
|
issue: GithubIssuePayload;
|
||||||
|
comment: {
|
||||||
|
html_url: string;
|
||||||
|
body: string;
|
||||||
|
user: GithubUserPayload;
|
||||||
|
};
|
||||||
|
repository: GithubRepoPayload;
|
||||||
|
sender: GithubUserPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GithubForkPayload {
|
||||||
|
forkee: GithubRepoPayload;
|
||||||
|
repository: GithubRepoPayload;
|
||||||
|
sender: GithubUserPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The structure of the top level issue data from the GitHub webhook.
|
||||||
|
*/
|
||||||
|
export interface GithubIssuesPayload {
|
||||||
|
action: string;
|
||||||
|
issue: GithubIssuePayload;
|
||||||
|
repository: GithubRepoPayload;
|
||||||
|
sender: GithubUserPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The structure of the ping payload when a new GitHub webhook
|
||||||
|
* is initialised.
|
||||||
|
*/
|
||||||
|
export interface GithubPingPayload {
|
||||||
|
zen: string;
|
||||||
|
hook_id: string;
|
||||||
|
hook: Record<string, unknown>;
|
||||||
|
repository: GithubRepoPayload;
|
||||||
|
organization: Record<string, unknown>;
|
||||||
|
sender: GithubUserPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structure of the pull request data from the GitHub Webhook.
|
||||||
|
*/
|
||||||
|
export interface GithubPullPayload {
|
||||||
|
action: string;
|
||||||
|
number: number;
|
||||||
|
pull_request: GithubPullRequestPayload;
|
||||||
|
repository: GithubRepoPayload;
|
||||||
|
sender: GithubUserPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structure of the star data sent from the GitHub Webhook.
|
||||||
|
*/
|
||||||
|
export interface GithubStarPayload {
|
||||||
|
action: "created" | "deleted";
|
||||||
|
starred_at: string;
|
||||||
|
repository: GithubRepoPayload;
|
||||||
|
sender: GithubUserPayload;
|
||||||
|
}
|
15
src/interfaces/Interactions.ts
Normal file
15
src/interfaces/Interactions.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import {
|
||||||
|
ChatInputCommandInteraction,
|
||||||
|
ContextMenuCommandInteraction,
|
||||||
|
Guild,
|
||||||
|
GuildMember
|
||||||
|
} from "discord.js";
|
||||||
|
|
||||||
|
export interface GuildCommandInteraction extends ChatInputCommandInteraction {
|
||||||
|
guild: Guild;
|
||||||
|
member: GuildMember;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuildContextInteraction extends ContextMenuCommandInteraction {
|
||||||
|
guild: Guild;
|
||||||
|
}
|
28
src/modules/buttons/handleCopyIdButton.ts
Normal file
28
src/modules/buttons/handleCopyIdButton.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { ButtonInteraction } from "discord.js";
|
||||||
|
|
||||||
|
import { ExtendedClient } from "../../interfaces/ExtendedClient";
|
||||||
|
import { errorHandler } from "../../utils/errorHandler";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the logic for the acknowledge button on message reports.
|
||||||
|
*
|
||||||
|
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||||
|
* @param {ButtonInteraction} interaction The interaction payload from Discord.
|
||||||
|
*/
|
||||||
|
export const handleCopyIdButton = async (
|
||||||
|
bot: ExtendedClient,
|
||||||
|
interaction: ButtonInteraction
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
const id = interaction.customId.split("-")[1];
|
||||||
|
await interaction.editReply({
|
||||||
|
content: id || "Unable to parse ID."
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "handle copy id button", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
43
src/modules/buttons/handleReportAcknowledgeButton.ts
Normal file
43
src/modules/buttons/handleReportAcknowledgeButton.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { ButtonInteraction } from "discord.js";
|
||||||
|
|
||||||
|
import { ExtendedClient } from "../../interfaces/ExtendedClient";
|
||||||
|
import { errorHandler } from "../../utils/errorHandler";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the logic for the acknowledge button on message reports.
|
||||||
|
*
|
||||||
|
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||||
|
* @param {ButtonInteraction} interaction The interaction payload from Discord.
|
||||||
|
*/
|
||||||
|
export const handleReportAcknowledgeButton = async (
|
||||||
|
bot: ExtendedClient,
|
||||||
|
interaction: ButtonInteraction
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await interaction.deferUpdate();
|
||||||
|
const message = interaction.message;
|
||||||
|
const embed = message.embeds[0];
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [
|
||||||
|
{
|
||||||
|
title: embed?.title || "wtf",
|
||||||
|
description: embed?.description || "wtf",
|
||||||
|
fields: [
|
||||||
|
...(embed?.fields ?? []),
|
||||||
|
{
|
||||||
|
name: "Acknowledged by",
|
||||||
|
value: `<@${interaction.user.id}>`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
color: 0x00ff00
|
||||||
|
}
|
||||||
|
],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "handle report acknowledge button", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
24
src/modules/commands/calculateMuteDuration.ts
Normal file
24
src/modules/commands/calculateMuteDuration.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Parses a value/unit pair into a number of milliseconds. For example,
|
||||||
|
* (1, "seconds") would return one second in milliseconds.
|
||||||
|
*
|
||||||
|
* @param {number} value The number of "unit" to convert to milliseconds.
|
||||||
|
* @param {string} unit The unit of time to convert to milliseconds.
|
||||||
|
* @returns {number} The number of milliseconds.
|
||||||
|
*/
|
||||||
|
export const calculateMuteDuration = (value: number, unit: string) => {
|
||||||
|
switch (unit) {
|
||||||
|
case "seconds":
|
||||||
|
return value * 1000;
|
||||||
|
case "minutes":
|
||||||
|
return value * 60000;
|
||||||
|
case "hours":
|
||||||
|
return value * 3600000;
|
||||||
|
case "days":
|
||||||
|
return value * 86400000;
|
||||||
|
case "weeks":
|
||||||
|
return value * 604800000;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
213
src/modules/commands/generateProfileImage.ts
Normal file
213
src/modules/commands/generateProfileImage.ts
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
import { levels } from "@prisma/client";
|
||||||
|
import { AttachmentBuilder } from "discord.js";
|
||||||
|
import nodeHtmlToImage from "node-html-to-image";
|
||||||
|
|
||||||
|
import { ExtendedClient } from "../../interfaces/ExtendedClient";
|
||||||
|
import { errorHandler } from "../../utils/errorHandler";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an image from the user's profile settings, converts it into a Discord
|
||||||
|
* attachment, and returns it.
|
||||||
|
*
|
||||||
|
* @param {ExtendedClient} CamperChan The CamperChan's Discord instance.
|
||||||
|
* @param {levels} record The user's record from the database.
|
||||||
|
* @returns {AttachmentBuilder} The attachment, or null on error.
|
||||||
|
*/
|
||||||
|
export const generateProfileImage = async (
|
||||||
|
CamperChan: ExtendedClient,
|
||||||
|
record: levels
|
||||||
|
): Promise<AttachmentBuilder | null> => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
avatar,
|
||||||
|
backgroundColour,
|
||||||
|
backgroundImage,
|
||||||
|
colour,
|
||||||
|
username,
|
||||||
|
level,
|
||||||
|
points
|
||||||
|
} = record;
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Roboto";
|
||||||
|
src: url("https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,100..700;1,100..700");
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background: url(${backgroundImage}) no-repeat center center fixed;
|
||||||
|
background-size: cover;
|
||||||
|
width: 1920px;
|
||||||
|
height: 1080px;
|
||||||
|
text-align: center;
|
||||||
|
font-family: "Roboto", Courier, monospace;
|
||||||
|
font-size: 75px;
|
||||||
|
padding: 2.5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #${backgroundColour}bf;
|
||||||
|
color: #${colour};
|
||||||
|
padding: 2.5%;
|
||||||
|
border-radius: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 250px;
|
||||||
|
height: 250px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 125px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 250px auto;
|
||||||
|
justify-items: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<div class="header">
|
||||||
|
<img class="avatar" src=${avatar || "https://cdn.freecodecamp.org/platform/universal/fcc_puck_500.jpg"}></img>
|
||||||
|
<div>
|
||||||
|
<h1>${username}</h1>
|
||||||
|
<p>Level ${level} (${points.toLocaleString("en-GB")}xp)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
`;
|
||||||
|
const alt = `${username} is at level ${level} with ${points.toLocaleString("en-GB")} experience points.`;
|
||||||
|
|
||||||
|
const image = await nodeHtmlToImage({
|
||||||
|
html,
|
||||||
|
selector: "body",
|
||||||
|
transparent: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!(image instanceof Buffer)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachment = new AttachmentBuilder(image, {
|
||||||
|
name: `${username}.png`,
|
||||||
|
description: alt
|
||||||
|
});
|
||||||
|
|
||||||
|
return attachment;
|
||||||
|
} catch (err) {
|
||||||
|
await errorHandler(CamperChan, "generate profile image module", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the image for the leaderboard.
|
||||||
|
*
|
||||||
|
* @param {ExtendedClient} CamperChan The CamperChan's Discord instance.
|
||||||
|
* @param {levels} levels The user's record from the database.
|
||||||
|
* @returns {AttachmentBuilder} The attachment, or null on error.
|
||||||
|
*/
|
||||||
|
export const generateLeaderboardImage = async (
|
||||||
|
CamperChan: ExtendedClient,
|
||||||
|
levels: (levels & { index: number })[]
|
||||||
|
): Promise<AttachmentBuilder | null> => {
|
||||||
|
try {
|
||||||
|
const html = `
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Roboto";
|
||||||
|
src: url("https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,100..700;1,100..700");
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background: transparent;
|
||||||
|
height: 4100px;
|
||||||
|
width: 2510px;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 250px;
|
||||||
|
height: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 100px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
width: 2500px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 250px 2250px;
|
||||||
|
height: 400px;
|
||||||
|
margin: 5px 10px;
|
||||||
|
justify-items: left;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<body>
|
||||||
|
${levels.map(
|
||||||
|
(l) =>
|
||||||
|
`<div class="row" style="background-color: #${l.backgroundColour || "0a0a23"}bf;color: #${l.colour || "d0d0d5"};padding: 2.5%;border-radius: 100px;">
|
||||||
|
<img style="border-radius: 50%;" src=${l.avatar || "https://cdn.freecodecamp.org/platform/universal/fcc_puck_500.jpg"}></img>
|
||||||
|
<div style="text-align: left;padding-left:100px;">
|
||||||
|
<h1>#${l.index}. ${l.username}</h1>
|
||||||
|
<p>Level ${l.level} (${l.points}xp)</p>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
)}
|
||||||
|
</body>
|
||||||
|
`;
|
||||||
|
const alt = levels
|
||||||
|
.map(
|
||||||
|
(l) =>
|
||||||
|
`${l.username} is rank ${l.index} at ${l.level} with ${l.points.toLocaleString("en-GB")} experience points.`
|
||||||
|
)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
const image = await nodeHtmlToImage({
|
||||||
|
html,
|
||||||
|
selector: "body",
|
||||||
|
transparent: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!(image instanceof Buffer)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachment = new AttachmentBuilder(image, {
|
||||||
|
name: `leaderboard-${levels[0]?.index}.png`,
|
||||||
|
description: alt
|
||||||
|
});
|
||||||
|
|
||||||
|
return attachment;
|
||||||
|
} catch (err) {
|
||||||
|
await errorHandler(CamperChan, "generate leaderboard image module", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
32
src/modules/commands/profileValidation.ts
Normal file
32
src/modules/commands/profileValidation.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Checks if a string matches a 6 character hex code.
|
||||||
|
*
|
||||||
|
* @param {string} colour The colour code to validate.
|
||||||
|
* @returns {boolean} If the string is in the correct format.
|
||||||
|
*/
|
||||||
|
export const validateColour = (colour: string): boolean => {
|
||||||
|
return /[\da-f]{6}/gi.test(colour);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a url points to a valid image.
|
||||||
|
*
|
||||||
|
* @param {string} url The URL to validate.
|
||||||
|
* @returns {boolean} If the URL provides a 2XX response, and if the response content type
|
||||||
|
* is an image.
|
||||||
|
*/
|
||||||
|
export const validateImage = async (url: string): Promise<boolean> => {
|
||||||
|
const validImage = await fetch(url, {
|
||||||
|
method: "HEAD"
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!validImage) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validImage.headers.get("content-type")?.startsWith("image/")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
39
src/modules/data/getConfig.ts
Normal file
39
src/modules/data/getConfig.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { configs } from "@prisma/client";
|
||||||
|
|
||||||
|
import { defaultConfig } from "../../config/DefaultConfig";
|
||||||
|
import { ExtendedClient } from "../../interfaces/ExtendedClient";
|
||||||
|
import { errorHandler } from "../../utils/errorHandler";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module to get the server config from the cache, database, or default.
|
||||||
|
*
|
||||||
|
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||||
|
* @param {string} serverId The ID of the server to get the config for.
|
||||||
|
* @returns {ExtendedClient["config"]} The server config.
|
||||||
|
*/
|
||||||
|
export const getConfig = async (
|
||||||
|
bot: ExtendedClient,
|
||||||
|
serverId: string
|
||||||
|
): Promise<Omit<configs, "id">> => {
|
||||||
|
try {
|
||||||
|
const exists = bot.configs[serverId];
|
||||||
|
if (exists) {
|
||||||
|
return exists;
|
||||||
|
}
|
||||||
|
const record = await bot.db.configs.upsert({
|
||||||
|
where: {
|
||||||
|
serverId
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
...defaultConfig,
|
||||||
|
serverId
|
||||||
|
},
|
||||||
|
update: {}
|
||||||
|
});
|
||||||
|
bot.configs[serverId] = record;
|
||||||
|
return record;
|
||||||
|
} catch (err) {
|
||||||
|
await errorHandler(bot, "get config", err);
|
||||||
|
return defaultConfig;
|
||||||
|
}
|
||||||
|
};
|
40
src/modules/data/setConfig.ts
Normal file
40
src/modules/data/setConfig.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { defaultConfig } from "../../config/DefaultConfig";
|
||||||
|
import { ExtendedClient } from "../../interfaces/ExtendedClient";
|
||||||
|
import { errorHandler } from "../../utils/errorHandler";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module to update a config.
|
||||||
|
*
|
||||||
|
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||||
|
* @param {string} serverId The ID of the server to update.
|
||||||
|
* @param {keyof ExtendedClient["config"]} setting The setting to update.
|
||||||
|
* @param {number | string} value The value to update the setting to.
|
||||||
|
* @returns {boolean} True on successful update.
|
||||||
|
*/
|
||||||
|
export const setConfig = async (
|
||||||
|
bot: ExtendedClient,
|
||||||
|
serverId: string,
|
||||||
|
setting: keyof ExtendedClient["configs"][""],
|
||||||
|
value: number | string
|
||||||
|
): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const configData = await bot.db.configs.upsert({
|
||||||
|
where: {
|
||||||
|
serverId
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
...defaultConfig,
|
||||||
|
serverId,
|
||||||
|
[setting]: value
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
[setting]: value
|
||||||
|
}
|
||||||
|
});
|
||||||
|
bot.configs[serverId] = configData;
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
await errorHandler(bot, "set config", err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
46
src/modules/events/checkSpamDomain.ts
Normal file
46
src/modules/events/checkSpamDomain.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { ExtendedClient } from "../../interfaces/ExtendedClient";
|
||||||
|
import { errorHandler } from "../../utils/errorHandler";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a domain is a known source of Discord scams.
|
||||||
|
*
|
||||||
|
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||||
|
* @param {string} domain The domain to validate. DO NOT include the protocol or path.
|
||||||
|
* @returns {boolean} True if the domain is known as a scam.
|
||||||
|
*/
|
||||||
|
export const checkSpamDomain = async (
|
||||||
|
bot: ExtendedClient,
|
||||||
|
domain: string
|
||||||
|
): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const walshyReq = await fetch("https://bad-domains.walshy.dev/check", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
"X-Identity": "Naomi's mod bot - built by naomi_lgbt"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ domain })
|
||||||
|
});
|
||||||
|
const walshyRes = (await walshyReq.json()) as { badDomain: boolean };
|
||||||
|
if (walshyRes.badDomain) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const yachtsReq = await fetch(
|
||||||
|
`https://phish.sinking.yachts/v2/check/${encodeURI(domain)}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
"X-Identity": "Naomi's mod bot - built by naomi_lgbt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const yachtsRes = (await yachtsReq.json()) as boolean;
|
||||||
|
if (yachtsRes) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (err) {
|
||||||
|
await errorHandler(bot, "load spam domains", err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
35
src/modules/events/getModActionFromAuditLog.ts
Normal file
35
src/modules/events/getModActionFromAuditLog.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { AuditLogEvent, GuildAuditLogsEntry } from "discord.js";
|
||||||
|
|
||||||
|
import { Action } from "../../interfaces/Action";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module to parse the audit log entry and return the moderation action.
|
||||||
|
*
|
||||||
|
* @param {GuildAuditLogsEntry} log The audit log entry payload.
|
||||||
|
* @returns {Action | null} The mod action string, or null if not found.
|
||||||
|
*/
|
||||||
|
export const getModActionFromAuditLog = (
|
||||||
|
log: GuildAuditLogsEntry
|
||||||
|
): Action | null => {
|
||||||
|
const muteChange = log.changes.find(
|
||||||
|
(change) => change.key === "communication_disabled_until"
|
||||||
|
);
|
||||||
|
switch (log.action) {
|
||||||
|
case AuditLogEvent.MemberBanAdd:
|
||||||
|
return "ban";
|
||||||
|
case AuditLogEvent.MemberBanRemove:
|
||||||
|
return "unban";
|
||||||
|
case AuditLogEvent.MemberKick:
|
||||||
|
return "kick";
|
||||||
|
case AuditLogEvent.MemberUpdate:
|
||||||
|
if (!muteChange) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (muteChange.new) {
|
||||||
|
return "mute";
|
||||||
|
}
|
||||||
|
return "unmute";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
55
src/modules/interactions/handleChatInputCommand.ts
Normal file
55
src/modules/interactions/handleChatInputCommand.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { ChatInputCommandInteraction } from "discord.js";
|
||||||
|
|
||||||
|
import { ExtendedClient } from "../../interfaces/ExtendedClient";
|
||||||
|
import { errorHandler } from "../../utils/errorHandler";
|
||||||
|
import { isModerator } from "../../utils/isModerator";
|
||||||
|
import { isGuildCommandInteraction } from "../validateGuildCommands";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the logic for running slash commands.
|
||||||
|
*
|
||||||
|
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||||
|
* @param {ChatInputCommandInteraction} interaction The interaction payload from Discord.
|
||||||
|
*/
|
||||||
|
export const handleChatInputCommand = async (
|
||||||
|
bot: ExtendedClient,
|
||||||
|
interaction: ChatInputCommandInteraction
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (!isGuildCommandInteraction(interaction)) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: "You can only use commands in a server.",
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const command = bot.commands.find(
|
||||||
|
(c) => c.data.name === interaction.commandName
|
||||||
|
);
|
||||||
|
if (!command) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: "That's not a valid command. Please contact Naomi.",
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(!interaction.member || !isModerator(interaction.member)) &&
|
||||||
|
!["leaderboard", "rank", "profile", "role", "help", "ping"].includes(
|
||||||
|
interaction.commandName
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: "You must be a moderator to use bot commands.",
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await command.run(bot, interaction);
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "handle chat input command", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
42
src/modules/interactions/handleContextMenuCommand.ts
Normal file
42
src/modules/interactions/handleContextMenuCommand.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { ContextMenuCommandInteraction } from "discord.js";
|
||||||
|
|
||||||
|
import { ExtendedClient } from "../../interfaces/ExtendedClient";
|
||||||
|
import { errorHandler } from "../../utils/errorHandler";
|
||||||
|
import { isGuildContextInteraction } from "../validateGuildCommands";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the logic for running context commands.
|
||||||
|
*
|
||||||
|
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||||
|
* @param {ContextMenuCommandInteraction} interaction The interaction payload from Discord.
|
||||||
|
*/
|
||||||
|
export const handleContextMenuCommand = async (
|
||||||
|
bot: ExtendedClient,
|
||||||
|
interaction: ContextMenuCommandInteraction
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (!isGuildContextInteraction(interaction)) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: "You can only use this in a server.",
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const context = bot.contexts.find(
|
||||||
|
(c) => c.data.name === interaction.commandName
|
||||||
|
);
|
||||||
|
if (!context) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: "That's not a valid context. Please contact Naomi.",
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await context.run(bot, interaction);
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "handle context menu command", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
127
src/modules/modals/handleMassBanModal.ts
Normal file
127
src/modules/modals/handleMassBanModal.ts
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import {
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
ComponentType,
|
||||||
|
EmbedBuilder,
|
||||||
|
Message,
|
||||||
|
ModalSubmitInteraction
|
||||||
|
} from "discord.js";
|
||||||
|
|
||||||
|
import { ExtendedClient } from "../../interfaces/ExtendedClient";
|
||||||
|
import { errorHandler } from "../../utils/errorHandler";
|
||||||
|
import { getConfig } from "../data/getConfig";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the submission of the mass ban form.
|
||||||
|
*
|
||||||
|
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||||
|
* @param {ModalSubmitInteraction} interaction The interaction payload from Discord.
|
||||||
|
*/
|
||||||
|
export const handleMassBanModal = async (
|
||||||
|
bot: ExtendedClient,
|
||||||
|
interaction: ModalSubmitInteraction
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
const { guild } = interaction;
|
||||||
|
if (!guild) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "This command can only be used in a guild."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBanList = interaction.fields.getTextInputValue("mass-ban-ids");
|
||||||
|
const banList = rawBanList.trim().split(/\b/g);
|
||||||
|
|
||||||
|
const reason = interaction.fields.getTextInputValue("reason");
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder();
|
||||||
|
embed.setTitle("Confirm Mass Ban of Following IDs:");
|
||||||
|
embed.setDescription(banList.join("\n"));
|
||||||
|
embed.addFields({
|
||||||
|
name: "Reason",
|
||||||
|
value: reason
|
||||||
|
});
|
||||||
|
|
||||||
|
const yes = new ButtonBuilder()
|
||||||
|
.setCustomId("confirm")
|
||||||
|
.setLabel("Confirm")
|
||||||
|
.setStyle(ButtonStyle.Success);
|
||||||
|
const no = new ButtonBuilder()
|
||||||
|
.setCustomId("cancel")
|
||||||
|
.setLabel("Cancel")
|
||||||
|
.setStyle(ButtonStyle.Danger);
|
||||||
|
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(yes, no);
|
||||||
|
const response = (await interaction.editReply({
|
||||||
|
embeds: [embed],
|
||||||
|
components: [row]
|
||||||
|
})) as Message;
|
||||||
|
|
||||||
|
const collector =
|
||||||
|
response.createMessageComponentCollector<ComponentType.Button>({
|
||||||
|
filter: (click) => click.user.id === interaction.user.id,
|
||||||
|
time: 10000,
|
||||||
|
max: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on("end", async (clicks) => {
|
||||||
|
const choice = clicks.first()?.customId;
|
||||||
|
if (!clicks || clicks.size <= 0 || !choice) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "This command has timed out.",
|
||||||
|
embeds: [],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (choice === "confirm") {
|
||||||
|
for (const id of banList) {
|
||||||
|
await guild.bans.create(id, {
|
||||||
|
reason: `Massban by ${interaction.user.tag} for: ${reason}`,
|
||||||
|
deleteMessageSeconds: 86400
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getConfig(bot, guild.id);
|
||||||
|
if (!config.modLogChannel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const channel =
|
||||||
|
guild.channels.cache.get(config.modLogChannel) ||
|
||||||
|
(await guild.channels.fetch(config.modLogChannel));
|
||||||
|
|
||||||
|
if (!channel || !("send" in channel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
embed.setTitle("Mass Ban:");
|
||||||
|
embed.setAuthor({
|
||||||
|
name: interaction.user.tag,
|
||||||
|
iconURL: interaction.user.displayAvatarURL()
|
||||||
|
});
|
||||||
|
await channel.send({ embeds: [embed] });
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "Mass ban complete.",
|
||||||
|
embeds: [],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (choice === "cancel") {
|
||||||
|
interaction.editReply({
|
||||||
|
content: "Mass ban cancelled.",
|
||||||
|
embeds: [],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "handle mass ban modal", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
82
src/modules/modals/handleMessageReportModal.ts
Normal file
82
src/modules/modals/handleMessageReportModal.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { ModalSubmitInteraction } from "discord.js";
|
||||||
|
|
||||||
|
import { ExtendedClient } from "../../interfaces/ExtendedClient";
|
||||||
|
import { errorHandler } from "../../utils/errorHandler";
|
||||||
|
import { getConfig } from "../data/getConfig";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the submission of the message report form.
|
||||||
|
*
|
||||||
|
* @param {ExtendedClient} bot The bot's Discord instance.
|
||||||
|
* @param {ModalSubmitInteraction} interaction The interaction payload from Discord.
|
||||||
|
*/
|
||||||
|
export const handleMessageReportModal = async (
|
||||||
|
bot: ExtendedClient,
|
||||||
|
interaction: ModalSubmitInteraction
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
if (!interaction.guild) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "This command can only be used in a guild."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reportLogId = interaction.customId.split("-")[1] ?? "oops";
|
||||||
|
const config = await getConfig(bot, interaction.guild.id);
|
||||||
|
|
||||||
|
if (!config.messageReportChannel) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "Reporting has not been set up for this server."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel =
|
||||||
|
interaction.guild.channels.cache.get(config.messageReportChannel) ||
|
||||||
|
(await interaction.guild.channels.fetch(config.messageReportChannel));
|
||||||
|
|
||||||
|
if (!channel || !("send" in channel)) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "Reporting channel not found."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportLog = await channel.messages
|
||||||
|
.fetch(reportLogId)
|
||||||
|
.catch(() => null);
|
||||||
|
if (!reportLog) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "Could not find the report log."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = reportLog.embeds[0];
|
||||||
|
await reportLog.edit({
|
||||||
|
embeds: [
|
||||||
|
{
|
||||||
|
title: embed?.title || "wtf",
|
||||||
|
description: embed?.description || "wtf",
|
||||||
|
fields: [
|
||||||
|
...(embed?.fields ?? []),
|
||||||
|
{
|
||||||
|
name: "Reason",
|
||||||
|
value: interaction.fields.getTextInputValue("reason")
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "Your report has been submitted."
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "handle message report modal", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
34
src/modules/subcommands/config/handleAppealLink.ts
Normal file
34
src/modules/subcommands/config/handleAppealLink.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { CommandHandler } from "../../../interfaces/CommandHandler";
|
||||||
|
import { errorHandler } from "../../../utils/errorHandler";
|
||||||
|
import { setConfig } from "../../data/setConfig";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the ban appeal link for the server.
|
||||||
|
*/
|
||||||
|
export const handleAppealLink: CommandHandler = async (bot, interaction) => {
|
||||||
|
try {
|
||||||
|
const link = interaction.options.getString("link", true);
|
||||||
|
|
||||||
|
const success = await setConfig(
|
||||||
|
bot,
|
||||||
|
interaction.guild.id,
|
||||||
|
"banAppealLink",
|
||||||
|
link
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Members who are banned can appeal at <${link}>.`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "Failed to set the settings."
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "automod logging subcommand", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
34
src/modules/subcommands/config/handleInviteLink.ts
Normal file
34
src/modules/subcommands/config/handleInviteLink.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { CommandHandler } from "../../../interfaces/CommandHandler";
|
||||||
|
import { errorHandler } from "../../../utils/errorHandler";
|
||||||
|
import { setConfig } from "../../data/setConfig";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the invite link for the server.
|
||||||
|
*/
|
||||||
|
export const handleInviteLink: CommandHandler = async (bot, interaction) => {
|
||||||
|
try {
|
||||||
|
const link = interaction.options.getString("link", true);
|
||||||
|
|
||||||
|
const success = await setConfig(
|
||||||
|
bot,
|
||||||
|
interaction.guild.id,
|
||||||
|
"inviteLink",
|
||||||
|
link
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Members who are kicked will be invited back with <${link}>.`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "Failed to set the settings."
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "automod logging subcommand", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
141
src/modules/subcommands/config/handleList.ts
Normal file
141
src/modules/subcommands/config/handleList.ts
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import {
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
ComponentType,
|
||||||
|
EmbedBuilder
|
||||||
|
} from "discord.js";
|
||||||
|
|
||||||
|
import { CommandHandler } from "../../../interfaces/CommandHandler";
|
||||||
|
import { errorHandler } from "../../../utils/errorHandler";
|
||||||
|
import { getNextIndex, getPreviousIndex } from "../../../utils/getArrayIndex";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the automod settings for the given guild.
|
||||||
|
*/
|
||||||
|
export const handleList: CommandHandler = async (bot, interaction, config) => {
|
||||||
|
try {
|
||||||
|
const embed = new EmbedBuilder();
|
||||||
|
|
||||||
|
embed.setTitle("Automod Settings");
|
||||||
|
embed.addFields([
|
||||||
|
{
|
||||||
|
name: "Moderation Log Channel",
|
||||||
|
value: config.modLogChannel ? `<#${config.modLogChannel}>` : "Not set.",
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Event Log Channel",
|
||||||
|
value: config.eventLogChannel
|
||||||
|
? `<#${config.eventLogChannel}>`
|
||||||
|
: "Not set.",
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Message Report Channel",
|
||||||
|
value: config.messageReportChannel
|
||||||
|
? `<#${config.messageReportChannel}>`
|
||||||
|
: "Not set.",
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invite Link",
|
||||||
|
value: config.inviteLink || "None",
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Ban Appeal Link",
|
||||||
|
value: config.banAppealLink || "None",
|
||||||
|
inline: true
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const roles = await bot.db.levelRoles.findMany({
|
||||||
|
where: { serverId: interaction.guild.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
const levelRoles = new EmbedBuilder();
|
||||||
|
levelRoles.setTitle("Level Roles");
|
||||||
|
levelRoles.setDescription(
|
||||||
|
roles
|
||||||
|
.map((r) => `- <@&${r.roleId}> is assigned at level ${r.level}`)
|
||||||
|
.join("\n") || "No roles are currently set."
|
||||||
|
);
|
||||||
|
|
||||||
|
const assignRoles = await bot.db.roles.findMany({
|
||||||
|
where: {
|
||||||
|
serverId: interaction.guild.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const assignRolesEmbed = new EmbedBuilder();
|
||||||
|
assignRolesEmbed.setTitle("Self-Assignable Roles");
|
||||||
|
assignRolesEmbed.setDescription(
|
||||||
|
assignRoles.map((r) => `<@&${r.roleId}>`).join(" ") ||
|
||||||
|
"No roles are currently set."
|
||||||
|
);
|
||||||
|
const embeds = [embed, levelRoles, assignRolesEmbed];
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
const nextButton = new ButtonBuilder()
|
||||||
|
.setCustomId("next")
|
||||||
|
.setStyle(ButtonStyle.Primary)
|
||||||
|
.setLabel(
|
||||||
|
embeds[getNextIndex(embeds, index)]?.data.title || "Unknown embed."
|
||||||
|
)
|
||||||
|
.setEmoji("▶️");
|
||||||
|
const prevButton = new ButtonBuilder()
|
||||||
|
.setCustomId("prev")
|
||||||
|
.setStyle(ButtonStyle.Primary)
|
||||||
|
.setLabel(
|
||||||
|
embeds[getPreviousIndex(embeds, index)]?.data.title || "Unknown embed."
|
||||||
|
)
|
||||||
|
.setEmoji("◀️");
|
||||||
|
const initialRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
prevButton,
|
||||||
|
nextButton
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await interaction.editReply({
|
||||||
|
embeds: [embeds[index] as EmbedBuilder],
|
||||||
|
components: [initialRow]
|
||||||
|
});
|
||||||
|
|
||||||
|
const collector =
|
||||||
|
response.createMessageComponentCollector<ComponentType.Button>({
|
||||||
|
time: 1000 * 60 * 5
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on("collect", async (i) => {
|
||||||
|
await i.deferUpdate();
|
||||||
|
index =
|
||||||
|
i.customId === "next"
|
||||||
|
? getNextIndex(embeds, index)
|
||||||
|
: getPreviousIndex(embeds, index);
|
||||||
|
prevButton.setLabel(
|
||||||
|
embeds[getPreviousIndex(embeds, index)]?.data.title || "Unknown embed."
|
||||||
|
);
|
||||||
|
nextButton.setLabel(
|
||||||
|
embeds[getNextIndex(embeds, index)]?.data.title || "Unknown embed."
|
||||||
|
);
|
||||||
|
const newRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
prevButton,
|
||||||
|
nextButton
|
||||||
|
);
|
||||||
|
await i.editReply({
|
||||||
|
embeds: [embeds[index] as EmbedBuilder],
|
||||||
|
components: [newRow]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on("end", async () => {
|
||||||
|
await interaction.editReply({
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "automod list subcommand", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
40
src/modules/subcommands/config/handleLogging.ts
Normal file
40
src/modules/subcommands/config/handleLogging.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { logChannelChoicesMap } from "../../../config/LogChannelChoices";
|
||||||
|
import { CommandHandler } from "../../../interfaces/CommandHandler";
|
||||||
|
import { ExtendedClient } from "../../../interfaces/ExtendedClient";
|
||||||
|
import { errorHandler } from "../../../utils/errorHandler";
|
||||||
|
import { setConfig } from "../../data/setConfig";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the logging channel for the server.
|
||||||
|
*/
|
||||||
|
export const handleLogging: CommandHandler = async (bot, interaction) => {
|
||||||
|
try {
|
||||||
|
const logType = interaction.options.getString(
|
||||||
|
"log-type",
|
||||||
|
true
|
||||||
|
) as keyof ExtendedClient["configs"][""];
|
||||||
|
const channel = interaction.options.getChannel("channel", true);
|
||||||
|
|
||||||
|
const success = await setConfig(
|
||||||
|
bot,
|
||||||
|
interaction.guild.id,
|
||||||
|
logType,
|
||||||
|
channel.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Your server will log ${logChannelChoicesMap[logType]} in <#${channel.id}>.`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "Failed to set the settings."
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "automod logging subcommand", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
47
src/modules/subcommands/config/handleRole.ts
Normal file
47
src/modules/subcommands/config/handleRole.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { CommandHandler } from "../../../interfaces/CommandHandler";
|
||||||
|
import { errorHandler } from "../../../utils/errorHandler";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles a role to be self-assignable or not.
|
||||||
|
*/
|
||||||
|
export const handleRole: CommandHandler = async (bot, interaction) => {
|
||||||
|
try {
|
||||||
|
const role = interaction.options.getRole("role", true);
|
||||||
|
const exists = await bot.db.roles.findUnique({
|
||||||
|
where: {
|
||||||
|
serverId_roleId: {
|
||||||
|
serverId: interaction.guild.id,
|
||||||
|
roleId: role.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (exists) {
|
||||||
|
await bot.db.roles.delete({
|
||||||
|
where: {
|
||||||
|
serverId_roleId: {
|
||||||
|
serverId: interaction.guild.id,
|
||||||
|
roleId: role.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Your <@&${role.id}> role is no longer self-assignable.`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await bot.db.roles.create({
|
||||||
|
data: {
|
||||||
|
serverId: interaction.guild.id,
|
||||||
|
roleId: role.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Your <@&${role.id}> role is now self-assignable.`
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const id = await errorHandler(bot, "automod logging subcommand", err);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\``
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
27
src/modules/validateEnv.ts
Normal file
27
src/modules/validateEnv.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { WebhookClient } from "discord.js";
|
||||||
|
|
||||||
|
import { ExtendedClient } from "../interfaces/ExtendedClient";
|
||||||
|
import { logHandler } from "../utils/logHandler";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the environment variables and constructs the object.
|
||||||
|
*
|
||||||
|
* @returns {ExtendedClient["env"]} The environment variable object to attach to the bot.
|
||||||
|
*/
|
||||||
|
export const validateEnv = (): ExtendedClient["env"] => {
|
||||||
|
if (
|
||||||
|
!process.env.BOT_TOKEN ||
|
||||||
|
!process.env.DEBUG_HOOK ||
|
||||||
|
!process.env.MONGO_URI
|
||||||
|
) {
|
||||||
|
logHandler.log("error", "MIssing environment variables!");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: process.env.BOT_TOKEN,
|
||||||
|
debugHook: new WebhookClient({ url: process.env.DEBUG_HOOK }),
|
||||||
|
mongoUri: process.env.MONGO_URI,
|
||||||
|
devMode: process.env.NODE_ENV !== "production"
|
||||||
|
};
|
||||||
|
};
|
33
src/modules/validateGuildCommands.ts
Normal file
33
src/modules/validateGuildCommands.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import {
|
||||||
|
ChatInputCommandInteraction,
|
||||||
|
ContextMenuCommandInteraction,
|
||||||
|
Guild,
|
||||||
|
GuildMember
|
||||||
|
} from "discord.js";
|
||||||
|
|
||||||
|
import {
|
||||||
|
GuildCommandInteraction,
|
||||||
|
GuildContextInteraction
|
||||||
|
} from "../interfaces/Interactions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a slash command payload has the guild and member.
|
||||||
|
*
|
||||||
|
* @param {ChatInputCommandInteraction} interaction The interaction payload from Discord.
|
||||||
|
* @returns {boolean} Whether the expected properties are present.
|
||||||
|
*/
|
||||||
|
export const isGuildCommandInteraction = (
|
||||||
|
interaction: ChatInputCommandInteraction
|
||||||
|
): interaction is GuildCommandInteraction =>
|
||||||
|
interaction.guild instanceof Guild &&
|
||||||
|
interaction.member instanceof GuildMember;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a slash command payload has the guild and member.
|
||||||
|
*
|
||||||
|
* @param {ContextMenuCommandInteraction} interaction The interaction payload from Discord.
|
||||||
|
* @returns {boolean} Whether the expected properties are present.
|
||||||
|
*/
|
||||||
|
export const isGuildContextInteraction = (
|
||||||
|
interaction: ContextMenuCommandInteraction
|
||||||
|
): interaction is GuildContextInteraction => interaction.guild instanceof Guild;
|
5
src/server/github/generateCommentEmbed.ts
Normal file
5
src/server/github/generateCommentEmbed.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { GithubCommentPayload } from "../../interfaces/GitHubPayloads";
|
||||||
|
|
||||||
|
export const generateCommentEmbed = (data: GithubCommentPayload): string => {
|
||||||
|
return `[New comment detected on ${data.repository.name}#${data.issue.number}.](<${data.comment.html_url}>)`;
|
||||||
|
};
|
5
src/server/github/generateForkEmbed.ts
Normal file
5
src/server/github/generateForkEmbed.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { GithubForkPayload } from "../../interfaces/GitHubPayloads";
|
||||||
|
|
||||||
|
export const generateForkEmbed = (data: GithubForkPayload): string => {
|
||||||
|
return `[New fork detected - ${data.repository.name}](<${data.forkee.html_url}>)`;
|
||||||
|
};
|
25
src/server/github/generateIssueEmbed.ts
Normal file
25
src/server/github/generateIssueEmbed.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { GithubIssuesPayload } from "../../interfaces/GitHubPayloads";
|
||||||
|
|
||||||
|
export const generateIssuesEmbed = (
|
||||||
|
data: GithubIssuesPayload
|
||||||
|
): string | null => {
|
||||||
|
if (!["opened", "edited", "closed"].includes(data.action)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (data.action === "closed") {
|
||||||
|
if (data.issue.state_reason === "completed") {
|
||||||
|
return `[Issue closed as complete - ${data.repository.name}#${data.issue.number}](<${data.issue.html_url}>)`;
|
||||||
|
}
|
||||||
|
if (data.issue.state_reason === "not_planned") {
|
||||||
|
return `[Issue closed as not planned - ${data.repository.name}#${data.issue.number}](<${data.issue.html_url}>)`;
|
||||||
|
}
|
||||||
|
return `[Issue closed - ${data.repository.name}#${data.issue.number}](<${data.issue.html_url}>)`;
|
||||||
|
}
|
||||||
|
if (data.issue.state_reason === "reopened") {
|
||||||
|
return `[Issue reopened - ${data.repository.name}#${data.issue.number}](<${data.issue.html_url}>)`;
|
||||||
|
}
|
||||||
|
if (data.action === "edited") {
|
||||||
|
return `[Issue updated - ${data.repository.name}#${data.issue.number}](<${data.issue.html_url}>)`;
|
||||||
|
}
|
||||||
|
return `[New issue created - ${data.repository.name}#${data.issue.number}](<${data.issue.html_url}>)`;
|
||||||
|
};
|
5
src/server/github/generatePingEmbed.ts
Normal file
5
src/server/github/generatePingEmbed.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { GithubPingPayload } from "../../interfaces/GitHubPayloads";
|
||||||
|
|
||||||
|
export const generatePingEmbed = (data: GithubPingPayload): string => {
|
||||||
|
return `[Now watching ${data.repository.name}](<${data.repository.url}>)`;
|
||||||
|
};
|
17
src/server/github/generatePullEmbed.ts
Normal file
17
src/server/github/generatePullEmbed.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { GithubPullPayload } from "../../interfaces/GitHubPayloads";
|
||||||
|
|
||||||
|
export const generatePullEmbed = (data: GithubPullPayload): string | null => {
|
||||||
|
if (!["opened", "edited", "closed"].includes(data.action)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (data.pull_request.merged) {
|
||||||
|
return `[Pull request merged - ${data.repository.name}#${data.pull_request.number}](<${data.pull_request.html_url}>)`;
|
||||||
|
}
|
||||||
|
if (data.action === "edited") {
|
||||||
|
return `[Pull request updated - ${data.repository.name}#${data.pull_request.number}](<${data.pull_request.html_url}>)`;
|
||||||
|
}
|
||||||
|
if (data.action === "closed") {
|
||||||
|
return `[Pull request closed - ${data.repository.name}#${data.pull_request.number}](<${data.pull_request.html_url}>)`;
|
||||||
|
}
|
||||||
|
return `[New pull request - ${data.repository.name}#${data.pull_request.number}](<${data.pull_request.html_url}>)`;
|
||||||
|
};
|
8
src/server/github/generateStarEmbed.ts
Normal file
8
src/server/github/generateStarEmbed.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { GithubStarPayload } from "../../interfaces/GitHubPayloads";
|
||||||
|
|
||||||
|
export const generateStarEmbed = (data: GithubStarPayload): string | null => {
|
||||||
|
if (data.action !== "created") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `[New stargazer! ${data.repository.name}](<${data.repository.html_url}>)`;
|
||||||
|
};
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user