diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +node_modules diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..265a5b0 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +DISCORD_TOKEN= +DEFAULT_PREFIX=! +DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public" diff --git a/.gitignore b/.gitignore index ce1fa8f..d1ff3d6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +dist/ lib-cov *.seed *.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c57ac4f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,80 @@ +# ================ # +# Base Stage # +# ================ # + +FROM node:16-buster-slim as base + +WORKDIR /opt/app + +ENV HUSKY=0 +ENV CI=true + +RUN apt-get update && \ + apt-get upgrade -y --no-install-recommends && \ + apt-get install -y --no-install-recommends build-essential python3 libfontconfig1 dumb-init && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# ------------------------------------ # +# Conditional steps for end-users # +# ------------------------------------ # + +COPY --chown=node:node yarn.lock . + +ENV NODE_OPTIONS="--enable-source-maps" + +# ---------------------------------------- # +# End Conditional steps for end-users # +# ---------------------------------------- # + +COPY --chown=node:node package.json . +COPY --chown=node:node tsconfig.json . + +RUN sed -i 's/"prepare": "husky install\( .github\/husky\)\?"/"prepare": ""/' ./package.json + +ENTRYPOINT ["dumb-init", "--"] + +# =================== # +# Development Stage # +# =================== # + +# Development, used for development only (defaults to watch command) +FROM base as development + +ENV NODE_ENV="development" + +USER node + +CMD [ "npm", "run", "docker:watch"] + +# ================ # +# Builder Stage # +# ================ # + +# Build stage for production +FROM base as build + +RUN npm install + +COPY . /opt/app + +RUN npm run build + +# ==================== # +# Production Stage # +# ==================== # + +# Production image used to run the bot in production, only contains node_modules & dist contents. +FROM base as production + +ENV NODE_ENV="production" + +COPY --from=build /opt/app/dist /opt/app/dist +COPY --from=build /opt/app/node_modules /opt/app/node_modules +COPY --from=build /opt/app/package.json /opt/app/package.json + +RUN chown node:node /opt/app/ + +USER node + +CMD [ "npm", "run", "start"] diff --git a/LICENSE b/LICENSE index 148cae4..b0c62e8 100644 --- a/LICENSE +++ b/LICENSE @@ -631,7 +631,7 @@ to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - Copyright (C) 2021 NewCircuit + Copyright (C) 2022 FG-Devs This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -651,7 +651,7 @@ Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: - Suggestions Bot Copyright (C) 2021 NewCircuit + Suggestions Bot Copyright (C) 2021-2022 FG-Devs This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. diff --git a/README.md b/README.md deleted file mode 100644 index 493f021..0000000 --- a/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# Suggestions Bot -It's always great to add a suggestions channel to your Discord server whether -it's for your community or even YouTube. This bot helps you implement a -suggestions channel and allows the moderators to moderate the incoming -suggestions with ease. - -W.I.P diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e3a2ad4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +version: "3.9" + +services: + suggestions-bot: + build: + context: . + target: development + env_file: .env + volumes: + - ./:/opt/app diff --git a/package.json b/package.json index ab4304c..4178fc2 100644 --- a/package.json +++ b/package.json @@ -1,34 +1,36 @@ { "name": "suggestions", "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", - "start": "tsc && node build/." - }, - "keywords": [], - "author": "NewCircuit ", + "main": "src/index.ts", + "repository": "git@github.com:fg-devs/suggestions.git", + "author": "Matthew Bakhtiari ", "license": "GPL-3.0", - "dependencies": { - "@types/concat-stream": "^1.6.0", - "@types/js-yaml": "^3.12.5", - "@types/pg": "^7.14.5", - "@types/request": "^2.48.5", - "concat-stream": "^2.0.0", - "discord.js": "^12.3.1", - "discord.js-commando": "github:discordjs/Commando", - "js-yaml": "^3.14.0", - "pg": "^8.3.3", - "request": "^2.88.2" + "scripts": { + "start": "tsc && node dist/index.js" }, "devDependencies": { - "@types/node": "^14.10.1", - "@typescript-eslint/eslint-plugin": "^4.16.1", - "@typescript-eslint/parser": "^4.16.1", - "eslint": "^7.21.0", - "eslint-config-airbnb-base": "^14.2.1", - "eslint-plugin-import": "^2.22.1", - "typescript": "^4.2.3" + "@sapphire/ts-config": "^3.3.3", + "@types/node": "^17.0.23", + "@types/ws": "^8.5.3", + "prisma": "^3.11.1", + "ts-node": "^10.7.0", + "typescript": "^4.6.2" + }, + "dependencies": { + "@discordjs/collection": "^0.5.0", + "@sapphire/decorators": "^4.3.3", + "@sapphire/discord-utilities": "^2.10.2", + "@sapphire/discord.js-utilities": "^4.9.3", + "@sapphire/fetch": "^2.3.0", + "@sapphire/framework": "^3.0.0-next.0ea9553.0", + "@sapphire/plugin-api": "^3.2.0", + "@sapphire/plugin-editable-commands": "^1.2.0", + "@sapphire/plugin-logger": "^2.2.0", + "@sapphire/plugin-subcommands": "^2.2.0", + "@sapphire/time-utilities": "^1.7.3", + "@sapphire/type": "^2.2.0", + "@sapphire/utilities": "^3.6.1", + "discord.js": "^13.6.0", + "dotenv": "^16.0.0" } } diff --git a/pg.sql b/pg.sql deleted file mode 100644 index 448d4cd..0000000 --- a/pg.sql +++ /dev/null @@ -1,66 +0,0 @@ -create schema if not exists anon_muting; - -create table if not exists anon_muting.users -( - user_id text not null - constraint users_pk - primary key, - muted boolean default false not null, - offence smallint default 0 not null, - muted_at timestamp -); - -alter table anon_muting.users - owner to current_user; - -create unique index if not exists users_user_id_uindex - on anon_muting.users (user_id); - -create table if not exists anon_muting.events -( - created_by text not null, - submissions_channel_id text not null, - review_channel_id text not null, - event_id serial not null - constraint events_pk - primary key, - name text not null, - active boolean default true not null, - restriction integer default 0 not null -); - -alter table anon_muting.events - owner to current_user; - -create unique index if not exists events_submissions_channel_id_uindex - on anon_muting.events (submissions_channel_id); - -create unique index if not exists events_event_id_uindex - on anon_muting.events (event_id); - -create table if not exists anon_muting.submissions -( - submission_id bigserial not null - constraint submissions_pk - primary key, - user_id text not null, - approved boolean, - review_message_id text not null, - event_id integer not null - constraint submissions_events_event_id_fk - references anon_muting.events, - reviewed_by text -); - -alter table anon_muting.submissions - owner to current_user; - -create unique index if not exists submissions_review_message_id_uindex - on anon_muting.submissions (review_message_id); - -create unique index if not exists submissions_review_message_id_uindex_2 - on anon_muting.submissions (review_message_id); - -create unique index if not exists submissions_submission_id_uindex - on anon_muting.submissions (submission_id); - diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..d205f42 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,11 @@ +// 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 = "postgresql" + url = env("DATABASE_URL") +} diff --git a/src/commands/General/ping.ts b/src/commands/General/ping.ts new file mode 100644 index 0000000..3994886 --- /dev/null +++ b/src/commands/General/ping.ts @@ -0,0 +1,21 @@ +import { Command, CommandOptions, PieceContext } from '@sapphire/framework'; +import type { Message } from 'discord.js'; + +export class UserCommand extends Command { + public constructor(context: PieceContext, options: CommandOptions) { + super(context, { + ...options, + description: 'ping pong' + }); + } + + public async messageRun(message: Message) { + const msg = await message.channel.send('Ping?'); + + return msg.edit( + `Pong from Docker! Bot Latency ${Math.round(this.container.client.ws.ping)}ms. API Latency ${ + msg.createdTimestamp - message.createdTimestamp + }ms.` + ); + } +} diff --git a/src/commands/bot/utils.ts b/src/commands/bot/utils.ts deleted file mode 100644 index 91e38ab..0000000 --- a/src/commands/bot/utils.ts +++ /dev/null @@ -1,117 +0,0 @@ -import * as discord from 'discord.js'; -import { MessageEmbed } from 'discord.js'; -import { CommandoClient, CommandoMessage } from 'discord.js-commando'; -import { pool } from '../../db/db'; - -export async function getMember(uid: string, guild: discord.Guild) { - let uidParsed = uid; - // Check if user was tagged or not. If the user was tagged remove the - // tag from id. - if (uid.startsWith('<@') && uid.endsWith('>')) { - const re = new RegExp('[<@!>]', 'g'); - uidParsed = uid.replace(re, ''); - } - // Try recovering the user and report if it was successful or not. - try { - return await guild.members.fetch(uidParsed); - } catch (e) { - console.log(`User not found because ${e}`); - return undefined; - } -} - -export async function getChannel(uid: string, client: CommandoClient) { - let uidParsed = uid; - // Check if user was tagged or not. If the user was tagged remove the - // tag from id. - if (uid.startsWith('<#') && uid.endsWith('>')) { - const re = new RegExp('[<#!>]', 'g'); - uidParsed = uid.replace(re, ''); - } - // Try recovering the user and report if it was successful or not. - try { - return await client.channels.fetch(uidParsed, true, true); - } catch (e) { - console.log(`User not found because ${e}`); - return undefined; - } -} - -export function sleep(ms: number) { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -export async function checkIfUserMuted(userId: string) { - const res = await pool.query('SELECT * FROM anon_muting.users WHERE user_id = $1', [userId]); - return !(res.rowCount === 0 || !res.rows[0].muted); -} - -export function getMuteReadableTime(offence: number) { - switch (offence) { - case 1: - return 'for 7 days'; - case 2: - return 'for 14 days'; - case 3: - return 'for 1 month'; - default: - return 'permanently'; - } -} - -export function getFileExtension(filename: string) { - return filename.substr(filename.lastIndexOf('.') + 1); -} - -export async function selectRestriction(msg: CommandoMessage): Promise { - // 0: No restrictions 🟢 - // 1: image only 📷 - // 2: gif/mp4 only 🎥 - // 3: mp4/gif/image only 📸 - // 4: text only 📖 - - const embed = new MessageEmbed({ - title: 'Select restriction', - color: 'BLURPLE', - description: '__**Options:**__\n\n' - + '🟢: **No restrictions**\n' - + '📷: **Image only**.\n' - + '🎥: **Gif/mp4 only.**\n' - + '📸: **Require attachment.**\n' - + '📖: **No attachments allowed, text only.**', - }); - - const embedMsg = await msg.channel.send(embed); - - const emotes = ['🟢', '📷', '🎥', '📸', '📖']; - - for (const emote of emotes) { - await embedMsg.react(emote); - } - - const collected = await embedMsg.awaitReactions((reaction, user) => emotes.includes(reaction.emoji.name) && user.id === msg.author.id, - { max: 1, time: 60000, errors: ['time'] }); - - const reaction = collected.first(); - if (reaction === undefined) { - console.error('How tf did you get here'); - return 0; - } - - switch (reaction.emoji.name) { - case '🟢': - return 0; - case '📷': - return 1; - case '🎥': - return 2; - case '📸': - return 3; - case '📖': - return 4; - default: - return 0; - } -} diff --git a/src/commands/events/linkevent.ts b/src/commands/events/linkevent.ts deleted file mode 100644 index 12961d3..0000000 --- a/src/commands/events/linkevent.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { CommandoClient, Command, CommandoMessage } from 'discord.js-commando'; -import { getChannel, selectRestriction } from '../bot/utils'; -import { pool } from '../../db/db'; - -export = class CreateEvent extends Command { - constructor(bot: CommandoClient) { - super(bot, { - name: 'link_event', - aliases: ['levent', 'event_link', 'lev', 'link'], - group: 'events', - memberName: 'link event', - userPermissions: ['MANAGE_CHANNELS'], - description: 'Link an event for submissions', - args: [ - { - key: 'name', - prompt: 'The name for the event.', - type: 'string', - }, - { - key: 'channel', - prompt: 'The channel I need to look on for this event.', - type: 'string', - }, - { - key: 'reviewChannel', - prompt: 'The channel I need to send review requests in.', - type: 'string', - }, - ], - argsType: 'multiple', - guildOnly: true, - }); - } - - async run(msg: CommandoMessage, { name, channel, reviewChannel }: {name: string, channel: string, reviewChannel: string}) { - const restriction = await selectRestriction(msg); - const Channel = await getChannel(channel, this.client); - - if (Channel === undefined) { - return msg.reply('Unable to locate that submission channel'); - } - - const reviewChannelFetched = await getChannel(reviewChannel, this.client); - - if (reviewChannelFetched === undefined) { - return msg.reply('Unable to locate that review channel'); - } - - await pool.query('INSERT INTO anon_muting.events (created_by, submissions_channel_id, review_channel_id, name, restriction) \ - VALUES ($1, $2, $3, $4, $5) \ - ON CONFLICT (submissions_channel_id) \ - DO UPDATE SET active = true, review_channel_id = $3, name = $4, restriction = $5', - [msg.author.id, Channel.id, reviewChannelFetched.id, name, restriction]); - - return msg.say('Linked!'); - } -} diff --git a/src/commands/events/stopevent.ts b/src/commands/events/stopevent.ts deleted file mode 100644 index 668404b..0000000 --- a/src/commands/events/stopevent.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { CommandoClient, Command, CommandoMessage } from 'discord.js-commando'; -import { getChannel } from '../bot/utils'; -import { pool } from '../../db/db'; - -export = class CreateEvent extends Command { - constructor(bot: CommandoClient) { - super(bot, { - name: 'stop_event', - aliases: ['sevent', 'event_stop', 'sev', 'delete_event', 'devent', 'event_delete', 'dev', 'clear'], - group: 'events', - memberName: 'stop event', - userPermissions: ['MANAGE_CHANNELS'], - description: 'Stop event for submissions', - args: [ - { - key: 'channel', - prompt: 'The channel which I should stop looking in for submissions. (Can be a mention of either the submission channel or the review channel', - type: 'string', - }, - ], - argsType: 'multiple', - guildOnly: true, - }); - } - - async run(msg: CommandoMessage, { channel }: {channel: string}) { - const Channel = await getChannel(channel, this.client); - - if (Channel === undefined) { - return msg.reply('Unable to locate that channel'); - } - - await pool.query('UPDATE anon_muting.events SET active = false WHERE submissions_channel_id = $1 OR review_channel_id = $1', [Channel.id]); - - return msg.say('I will stop looking in those channels!'); - } -} diff --git a/src/commands/staff/removesuggestmute.ts b/src/commands/staff/removesuggestmute.ts deleted file mode 100644 index a9584f3..0000000 --- a/src/commands/staff/removesuggestmute.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { CommandoClient, Command, CommandoMessage } from 'discord.js-commando'; -import * as utils from '../bot/utils'; -import { checkIfUserMuted } from '../bot/utils'; -import { pool } from '../../db/db'; - -// mute command -export = class UnMuteCommand extends Command { - // constructor for the command class where we define attributes used - constructor(bot: CommandoClient) { - super(bot, { - name: 'unmute', - aliases: ['rsm'], - group: 'staff', - memberName: 'suggestion unmute', - userPermissions: ['MANAGE_CHANNELS'], - description: 'Unmute a user from suggestions', - args: [ - { - key: 'UserID', - prompt: 'ID of a user that will be muted', - type: 'string', - }, - ], - argsPromptLimit: 0, - argsType: 'multiple', - guildOnly: true, - }); - } - - // Function that executes when command is provided in chat - async run(msg: CommandoMessage, { UserID }: {UserID: string}) { - const muteRole = msg.guild.roles.cache.find((role) => role.name == 'Suggestionmuted'); - - const member = await utils.getMember(UserID, msg.guild); - - if (member === undefined) { - return msg.reply('Please mention a valid member of this server'); - } - - if (!await checkIfUserMuted(member.id)) { - return msg.reply('User is not muted!'); - } - - await pool.query('UPDATE anon_muting.users SET muted = false WHERE user_id = $1', [member.id]); - - return msg.say(`Unmuted **${member.user.tag}**`); - } -} diff --git a/src/commands/staff/suggestmute.ts b/src/commands/staff/suggestmute.ts deleted file mode 100644 index 84ecf21..0000000 --- a/src/commands/staff/suggestmute.ts +++ /dev/null @@ -1,65 +0,0 @@ -import * as commando from 'discord.js-commando'; -import { CommandoClient, Command, CommandoMessage } from 'discord.js-commando'; -import { GuildMember, Snowflake } from 'discord.js'; -import * as utils from '../bot/utils'; -import { pool } from '../../db/db'; -import { checkIfUserMuted, getMuteReadableTime } from '../bot/utils'; - -// mute command -export = class MuteCommand extends commando.Command { - // constructor for the command class where we define attributes used - constructor(bot: CommandoClient) { - super(bot, { - name: 'mute', - aliases: ['sm'], - group: 'staff', - memberName: 'suggestion mute', - userPermissions: ['MANAGE_CHANNELS'], - description: 'Mutes a user from suggestions', - args: [ - { - key: 'UserID', - prompt: 'ID of a user that will be muted', - type: 'string', - }, - ], - argsPromptLimit: 0, - argsType: 'multiple', - guildOnly: true, - }); - } - - async run(msg: CommandoMessage, { UserID }: { UserID: string }) { - const member = await utils.getMember(UserID, msg.guild); - - if (member === undefined) { - return msg.reply('Please mention a valid member of this server'); - } - - if (await checkIfUserMuted(member.id)) { - return msg.reply('User is already muted.'); - } - - const offence = await MuteCommand.mute(member); - - return msg.channel.send(`Suggestion Muted **${member.user.tag}** ${getMuteReadableTime(offence)}`); - } - - public static async mute(member: GuildMember): Promise { - const res = await pool.query('SELECT * FROM anon_muting.users WHERE user_id = $1 LIMIT 1', [member.user.id]); - - const offence = res.rowCount === 0 ? 1 : res.rows[0].offence + 1; - - await pool.query( - 'INSERT INTO anon_muting.users (user_id, muted, offence, muted_at) \ - VALUES ($1, true, $2, now()) ON CONFLICT (user_id) DO UPDATE SET muted = true, offence = $2', [member.user.id, offence], - ); - - try { - await member.send(`You have been Suggestion muted ${getMuteReadableTime(offence)}`); - } catch (_) { - } - - return offence; - } -} diff --git a/src/commands/staff/userinfo.ts b/src/commands/staff/userinfo.ts deleted file mode 100644 index 507dbaa..0000000 --- a/src/commands/staff/userinfo.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { CommandoClient, Command, CommandoMessage } from 'discord.js-commando'; -import { MessageEmbed } from 'discord.js'; -import { getMember } from '../bot/utils'; -import { pool } from '../../db/db'; - -export = class UserInfo extends Command { - constructor(bot: CommandoClient) { - super(bot, { - name: 'userinfo', - aliases: ['ui', 'about'], - group: 'staff', - memberName: 'userinfo', - userPermissions: ['MANAGE_CHANNELS'], - description: 'Get information about user', - args: [ - { - key: 'userID', - prompt: 'The user you want to display', - type: 'string', - }, - ], - argsPromptLimit: 0, - argsType: 'multiple', - guildOnly: true, - }); - } - - async run(msg: CommandoMessage, { userID }: {userID: string}) { - const member = await getMember(userID, msg.guild); - - if (member == undefined) { - return await msg.reply('Unable to locate that user.'); - } - - const res = await pool.query('SELECT * FROM anon_muting.users WHERE user_id = $1', [member.id]); - if (res.rowCount === 0) { - return await msg.reply("Sorry, I don't have any information about that user."); - } - const userDb = res.rows[0]; - const embed = new MessageEmbed({ - author: { - name: `${member.user.username}#${member.user.discriminator} (${member.id})`, - iconURL: member.user.displayAvatarURL(), - }, - description: - `• Muted: **${UserInfo.makeBoolReadable(userDb.muted)}** - • Offence: **Lvl. ${userDb.offence}**`, - color: 'BLURPLE', - footer: { - text: `Requested by ${msg.author.username}#${msg.author.discriminator}`, - iconURL: msg.author.displayAvatarURL(), - }, - timestamp: new Date(), - }); - - return msg.say(embed); - } - - // Function that executes if something blocked the exuction of the run function. - // e.g. Insufficient permissions, throttling, nsfw, ... - async onBlock(msg: CommandoMessage) { - // Member that wanted to unmute didn't have enough perms to do it. Report - // it back and delete message after a second. - return msg.channel.send('Insufficient permissions to run this command.');/* .delete({timeout:utils.MILIS});/ */ - } - - private static makeBoolReadable(i: boolean) { - return i ? 'Yes' : 'No'; - } -} diff --git a/src/config/config.ts b/src/config/config.ts deleted file mode 100644 index 479c6bb..0000000 --- a/src/config/config.ts +++ /dev/null @@ -1,20 +0,0 @@ -import fs from "fs"; -import * as yaml from "js-yaml"; - -export interface Config { - token: string - mute_role: string - prefix: string - database: { - user: string, - host: string, - database: string, - password: string, - port: number, - } -} - -export function loadConfig(): Config { - let fileContents = fs.readFileSync('./config.yml', 'utf-8'); - return yaml.safeLoad(fileContents); -} diff --git a/src/db/db.ts b/src/db/db.ts deleted file mode 100644 index 8e21200..0000000 --- a/src/db/db.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Pool, PoolClient } from 'pg'; -import {loadConfig} from "../config/config"; -import * as fs from "fs"; - -let config = loadConfig(); - -// Create a new pool for db access. -// Database information is given in the config file. -// Export pool so it can be used in other files -export const pool = new Pool({ - host: config.database.host, - database: config.database.database, - port: config.database.port, - user: config.database.user, - password: config.database.password, -}); - -const sql = fs.readFileSync("pg.sql").toString(); - -// Create a connection for the pool so schema and table can be created and used -// by the bot. -pool.connect((err?: Error, client?: PoolClient, rel?: (_?: any) => void) => { - if (err) { - return console.error('Error acquiring client', err.stack) - } - // if error is undefined then client is not. - client = client as PoolClient - - let errHandle = (err?: Error) => { - if (err) { - return console.error('Error executing query', err.stack); - } - }; - - client.query(sql, errHandle); -}); - diff --git a/src/handler/messagehandler.ts b/src/handler/messagehandler.ts deleted file mode 100644 index 7e1bc13..0000000 --- a/src/handler/messagehandler.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { - Message, MessageAttachment, MessageEmbed, TextChannel, -} from 'discord.js'; -import { CommandoClient } from 'discord.js-commando'; -import { pool } from '../db/db'; -import { checkIfUserMuted, getFileExtension } from '../commands/bot/utils'; - -const request = require('request').defaults({ encoding: null }); - -export default class MessageHandler { - private readonly msg: Message; - - private client: CommandoClient; - - constructor(message: Message, client: CommandoClient) { - this.msg = message; - this.client = client; - this.handleSubmission().then((_) => _); - } - - private async handleSubmission() { - const event = await pool.query('SELECT * FROM anon_muting.events WHERE submissions_channel_id = $1 AND active = true', [this.msg.channel.id]); - if (event.rowCount === 0) return; // Return if channel not in database - - const DMChannel = await this.msg.author.createDM(); - - if (await checkIfUserMuted(this.msg.author.id)) { - await this.msg.delete(); - return; - } - - // eslint-disable-next-line max-len - const channel = (await this.client.channels.fetch(event.rows[0].review_channel_id)) as TextChannel; - const embed = new MessageEmbed({ - title: 'Submission review', - timestamp: new Date(), - description: this.msg.content, - author: { - name: `${this.msg.author.username}#${this.msg.author.discriminator}`, - iconURL: this.msg.author.displayAvatarURL(), - }, - footer: { - text: `Event: ${event.rows[0].name}`, - }, - color: 'BLURPLE', - }); - - const attachment = this.msg.attachments.first(); - let img; - - if (attachment != null) { - if (event.rows[0].restriction === 4) { - await this.msg.delete(); - await DMChannel.send('Sorry, this event is text only. No attachments allowed'); - return; - } - - img = await new Promise((resolve) => { - // @ts-ignore - request.get(attachment.url, async (err: any, res: any, body: Buffer) => { - if (body != null) { - resolve(body); - } - }); - }); - } else if (![0, 4].includes(event.rows[0].restriction)) { - await this.msg.delete(); - await DMChannel.send('Sorry, this event requires an attachment.'); - return; - } - - if (img != null) { - // @ts-ignore - const ext = getFileExtension(attachment.name); - if (!MessageHandler.checkIfExtValid(ext.trim(), event.rows[0].restriction)) { - await this.msg.delete(); - await DMChannel.send('Sorry, that file extension is not allowed in the current event.\n' - + 'If it does follow the rules try formatting it to a common extension like png or mp4'); - return; - } - embed.files = [new MessageAttachment((img) as Buffer, `file.${ext}`)]; - embed.setImage(`attachment://file.${ext}`); - } - - const reviewMsg = await channel.send(embed); - - await this.msg.delete(); - - try { - await DMChannel.send('Thanks for submitting! Your post is currently in review and will show up shortly'); - } catch (_) { - - } - - await reviewMsg.react('👍'); - await reviewMsg.react('👎'); - await reviewMsg.react('🔇'); - - await pool.query('INSERT INTO anon_muting.submissions \ - (user_id, review_message_id, event_id) VALUES \ - ($1, $2, $3)', [this.msg.author.id, reviewMsg.id, event.rows[0].event_id]); - } - - private static checkIfExtValid(extension: string, restriction: number): boolean { - // 0: No restrictions - // 1: image only - // 2: gif/video only - // 3: mp4/gif/image only - // 4: text only - - switch (restriction) { - case 0: - return true; - case 1: - return (/\.|jpe?g|tiff?|png|webp|bmp$/i).test(extension); - case 2: - return (/\.|gif|mp4|mov|wmv|flv$/i).test(extension); - case 3: - return (/\.|gif|mp4|mov|wmv|flv|jpe?g|tiff?|png|webp|bmp$/i).test(extension); - default: - return false; - } - } -} diff --git a/src/handler/reactionhandler.ts b/src/handler/reactionhandler.ts deleted file mode 100644 index 318c6b5..0000000 --- a/src/handler/reactionhandler.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { - MessageEmbed, MessageEmbedImage, MessageReaction, TextChannel, User, -} from 'discord.js'; -import { CommandoClient } from 'discord.js-commando'; -import { pool } from '../db/db'; -import MuteCommand from '../commands/staff/suggestmute'; -import { getFileExtension, getMember, getMuteReadableTime } from '../commands/bot/utils'; - -export default class ReactionHandler { - private readonly reaction: MessageReaction; - - private client: CommandoClient; - - private user: User; - - private readonly red: number; - - private readonly green: number; - - private readonly purple: number; - - constructor(Reaction: MessageReaction, user: User, client: CommandoClient) { - this.reaction = Reaction; - this.client = client; - this.user = user; - this.red = 0xff0000; - this.green = 0x008000; - this.purple = 0x9400D3; - this.handleSubmissionReview().then((_) => _); - } - - private async handleSubmissionReview() { - const submission = await pool.query( - 'SELECT e.submissions_channel_id, submission_id, user_id FROM anon_muting.submissions \ - INNER JOIN anon_muting.events e on e.event_id = submissions.event_id \ - WHERE submissions.review_message_id = $1 AND e.review_channel_id = $2', [this.reaction.message.id, this.reaction.message.channel.id], - ); - if (submission.rowCount === 0 || this.reaction.message.author != this.client.user) return; - - const embed = this.reaction.message.embeds[0]; - let approved: boolean; - - let imageUrl: MessageEmbedImage | null; - if (embed.image != null) { - const file = (embed.image) as MessageEmbedImage; - // @ts-ignore - const ext = getFileExtension(file.url); - imageUrl = embed.image; - embed.setImage(`attachment://file.${ext}`); - } - - switch (this.reaction.emoji.name) { - case '👍': - embed.color = this.green; - embed.title = 'Approved'; - embed.footer = { - text: `Approved by ${this.user.username}#${this.user.discriminator}`, - iconURL: this.user.displayAvatarURL(), - }; - - await this.reaction.message.edit(embed); - - const submissionsChannel = (await this.client.channels.fetch(submission.rows[0].submissions_channel_id)) as TextChannel; - - const submissionEmbed = new MessageEmbed({ - timestamp: new Date(), - color: 'BLURPLE', - }); - if (embed.description != null) { - submissionEmbed.setDescription(embed.description); - } - - // @ts-ignore - submissionEmbed.image = imageUrl; - submissionEmbed.author = { - name: 'Submission', - }; - await submissionsChannel.send(submissionEmbed); - - approved = true; - break; - - case '👎': - embed.files.pop(); - embed.color = this.red; - embed.title = 'Removed'; - embed.footer = { - text: `Removed by ${this.user.username}#${this.user.discriminator}`, - iconURL: this.user.displayAvatarURL(), - }; - - await this.reaction.message.edit(embed); - - approved = false; - break; - case '🔇': - // @ts-ignore - const member = await getMember(submission.rows[0].user_id, this.reaction.message.guild); - if (member === undefined) { - await this.reaction.message.channel.send('Something went wrong. Please contact my dad'); - return; - } - - const offence = await MuteCommand.mute(member); - - embed.files.pop(); - embed.title = `Removed & Muted ${getMuteReadableTime(offence)}`; - embed.color = this.purple; - embed.footer = { - text: `Removed by ${this.user.username}#${this.user.discriminator}`, - iconURL: this.user.displayAvatarURL(), - }; - - await this.reaction.message.edit(embed); - - approved = false; - break; - default: - return; - } - - await this.reaction.message.reactions.removeAll(); - - await pool.query('UPDATE anon_muting.submissions \ - SET approved = $1, reviewed_by = $2 \ - WHERE submission_id = $3', [approved, this.user.id, submission.rows[0].submission_id]); - } -} diff --git a/src/index.ts b/src/index.ts index c108907..60ec688 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,53 +1,40 @@ -import { CommandoClient } from 'discord.js-commando'; -import path from 'path'; -import { MessageReaction, User } from 'discord.js'; -import { loadConfig } from './config/config'; -import MessageHandler from './handler/messagehandler'; -import ReactionHandler from './handler/reactionhandler'; -import MuteLoop from './loops/muteloop'; - -export const config = loadConfig(); - -// Create a new commando client with provided attributes -const bot: CommandoClient = new CommandoClient({ - commandPrefix: config.prefix, - commandEditableDuration: 10, - nonCommandEditable: false, +import 'dotenv/config' +import './lib/setup'; +import { LogLevel, SapphireClient } from '@sapphire/framework'; + +const client = new SapphireClient({ + defaultPrefix: process.env.DEFAULT_PREFIX, + regexPrefix: /^(hey +)?bot[,! ]/i, + caseInsensitiveCommands: true, + logger: { + level: LogLevel.Debug + }, + shards: 'auto', + intents: [ + 'GUILDS', + 'GUILD_MEMBERS', + 'GUILD_BANS', + 'GUILD_EMOJIS_AND_STICKERS', + 'GUILD_VOICE_STATES', + 'GUILD_MESSAGES', + 'GUILD_MESSAGE_REACTIONS', + 'DIRECT_MESSAGES', + 'DIRECT_MESSAGE_REACTIONS' + ] }); -// Register bot commands -bot.registry - .registerGroups([ - ['public'], - ['staff'], - ['logs'], - ['events'], - ]) - .registerDefaults() - .registerCommandsIn(path.join(__dirname, 'commands')); - -// Function that executes when bot is running. Calls the periodic function -// to check if there are any muted users. -bot.on('ready', async () => { - if (bot.user === null) return; - console.log(`${bot.user.tag} is online!`); - await bot.user.setActivity('your submissions', { type: 'WATCHING' }); - - const muteloop = new MuteLoop(bot); - setInterval(() => { - muteloop.run().then((_) => _); - }, 300000); // 5 Minutes (300000) -}); - -bot.on('message', async (msg) => { - if (msg.author === bot.user) return; - new MessageHandler(msg, bot); -}); - -bot.on('messageReactionAdd', async (reaction: MessageReaction, user) => { - if (user === bot.user) return; - new ReactionHandler(reaction, user as User, bot); -}); +const main = async () => { + try { + client.logger.debug(process.env) + client.logger.info('Logging in'); + await client.login(process.env.DISCORD_TOKEN); + client.logger.info('logged in'); + } catch (error) { + client.logger.fatal(error); + client.destroy(); + process.exit(1); + } +}; + +main(); -// login bot for given token -bot.login(config.token).catch(console.log); diff --git a/src/lib/setup.ts b/src/lib/setup.ts new file mode 100644 index 0000000..2ee3571 --- /dev/null +++ b/src/lib/setup.ts @@ -0,0 +1,11 @@ +import '@sapphire/plugin-logger/register'; +import '@sapphire/plugin-api/register'; +import '@sapphire/plugin-editable-commands/register'; +import * as colorette from 'colorette'; +import { inspect } from 'util'; + +// Set default inspection depth +inspect.defaultOptions.depth = 1; + +// Enable colorette +colorette.createColors({ useColor: true }); diff --git a/src/loops/muteloop.ts b/src/loops/muteloop.ts deleted file mode 100644 index 396e742..0000000 --- a/src/loops/muteloop.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { CommandoClient } from 'discord.js-commando'; -import { pool } from '../db/db'; - -export default class MuteLoop { - private client: CommandoClient; - - constructor(bot: CommandoClient) { - this.client = bot; - } - - public async run() { - const res = await pool.query('SELECT * FROM anon_muting.users WHERE muted = true AND offence < 4'); - for (let i = 0; i < res.rows.length; i++) { - if (MuteLoop.canBeUnmuted(res.rows[i].offence, res.rows[i].muted_at)) { - await pool.query('UPDATE anon_muting.users SET muted = false WHERE user_id = $1', [res.rows[i].user_id]); - } - } - } - - private static canBeUnmuted(offence: number, mutedAt: Date) { - const now = new Date(); - switch (offence) { - case 1: - const daysAgo7 = new Date().setDate(now.getDate() - 7); - return (mutedAt.getTime() <= daysAgo7); - case 2: - const daysAgo14 = new Date().setDate(now.getDate() - 14); - return (mutedAt.getTime() <= daysAgo14); - case 3: - const monthAgo = new Date().setMonth(now.getMonth() - 1); - return (mutedAt.getTime() <= monthAgo); - default: - return false; - } - } -} diff --git a/tsconfig.json b/tsconfig.json index 870a25b..72ac11a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,70 +1,13 @@ { - "compilerOptions": { - /* Visit https://aka.ms/tsconfig.json to read more about this file */ - - /* Basic Options */ - // "incremental": true, /* Enable incremental compilation */ - "target": "es2020", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ - "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ - "resolveJsonModule": true, - // "lib": [], /* Specify library files to be included in the compilation. */ - // "allowJs": true, /* Allow javascript files to be compiled. */ - // "checkJs": true, /* Report errors in .js files. */ - // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ - // "declaration": true, /* Generates corresponding '.d.ts' file. */ - // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ - // "sourceMap": true, /* Generates corresponding '.map' file. */ - // "outFile": "./", /* Concatenate and emit output to single file. */ - "outDir": "./build", /* Redirect output structure to the directory. */ - "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ - // "composite": true, /* Enable project compilation */ - // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ - // "removeComments": true, /* Do not emit comments to output. */ - // "noEmit": true, /* Do not emit outputs. */ - // "importHelpers": true, /* Import emit helpers from 'tslib'. */ - // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ - // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ - - /* Strict Type-Checking Options */ - "strict": true, /* Enable all strict type-checking options. */ - // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ - // "strictNullChecks": true, /* Enable strict null checks. */ - // "strictFunctionTypes": true, /* Enable strict checking of function types. */ - // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ - // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ - // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ - // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ - - /* Additional Checks */ - // "noUnusedLocals": true, /* Report errors on unused locals. */ - // "noUnusedParameters": true, /* Report errors on unused parameters. */ - // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ - // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ - - /* Module Resolution Options */ - // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ - // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ - // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ - // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ - // "typeRoots": [], /* List of folders to include type definitions from. */ - // "types": [], /* Type declaration files to be included in compilation. */ - // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ - "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ - // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ - // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ - - /* Source Map Options */ - // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ - // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ - // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ - // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ - - /* Experimental Options */ - // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ - // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ - - /* Advanced Options */ - "skipLibCheck": true, /* Skip type checking of declaration files. */ - "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ - } + "extends": "@sapphire/ts-config", + "compilerOptions": { + "strict": true, + "rootDir": "src", + "outDir": "dist", + "lib": ["esnext"], + "esModuleInterop": true, + "sourceMap": true, + "tsBuildInfoFile": "dist/.tsbuildinfo" + }, + "include": ["src"] }