├── .gitignore ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── LICENSE ├── README.md ├── app.json ├── dist └── twopg-dashboard │ └── browser │ ├── 3rdpartylicenses.txt │ ├── 5-es2015.9c1eb35be111cf60b2b2.js │ ├── 5-es5.9c1eb35be111cf60b2b2.js │ ├── assets │ ├── css │ │ ├── animations.css │ │ ├── main.css │ │ ├── nav-icon.css │ │ ├── spinner.css │ │ ├── utils.css │ │ └── vs2015.css │ └── img │ │ ├── 2pg-avatar-transparent.png │ │ ├── 2pg-avatar-transparent.svg │ │ ├── 2pg-plus-avatar-transparent.png │ │ ├── 2pg-plus-avatar-transparent.svg │ │ ├── 404.svg │ │ ├── cool-unused-asset.jpg │ │ ├── earth.svg │ │ ├── mars.svg │ │ ├── moon.svg │ │ ├── overlay-stars.svg │ │ └── rocket.svg │ ├── favicon.ico │ ├── index.html │ ├── main-es2015.650ffd035008d4774530.js │ ├── main-es5.650ffd035008d4774530.js │ ├── overlay-stars.3e7fd575d359a0f17a97.svg │ ├── polyfills-es2015.c131ee7c37c2ac42bd19.js │ ├── polyfills-es5.5895610738345f8f991d.js │ ├── runtime-es2015.bcfb5e6c406248cfb257.js │ ├── runtime-es5.bcfb5e6c406248cfb257.js │ ├── scripts.e5df102575a5add8215d.js │ └── styles.334f80e22f967f5be11c.css ├── package-lock.json ├── package.json ├── src ├── api │ ├── modules │ │ ├── api-utils.ts │ │ ├── audit-logger.ts │ │ ├── auth-client.ts │ │ ├── image │ │ │ ├── wallpaper.png │ │ │ └── xp-card-generator.ts │ │ ├── logging │ │ │ ├── error-logger.ts │ │ │ └── webhook-logger.ts │ │ ├── middleware.ts │ │ ├── performance │ │ │ ├── rate-limiter.ts │ │ │ └── session-manager.ts │ │ ├── stats.ts │ │ └── users │ │ │ └── partial-users.ts │ ├── routes │ │ ├── api-routes.ts │ │ ├── auth-routes.ts │ │ ├── guilds-routes.ts │ │ ├── music-routes.ts │ │ ├── pay-routes.ts │ │ └── user-routes.ts │ └── server.ts ├── bot.ts ├── commands │ ├── ban.ts │ ├── clear.ts │ ├── command.ts │ ├── dashboard.ts │ ├── help.ts │ ├── invite.ts │ ├── kick.ts │ ├── leaderboard.ts │ ├── list.ts │ ├── now-playing.ts │ ├── pause.ts │ ├── ping.ts │ ├── play.ts │ ├── reaction-roles.ts │ ├── resume.ts │ ├── say.ts │ ├── seek.ts │ ├── shuffle.ts │ ├── skip.ts │ ├── stats.ts │ ├── stop.ts │ ├── warn.ts │ ├── warnings.ts │ └── xp.ts ├── data │ ├── db-wrapper.ts │ ├── guilds.ts │ ├── logs.ts │ ├── members.ts │ ├── models │ │ ├── guild.ts │ │ ├── log.ts │ │ ├── member.ts │ │ └── user.ts │ ├── snowflake-entity.ts │ └── users.ts ├── handlers │ ├── commands │ │ ├── command.service.ts │ │ ├── cooldowns.ts │ │ └── validators.ts │ ├── emit.ts │ ├── event-handler.ts │ └── events │ │ ├── announce-handler.ts │ │ ├── custom │ │ ├── command-executed.handler.ts │ │ ├── config-update.handler.ts │ │ ├── level-up.handler.ts │ │ └── user-warn.handler.ts │ │ ├── event-handler.ts │ │ ├── guild-create.handler.ts │ │ ├── guild-member-add.handler.ts │ │ ├── guild-member-leave.handler.ts │ │ ├── message-deleted.handler.ts │ │ ├── message-reaction-add.handler.ts │ │ ├── message-reaction-remove.handler.ts │ │ ├── message.handler.ts │ │ └── ready.handler.ts ├── main.ts ├── modules │ ├── announce │ │ └── event-variables.ts │ ├── auto-mod │ │ ├── auto-mod.ts │ │ └── validators │ │ │ ├── bad-link.validator.ts │ │ │ ├── bad-word.validator.ts │ │ │ ├── content-validator.ts │ │ │ ├── mass-caps.validator.ts │ │ │ └── mass-mention.validator.ts │ ├── general │ │ └── reaction-roles.ts │ ├── music │ │ └── music.ts │ ├── stats │ │ └── dbots.service.ts │ └── xp │ │ └── leveling.ts └── utils │ ├── command-utils.ts │ ├── deps.ts │ ├── keep-alive.ts │ ├── log.ts │ └── validate-env.ts ├── test ├── integration │ ├── auto-mod.tests.ts │ ├── command.service.tests.ts │ ├── logs.tests.ts │ ├── routes.tests.ts │ ├── user-warn.tests.ts │ └── users.tests.ts ├── mocks │ └── mock.ts ├── test.ts └── unit │ ├── audit-logger.tests.ts │ ├── commands │ └── play.tests.ts │ ├── cooldowns.tests.ts │ ├── data.tests.ts │ ├── event-variables.tests.ts │ ├── leveling.tests.ts │ ├── logs.tests.ts │ ├── ranks.tests.ts │ ├── stats.tests.ts │ └── utils │ └── validate-env.tests.ts ├── tsconfig.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | 3 | node_modules/ 4 | lib/ -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "skipFiles": [ 12 | "/**" 13 | ], 14 | "program": "${workspaceFolder}/lib/bot.js", 15 | "outFiles": [ 16 | "${workspaceFolder}/lib/*.js" 17 | ] 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "mochaExplorer.files": "test/unit/*.tests.ts", 3 | "mochaExplorer.require": "ts-node/register", 4 | "mochaExplorer.logpanel": true, 5 | "editor.tabSize": 2, 6 | "cSpell.words": [ 7 | "Segeo", 8 | "cooldowns" 9 | ] 10 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "typescript", 6 | "tsconfig": "tsconfig.json", 7 | "option": "watch", 8 | "problemMatcher": [ 9 | "$tsc-watch" 10 | ], 11 | "group": { 12 | "kind": "build", 13 | "isDefault": true 14 | }, 15 | "label": "tsc: watch - tsconfig.json" 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 theADAMJR 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 2PG - Simple, powerful Discord bot 2 | Simple multi-purpose Discord bot made with TypeScript 3 | 4 | [![Discord](https://img.shields.io/discord/685862664223850497?color=46828d&label=Support&style=for-the-badge)](https://discord.io/twopg) 5 | ![Lines of Code](https://img.shields.io/tokei/lines/github/twopg/Bot?color=46828d&style=for-the-badge) 6 | ![Repo Stars](https://img.shields.io/github/stars/twopg/Bot?color=46828d&style=for-the-badge) 7 | 8 | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/twopg/Bot/tree/stable) 9 | 10 | [2PG Bot Tutorials](https://www.youtube.com/watch?v=rYpR0CiEGgk&list=PLGfT2ttRbfixMStpAhPD4pKBQN9wjJmbP&index=1) • 11 | [2PG Dashboard Tutorials](https://www.youtube.com/watch?v=rYpR0CiEGgk&list=PLGfT2ttRbfizIr60zU_S_6_i8O3xmP9ia&index=1) 12 | 13 | **Dashboard**: https://github.com/twopg/Dashboard 14 | 15 | ![2PG Avatar](https://i.ibb.co/h2BjCJh/2pg-smol.png) 16 | 17 | ## Setup 101 18 | https://help.codea.live/projects/2pg 19 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "2PG - Simple, powerful Discord Bot", 3 | "description": "Host a bot without code, with one simple step.", 4 | "keywords": [ 5 | "bot", 6 | "discord bot", 7 | "simple discord bot", 8 | "powerful discord bot", 9 | "ai discord bot", 10 | "2pg" 11 | ], 12 | "repository": "https://github.com/theADAMJR/6PG", 13 | "env": { 14 | "API_URL": { 15 | "description": "The API URL [https://.herokuapp.com/api]", 16 | "value": "https://.herokuapp.com/api" 17 | }, 18 | "BOT_ID": { 19 | "description": "Bot ID from https://discord.com/developers.", 20 | "value": "" 21 | }, 22 | "BOT_TOKEN": { 23 | "description": "Bot Token from https://discord.com/developers.", 24 | "value": "" 25 | }, 26 | "CLIENT_SECRET": { 27 | "description": "Client Secret from https://discord.com/developers.", 28 | "value": "" 29 | }, 30 | "DASHBOARD_URL": { 31 | "description": "The Website URL [https://.herokuapp.com]", 32 | "value": "https://.herokuapp.com" 33 | }, 34 | "GUILD_ID": { 35 | "description": "Guild ID with the premium role in (optional).", 36 | "value": "https://.herokuapp.com", 37 | "required": false 38 | }, 39 | "MONGO_URI": { 40 | "description": "The MongoDB URI to your database [i.e. from MongoDB Atlas].", 41 | "value": "" 42 | }, 43 | "OWNER_ID": { 44 | "description": "Discord bot owner ID.", 45 | "value": "", 46 | "required": false 47 | }, 48 | "PORT": { 49 | "description": "Port for Heroku to use (default: 3000).", 50 | "value": "3000" 51 | }, 52 | "PREMIUM_ROLE_ID": { 53 | "description": "Premium role ID in the GUILD_ID guild.", 54 | "value": "", 55 | "required": false 56 | }, 57 | "STRIPE_SECRET_KEY": { 58 | "description": "Secret key used for payments (optional).", 59 | "value": "", 60 | "required": false 61 | }, 62 | "STRIPE_WEBHOOK_SECRET": { 63 | "description": "Stripe webhook secret [whsec...] (optional).", 64 | "value": "", 65 | "required": false 66 | }, 67 | "FEEDBACK_CHANNEL_ID": { 68 | "description": "Channel ID to get dashboard feedback (optional).", 69 | "value": "", 70 | "required": false 71 | }, 72 | "VOTE_CHANNEL_ID": { 73 | "description": "Channel ID to log top.gg votes (optional).", 74 | "value": "", 75 | "required": false 76 | }, 77 | "TOP_GG_AUTH": { 78 | "description": "top.gg API Key, to receive vote logs (optional).", 79 | "value": "", 80 | "required": false 81 | }, 82 | "DBOTS_AUTH": { 83 | "description": "dbots.co API Key, to post stats (optional).", 84 | "value": "", 85 | "required": false 86 | } 87 | }, 88 | "buildpacks": [ 89 | { 90 | "url": "heroku/nodejs" 91 | } 92 | ] 93 | } -------------------------------------------------------------------------------- /dist/twopg-dashboard/browser/5-es2015.9c1eb35be111cf60b2b2.js: -------------------------------------------------------------------------------- 1 | (window.webpackJsonp=window.webpackJsonp||[]).push([[5],{gPJM:function(e,n){!function(e,n){"use strict";var t,r=/\r\n|\r|\n/g;function o(t){try{var r=n.querySelectorAll("code.hljs,code.nohighlight");for(var o in r)r.hasOwnProperty(o)&&a(r[o],t)}catch(i){e.console.error("LineNumbers error: ",i)}}function a(n,t){"object"==typeof n&&e.setTimeout(function(){n.innerHTML=i(n,t)},0)}function i(e,n){var t=(n=n||{singleLine:!1}).singleLine?0:1;return l(e),function(e,n){var t=c(e);if(""===t[t.length-1].trim()&&t.pop(),t.length>n){for(var r="",o=0,a=t.length;o
{6}',["hljs-ln-line","hljs-ln-numbers","hljs-ln-n","data-line-number","hljs-ln-code",o+1,t[o].length>0?t[o]:" "]);return d('{1}
',["hljs-ln",r])}return e}(e.innerHTML,t)}function l(e){var n=e.childNodes;for(var t in n)if(n.hasOwnProperty(t)){var o=n[t];(o.textContent.trim().match(r)||[]).length>0&&(o.childNodes.length>0?l(o):s(o.parentNode))}}function s(e){var n=e.className;if(/hljs-/.test(n)){for(var t=c(e.innerHTML),r=0,o="";r{1}\n',[n,t[r].length>0?t[r]:" "]);e.innerHTML=o.trim()}}function c(e){return 0===e.length?[]:e.split(r)}function d(e,n){return e.replace(/\{(\d+)\}/g,function(e,t){return n[t]?n[t]:e})}e.hljs?(e.hljs.initLineNumbersOnLoad=function(t){"interactive"===n.readyState||"complete"===n.readyState?o(t):e.addEventListener("DOMContentLoaded",function(){o(t)})},e.hljs.lineNumbersBlock=a,e.hljs.lineNumbersValue=function(e,n){if("string"==typeof e){var t=document.createElement("code");return t.innerHTML=e,i(t,n)}},(t=n.createElement("style")).type="text/css",t.innerHTML=d(".{0}{border-collapse:collapse}.{0} td{padding:0}.{1}:before{content:attr({2})}",["hljs-ln","hljs-ln-n","data-line-number"]),n.getElementsByTagName("head")[0].appendChild(t)):e.console.error("highlight.js not detected!"),document.addEventListener("copy",function(e){var n,t=window.getSelection();(function(e){for(var n=e;n;){if(n.className&&-1!==n.className.indexOf("hljs-ln-code"))return!0;n=n.parentNode}return!1})(t.anchorNode)&&(n=-1!==window.navigator.userAgent.indexOf("Edge")?function(e){for(var n=e.toString(),t=e.anchorNode;"TD"!==t.nodeName;)t=t.parentNode;for(var r=e.focusNode;"TD"!==r.nodeName;)r=r.parentNode;var o=parseInt(t.dataset.lineNumber),a=parseInt(r.dataset.lineNumber);if(o!=a){var i=t.textContent,l=r.textContent;if(o>a){var s=o;o=a,a=s,s=i,i=l,l=s}for(;0!==n.indexOf(i);)i=i.slice(1);for(;-1===n.lastIndexOf(l);)l=l.slice(0,-1);for(var c=i,u=function(e){for(var n=e;"TABLE"!==n.nodeName;)n=n.parentNode;return n}(t),f=o+1;fn){for(var r="",o=0,a=t.length;o
{6}',["hljs-ln-line","hljs-ln-numbers","hljs-ln-n","data-line-number","hljs-ln-code",o+1,t[o].length>0?t[o]:" "]);return d('{1}
',["hljs-ln",r])}return e}(e.innerHTML,t)}function l(e){var n=e.childNodes;for(var t in n)if(n.hasOwnProperty(t)){var o=n[t];(o.textContent.trim().match(r)||[]).length>0&&(o.childNodes.length>0?l(o):s(o.parentNode))}}function s(e){var n=e.className;if(/hljs-/.test(n)){for(var t=c(e.innerHTML),r=0,o="";r{1}\n',[n,t[r].length>0?t[r]:" "]);e.innerHTML=o.trim()}}function c(e){return 0===e.length?[]:e.split(r)}function d(e,n){return e.replace(/\{(\d+)\}/g,function(e,t){return n[t]?n[t]:e})}e.hljs?(e.hljs.initLineNumbersOnLoad=function(t){"interactive"===n.readyState||"complete"===n.readyState?o(t):e.addEventListener("DOMContentLoaded",function(){o(t)})},e.hljs.lineNumbersBlock=a,e.hljs.lineNumbersValue=function(e,n){if("string"==typeof e){var t=document.createElement("code");return t.innerHTML=e,i(t,n)}},(t=n.createElement("style")).type="text/css",t.innerHTML=d(".{0}{border-collapse:collapse}.{0} td{padding:0}.{1}:before{content:attr({2})}",["hljs-ln","hljs-ln-n","data-line-number"]),n.getElementsByTagName("head")[0].appendChild(t)):e.console.error("highlight.js not detected!"),document.addEventListener("copy",function(e){var n,t=window.getSelection();(function(e){for(var n=e;n;){if(n.className&&-1!==n.className.indexOf("hljs-ln-code"))return!0;n=n.parentNode}return!1})(t.anchorNode)&&(n=-1!==window.navigator.userAgent.indexOf("Edge")?function(e){for(var n=e.toString(),t=e.anchorNode;"TD"!==t.nodeName;)t=t.parentNode;for(var r=e.focusNode;"TD"!==r.nodeName;)r=r.parentNode;var o=parseInt(t.dataset.lineNumber),a=parseInt(r.dataset.lineNumber);if(o!=a){var i=t.textContent,l=r.textContent;if(o>a){var s=o;o=a,a=s,s=i,i=l,l=s}for(;0!==n.indexOf(i);)i=i.slice(1);for(;-1===n.lastIndexOf(l);)l=l.slice(0,-1);for(var c=i,u=function(e){for(var n=e;"TABLE"!==n.nodeName;)n=n.parentNode;return n}(t),f=o+1;f 5 | 6 | earth -------------------------------------------------------------------------------- /dist/twopg-dashboard/browser/assets/img/mars.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dist/twopg-dashboard/browser/assets/img/moon.svg: -------------------------------------------------------------------------------- 1 | 5 | 6 | moon_1 -------------------------------------------------------------------------------- /dist/twopg-dashboard/browser/assets/img/overlay-stars.svg: -------------------------------------------------------------------------------- 1 | 5 | 6 | overlay_stars_1 -------------------------------------------------------------------------------- /dist/twopg-dashboard/browser/assets/img/rocket.svg: -------------------------------------------------------------------------------- 1 | 5 | 6 | rocket_1 -------------------------------------------------------------------------------- /dist/twopg-dashboard/browser/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twopg/Bot/b635d50351b7fe9d602d2d1b7bf1e52f6453bfcd/dist/twopg-dashboard/browser/favicon.ico -------------------------------------------------------------------------------- /dist/twopg-dashboard/browser/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 2PG - Discord Bot 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 |
21 |
© 2021 2PG
22 | 23 | 24 | -------------------------------------------------------------------------------- /dist/twopg-dashboard/browser/overlay-stars.3e7fd575d359a0f17a97.svg: -------------------------------------------------------------------------------- 1 | 5 | 6 | overlay_stars_1 -------------------------------------------------------------------------------- /dist/twopg-dashboard/browser/runtime-es2015.bcfb5e6c406248cfb257.js: -------------------------------------------------------------------------------- 1 | !function(e){function r(r){for(var n,i,a=r[0],c=r[1],l=r[2],p=0,s=[];p([ 19 | [400, 'Bad request'], 20 | [401, 'Unauthorized'], 21 | [403, 'Forbidden'], 22 | [404, 'Not found'], 23 | [429, 'You are being rate limited'], 24 | [500, 'Internal server error'], 25 | ]) 26 | 27 | constructor(public readonly status = 400) { 28 | super(APIError.messages.get(status)); 29 | } 30 | } 31 | 32 | export interface DefaultAPIResponse { 33 | message: string; 34 | code?: number; 35 | } 36 | 37 | export enum HexColor { 38 | Blue = '#4287f5', 39 | Green = '#42f54e', 40 | Red = '#f54242' 41 | } 42 | -------------------------------------------------------------------------------- /src/api/modules/audit-logger.ts: -------------------------------------------------------------------------------- 1 | import { Change } from "../../data/models/log"; 2 | 3 | export default class AuditLogger { 4 | static getChanges(values: { old: {}, new: {} }, module: string, by: string) { 5 | let changes = { old: {}, new: {} }; 6 | 7 | for (const key in values.old) { 8 | const changed = JSON.stringify(values.old[key]) !== JSON.stringify(values.new[key]); 9 | if (changed) { 10 | changes.old[key] = values.old[key]; 11 | changes.new[key] = values.new[key]; 12 | } 13 | } 14 | return new Change(by, changes, module); 15 | } 16 | } -------------------------------------------------------------------------------- /src/api/modules/auth-client.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '@2pg/oauth'; 2 | 3 | export const auth = new Client({ 4 | id: process.env.CLIENT_ID, 5 | secret: process.env.CLIENT_SECRET, 6 | redirectURI: `${process.env.API_URL}/auth`, 7 | scopes: ['identify', 'guilds'] 8 | }); 9 | -------------------------------------------------------------------------------- /src/api/modules/image/wallpaper.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twopg/Bot/b635d50351b7fe9d602d2d1b7bf1e52f6453bfcd/src/api/modules/image/wallpaper.png -------------------------------------------------------------------------------- /src/api/modules/image/xp-card-generator.ts: -------------------------------------------------------------------------------- 1 | import { Rank } from 'canvacord'; 2 | import { MemberDocument } from '../../../data/models/member'; 3 | import { UserDocument, XPCard } from '../../../data/models/user'; 4 | import Leveling from '../../../modules/xp/leveling'; 5 | import Deps from '../../../utils/deps'; 6 | import { PartialUsers } from '../users/partial-users'; 7 | 8 | export class XPCardGenerator { 9 | constructor( 10 | private partial = Deps.get(PartialUsers), 11 | ) {} 12 | 13 | async generate( 14 | savedUser: UserDocument, 15 | savedMember: MemberDocument, 16 | rank: number, 17 | preview?: XPCard 18 | ) { 19 | preview = { 20 | primary: '#F4F2F3', 21 | secondary: '#46828D', 22 | tertiary: '#36E2CA', 23 | ...preview, 24 | }; 25 | const partialUser = await this.partial.get(savedMember.userId); 26 | if (!partialUser) 27 | throw new TypeError('User not found'); 28 | 29 | const info = Leveling.xpInfo(savedMember.xp); 30 | const defaultWallpaper = `${__dirname}/wallpaper.png`; 31 | 32 | try { 33 | return new Rank() 34 | .setAvatar(partialUser.displayAvatarURL.replace('.webp', '.png')) 35 | .setCurrentXP(info.xp) 36 | .setRequiredXP(info.xpForNextLevel) 37 | .setRank(rank) 38 | .setProgressBar(preview.secondary, 'COLOR') 39 | .setUsername(partialUser.username) 40 | .setDiscriminator(partialUser.discriminator) 41 | .build(); 42 | } catch (error) { 43 | console.log(error.message); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/api/modules/logging/error-logger.ts: -------------------------------------------------------------------------------- 1 | import { promisify } from 'util'; 2 | import { resolve } from 'path'; 3 | import { exec } from 'child_process'; 4 | import fs from 'fs'; 5 | 6 | const appendFile = promisify(fs.appendFile); 7 | 8 | export class ErrorLogger { 9 | private logsPath = resolve('./logs'); 10 | private sessionDate = new Date() 11 | .toISOString() 12 | .replace(/:/g, ''); 13 | 14 | private get timestamp() { 15 | return new Date().toISOString(); 16 | } 17 | 18 | constructor() { 19 | exec(` 20 | mkdir -p 21 | ${this.logsPath}/logs/dashboard 22 | ${this.logsPath}/logs/api`.trim() 23 | ); 24 | } 25 | 26 | async dashboard(message: string) { 27 | await appendFile( 28 | `${this.logsPath}/dashboard/${this.sessionDate}.log`, 29 | `[${this.timestamp}] ${message}\n` 30 | ); 31 | } 32 | 33 | async api(status: number, message: string, route: string) { 34 | await appendFile( 35 | `${this.logsPath}/api/${this.sessionDate}.log`, 36 | `[${this.timestamp}] [${status}] [${route}] ${message}\n` 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/api/modules/logging/webhook-logger.ts: -------------------------------------------------------------------------------- 1 | import { Client, MessageEmbed, TextChannel } from 'discord.js'; 2 | import Deps from '../../../utils/deps'; 3 | 4 | export class WebhookLogger { 5 | constructor( 6 | private bot = Deps.get(Client) 7 | ) {} 8 | 9 | public async get(channelId: string, name: string) { 10 | const channel = this.bot.channels.cache.get(channelId) as TextChannel; 11 | if (!channel) return; 12 | 13 | const webhooks = await channel.fetchWebhooks(); 14 | return webhooks.find(w => w.name === name) 15 | ?? await channel.createWebhook(name, { 16 | avatar: this.bot.user.displayAvatarURL(), 17 | reason: `Created for 2PG's webhook logger.` 18 | }); 19 | } 20 | 21 | public async feedback(message: string) { 22 | const webhook = await this.get(process.env.FEEDBACK_CHANNEL_ID, '2PG - Feedback'); 23 | if (!webhook) return; 24 | 25 | await webhook.send(new MessageEmbed({ 26 | title: 'Feedback', 27 | description: message 28 | })); 29 | } 30 | 31 | public async vote(userId: string, votes: number) { 32 | const webhook = await this.get(process.env.VOTE_CHANNEL_ID, '2PG - Vote'); 33 | if (!webhook) return; 34 | 35 | await webhook.send(new MessageEmbed({ 36 | title: 'Vote', 37 | description: `✅ <@!${userId}> has entered, and now has \`${votes}\` entries!` 38 | })); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/api/modules/middleware.ts: -------------------------------------------------------------------------------- 1 | import Deps from '../../utils/deps'; 2 | import { SessionManager } from './performance/session-manager'; 3 | import { APIError, sendError } from './api-utils'; 4 | 5 | const sessions = Deps.get(SessionManager); 6 | 7 | export async function validateBotOwner(req, res, next) { 8 | const key = req.query.key; 9 | if (!key) 10 | return sendError(res, new APIError(400)); 11 | 12 | const session = await sessions.get(key); 13 | if (session.authUser.id !== process.env.OWNER_ID) 14 | return sendError(res, new APIError(401)); 15 | return next(); 16 | } 17 | 18 | export async function validateGuildManager(req, res, next) { 19 | const guildId = req.params.id; 20 | const key = req.query.key; 21 | if (!key) 22 | return sendError(res, new APIError(400)); 23 | 24 | const session = await sessions.get(key); 25 | if (!session.guilds.some(g => g.id === guildId)) 26 | return sendError(res, new APIError(403)); 27 | return next(); 28 | } -------------------------------------------------------------------------------- /src/api/modules/performance/rate-limiter.ts: -------------------------------------------------------------------------------- 1 | import rateLimit from 'express-rate-limit'; 2 | import * as RateLimitStore from 'rate-limit-mongo'; 3 | 4 | export default rateLimit({ 5 | max: 300, 6 | message: JSON.stringify({ message: 'You are being rate limited.' }), 7 | store: new RateLimitStore({ uri: process.env.MONGO_URI }), 8 | windowMs: 60 * 1000 9 | }); 10 | -------------------------------------------------------------------------------- /src/api/modules/performance/session-manager.ts: -------------------------------------------------------------------------------- 1 | import User from '@2pg/oauth/lib/types/user'; 2 | import Guild from '@2pg/oauth/lib/types/guild'; 3 | import { auth } from '../auth-client'; 4 | import { Client } from 'discord.js'; 5 | import Deps from '../../../utils/deps'; 6 | 7 | export class SessionManager { 8 | private sessions = new Map(); 9 | 10 | constructor( 11 | private bot = Deps.get(Client) 12 | ) {} 13 | 14 | public get(key: string) { 15 | return this.sessions.get(key) ?? this.create(key); 16 | } 17 | 18 | public async create(key: string) { 19 | const timeToClear = 5 * 60 * 1000; 20 | setTimeout(() => this.sessions.delete(key), timeToClear); 21 | await this.update(key); 22 | 23 | return this.sessions.get(key); 24 | } 25 | 26 | public async update(key: string) { 27 | return this.sessions 28 | .set(key, { 29 | authUser: await auth.getUser(key), 30 | guilds: this.getManageableGuilds(await auth.getGuilds(key)) 31 | }); 32 | } 33 | 34 | private getManageableGuilds(authGuilds: any): Guild[] { 35 | return authGuilds 36 | .array() 37 | .filter(g => g.permissions.includes('MANAGE_GUILD')) 38 | .map(g => this.bot.guilds.cache.get(g.id)) 39 | .filter(g => g); 40 | } 41 | } 42 | 43 | interface UserSession { 44 | authUser: User; 45 | guilds: Guild[]; 46 | } 47 | -------------------------------------------------------------------------------- /src/api/modules/stats.ts: -------------------------------------------------------------------------------- 1 | import Deps from '../../utils/deps'; 2 | import Logs from '../../data/logs'; 3 | import { LogDocument } from '../../data/models/log'; 4 | 5 | 6 | const distinct = (v, i, a) => a.indexOf(v) === i; 7 | 8 | export default class Stats { 9 | private savedLogs: LogDocument[] = []; 10 | private initialized = false; 11 | 12 | private _commands: CommandStats[]; 13 | private _general: GeneralStats; 14 | private _inputs: InputStats[]; 15 | private _modules: ModuleStats[]; 16 | 17 | get commands(): CommandStats[] { 18 | if (this.initialized) 19 | return this._commands; 20 | 21 | const names = this.savedLogs 22 | .flatMap(l => l.commands 23 | .flatMap(c => c.name)); 24 | 25 | return names 26 | .filter(distinct) 27 | .map(name => ({ name, count: names.filter(n => n === name).length })) 28 | .sort((a, b) => b.count - a.count); 29 | } 30 | 31 | get general(): GeneralStats { 32 | if (this.initialized) 33 | return this._general; 34 | 35 | const commandsExecuted = this.savedLogs 36 | .reduce((a, b) => a + b.commands.length, 0); 37 | 38 | return { 39 | commandsExecuted, 40 | inputsChanged: this.inputs 41 | .reduce((a, b) => a + b.count, 0), 42 | inputsCount: this.inputs 43 | .map(c => c.path) 44 | .filter(distinct).length, 45 | iq: 10 46 | } 47 | } 48 | 49 | get inputs(): InputStats[] { 50 | if (this.initialized) 51 | return this._inputs; 52 | 53 | const paths = this.savedLogs 54 | .flatMap(l => l.changes 55 | .flatMap(c => Object.keys(c.changes.new) 56 | .flatMap(key => `${c.module}.${key}`))); 57 | 58 | return paths 59 | .filter(distinct) 60 | .map(path => ({ path, count: paths.filter(p => p === path).length })) 61 | .sort((a, b) => b.count - a.count); 62 | } 63 | 64 | get modules(): ModuleStats[] { 65 | if (this.initialized) 66 | return this._modules; 67 | 68 | const moduleNames = this.savedLogs 69 | .flatMap(l => l.changes.map(c => c.module)); 70 | 71 | return moduleNames 72 | .filter(distinct) 73 | .map(name => ({ name, count: moduleNames.filter(m => m === name).length })) 74 | .sort((a, b) => b.count - a.count); 75 | } 76 | 77 | constructor(private logs = Deps.get(Logs)) {} 78 | 79 | async init() { 80 | await this.updateValues(); 81 | 82 | const interval = 30 * 60 * 1000; 83 | setInterval(() => this.updateValues(), interval); 84 | } 85 | 86 | async updateValues() { 87 | this.savedLogs = await this.logs.getAll(); 88 | 89 | this.initialized = false; 90 | 91 | this._commands = this.commands; 92 | this._general = this.general; 93 | this._inputs = this.inputs; 94 | this._modules = this.modules; 95 | 96 | this.initialized = true; 97 | } 98 | } 99 | 100 | export interface CommandStats { 101 | name: string; 102 | count: number; 103 | } 104 | 105 | export interface GeneralStats { 106 | commandsExecuted: number; 107 | inputsCount: number; 108 | inputsChanged: number; 109 | iq: number; 110 | } 111 | 112 | export interface InputStats { 113 | path: string; 114 | count: number; 115 | } 116 | 117 | export interface ModuleStats { 118 | name: string; 119 | count: number; 120 | } -------------------------------------------------------------------------------- /src/api/modules/users/partial-users.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import { APIError } from '../api-utils'; 3 | 4 | export class PartialUsers { 5 | readonly cache = new Map(); 6 | 7 | async get(id: string): Promise { 8 | const isSnowflake = /\d{18}/.test(id); 9 | if (!isSnowflake) return null; 10 | 11 | const user = this.cache.get(id) ?? await this.fetchUser(id); 12 | if (!user) return null; 13 | if (user.message?.includes('401')) 14 | throw new APIError(500); 15 | else if (user.message?.includes('404')) 16 | throw new APIError(404); 17 | 18 | this.cache.set(id, user); 19 | setTimeout(() => this.cache.delete(id), 60 * 60 * 1000); 20 | 21 | return { 22 | ...user, 23 | displayAvatarURL: this.getAvatarURL(user), 24 | tag: `${user.username}#${user.discriminator}` 25 | }; 26 | } 27 | 28 | private getAvatarURL({ id, avatar }: PartialUser) { 29 | return (avatar) 30 | ? `https://cdn.discordapp.com/avatars/${id}/${avatar}.webp` 31 | : `https://cdn.discordapp.com/embed/avatars/0.png`; 32 | } 33 | 34 | private async fetchUser(id: string) { 35 | const discordRes = await fetch(`https://discord.com/api/v6/users/${id}`, { 36 | headers: { Authorization: `Bot ${process.env.BOT_TOKEN}` } 37 | }); 38 | 39 | if (discordRes.status === 429) 40 | throw new APIError(429); 41 | else if (discordRes.status === 404) 42 | throw new APIError(404); 43 | 44 | return await discordRes.json(); 45 | } 46 | } 47 | 48 | export interface PartialUser { 49 | id: string; 50 | username: string; 51 | avatar?: string; 52 | bot?: boolean; 53 | discriminator: string; 54 | public_flags: number; 55 | displayAvatarURL: string; 56 | tag: string; 57 | } 58 | -------------------------------------------------------------------------------- /src/api/routes/api-routes.ts: -------------------------------------------------------------------------------- 1 | import { Client, TextChannel } from 'discord.js'; 2 | import { Router } from 'express'; 3 | import Users from '../../data/users'; 4 | import CommandService from '../../handlers/commands/command.service'; 5 | import Deps from '../../utils/deps'; 6 | import { sendError, APIError } from '../modules/api-utils'; 7 | import { ErrorLogger } from '../modules/logging/error-logger'; 8 | import { WebhookLogger } from '../modules/logging/webhook-logger'; 9 | import Stats from '../modules/stats'; 10 | import { auth } from '../modules/auth-client'; 11 | import { validateBotOwner } from '../modules/middleware'; 12 | 13 | export const router = Router(); 14 | 15 | const bot = Deps.get(Client); 16 | const stats = Deps.get(Stats); 17 | const users = Deps.get(Users); 18 | const errorLogger = Deps.get(ErrorLogger); 19 | const webhookLogger = Deps.get(WebhookLogger); 20 | const commandService = Deps.get(CommandService); 21 | 22 | router.get('/', (req, res) => res.json({ hello: 'earth' })); 23 | 24 | router.get('/commands', async (req, res) => res.json( 25 | Array.from(commandService.commands.values()) 26 | )); 27 | router.post('/error', async(req, res) => { 28 | try { 29 | await errorLogger.dashboard(req.body.message); 30 | 31 | res.json({ message: 'Success' }); 32 | } catch (error) { sendError(res, new APIError(400)); } 33 | }); 34 | 35 | router.post('/feedback', async(req, res) => { 36 | try { 37 | await webhookLogger.feedback(req.body.message); 38 | 39 | res.json({ message: 'Success' }); 40 | } catch (error) { 41 | sendError(res, new APIError(400)); } 42 | }); 43 | 44 | router.get('/stats', validateBotOwner, async (req, res) => { 45 | try { 46 | res.json({ 47 | general: stats.general, 48 | commands: stats.commands, 49 | inputs: stats.inputs, 50 | modules: stats.modules 51 | }); 52 | } catch (error) { sendError(res, new APIError(400)); } 53 | }); 54 | 55 | router.post('/vote/top-gg', async (req, res) => { 56 | try { 57 | if (req.get('authorization') !== process.env.TOP_GG_AUTH) 58 | return res.status(400); 59 | 60 | const channel = bot.channels.cache.get(process.env.VOTE_CHANNEL_ID) as TextChannel; 61 | if (!channel) 62 | return res.status(400); 63 | 64 | const userId = req.body.user; 65 | const savedUser = await users.get({ id: userId }); 66 | savedUser.votes ??= 0; 67 | savedUser.votes++; 68 | await savedUser.updateOne(savedUser); 69 | 70 | await webhookLogger.vote(userId, savedUser.votes); 71 | } catch (error) { sendError(res, new APIError(400)); } 72 | }); 73 | -------------------------------------------------------------------------------- /src/api/routes/auth-routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { sendError, APIError } from '../modules/api-utils'; 3 | import { auth } from '../modules/auth-client'; 4 | 5 | export const router = Router(); 6 | 7 | router.get('/auth', async (req, res) => { 8 | try { 9 | const key = await auth.getAccess(req.query.code.toString()); 10 | res.redirect(`${process.env.DASHBOARD_URL}/auth?key=${key}`); 11 | } catch (error) { sendError(res, new APIError(400)); } 12 | }); 13 | 14 | router.get('/invite', (req, res) => { 15 | const newLineOrSpace = / |\n/g; 16 | res.redirect(` 17 | https://discordapp.com/api/oauth2/authorize 18 | ?client_id=${process.env.CLIENT_ID} 19 | &redirect_uri=${process.env.DASHBOARD_URL}/dashboard 20 | &response_type=code 21 | &permissions=8 22 | &scope=bot`.replace(newLineOrSpace, '') 23 | ) 24 | }); 25 | 26 | router.get('/login', (req, res) => res.redirect(auth.authCodeLink.url)); 27 | -------------------------------------------------------------------------------- /src/api/routes/guilds-routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { SavedMember } from '../../data/models/member'; 3 | import { XPCardGenerator } from '../modules/image/xp-card-generator'; 4 | import Deps from '../../utils/deps'; 5 | import Members from '../../data/members'; 6 | import Users from '../../data/users'; 7 | import Guilds from '../../data/guilds'; 8 | import Logs from '../../data/logs'; 9 | import AuditLogger from '../modules/audit-logger'; 10 | import { Client, TextChannel } from 'discord.js'; 11 | import Leveling from '../../modules/xp/leveling'; 12 | import Emit from '../../handlers/emit'; 13 | import { APIError, leaderboardMember, sendError } from '../modules/api-utils'; 14 | import { SessionManager } from '../modules/performance/session-manager'; 15 | import { validateGuildManager } from '../modules/middleware'; 16 | 17 | export const router = Router(); 18 | 19 | const bot = Deps.get(Client); 20 | const emit = Deps.get(Emit); 21 | const generator = Deps.get(XPCardGenerator); 22 | const guilds = Deps.get(Guilds); 23 | const logs = Deps.get(Logs); 24 | const members = Deps.get(Members); 25 | const sessions = Deps.get(SessionManager); 26 | const users = Deps.get(Users); 27 | 28 | router.get('/', async (req, res) => { 29 | try { 30 | const key = req.query.key.toString(); 31 | if (req.query.force === 'true') 32 | await sessions.update(key); 33 | 34 | const { guilds } = await sessions.get(key); 35 | res.json(guilds); 36 | } catch (error) { sendError(res, new APIError(400)); } 37 | }); 38 | 39 | router.put('/:id/:module', validateGuildManager, async (req, res) => { 40 | try { 41 | const { id, module } = req.params; 42 | 43 | const { authUser } = await sessions.get(req.query.key.toString()); 44 | const guild = bot.guilds.cache.get(id); 45 | const savedGuild = await guilds.get(guild); 46 | 47 | const change = AuditLogger.getChanges({ 48 | old: savedGuild[module], 49 | new: req.body 50 | }, module, authUser.id); 51 | 52 | savedGuild[module] = req.body; 53 | await savedGuild.save(); 54 | 55 | const log = await logs.get(guild); 56 | log.changes.push(change); 57 | await log.save(); 58 | 59 | emit.configSaved(guild, authUser, change); 60 | 61 | res.json(savedGuild); 62 | } catch (error) { sendError(res, new APIError(400)); } 63 | }); 64 | 65 | router.get('/:id/config', async (req, res) => { 66 | try { 67 | const guild = bot.guilds.cache.get(req.params.id); 68 | const savedGuild = await guilds.get(guild); 69 | res.json(savedGuild); 70 | } catch (error) { sendError(res, new APIError(400)); } 71 | }); 72 | 73 | router.get('/:id/channels', async (req, res) => { 74 | try { 75 | const guild = bot.guilds.cache.get(req.params.id); 76 | res.send(guild.channels.cache); 77 | } catch (error) { sendError(res, new APIError(400)); } 78 | }); 79 | 80 | router.get('/:id/log', async(req, res) => { 81 | try { 82 | const guild = bot.guilds.cache.get(req.params.id); 83 | const log = await logs.get(guild); 84 | res.send(log); 85 | } catch (error) { sendError(res, new APIError(400)); } 86 | }); 87 | 88 | router.get('/:id/commands', async (req, res) => { 89 | try { 90 | const savedGuild = await guilds.get({ id: req.params.id }); 91 | res.json({ 92 | guild: bot.guilds.cache.get(req.params.id), 93 | commands: savedGuild.commands.custom 94 | }); 95 | } catch (error) { sendError(res, new APIError(400)); } 96 | }); 97 | 98 | router.get('/:id/public', (req, res) => { 99 | const guild = bot.guilds.cache.get(req.params.id); 100 | res.json(guild); 101 | }); 102 | 103 | router.get('/:id/roles', async (req, res) => { 104 | try { 105 | const guild = bot.guilds.cache.get(req.params.id); 106 | res.send(guild.roles.cache.filter(r => r.name !== '@everyone')); 107 | } catch (error) { sendError(res, new APIError(400)); } 108 | }); 109 | 110 | router.get('/:id/members', async (req, res) => { 111 | try { 112 | const savedMembers = await SavedMember.find({ guildId: req.params.id }).lean(); 113 | let rankedMembers = []; 114 | for (const member of savedMembers) { 115 | const user = bot.users.cache.get(member.userId); 116 | if (!user) continue; 117 | 118 | const xpInfo = Leveling.xpInfo(member.xp); 119 | rankedMembers.push(leaderboardMember(user, xpInfo)); 120 | } 121 | rankedMembers.sort((a, b) => b.xp - a.xp); 122 | 123 | res.json(rankedMembers); 124 | } catch (error) { sendError(res, new APIError(400)); } 125 | }); 126 | 127 | router.get('/:id/members', async (req, res) => { 128 | try { 129 | const savedMembers = await SavedMember.find({ guildId: req.params.id }).lean(); 130 | let rankedMembers = []; 131 | for (const member of savedMembers) { 132 | const user = bot.users.cache.get(member.userId); 133 | if (!user) continue; 134 | 135 | const xpInfo = Leveling.xpInfo(member.xp); 136 | rankedMembers.push(leaderboardMember(user, xpInfo)); 137 | } 138 | rankedMembers.sort((a, b) => b.xp - a.xp); 139 | 140 | res.json(rankedMembers); 141 | } catch (error) { sendError(res, new APIError(400)); } 142 | }); 143 | 144 | router.get('/:guildId/members/:memberId/xp-card', async (req, res) => { 145 | try { 146 | const { guildId, memberId } = req.params; 147 | 148 | const guild = bot.guilds.cache.get(guildId); 149 | const member = guild?.members.cache.get(memberId); 150 | if (!member) 151 | throw TypeError('Could not find member in cache.'); 152 | 153 | const savedUser = await users.get(member); 154 | const savedMembers = await SavedMember.find({ guildId }); 155 | const savedMember = savedMembers.find(m => m.userId === savedUser.id) 156 | const rank = members.getRanked(member, savedMembers); 157 | 158 | const image = await generator.generate(savedUser, savedMember, rank); 159 | 160 | res.set({'Content-Type': 'image/png'}).send(image); 161 | } catch (error) { sendError(res, new APIError(400)); } 162 | }); 163 | 164 | router.get('/:id/bot-status', async (req, res) => { 165 | try { 166 | const id = req.params.id; 167 | const botMember = bot.guilds.cache 168 | .get(id)?.members.cache 169 | .get(bot.user.id); 170 | 171 | const requiredPermission = 'ADMINISTRATOR'; 172 | res.json({ hasAdmin: botMember.hasPermission(requiredPermission) }); 173 | } catch (error) { sendError(res, new APIError(400)); } 174 | }); 175 | 176 | router.get('/:id/channels/:channelId/messages/:messageId', async(req, res) => { 177 | try { 178 | const guild = bot.guilds.cache.get(req.params.id); 179 | const channel = guild?.channels.cache 180 | .get(req.params.channelId) as TextChannel; 181 | 182 | const msg = await channel.messages.fetch(req.params.messageId); 183 | 184 | res.json({ 185 | ...msg, 186 | member: guild.members.cache.get(msg.author.id), 187 | user: bot.users.cache.get(msg.author.id) 188 | }); 189 | } catch (error) { sendError(res, new APIError(400)); } 190 | }); 191 | -------------------------------------------------------------------------------- /src/api/routes/music-routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import Music from '../../modules/music/music'; 3 | import Deps from '../../utils/deps'; 4 | import Users from '../../data/users'; 5 | import { SessionManager } from '../modules/performance/session-manager'; 6 | import { validateGuildManager } from '../modules/middleware'; 7 | import { Client } from 'discord.js'; 8 | 9 | export const router = Router({ mergeParams: true }); 10 | 11 | const bot = Deps.get(Client); 12 | const music = Deps.get(Music); 13 | const users = Deps.get(Users); 14 | const sessions = Deps.get(SessionManager); 15 | 16 | router.get('/pause', async (req, res) => { 17 | try { 18 | const { player } = await getMusic(req.params.id, req.query.key); 19 | player.pause(); 20 | 21 | res.status(200).send({ success: true }); 22 | } catch (error) { res.status(400).send(error?.message); } 23 | }); 24 | 25 | router.get('/resume', async (req, res) => { 26 | try { 27 | const { player } = await getMusic(req.params.id, req.query.key); 28 | player.resume(); 29 | 30 | res.status(200).send({ success: true }); 31 | } catch (error) { res.status(400).send(error?.message); } 32 | }); 33 | 34 | router.get('/list', async (req, res) => { 35 | try { 36 | const { player } = await getMusic(req.params.id, req.query.key); 37 | 38 | for (const track of player.q.items) 39 | track['durationString'] = `${track.duration}`; 40 | 41 | res.status(200).json(player.q.items); 42 | } catch (error) { res.status(400).send(error?.message); } 43 | }); 44 | 45 | router.get('/skip', async (req, res) => { 46 | try { 47 | const { player } = await getMusic(req.params.id, req.query.key); 48 | await player.skip(); 49 | 50 | res.status(200).send({ success: true }); 51 | } catch (error) { res.status(400).send(error?.message); } 52 | }); 53 | 54 | router.get('/seek/:position', async (req, res) => { 55 | try { 56 | const { player } = await getMusic(req.params.id, req.query.key); 57 | 58 | player.seek(+req.params.position); 59 | 60 | res.status(200).send({ success: true }); 61 | } catch (error) { res.status(400).send(error?.message); } 62 | }); 63 | 64 | router.get('/remove/:number', async (req, res) => { 65 | try { 66 | const { player } = await getMusic(req.params.id, req.query.key); 67 | 68 | const track = player.q.items.splice(+req.params.number - 1); 69 | 70 | res.status(200).json(track); 71 | } catch (error) { res.status(400).send(error?.message); } 72 | }); 73 | 74 | router.get('/play', async (req, res) => { 75 | try { 76 | const { player, hasPremium } = await getMusic(req.params.id, req.query.key); 77 | const track = await player.play(req.query.query?.toString()); 78 | 79 | const maxSize = (hasPremium) ? 10 : 5; 80 | if (player.q.length >= maxSize) 81 | throw new TypeError('Queue limit reached.'); 82 | 83 | res.status(200).json(track); 84 | } catch (error) { res.status(400).send(error?.message); } 85 | }); 86 | 87 | router.get('/set-volume/:value', async (req, res) => { 88 | try { 89 | const { player } = await getMusic(req.params.id, req.query.key); 90 | 91 | await player.setVolume(+req.params.value / 100); 92 | 93 | res.status(200).send({ success: true }); 94 | } catch (error) { res.status(400).send(error?.message); } 95 | }); 96 | 97 | router.get('/shuffle', async (req, res) => { 98 | try { 99 | const { player } = await getMusic(req.params.id, req.query.key); 100 | 101 | player.q.shuffle(); 102 | 103 | res.status(200).send({ success: true }); 104 | } catch (error) { res.status(400).send(error?.message); } 105 | }); 106 | 107 | router.get('/stop', validateGuildManager, async (req, res) => { 108 | try { 109 | const { player } = await getMusic(req.params.id, req.query.key); 110 | await player.stop(); 111 | 112 | res.status(200).send({ success: true }); 113 | } catch (error) { res.status(400).send(error?.message); } 114 | }); 115 | 116 | async function getMusic(guildId: string, key: any) { 117 | const { authUser } = await sessions.get(key); 118 | 119 | const user = bot.users.cache.get(authUser.id); 120 | const guild = bot.guilds.cache.get(guildId); 121 | const member = guild.members.cache.get(authUser.id); 122 | 123 | const savedUser = await users.get(user); 124 | 125 | return { 126 | player: music.joinAndGetPlayer(member.voice.channel), 127 | hasPremium: savedUser.premium 128 | }; 129 | } 130 | -------------------------------------------------------------------------------- /src/api/routes/pay-routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { APIError, sendError } from '../modules/api-utils'; 3 | import Deps from '../../utils/deps'; 4 | import Users, { Plan } from '../../data/users'; 5 | import { SessionManager } from '../modules/performance/session-manager'; 6 | import paypal, { Payment } from 'paypal-rest-sdk'; 7 | import Log from '../../utils/log'; 8 | 9 | paypal.configure({ 10 | mode: process.env.PAYPAL_MODE, // sandbox / live 11 | client_id: process.env.PAYPAL_CLIENT_ID, 12 | client_secret: process.env.PAYPAL_SECRET, 13 | }); 14 | 15 | const sessions = Deps.get(SessionManager); 16 | 17 | const items: paypal.Item[] = [ 18 | { 19 | sku: '1_month', 20 | name: '2PG+ [1 Month]', 21 | price: '2.99', 22 | currency: 'USD', 23 | quantity: 1 24 | }, 25 | { 26 | sku: '3_month', 27 | name: '2PG+ [3 Months]', 28 | price: '4.99', 29 | currency: 'USD', 30 | quantity: 1 31 | }, 32 | { 33 | sku: '1_year', 34 | name: '2PG+ [1 Year]', 35 | price: '14.99', 36 | currency: 'USD', 37 | quantity: 1 38 | } 39 | ]; 40 | 41 | export const router = Router(); 42 | 43 | const users = Deps.get(Users); 44 | 45 | router.get('/', async(req, res) => { 46 | try { 47 | const { key, plan } = req.query as any; 48 | const { authUser } = await sessions.get(key); 49 | 50 | const item = items[+plan]; 51 | item.description = authUser.id; 52 | 53 | const payment: Payment = { 54 | intent: 'sale', 55 | payer: { 56 | payment_method: 'paypal' 57 | }, 58 | note_to_payer: `For Discord User - ${authUser.tag}`, 59 | redirect_urls: { 60 | return_url: `${process.env.API_URL}/pay/success`, 61 | cancel_url: `${process.env.DASHBOARD_URL}/plus?payment_status=failed`, 62 | }, 63 | transactions: [ 64 | { 65 | reference_id: authUser.id, 66 | item_list: { items: [item] }, 67 | amount: { 68 | currency: item.currency, 69 | total: item.price, 70 | }, 71 | }, 72 | ], 73 | }; 74 | 75 | paypal.payment.create(payment, { 76 | auth: authUser.id, 77 | }, (error, payment) => { 78 | if (error) 79 | throw error; 80 | 81 | for (const link of payment.links) { 82 | const paymentApproved = link.rel === 'approval_url'; 83 | if (paymentApproved) 84 | res.redirect(link.href); 85 | } 86 | }); 87 | } catch (error) { sendError(res, new APIError(400)); } 88 | }); 89 | 90 | router.get('/success', async (req, res) => { 91 | try { 92 | const executePayment: paypal.payment.ExecuteRequest = { 93 | payer_id: req.query.PayerID.toString() 94 | }; 95 | 96 | const paymentId = req.query.paymentId.toString(); 97 | paypal.payment.execute(paymentId, executePayment, 98 | async (error, payment) => { 99 | payment.payer 100 | if (error) { 101 | Log.error(error.response, 'pay'); 102 | throw error; 103 | } 104 | 105 | const item: paypal.Item = payment 106 | .transactions[0] 107 | .item_list.items[0]; 108 | await users.givePlus(item.description, item.sku as Plan); 109 | 110 | res.redirect(`${process.env.DASHBOARD_URL}/payment-success`); 111 | }); 112 | } catch (error) { sendError(res, new APIError(400)); } 113 | }); 114 | -------------------------------------------------------------------------------- /src/api/routes/user-routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { XPCardGenerator } from '../modules/image/xp-card-generator'; 3 | import { SavedMember } from '../../data/models/member'; 4 | import Deps from '../../utils/deps'; 5 | import Users from '../../data/users'; 6 | import { APIError, sendError } from '../modules/api-utils'; 7 | import { SessionManager } from '../modules/performance/session-manager'; 8 | 9 | export const router = Router(); 10 | 11 | const generator = Deps.get(XPCardGenerator); 12 | const users = Deps.get(Users); 13 | const sessions = Deps.get(SessionManager); 14 | 15 | router.get('/', async (req, res) => { 16 | try { 17 | const { authUser } = await sessions.get(req.query.key.toString()); 18 | res.json({ 19 | ...authUser, 20 | displayAvatarURL: authUser.displayAvatarURL 21 | }); 22 | } catch (error) { sendError(res, new APIError(400)); } 23 | }); 24 | 25 | router.get('/saved', async (req, res) => { 26 | try { 27 | const { authUser } = await sessions.get(req.query.key.toString()); 28 | const savedUser = await users.get(authUser); 29 | res.json(savedUser); 30 | } catch (error) { sendError(res, new APIError(400)); } 31 | }); 32 | 33 | router.get('/xp-card-preview', async (req, res) => { 34 | try { 35 | delete req.query.cache; 36 | 37 | const session = await sessions.get(req.query.key.toString()); 38 | const savedUser = await users.get(session.authUser); 39 | 40 | const savedMember = new SavedMember(); 41 | savedMember.xp = 1800; 42 | savedMember.userId = savedUser.id; 43 | 44 | delete req.query.key; 45 | const image = await generator.generate(savedUser, savedMember, 1, { 46 | ...savedUser.xpCard, 47 | ...req.query 48 | }); 49 | 50 | res.set({'Content-Type': 'image/png'}).send(image); 51 | } catch (error) { sendError(res, new APIError(400)); } 52 | }); 53 | 54 | router.put('/xp-card', async (req, res) => { 55 | try { 56 | const { authUser } = await sessions.get(req.query.key.toString()); 57 | 58 | const savedUser = await users.get(authUser); 59 | savedUser.xpCard = req.body; 60 | await savedUser.save(); 61 | 62 | res.send(savedUser); 63 | } catch (error) { sendError(res, new APIError(400)); } 64 | }); 65 | -------------------------------------------------------------------------------- /src/api/server.ts: -------------------------------------------------------------------------------- 1 | import bodyParser from 'body-parser'; 2 | import cors from 'cors'; 3 | import express from 'express'; 4 | import { join } from 'path'; 5 | 6 | import Deps from '../utils/deps'; 7 | import Log from '../utils/log'; 8 | import Stats from './modules/stats'; 9 | 10 | import { router as apiRoutes } from './routes/api-routes'; 11 | import { router as authRoutes } from './routes/auth-routes'; 12 | import { router as guildsRoutes } from './routes/guilds-routes'; 13 | import { router as musicRoutes } from './routes/music-routes'; 14 | import { router as payRoutes } from './routes/pay-routes'; 15 | import { router as userRoutes } from './routes/user-routes'; 16 | 17 | export const app = express(); 18 | 19 | export default class API { 20 | constructor(private stats = Deps.get(Stats)) { 21 | app.use(cors()); 22 | app.use(bodyParser.json()); 23 | 24 | app.use('/api/pay', payRoutes); 25 | app.use('/api/guilds/:id/music', musicRoutes); 26 | app.use('/api/guilds', guildsRoutes); 27 | app.use('/api/user', userRoutes); 28 | app.use('/api', apiRoutes, authRoutes); 29 | 30 | app.get('/api/*', (req, res) => res.status(404).json({ code: 404 })); 31 | 32 | const distPath = join(process.cwd(), '/dist/twopg-dashboard/browser'); 33 | app.use(express.static(distPath)); 34 | 35 | app.all('*', (req, res) => res.status(200).sendFile(`${distPath}/index.html`)); 36 | 37 | this.stats.init(); 38 | } 39 | } 40 | 41 | const port = process.env.PORT || 3000; 42 | app.listen(port, () => Log.info(`API is live on port ${port}`)); 43 | -------------------------------------------------------------------------------- /src/bot.ts: -------------------------------------------------------------------------------- 1 | import { validateEnv } from './utils/validate-env'; 2 | validateEnv(); 3 | 4 | import Log from './utils/log'; 5 | import { Client } from 'discord.js'; 6 | import mongoose from 'mongoose'; 7 | import Deps from './utils/deps'; 8 | import { EventEmitter } from 'events'; 9 | import { EventHandler } from './handlers/event-handler'; 10 | import { DBotsService } from './modules/stats/dbots.service'; 11 | 12 | Log.twoPG(); 13 | 14 | const bot = Deps.add(Client, new Client({ 15 | partials: ['GUILD_MEMBER', 'REACTION', 'MESSAGE', 'USER'], 16 | })); 17 | 18 | export const emitter = new EventEmitter(); 19 | 20 | bot.login(process.env.BOT_TOKEN); 21 | 22 | Deps.get(EventHandler).init(); 23 | Deps.add(DBotsService, new DBotsService()); 24 | 25 | mongoose.connect(process.env.MONGO_URI, { 26 | useUnifiedTopology: true, 27 | useNewUrlParser: true, 28 | useFindAndModify: false 29 | }, (error) => (error) 30 | ? Log.error('Failed to connect to db', 'bot') 31 | : Log.info('Connected to db', 'bot')); 32 | 33 | // Free Hosting -> stops apps from auto sleeping 34 | import './utils/keep-alive'; 35 | -------------------------------------------------------------------------------- /src/commands/ban.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandContext, Permission } from './command'; 2 | import Deps from '../utils/deps'; 3 | import AutoMod from '../modules/auto-mod/auto-mod'; 4 | import { getMemberFromMention } from '../utils/command-utils'; 5 | 6 | export default class implements Command { 7 | name = 'ban'; 8 | summary = `Ban a member`; 9 | precondition: Permission = 'BAN_MEMBERS'; 10 | cooldown = 3; 11 | module = 'Auto-mod'; 12 | 13 | constructor(private autoMod = Deps.get(AutoMod)) {} 14 | 15 | execute = async(ctx: CommandContext, targetMention: string, ...reasonArgs: string[]) => { 16 | const target = getMemberFromMention(targetMention, ctx.guild); 17 | this.autoMod.validateAction(target, ctx.user); 18 | 19 | const reason = reasonArgs.join(' '); 20 | return target.ban({ reason }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/commands/clear.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandContext, Permission } from './command'; 2 | 3 | export default class implements Command { 4 | precondition: Permission = 'MANAGE_MESSAGES'; 5 | name = 'clear'; 6 | usage = 'clear [count = 100]'; 7 | summary = 'Clear all messages that are less than 2 weeks old.'; 8 | cooldown = 5; 9 | module = 'Auto-mod'; 10 | 11 | async execute(ctx: CommandContext, count = '100') { 12 | const msgs = await ctx.channel.bulkDelete(+count); 13 | const reminder = await ctx.channel.send(`Deleted \`${msgs.size}\` messages`); 14 | setTimeout(() => reminder.delete(), 3 * 1000); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/commands/command.ts: -------------------------------------------------------------------------------- 1 | import { Message, GuildMember, TextChannel, Guild, User, Client, PermissionString } from 'discord.js'; 2 | import { GuildDocument } from '../data/models/guild'; 3 | 4 | export type Permission = '' | PermissionString; 5 | 6 | export interface Command { 7 | aliases?: string[]; 8 | cooldown?: number; 9 | module: string; 10 | name: string; 11 | precondition?: Permission; 12 | summary: string; 13 | usage?: string; 14 | 15 | execute: (ctx: CommandContext, ...args: any) => Promise | void; 16 | } 17 | 18 | export class CommandContext { 19 | member: GuildMember; 20 | channel: TextChannel; 21 | guild: Guild; 22 | user: User; 23 | bot: Client; 24 | 25 | constructor( 26 | public msg: Message, 27 | public savedGuild: GuildDocument, 28 | public command: Command) { 29 | this.member = msg.member; 30 | this.channel = msg.channel as TextChannel; 31 | this.guild = msg.guild; 32 | this.user = msg.member.user; 33 | this.bot = msg.client; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/commands/dashboard.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandContext, Permission } from './command'; 2 | 3 | 4 | export default class implements Command { 5 | name = 'dashboard'; 6 | summary = `Get a link to the server's dashboard`; 7 | precondition: Permission = 'MANAGE_GUILD'; 8 | cooldown = 3; 9 | module = 'General'; 10 | 11 | execute = async(ctx: CommandContext) => { 12 | return ctx.channel.send(`${ process.env.DASHBOARD_URL}/servers/${ctx.guild.id}`); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/commands/help.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandContext, Permission } from './command'; 2 | 3 | export default class implements Command { 4 | name = 'help'; 5 | summary = 'Get a link to list all commands.'; 6 | precondition: Permission = ''; 7 | cooldown = 3; 8 | module = 'General'; 9 | 10 | execute = async(ctx: CommandContext) => { 11 | await ctx.channel.send(`${process.env.DASHBOARD_URL}/commands?guild_id=${ctx.guild.id}`); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/commands/invite.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandContext, Permission } from './command'; 2 | 3 | 4 | export default class implements Command { 5 | name = 'invite'; 6 | summary = 'Get a link to invite the bot.'; 7 | precondition: Permission = ''; 8 | cooldown = 3; 9 | module = 'General'; 10 | 11 | execute = async(ctx: CommandContext) => { 12 | await ctx.channel.send(`${ process.env.API_URL}/invite`); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/commands/kick.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandContext, Permission } from './command'; 2 | 3 | import Deps from '../utils/deps'; 4 | import AutoMod from '../modules/auto-mod/auto-mod'; 5 | import { getMemberFromMention } from '../utils/command-utils'; 6 | 7 | export default class implements Command { 8 | name = 'kick'; 9 | summary = `Kick a member`; 10 | precondition: Permission = 'KICK_MEMBERS'; 11 | cooldown = 3; 12 | module = 'Auto-mod'; 13 | 14 | constructor(private autoMod = Deps.get(AutoMod)) {} 15 | 16 | execute = async(ctx: CommandContext, targetMention: string, ...reasonArgs: string[]) => { 17 | const target = getMemberFromMention(targetMention, ctx.guild); 18 | this.autoMod.validateAction(target, ctx.user); 19 | 20 | const reason = reasonArgs.join(' '); 21 | return target.kick(reason); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/commands/leaderboard.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandContext, Permission } from './command'; 2 | 3 | 4 | export default class implements Command { 5 | aliases = ['lb']; 6 | name = 'leaderboard'; 7 | summary = `Get a link to the server's leaderboard`; 8 | precondition: Permission = ''; 9 | cooldown = 3; 10 | module = 'Leveling'; 11 | 12 | execute = async(ctx: CommandContext) => { 13 | ctx.channel.send(`${ process.env.DASHBOARD_URL}/leaderboard/${ctx.guild.id}`); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/commands/list.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandContext, Permission } from './command'; 2 | import Deps from '../utils/deps'; 3 | import Music from '../modules/music/music'; 4 | import { Track } from '@2pg/music'; 5 | 6 | export default class implements Command { 7 | aliases = ['q']; 8 | name = 'list'; 9 | summary = 'Display the current track list.'; 10 | precondition: Permission = 'SPEAK'; 11 | cooldown = 3; 12 | module = 'Music'; 13 | 14 | constructor(private music = Deps.get(Music)) {} 15 | 16 | execute = async(ctx: CommandContext) => { 17 | const player = this.music.joinAndGetPlayer(ctx.member.voice.channel, ctx.channel); 18 | 19 | let details = '>>> **__List__**:\n'; 20 | for (let i = 0; i < player.q.length; i++) { 21 | const track: Track = player.q.items[i]; 22 | const prefix = (i === 0) 23 | ? `**Now Playing**:` 24 | : `**[${i + 1}]**`; 25 | details += `${prefix} \`${track.title}\` \`${this.music.getDuration(player, track)}\`\n`; 26 | } 27 | return ctx.channel.send(details || '> No tracks in list.'); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/commands/now-playing.ts: -------------------------------------------------------------------------------- 1 | import Music from '../modules/music/music'; 2 | import Deps from '../utils/deps'; 3 | import { Command, CommandContext, Permission } from './command'; 4 | import { Spotify } from 'canvacord'; 5 | 6 | export default class implements Command { 7 | name = 'now-playing'; 8 | summary = 'Show the track that is currently playing.'; 9 | precondition: Permission = ''; 10 | cooldown = 3; 11 | module = 'Music'; 12 | 13 | constructor(private music = Deps.get(Music)) {} 14 | 15 | async execute(ctx: CommandContext) { 16 | const player = this.music.joinAndGetPlayer(ctx.member.voice.channel, ctx.channel); 17 | if (!player.isPlaying) 18 | throw new TypeError('No track is currently playing'); 19 | 20 | const track = player.q.peek(); 21 | const card = new Spotify() 22 | .setAuthor(track.author.name) 23 | .setAlbum('YouTube') 24 | .setStartTimestamp(player.position) 25 | .setEndTimestamp(track.seconds) 26 | .setImage(track.thumbnail) 27 | .setTitle(track.title) 28 | .build(); 29 | 30 | await ctx.channel.send( 31 | { files: [{ attachment: card, name: 'now-playing.png' }] 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/commands/pause.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandContext, Permission } from './command'; 2 | import Deps from '../utils/deps'; 3 | import Music from '../modules/music/music'; 4 | 5 | export default class implements Command { 6 | name = 'pause'; 7 | summary = 'Pause playback if playing.'; 8 | precondition: Permission = 'SPEAK'; 9 | module = 'Music'; 10 | 11 | constructor(private music = Deps.get(Music)) {} 12 | 13 | execute = async (ctx: CommandContext) => { 14 | const player = this.music.joinAndGetPlayer(ctx.member.voice.channel, ctx.channel); 15 | 16 | if (player.isPaused) 17 | throw new TypeError('Player is already paused.'); 18 | 19 | await player.pause(); 20 | 21 | ctx.channel.send(`> **Paused**: \`${player.q.peek()?.title}\``); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/commands/ping.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandContext, Permission } from './command'; 2 | 3 | export default class implements Command { 4 | name = 'ping'; 5 | summary = 'Probably the best command ever created.'; 6 | precondition: Permission = ''; 7 | cooldown = 3; 8 | module = 'General'; 9 | 10 | async execute(ctx: CommandContext) { 11 | return ctx.channel.send(`🏓 Pong! \`${ctx.bot.ws.ping}ms\``); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/commands/play.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandContext, Permission } from './command'; 2 | import Deps from '../utils/deps'; 3 | import Music from '../modules/music/music'; 4 | 5 | export default class implements Command { 6 | aliases = ['p']; 7 | cooldown = 2; 8 | module = 'Music'; 9 | name = 'play'; 10 | precondition: Permission = 'SPEAK'; 11 | summary = 'Join and play a YouTube result.'; 12 | usage = 'play query' 13 | 14 | constructor(private music = Deps.get(Music)) {} 15 | 16 | execute = async(ctx: CommandContext, ...args: string[]) => { 17 | const query = args?.join(' '); 18 | if (!query) 19 | throw new TypeError('Query must be provided.'); 20 | 21 | const player = this.music.joinAndGetPlayer(ctx.member.voice.channel, ctx.channel); 22 | 23 | const maxQueueSize = 5; 24 | if (player.q.length >= maxQueueSize) 25 | throw new TypeError(`Max queue size of \`${maxQueueSize}\` reached.`); 26 | 27 | const track = await player.play(query); 28 | if (player.isPlaying) 29 | return ctx.channel.send(`> **Added**: \`${track.title}\` to list.`); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/commands/reaction-roles.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandContext, Permission } from './command'; 2 | import { TextChannel } from 'discord.js'; 3 | 4 | export default class implements Command { 5 | aliases = ['rr']; 6 | module = 'General'; 7 | name = 'reaction-roles'; 8 | precondition: Permission = 'MANAGE_ROLES'; 9 | summary = 'Update reaction roles, to users who have reacted, if not automated.'; 10 | usage = 'reaction-roles'; 11 | 12 | async execute(ctx: CommandContext) { 13 | let count = 0; 14 | const configs = ctx.savedGuild.reactionRoles.configs; 15 | for (const config of configs) { 16 | const channel = ctx.bot.channels.cache.get(config.channel) as TextChannel; 17 | if (!channel) continue; 18 | 19 | const reactions = channel.messages.cache 20 | .get(config.messageId)?.reactions.cache; 21 | if (!reactions) continue; 22 | 23 | for (const reaction of reactions.values()) { 24 | const matchesConfig = this.toHex(reaction.emoji.name) === this.toHex(config.emote); 25 | if (!matchesConfig) continue; 26 | 27 | count++; 28 | for (const user of reaction.users.cache.values()) 29 | await ctx.guild.members.cache 30 | .get(user.id).roles 31 | .add(config.role); 32 | } 33 | await ctx.channel.send(`> Added \`${count}\` reaction roles.`); 34 | } 35 | } 36 | 37 | toHex(a: string) { 38 | return a.codePointAt(0).toString(16); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/commands/resume.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandContext, Permission } from './command'; 2 | import Deps from '../utils/deps'; 3 | import Music from '../modules/music/music'; 4 | 5 | export default class implements Command { 6 | name = 'resume'; 7 | summary = 'Resume playing a track if paused.'; 8 | precondition: Permission = 'SPEAK'; 9 | module = 'Music'; 10 | 11 | constructor(private music = Deps.get(Music)) {} 12 | 13 | execute = async (ctx: CommandContext) => { 14 | const player = this.music.joinAndGetPlayer(ctx.member.voice.channel, ctx.channel); 15 | 16 | if (!player.isPaused) 17 | throw new TypeError('Player is already resumed.'); 18 | 19 | await player.resume(); 20 | 21 | ctx.channel.send(`> **Resumed**: \`${player.q.peek().title}\``); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/commands/say.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandContext, Permission } from './command'; 2 | 3 | export default class implements Command { 4 | name = 'say'; 5 | summary = `Get the bot to say something`; 6 | precondition: Permission = 'MENTION_EVERYONE'; 7 | cooldown = 3; 8 | module = 'General'; 9 | 10 | execute = async(ctx: CommandContext, ...args: string[]) => { 11 | return ctx.channel.send(args.join(' ')); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/commands/seek.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandContext, Permission } from './command'; 2 | import Deps from '../utils/deps'; 3 | import Music from '../modules/music/music'; 4 | 5 | export default class implements Command { 6 | precondition: Permission = 'SPEAK'; 7 | name = 'seek'; 8 | usage = 'seek [position]'; 9 | summary = 'View current track position, or go to a position in a track.'; 10 | cooldown = 1; 11 | module = 'Music'; 12 | 13 | constructor(private music = Deps.get(Music)) {} 14 | 15 | execute = async(ctx: CommandContext, position: string) => { 16 | const player = this.music.joinAndGetPlayer(ctx.member.voice.channel, ctx.channel); 17 | if (player.q.length <= 0) 18 | throw new TypeError('No tracks currently playing'); 19 | 20 | const pos = +position; 21 | if (!pos) 22 | return ctx.channel.send(`> Track at: \`${this.music.getDuration(player)}\``); 23 | 24 | await player.seek(pos); 25 | 26 | return ctx.channel.send(`> Player at \`${pos}s\`.`); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/commands/shuffle.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandContext, Permission } from './command'; 2 | import Deps from '../utils/deps'; 3 | import Music from '../modules/music/music'; 4 | 5 | export default class implements Command { 6 | precondition: Permission = 'SPEAK'; 7 | name = 'shuffle'; 8 | summary = 'Shuffle a playlist.'; 9 | cooldown = 3; 10 | module = 'Music'; 11 | 12 | constructor(private music = Deps.get(Music)) {} 13 | 14 | execute = async(ctx: CommandContext) => { 15 | const player = this.music.joinAndGetPlayer(ctx.member.voice.channel, ctx.channel); 16 | player.q.shuffle(); 17 | 18 | return ctx.channel.send('List shuffled.'); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/commands/skip.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandContext, Permission } from './command'; 2 | import Deps from '../utils/deps'; 3 | import Music from '../modules/music/music'; 4 | 5 | export default class implements Command { 6 | name = 'skip'; 7 | summary = 'Skip current playing track'; 8 | precondition: Permission = 'SPEAK'; 9 | cooldown = 5; 10 | module = 'Music'; 11 | 12 | constructor(private music = Deps.get(Music)) {} 13 | 14 | execute = async(ctx: CommandContext) => { 15 | const player = this.music.joinAndGetPlayer(ctx.member.voice.channel, ctx.channel); 16 | player.skip(); 17 | 18 | await ctx.channel.send(`> Skipped track.`); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/commands/stats.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandContext, Permission } from './command'; 2 | import { MessageEmbed } from 'discord.js'; 3 | import { SavedGuild } from '../data/models/guild'; 4 | 5 | export default class implements Command { 6 | aliases = ['bot'] 7 | name = 'stats'; 8 | summary = 'List bot statistics in a message embed.'; 9 | precondition: Permission = ''; 10 | cooldown = 3; 11 | module = 'General'; 12 | 13 | execute = async(ctx: CommandContext) => { 14 | const savedGuildsCount = await SavedGuild.count({}); 15 | 16 | await ctx.channel.send(new MessageEmbed({ 17 | title: `${ctx.bot.user.username} Stats`, 18 | fields: [ 19 | { name: 'Guilds', value: `\`${ctx.bot.guilds.cache.size}\``, inline: true }, 20 | { name: 'Users', value: `\`${ctx.bot.users.cache.size}\``, inline: true }, 21 | { name: 'Channels', value: `\`${ctx.bot.channels.cache.size}\``, inline: true }, 22 | 23 | { name: 'Created At', value: `\`${ctx.bot.user.createdAt.toDateString()}\``, inline: true }, 24 | { name: 'Uptime', value: `\`${ctx.bot.uptime / 1000 / 60 / 60}h\``, inline: true }, 25 | { name: 'Saved Guilds', value: `\`${savedGuildsCount}\``, inline: true } 26 | ] 27 | })); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/commands/stop.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandContext, Permission } from './command'; 2 | import Deps from '../utils/deps'; 3 | import Music from '../modules/music/music'; 4 | 5 | export default class implements Command { 6 | aliases = ['leave']; 7 | name = 'stop'; 8 | summary = 'Stop playback, clear list, and leave channel'; 9 | precondition: Permission = 'SPEAK'; 10 | cooldown = 5; 11 | module = 'Music'; 12 | 13 | constructor(private music = Deps.get(Music)) {} 14 | 15 | async execute(ctx: CommandContext) { 16 | 17 | const player = this.music.client.players.get(ctx.guild.id) 18 | if (!player) 19 | throw new TypeError('Not currently playing any track.'); 20 | 21 | player.stop(); 22 | player.leave(); 23 | 24 | await ctx.channel.send(`> Stopped playback, and left voice channel.`); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/commands/warn.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandContext, Permission } from './command'; 2 | import AutoMod from '../modules/auto-mod/auto-mod'; 3 | import Deps from '../utils/deps'; 4 | import { getMemberFromMention } from '../utils/command-utils'; 5 | 6 | export default class implements Command { 7 | precondition: Permission = 'BAN_MEMBERS'; 8 | name = 'warn'; 9 | usage = 'warn user reason'; 10 | summary = 'Warn a user and add a warning to their account.'; 11 | cooldown = 5; 12 | module = 'Auto-mod'; 13 | 14 | constructor(private autoMod = Deps.get(AutoMod)) {} 15 | 16 | execute = async(ctx: CommandContext, targetMention: string, ...args: string[]) => { 17 | const reason = args?.join(' '); 18 | if (!reason) 19 | throw new TypeError('Why warn someone for no reason :thinking: :joy:?'); 20 | 21 | const target = (targetMention) ? 22 | getMemberFromMention(targetMention, ctx.guild) : ctx.member; 23 | 24 | await this.autoMod.warn(target, { instigator: ctx.user, reason }); 25 | 26 | await ctx.channel.send(`<@!${target}> was warned for \`${reason}\``); 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /src/commands/warnings.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandContext, Permission } from './command'; 2 | import Members from '../data/members'; 3 | import { TextChannel } from 'discord.js'; 4 | import { MemberDocument } from '../data/models/member'; 5 | import Deps from '../utils/deps'; 6 | import { getMemberFromMention } from '../utils/command-utils'; 7 | 8 | export default class implements Command { 9 | precondition: Permission = 'VIEW_AUDIT_LOG'; 10 | name = 'warnings'; 11 | usage = 'warnings [user]' 12 | summary = 'Display your warnings, or the warnings of a member.'; 13 | cooldown = 3; 14 | module = 'Auto-mod'; 15 | 16 | constructor( 17 | private members = Deps.get(Members)) {} 18 | 19 | execute = async(ctx: CommandContext, userMention?: string, position?: string) => { 20 | const target = (userMention) ? 21 | getMemberFromMention(userMention, ctx.guild) : ctx.member; 22 | 23 | const savedMember = await this.members.get(target); 24 | 25 | if (position) 26 | return this.displayWarning(+position, savedMember, ctx.channel); 27 | 28 | await ctx.channel.send(`User has \`${savedMember.warnings.length}\` warnings.`) 29 | } 30 | 31 | private async displayWarning(position: number, savedMember: MemberDocument, channel: TextChannel) { 32 | if (position <= 0 || position > savedMember.warnings.length) 33 | throw new TypeError('Warning at position not found on user.'); 34 | 35 | const warning = savedMember.warnings[position - 1]; 36 | const instigator = channel.client.users.cache.get(warning.instigatorId); 37 | channel.send(`**Warning #${position}**\n**By**: <@!${instigator ?? 'N/A'}>\n**For**: \`${warning.reason}\``); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/commands/xp.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandContext, Permission } from './command'; 2 | 3 | import { getMemberFromMention } from '../utils/command-utils'; 4 | 5 | export default class implements Command { 6 | aliases = ['level', 'profile']; 7 | name = 'xp'; 8 | summary = 'Display the XP card of a user.'; 9 | precondition: Permission = ''; 10 | cooldown = 3; 11 | module = 'Leveling'; 12 | 13 | execute = (ctx: CommandContext, userMention: string) => { 14 | const target = (userMention) 15 | ? getMemberFromMention(userMention, ctx.guild) 16 | : ctx.member; 17 | 18 | if (target.user.bot) 19 | throw new TypeError(`Bot users cannot earn XP`); 20 | 21 | const xpCardURL = `${process.env.API_URL}/guilds/${ctx.guild.id}/members/${target.id}/xp-card`; 22 | return ctx.channel.send( 23 | { files: [{ attachment: xpCardURL, name: 'xp-card.png' }] 24 | }); 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /src/data/db-wrapper.ts: -------------------------------------------------------------------------------- 1 | import { Document } from 'mongoose'; 2 | 3 | export default abstract class DBWrapper { 4 | get(identifier: T1) { 5 | return this.getOrCreate(identifier); 6 | } 7 | 8 | protected abstract getOrCreate(type: T1): Promise; 9 | protected abstract create(type: T1): Promise; 10 | 11 | save(savedType: T2) { 12 | return savedType.save(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/data/guilds.ts: -------------------------------------------------------------------------------- 1 | import { GuildDocument, SavedGuild } from './models/guild'; 2 | import DBWrapper from './db-wrapper'; 3 | import SnowflakeEntity from './snowflake-entity'; 4 | 5 | export default class Guilds extends DBWrapper { 6 | protected async getOrCreate({ id }: SnowflakeEntity) { 7 | const savedGuild = await SavedGuild.findById(id); 8 | return savedGuild ?? this.create({ id }); 9 | } 10 | 11 | protected create({ id }: SnowflakeEntity) { 12 | return new SavedGuild({ _id: id }).save(); 13 | } 14 | } -------------------------------------------------------------------------------- /src/data/logs.ts: -------------------------------------------------------------------------------- 1 | import { Guild, Message } from 'discord.js'; 2 | import DBWrapper from './db-wrapper'; 3 | import { LogDocument, SavedLog } from './models/log'; 4 | import { Command } from '../commands/command'; 5 | 6 | export default class Logs extends DBWrapper { 7 | protected async getOrCreate(guild: Guild) { 8 | const savedLog = await SavedLog.findById(guild.id); 9 | return savedLog ?? this.create(guild); 10 | } 11 | 12 | protected async create(guild: Guild) { 13 | return new SavedLog({ _id: guild.id }).save(); 14 | } 15 | 16 | async logCommand(msg: Message, command: Command) { 17 | const log = await this.get(msg.guild); 18 | log.commands.push({ 19 | name: command.name, 20 | by: msg.author.id, 21 | at: new Date() 22 | }); 23 | await this.save(log); 24 | } 25 | 26 | async getAll() { 27 | return await SavedLog.find(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/data/members.ts: -------------------------------------------------------------------------------- 1 | import { GuildMember } from 'discord.js'; 2 | import { MemberDocument, SavedMember } from './models/member'; 3 | import DBWrapper from './db-wrapper'; 4 | 5 | export default class Members extends DBWrapper { 6 | protected async getOrCreate(member: GuildMember) { 7 | if (member.user.bot) 8 | throw new TypeError(`Bots don't have accounts`); 9 | 10 | const user = await SavedMember.findOne({ 11 | userId: member.id, 12 | guildId: member.guild.id 13 | }); 14 | return user ?? this.create(member); 15 | } 16 | 17 | protected create(member: GuildMember) { 18 | return new SavedMember({ 19 | userId: member.id, 20 | guildId: member.guild.id 21 | }).save(); 22 | } 23 | 24 | getRanked(member: GuildMember, savedMembers: MemberDocument[]) { 25 | return savedMembers 26 | .sort((a, b) => b.xp - a.xp) 27 | .findIndex(m => m.userId === member.id) + 1; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/data/models/guild.ts: -------------------------------------------------------------------------------- 1 | import { model, Schema, Document } from 'mongoose'; 2 | 3 | export class Module { 4 | enabled = true; 5 | } 6 | 7 | export class LogsModule extends Module { 8 | events: LogEvent[] = []; 9 | } 10 | 11 | export enum EventType { 12 | Ban = 'BAN', 13 | CommandExecuted = 'COMMAND_EXECUTED', 14 | ConfigUpdate = 'CONFIG_UPDATE', 15 | LevelUp = 'LEVEL_UP', 16 | MessageDeleted = 'MESSAGE_DELETED', 17 | MemberJoin = 'MEMBER_JOIN', 18 | MemberLeave = 'MEMBER_LEAVE', 19 | Unban = 'UNBAN', 20 | Warn ='WARN' 21 | } 22 | 23 | export interface AutoPunishment { 24 | warnings: number; 25 | minutes: number; 26 | type: 'BAN' | 'KICK'; 27 | } 28 | 29 | export interface LogEvent { 30 | channel: string; 31 | enabled: boolean; 32 | event: EventType; 33 | message: string; 34 | } 35 | 36 | export class AutoModModule extends Module { 37 | ignoredRoles: string[] = []; 38 | autoDeleteMessages = true; 39 | filters: MessageFilter[] = []; 40 | banWords: string[] = []; 41 | banLinks: string[] = []; 42 | autoWarnUsers = true; 43 | filterThreshold = 5; 44 | punishments: AutoPunishment[]; 45 | } 46 | 47 | export class CommandsModule extends Module { 48 | configs: CommandConfig[] = []; 49 | custom: CustomCommand[] = []; 50 | } 51 | 52 | export enum MessageFilter { 53 | Links = 'LINKS', 54 | MassCaps = 'MASS_CAPS', 55 | MassMention = 'MASS_MENTION', 56 | Words = 'WORDS', 57 | Toxicity = 'TOXICITY' 58 | } 59 | 60 | export class GeneralModule extends Module { 61 | prefix = '.'; 62 | ignoredChannels: string[] = []; 63 | autoRoles: string[] = []; 64 | } 65 | 66 | export class LevelingModule extends Module { 67 | levelRoles: LevelRole[] = []; 68 | ignoredRoles: string[] = []; 69 | xpPerMessage = 50; 70 | maxMessagesPerMinute = 3; 71 | } 72 | 73 | export interface LevelRole { 74 | level: number; 75 | role: string; 76 | } 77 | 78 | export class MusicModule extends Module {} 79 | 80 | export interface CommandConfig { 81 | name: string; 82 | enabled: boolean; 83 | } 84 | export interface CustomCommand { 85 | alias: string; 86 | anywhere: boolean; 87 | command: string; 88 | } 89 | 90 | export class ReactionRolesModule extends Module { 91 | configs: ReactionRole[] = []; 92 | } 93 | export interface ReactionRole { 94 | channel: string, 95 | messageId: string, 96 | emote: string, 97 | role: string 98 | } 99 | 100 | export class DashboardSettings { 101 | privateLeaderboard = false; 102 | } 103 | 104 | const guildSchema = new Schema({ 105 | _id: String, 106 | autoMod: { type: Object, default: new AutoModModule() }, 107 | commands: { type: Object, default: new CommandsModule() }, 108 | general: { type: Object, default: new GeneralModule() }, 109 | leveling: { type: Object, default: new LevelingModule() }, 110 | logs: { type: Object, default: new LogsModule() }, 111 | music: { type: Object, default: new MusicModule }, 112 | reactionRoles: { type: Object, default: new ReactionRolesModule() }, 113 | settings: { type: Object, default: new DashboardSettings() } 114 | }); 115 | 116 | export interface GuildDocument extends Document { 117 | _id: string; 118 | autoMod: AutoModModule; 119 | commands: CommandsModule; 120 | general: GeneralModule; 121 | music: MusicModule; 122 | leveling: LevelingModule; 123 | logs: LogsModule; 124 | reactionRoles: ReactionRolesModule; 125 | settings: DashboardSettings; 126 | } 127 | 128 | export const SavedGuild = model('guild', guildSchema); 129 | -------------------------------------------------------------------------------- /src/data/models/log.ts: -------------------------------------------------------------------------------- 1 | import { model, Schema, Document } from 'mongoose'; 2 | 3 | export class Change { 4 | public at = new Date(); 5 | 6 | constructor( 7 | public by: string, 8 | public changes: { old: {}, new: {}}, 9 | public module: string) {} 10 | } 11 | 12 | export interface CommandLog { 13 | name: string, 14 | by: string, 15 | at: Date 16 | } 17 | 18 | const LogSchema = new Schema({ 19 | _id: String, 20 | changes: { type: Array, default: [] }, 21 | commands: { type: Array, default: [] } 22 | }); 23 | 24 | export interface LogDocument extends Document { 25 | _id: string; 26 | changes: Change[]; 27 | commands: CommandLog[] 28 | } 29 | 30 | export const SavedLog = model('log', LogSchema); -------------------------------------------------------------------------------- /src/data/models/member.ts: -------------------------------------------------------------------------------- 1 | import { model, Schema, Document } from 'mongoose'; 2 | 3 | const memberSchema = new Schema({ 4 | userId: String, 5 | guildId: String, 6 | recentMessages: { type: Array, default: [] }, 7 | warnings: { type: Array, default: [] }, 8 | xp: { type: Number, default: 0 } 9 | }); 10 | 11 | export interface MemberDocument extends Document { 12 | userId: string; 13 | guildId: string; 14 | recentMessages: Date[]; 15 | warnings: Warning[]; 16 | xp: number; 17 | } 18 | 19 | export interface Warning { 20 | reason: string; 21 | instigatorId: string; 22 | at: Date; 23 | } 24 | 25 | export const SavedMember = model('member', memberSchema); 26 | -------------------------------------------------------------------------------- /src/data/models/user.ts: -------------------------------------------------------------------------------- 1 | import { model, Schema, Document } from 'mongoose'; 2 | 3 | export class XPCard { 4 | backgroundURL = ''; 5 | primary = ''; 6 | secondary = ''; 7 | tertiary = ''; 8 | } 9 | 10 | export interface UserDocument extends Document { 11 | _id: string; 12 | premium: boolean; 13 | premiumExpiration: Date; 14 | votes: number; 15 | xpCard: XPCard; 16 | } 17 | 18 | export const SavedUser = model('user', new Schema({ 19 | _id: String, 20 | premium: { type: Boolean, default: false }, 21 | premiumExpiration: { type: Date, default: new Date(0) }, 22 | votes: Number, 23 | xpCard: { type: Object, default: new XPCard() } 24 | })); 25 | -------------------------------------------------------------------------------- /src/data/snowflake-entity.ts: -------------------------------------------------------------------------------- 1 | export default interface SnowflakeEntity { 2 | id: string; 3 | } -------------------------------------------------------------------------------- /src/data/users.ts: -------------------------------------------------------------------------------- 1 | import { SavedUser, UserDocument } from './models/user'; 2 | import DBWrapper from './db-wrapper'; 3 | import SnowflakeEntity from './snowflake-entity'; 4 | import Deps from '../utils/deps'; 5 | import { Client } from 'discord.js'; 6 | 7 | export default class Users extends DBWrapper { 8 | constructor( 9 | private bot = Deps.get(Client) 10 | ) { super(); } 11 | 12 | protected async getOrCreate({ id }: SnowflakeEntity) { 13 | const user = this.bot.users.cache.get(id); 14 | if (user?.bot) 15 | throw new TypeError(`Bots don't have accounts`); 16 | 17 | const savedUser = await SavedUser.findById(id); 18 | if (savedUser 19 | && savedUser.premiumExpiration 20 | && savedUser.premiumExpiration <= new Date()) 21 | await this.removePremium(savedUser); 22 | 23 | return savedUser ?? this.create({ id }); 24 | } 25 | 26 | public async givePlus(id: string, plan: Plan) { 27 | const savedUser = await this.get({ id }); 28 | 29 | const expiration = (new Date() > savedUser.premiumExpiration) 30 | ? new Date() 31 | : savedUser.premiumExpiration; 32 | 33 | savedUser.premiumExpiration = this.getExpiration(expiration, plan); 34 | savedUser.premium = true; 35 | return savedUser.updateOne(savedUser); 36 | } 37 | private getExpiration(date: Date, plan: Plan) { 38 | if (plan === '1_month') 39 | date.setDate(date.getDate() + 30); 40 | else if (plan === '3_month') 41 | date.setDate(date.getDate() + 90); 42 | else 43 | date.setFullYear(date.getFullYear() + 1) 44 | return date; 45 | } 46 | private removePremium(savedUser: UserDocument) { 47 | savedUser.premium = false; 48 | return savedUser.save(); 49 | } 50 | 51 | protected async create({ id }: SnowflakeEntity) { 52 | return new SavedUser({ _id: id }).save(); 53 | } 54 | } 55 | 56 | export type Plan = '1_month' | '3_month' | '1_year'; 57 | -------------------------------------------------------------------------------- /src/handlers/commands/command.service.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { Message, TextChannel } from 'discord.js'; 3 | import { Command, CommandContext } from '../../commands/command'; 4 | import Log from '../../utils/log'; 5 | import Deps from '../../utils/deps'; 6 | import { GuildDocument } from '../../data/models/guild'; 7 | import Validators from './validators'; 8 | import { promisify } from 'util'; 9 | import Emit from '../emit'; 10 | 11 | const readdir = promisify(fs.readdir); 12 | 13 | export default class CommandService { 14 | public readonly commands = new Map(); 15 | 16 | constructor( 17 | private emit = Deps.get(Emit), 18 | private validators = Deps.get(Validators) 19 | ) {} 20 | 21 | public async init() { 22 | const files = await readdir('./src/commands'); 23 | 24 | for (const fileName of files) { 25 | const cleanName = fileName.replace(/(\..*)/, ''); 26 | 27 | const { default: Command } = await import(`../../commands/${cleanName}`); 28 | if (!Command) continue; 29 | 30 | const command = new Command(); 31 | this.commands.set(command.name, command); 32 | } 33 | Log.info(`Loaded: ${this.commands.size} commands`, `cmds`); 34 | } 35 | 36 | public async handle(msg: Message, savedGuild: GuildDocument) { 37 | try { 38 | const prefix = savedGuild.general.prefix; 39 | const slicedContent = msg.content.slice(prefix.length); 40 | 41 | const command = this.findCommand(slicedContent, savedGuild); 42 | const customCommand = this.findCustomCommand(slicedContent, savedGuild); 43 | if (!command && !customCommand) return; 44 | 45 | this.validators.checkChannel(msg.channel as TextChannel, savedGuild, customCommand); 46 | this.validators.checkCommand(command, savedGuild, msg); 47 | this.validators.checkPreconditions(command, msg.member); 48 | 49 | const ctx = new CommandContext(msg, savedGuild, command); 50 | await command.execute(ctx, ...this.getCommandArgs(slicedContent, savedGuild)); 51 | 52 | this.emit.commandExecuted(ctx); 53 | return command; 54 | } catch (error) { 55 | const content = error?.message ?? 'Un unknown error occurred.'; 56 | await msg.channel.send(`> :warning: ${content}`); 57 | } 58 | } 59 | 60 | private findCommand(slicedContent: string, savedGuild: GuildDocument) { 61 | const name = this.getCommandName(slicedContent); 62 | return this.commands.get(name) 63 | ?? this.findByAlias(name) 64 | ?? this.commands.get( 65 | this.findCustomCommand(name, savedGuild)?.command 66 | ); 67 | } 68 | private findByAlias(name: string) { 69 | return Array 70 | .from(this.commands.values()) 71 | .find(c => c.aliases?.some(a => a === name)); 72 | } 73 | private findCustomCommand(slicedContent: string, savedGuild: GuildDocument) { 74 | const name = this.getCommandName(slicedContent); 75 | return savedGuild.commands.custom 76 | ?.find(c => c.alias === name); 77 | } 78 | 79 | private getCommandArgs(slicedContent: string, savedGuild: GuildDocument) { 80 | const customCommand = this.findCustomCommand(slicedContent, savedGuild)?.command; 81 | return (customCommand ?? slicedContent) 82 | .split(' ') 83 | .slice(1); 84 | } 85 | private getCommandName(slicedContent: string) { 86 | return slicedContent 87 | ?.toLowerCase() 88 | .split(' ')[0]; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/handlers/commands/cooldowns.ts: -------------------------------------------------------------------------------- 1 | import { User } from 'discord.js'; 2 | import { Command } from '../../commands/command'; 3 | 4 | export default class Cooldowns { 5 | private cooldowns: CommandCooldown[] = []; 6 | 7 | get(author: User, command: Command) { 8 | return this.cooldowns 9 | .find(c => c.userId === author.id && c.commandName === command.name); 10 | } 11 | add(user: User, command: Command) { 12 | const cooldown = { userId: user.id, commandName: command.name }; 13 | 14 | if (!this.get(user, command)) 15 | this.cooldowns.push(cooldown); 16 | 17 | const seconds = (command.cooldown ?? 0) * 1000; 18 | setTimeout(() => this.remove(user, command), seconds); 19 | } 20 | remove(user: User, command: Command) { 21 | const index = this.cooldowns 22 | .findIndex(c => c.userId === user.id && c.commandName === command.name); 23 | this.cooldowns.splice(index, 1); 24 | } 25 | } 26 | 27 | export interface CommandCooldown { 28 | userId: string; 29 | commandName: string; 30 | } 31 | -------------------------------------------------------------------------------- /src/handlers/commands/validators.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '../../commands/command'; 2 | import { GuildMember, TextChannel, Message } from 'discord.js'; 3 | import { GuildDocument, CustomCommand } from '../../data/models/guild'; 4 | import Cooldowns from './cooldowns'; 5 | import Deps from '../../utils/deps'; 6 | 7 | export default class Validators { 8 | constructor(private cooldowns = Deps.get(Cooldowns)) {} 9 | 10 | checkCommand(command: Command, guild: GuildDocument, msg: Message) { 11 | const config = guild.commands.configs.find(c => c.name === command.name); 12 | if (!config) return; 13 | 14 | if (!config.enabled) 15 | throw new TypeError('Command not enabled!'); 16 | 17 | const cooldown = this.cooldowns.get(msg.author, command); 18 | if (cooldown) 19 | throw new TypeError(`Command is in cooldown for another \`${cooldown}s\`.`); 20 | } 21 | 22 | checkPreconditions(command: Command, executor: GuildMember) { 23 | if (command.precondition && !executor.hasPermission(command.precondition)) 24 | throw new TypeError(`**Required Permission**: \`${command.precondition}\``); 25 | } 26 | 27 | checkChannel(channel: TextChannel, savedGuild: GuildDocument, customCommand?: CustomCommand) { 28 | const isIgnored = savedGuild.general.ignoredChannels 29 | .some(id => id === channel.id); 30 | 31 | if (isIgnored && !customCommand) 32 | throw new TypeError('Commands cannot be executed in this channel.'); 33 | else if (isIgnored && !customCommand.anywhere) 34 | throw new TypeError('This custom command cannot be executed in this channel.'); 35 | } 36 | } -------------------------------------------------------------------------------- /src/handlers/emit.ts: -------------------------------------------------------------------------------- 1 | import { emitter } from '../bot'; 2 | import { Guild, User, GuildMember, Message } from 'discord.js'; 3 | import { PunishmentArgs } from '../modules/auto-mod/auto-mod'; 4 | import { MemberDocument } from '../data/models/member'; 5 | import { Change } from '../data/models/log'; 6 | import { CommandContext } from '../commands/command'; 7 | 8 | /** 9 | * Used for emitting custom events. 10 | */ 11 | export default class Emit { 12 | configSaved(guild: Guild, user: any, change: Change) { 13 | const eventArgs: ConfigUpdateArgs = { 14 | guild, 15 | instigator: user, 16 | module: change.module, 17 | new: change.changes.new, 18 | old: change.changes.old 19 | }; 20 | emitter.emit('configUpdate', eventArgs); 21 | } 22 | 23 | levelUp(args: { newLevel: number, oldLevel: number }, msg: Message, savedMember: MemberDocument) { 24 | const eventArgs: LevelUpEventArgs = { 25 | ...args, 26 | guild: msg.guild, 27 | xp: savedMember.xp, 28 | user: msg.member.user 29 | }; 30 | emitter.emit('levelUp', eventArgs); 31 | } 32 | 33 | warning(args: PunishmentArgs, target: GuildMember, savedMember: MemberDocument) { 34 | const eventArgs: PunishmentEventArgs = { 35 | ...args, 36 | guild: target.guild, 37 | reason: args.reason, 38 | user: target.user, 39 | warnings: savedMember.warnings.length 40 | } 41 | emitter.emit('userWarn', eventArgs, savedMember); 42 | } 43 | 44 | commandExecuted(ctx: CommandContext) { 45 | emitter.emit('commandExecuted', ctx); 46 | } 47 | } 48 | 49 | export interface ConfigUpdateArgs { 50 | guild: Guild; 51 | instigator: any | User; 52 | module: string; 53 | new: any; 54 | old: any; 55 | } 56 | 57 | export interface LevelUpEventArgs { 58 | guild: Guild; 59 | newLevel: number; 60 | oldLevel: number; 61 | xp: number; 62 | user: User; 63 | } 64 | 65 | export interface PunishmentEventArgs { 66 | until?: Date; 67 | guild: Guild; 68 | user: User; 69 | instigator: User; 70 | warnings: number; 71 | reason: string; 72 | } 73 | -------------------------------------------------------------------------------- /src/handlers/event-handler.ts: -------------------------------------------------------------------------------- 1 | import { emitter } from '../bot'; 2 | import Log from '../utils/log'; 3 | import fs from 'fs'; 4 | import { promisify } from 'util'; 5 | import Event from './events/event-handler'; 6 | import Deps from '../utils/deps'; 7 | import { Client } from 'discord.js'; 8 | 9 | const readdir = promisify(fs.readdir); 10 | 11 | export class EventHandler { 12 | private readonly handlers: Event[] = []; 13 | private readonly customHandlers: Event[] = []; 14 | 15 | constructor( 16 | private bot = Deps.get(Client) 17 | ) {} 18 | 19 | async init() { 20 | const handlerFiles = await readdir(`${__dirname}/events`); 21 | for (const file of handlerFiles.filter(n => n !== 'custom')) { 22 | const { default: Handler } = await import(`./events/${file}`); 23 | const handler = Handler && new Handler(); 24 | if (!handler?.on) continue; 25 | 26 | this.handlers.push(new Handler()); 27 | } 28 | 29 | const customHandlerFiles = await readdir(`${__dirname}/events/custom`); 30 | for (const file of customHandlerFiles) { 31 | const { default: Handler } = await import(`./events/custom/${file}`); 32 | const handler = Handler && new Handler(); 33 | if (!handler?.on) continue; 34 | 35 | this.customHandlers.push(new Handler()); 36 | } 37 | this.hookEvents(); 38 | } 39 | 40 | private hookEvents() { 41 | for (const handler of this.handlers) 42 | this.bot.on(handler.on as any, handler.invoke.bind(handler)); 43 | 44 | for (const handler of this.customHandlers) 45 | emitter.on(handler.on, handler.invoke.bind(handler)); 46 | 47 | Log.info(`Loaded: ${this.handlers.length} handlers`, 'events'); 48 | Log.info(`Loaded: ${this.customHandlers.length} custom handlers`, 'events'); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/handlers/events/announce-handler.ts: -------------------------------------------------------------------------------- 1 | import { EventType, GuildDocument, LogEvent } from '../../data/models/guild'; 2 | import Guilds from '../../data/guilds'; 3 | import { Guild, TextChannel } from 'discord.js'; 4 | import Deps from '../../utils/deps'; 5 | import Event from './event-handler'; 6 | 7 | export default abstract class implements Event { 8 | abstract on: string; 9 | abstract event: EventType; 10 | 11 | constructor(protected guilds = Deps.get(Guilds)) {} 12 | 13 | protected async getEvent(guild: Guild, savedGuild?: GuildDocument) { 14 | savedGuild ??= await this.guilds.get(guild); 15 | 16 | const event = savedGuild.logs.events.find(e => e.event === this.event); 17 | return (savedGuild.logs.enabled && event?.enabled) ? event : null; 18 | } 19 | 20 | protected getChannel(config: LogEvent, guild: Guild) { 21 | return guild.channels.cache.get(config?.channel) as TextChannel; 22 | } 23 | 24 | protected async announce(guild: Guild, applyEventArgs: any[], savedGuild?: GuildDocument) { 25 | const config = await this.getEvent(guild, savedGuild); 26 | if (!config) return; 27 | 28 | const message = await this.applyEventVariables(config.message, ...applyEventArgs); 29 | 30 | if (message.length <= 0) return; 31 | 32 | let channel = this.getChannel(config, guild); 33 | await channel?.send(message); 34 | } 35 | 36 | protected abstract applyEventVariables(...args: any[]): string | Promise; 37 | 38 | abstract invoke(...args: any[]): Promise | void; 39 | } 40 | -------------------------------------------------------------------------------- /src/handlers/events/custom/command-executed.handler.ts: -------------------------------------------------------------------------------- 1 | import AnnounceHandler from '../announce-handler'; 2 | import { EventType } from '../../../data/models/guild'; 3 | import EventVariables from '../../../modules/announce/event-variables'; 4 | import { ConfigUpdateArgs } from '../../emit'; 5 | import { CommandContext } from '../../../commands/command'; 6 | 7 | export default class extends AnnounceHandler { 8 | on = 'commandExecuted'; 9 | event = EventType.CommandExecuted; 10 | 11 | async invoke(args: ConfigUpdateArgs) { 12 | await super.announce(args.guild, [ args ]); 13 | } 14 | 15 | protected async applyEventVariables(content: string, args: CommandContext) { 16 | return new EventVariables(content) 17 | .guild(args.guild) 18 | .instigator(args.member) 19 | .memberCount(args.guild) 20 | .name(args.command.name) 21 | .toString(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/handlers/events/custom/config-update.handler.ts: -------------------------------------------------------------------------------- 1 | import AnnounceHandler from '../announce-handler'; 2 | import { EventType } from '../../../data/models/guild'; 3 | import EventVariables from '../../../modules/announce/event-variables'; 4 | import { ConfigUpdateArgs } from '../../emit'; 5 | 6 | export default class ConfigUpdateHandler extends AnnounceHandler { 7 | on = 'configUpdate'; 8 | event = EventType.ConfigUpdate; 9 | 10 | async invoke(args: ConfigUpdateArgs) { 11 | await super.announce(args.guild, [ args ]); 12 | } 13 | 14 | protected async applyEventVariables(content: string, args: ConfigUpdateArgs) { 15 | return new EventVariables(content) 16 | .guild(args.guild) 17 | .instigator(args.instigator) 18 | .memberCount(args.guild) 19 | .module(args.module) 20 | .newValue(args.new) 21 | .oldValue(args.old) 22 | .toString(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/handlers/events/custom/level-up.handler.ts: -------------------------------------------------------------------------------- 1 | import AnnounceHandler from '../announce-handler'; 2 | import { EventType } from '../../../data/models/guild'; 3 | import EventVariables from '../../../modules/announce/event-variables'; 4 | import { LevelUpEventArgs } from '../../emit'; 5 | 6 | export default class LevelUpHandler extends AnnounceHandler { 7 | on = 'levelUp'; 8 | event = EventType.LevelUp; 9 | 10 | async invoke(args: LevelUpEventArgs) { 11 | await super.announce(args.guild, [ args ]); 12 | } 13 | 14 | protected async applyEventVariables(content: string, args: LevelUpEventArgs) { 15 | return new EventVariables(content) 16 | .guild(args.guild) 17 | .memberCount(args.guild) 18 | .user(args.user) 19 | .oldLevel(args.oldLevel) 20 | .newLevel(args.newLevel) 21 | .xp(args.xp) 22 | .toString(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/handlers/events/custom/user-warn.handler.ts: -------------------------------------------------------------------------------- 1 | import AnnounceHandler from '../announce-handler'; 2 | import { AutoPunishment, EventType, GuildDocument } from '../../../data/models/guild'; 3 | import EventVariables from '../../../modules/announce/event-variables'; 4 | import { PunishmentEventArgs } from '../../emit'; 5 | import { MemberDocument } from '../../../data/models/member'; 6 | 7 | export default class extends AnnounceHandler { 8 | on = 'userWarn'; 9 | event = EventType.Warn; 10 | 11 | public async invoke(args: PunishmentEventArgs, savedMember: MemberDocument) { 12 | const savedGuild = await this.guilds.get(args.guild); 13 | await this.autoPunish(args, savedGuild, savedMember); 14 | 15 | await super.announce(args.guild, [ args ], savedGuild); 16 | } 17 | 18 | private getMinutesSince(date: Date) { 19 | const ms = new Date().getTime() - date.getTime(); 20 | return ms / 1000 / 60; 21 | } 22 | 23 | private async autoPunish(args: PunishmentEventArgs, savedGuild: GuildDocument, savedMember: MemberDocument) { 24 | const punishments = savedGuild.autoMod.punishments 25 | ?.sort((a, b) => (a.warnings < b.warnings) ? 1 : -1); 26 | for (const punishment of punishments) { 27 | if (!this.shouldPunish(savedMember, punishment)) continue; 28 | 29 | const member = args.guild.members.cache.get(args.user.id); 30 | try { 31 | if (punishment.type === 'KICK') 32 | return member.kick(`Auto-punish - ${args.warnings} warnings`); 33 | else if (punishment.type === 'BAN') 34 | return member.ban({ reason: `Auto-punish - ${args.warnings} warnings` }); 35 | } catch (error) {} 36 | } 37 | } 38 | 39 | private shouldPunish(savedMember: MemberDocument, punishment: AutoPunishment) { 40 | return (savedMember.warnings.length >= punishment.warnings) 41 | && savedMember.warnings 42 | .slice(-punishment.warnings) 43 | .every(p => this.getMinutesSince(p.at) <= punishment.minutes); 44 | } 45 | 46 | protected async applyEventVariables(content: string, args: PunishmentEventArgs) { 47 | return new EventVariables(content) 48 | .guild(args.guild) 49 | .instigator(args.instigator) 50 | .memberCount(args.guild) 51 | .reason(args.reason) 52 | .user(args.user) 53 | .warnings(args.warnings) 54 | .toString(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/handlers/events/event-handler.ts: -------------------------------------------------------------------------------- 1 | export default interface Event { 2 | on: string; 3 | 4 | invoke(...args: any[]): Promise | void; 5 | } -------------------------------------------------------------------------------- /src/handlers/events/guild-create.handler.ts: -------------------------------------------------------------------------------- 1 | import Event from './event-handler'; 2 | import { Guild, TextChannel } from 'discord.js'; 3 | import Deps from '../../utils/deps'; 4 | import Guilds from '../../data/guilds'; 5 | import { SessionManager } from '../../api/modules/performance/session-manager'; 6 | 7 | export default class GuildCreateHandler implements Event { 8 | on = 'guildCreate'; 9 | 10 | constructor( 11 | private guilds = Deps.get(Guilds), 12 | ) {} 13 | 14 | async invoke(guild: Guild): Promise { 15 | await this.guilds.get(guild); 16 | 17 | await this.sendWelcomeMessage(guild.systemChannel); 18 | } 19 | 20 | private async sendWelcomeMessage(channel: TextChannel | null) { 21 | const url = `${process.env.DASHBOARD_URL}/servers/${channel?.guild.id}`; 22 | await channel?.send(`Hey, I'm 2PG! Customize me at ${url}`); 23 | } 24 | } -------------------------------------------------------------------------------- /src/handlers/events/guild-member-add.handler.ts: -------------------------------------------------------------------------------- 1 | import AnnounceHandler from './announce-handler'; 2 | import { GuildMember } from 'discord.js'; 3 | import { EventType } from '../../data/models/guild'; 4 | import EventVariables from '../../modules/announce/event-variables'; 5 | 6 | export default class extends AnnounceHandler { 7 | on = 'guildMemberAdd'; 8 | event = EventType.MemberJoin; 9 | 10 | async invoke(member: GuildMember) { 11 | await super.announce(member.guild, [ member ]); 12 | await this.addAutoRoles(member); 13 | } 14 | 15 | private async addAutoRoles(member: GuildMember) { 16 | const guild = await this.guilds.get(member.guild); 17 | 18 | await member.roles.add(guild.general.autoRoles, 'Auto role'); 19 | } 20 | 21 | protected applyEventVariables(content: string, member: GuildMember) { 22 | return new EventVariables(content) 23 | .user(member.user) 24 | .guild(member.guild) 25 | .memberCount(member.guild) 26 | .toString(); 27 | } 28 | } -------------------------------------------------------------------------------- /src/handlers/events/guild-member-leave.handler.ts: -------------------------------------------------------------------------------- 1 | import { GuildMember, TextChannel } from 'discord.js'; 2 | import { EventType } from '../../data/models/guild'; 3 | import AnnounceHandler from './announce-handler'; 4 | import EventVariables from '../../modules/announce/event-variables'; 5 | 6 | export default class extends AnnounceHandler { 7 | on = 'guildMemberRemove'; 8 | event = EventType.MemberLeave; 9 | 10 | async invoke(member: GuildMember) { 11 | await super.announce(member.guild, [ member ]); 12 | } 13 | 14 | protected applyEventVariables(content: string, member: GuildMember) { 15 | return new EventVariables(content) 16 | .user(member.user) 17 | .guild(member.guild) 18 | .memberCount(member.guild) 19 | .toString(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/handlers/events/message-deleted.handler.ts: -------------------------------------------------------------------------------- 1 | import AnnounceHandler from './announce-handler'; 2 | import { Message, TextChannel } from 'discord.js'; 3 | import { EventType } from '../../data/models/guild'; 4 | import EventVariables from '../../modules/announce/event-variables'; 5 | 6 | export default class MessageDeleteHandler extends AnnounceHandler { 7 | on = 'messageDelete'; 8 | event = EventType.MessageDeleted; 9 | 10 | async invoke(msg: Message) { 11 | if (!msg.author.bot) 12 | await super.announce(msg.guild, [ msg ]); 13 | } 14 | 15 | protected applyEventVariables(content: string, msg: Message) { 16 | return new EventVariables(content) 17 | .guild(msg.guild) 18 | .memberCount(msg.guild) 19 | .message(msg) 20 | .user(msg.author) 21 | .toString(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/handlers/events/message-reaction-add.handler.ts: -------------------------------------------------------------------------------- 1 | import { User, MessageReaction } from 'discord.js'; 2 | import Event from './event-handler'; 3 | import Deps from '../../utils/deps'; 4 | import ReactionRoles from '../../modules/general/reaction-roles'; 5 | import Guilds from '../../data/guilds'; 6 | 7 | export default class MessageReactionAddHandler implements Event { 8 | on = 'messageReactionAdd'; 9 | 10 | constructor( 11 | private guilds = Deps.get(Guilds), 12 | private reactionRoles = Deps.get(ReactionRoles)) {} 13 | 14 | async invoke(reaction: MessageReaction, user: User) { 15 | reaction = await reaction.fetch(); 16 | const guild = reaction.message.guild; 17 | if (!guild) return; 18 | 19 | const savedGuild = await this.guilds.get(guild); 20 | await this.reactionRoles.checkToAdd(user, reaction, savedGuild); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/handlers/events/message-reaction-remove.handler.ts: -------------------------------------------------------------------------------- 1 | import { User, MessageReaction } from 'discord.js'; 2 | import Event from './event-handler'; 3 | import Guilds from '../../data/guilds'; 4 | import ReactionRoles from '../../modules/general/reaction-roles'; 5 | import Deps from '../../utils/deps'; 6 | 7 | export default class MessageReactionRemoveHandler implements Event { 8 | on = 'messageReactionRemove'; 9 | 10 | constructor( 11 | private guilds = Deps.get(Guilds), 12 | private reactionRoles = Deps.get(ReactionRoles)) {} 13 | 14 | async invoke(reaction: MessageReaction, user: User) { 15 | const guild = reaction.message.guild; 16 | if (!guild) return; 17 | 18 | await reaction.fetch(); 19 | 20 | const savedGuild = await this.guilds.get(guild); 21 | await this.reactionRoles.checkToRemove(user, reaction, savedGuild); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/handlers/events/message.handler.ts: -------------------------------------------------------------------------------- 1 | import Event from './event-handler'; 2 | import Deps from '../../utils/deps'; 3 | import CommandService from '../commands/command.service'; 4 | import Guilds from '../../data/guilds'; 5 | import AutoMod from '../../modules/auto-mod/auto-mod'; 6 | import Leveling from '../../modules/xp/leveling'; 7 | import { Message } from 'discord.js'; 8 | import Logs from '../../data/logs'; 9 | 10 | export default class MessageHandler implements Event { 11 | on = 'message'; 12 | 13 | constructor( 14 | private autoMod = Deps.get(AutoMod), 15 | private commands = Deps.get(CommandService), 16 | private guilds = Deps.get(Guilds), 17 | private leveling = Deps.get(Leveling), 18 | private logs = Deps.get(Logs)) {} 19 | 20 | async invoke(msg: Message) { 21 | if (!msg.member || msg.author.bot) return; 22 | 23 | const savedGuild = await this.guilds.get(msg.guild); 24 | 25 | const isCommand = msg.content.startsWith(savedGuild.general.prefix); 26 | if (isCommand) { 27 | const command = await this.commands.handle(msg, savedGuild); 28 | if (!command) return; 29 | 30 | return await this.logs.logCommand(msg, command); 31 | } 32 | 33 | if (savedGuild.autoMod.enabled) { 34 | try { 35 | await this.autoMod.validate(msg, savedGuild); 36 | } catch (validation) { 37 | await msg.channel.send(`> ${validation.message}`); 38 | } 39 | } 40 | if (savedGuild.leveling.enabled) 41 | try { 42 | await this.leveling.validateXPMsg(msg, savedGuild); 43 | } catch {} 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/handlers/events/ready.handler.ts: -------------------------------------------------------------------------------- 1 | import Log from '../../utils/log'; 2 | import Event from './event-handler'; 3 | import Deps from '../../utils/deps'; 4 | import CommandService from '../commands/command.service'; 5 | 6 | import AutoMod from '../../modules/auto-mod/auto-mod'; 7 | import ReactionRoles from '../../modules/general/reaction-roles'; 8 | import { Client } from 'discord.js'; 9 | import API from '../../api/server'; 10 | 11 | export default class ReadyHandler implements Event { 12 | started = false; 13 | on = 'ready'; 14 | 15 | constructor( 16 | private autoMod = Deps.get(AutoMod), 17 | private bot = Deps.get(Client), 18 | private commandService = Deps.get(CommandService), 19 | private reactionRoles = Deps.get(ReactionRoles) 20 | ) {} 21 | 22 | async invoke() { 23 | Log.info(`Bot is live!`, `events`); 24 | 25 | if (this.started) return; 26 | this.started = true; 27 | 28 | await this.autoMod.init(); 29 | await this.commandService.init(); 30 | await this.reactionRoles.init(); 31 | 32 | Deps.get(API); 33 | 34 | await this.bot.user?.setActivity('2PG.xyz'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | export default class { 2 | constructor( 3 | private config: Config, 4 | private options?: Options, 5 | ) { 6 | process.env.PORT = config.port?.toString() || '3000'; 7 | process.env.API_URL = config.apiURL || `http://localhost:${process.env.PORT}/api`; 8 | process.env.CLIENT_ID = config.botId; 9 | process.env.BOT_TOKEN = config.botToken; 10 | process.env.CLIENT_SECRET = config.clientSecret; 11 | process.env.DASHBOARD_URL = config.dashboardURL || `http://localhost:${process.env.PORT}`; 12 | process.env.MONGO_URI = config.mongoURI || 'mongodb://localhost/2PG'; 13 | 14 | this.options = { 15 | ...options 16 | }; 17 | } 18 | 19 | /** Start the app, using the specific options. */ 20 | async init() { 21 | await import('./bot'); 22 | } 23 | } 24 | 25 | interface Config { 26 | apiURL?: string; 27 | botId: string; 28 | botToken: string; 29 | clientSecret: string; 30 | dashboardURL?: string; 31 | mongoURI?: string; 32 | port?: number; 33 | } 34 | 35 | interface Options { 36 | } 37 | 38 | /** 39 | * API_URL="http://localhost:3000/api" 40 | BOT_ID="" 41 | BOT_TOKEN="" 42 | CLIENT_SECRET="" 43 | DASHBOARD_URL="http://localhost:4200" 44 | DBOTS_AUTH="" 45 | FEEDBACK_CHANNEL_ID="" 46 | GUILD_ID="" 47 | OWNER_ID="" 48 | MONGO_URI="mongodb://localhost/2PG" 49 | PORT="3000" 50 | PREMIUM_ROLE_ID="" 51 | STRIPE_SECRET_KEY="" 52 | STRIPE_WEBHOOK_SECRET="" 53 | TOP_GG_AUTH="" 54 | VOTE_CHANNEL_ID="788001309197860874" 55 | */ 56 | -------------------------------------------------------------------------------- /src/modules/announce/event-variables.ts: -------------------------------------------------------------------------------- 1 | import { User, Guild, Message } from 'discord.js'; 2 | 3 | export default class EventVariables { 4 | constructor(private content: string) {} 5 | 6 | toString() { return this.content; } 7 | 8 | private replace(regex: RegExp, replacement: string) { 9 | this.content = this.content.replace(regex, replacement); 10 | return this; 11 | } 12 | 13 | guild(guild: Guild) { 14 | return this.replace(/\[GUILD\]/g, guild.name); 15 | } 16 | 17 | instigator({ id }) { 18 | return this.replace(/\[INSTIGATOR\]/g, `<@!${id}>`); 19 | } 20 | 21 | module(name: string) { 22 | return this.replace(/\[MODULE\]/g, name); 23 | } 24 | 25 | name(name: string) { 26 | return this.replace(/\[NAME\]/g, name); 27 | } 28 | 29 | memberCount(guild: Guild) { 30 | return this.replace(/\[MEMBER_COUNT\]/g, guild.memberCount.toString()); 31 | } 32 | 33 | message(msg: Message) { 34 | return this.replace(/\[MESSAGE\]/g, msg.content); 35 | } 36 | 37 | oldValue(value: any) { 38 | return this.replace(/\[OLD_VALUE\]/g, JSON.stringify(value, null, 2)); 39 | } 40 | 41 | oldLevel(level: number) { 42 | return this.replace(/\[OLD_LEVEL\]/g, level.toString()); 43 | } 44 | 45 | newValue(value: any) { 46 | return this.replace(/\[NEW_VALUE\]/g, JSON.stringify(value, null, 2)); 47 | } 48 | 49 | newLevel(level: number) { 50 | return this.replace(/\[NEW_LEVEL\]/g, level.toString()); 51 | } 52 | 53 | reason(reason: string) { 54 | return this.replace(/\[REASON\]/g, reason); 55 | } 56 | 57 | user(user: User) { 58 | return this.replace(/\[USER\]/g, `<@!${user.id}>`); 59 | } 60 | 61 | warnings(warnings: number) { 62 | return this.replace(/\[WARNINGS\]/g, warnings.toString()); 63 | } 64 | 65 | xp(xp: number) { 66 | return this.replace(/\[XP\]/g, xp.toString()); 67 | } 68 | } -------------------------------------------------------------------------------- /src/modules/auto-mod/auto-mod.ts: -------------------------------------------------------------------------------- 1 | import { Message, GuildMember, User } from 'discord.js'; 2 | import { GuildDocument, MessageFilter } from '../../data/models/guild'; 3 | import Deps from '../../utils/deps'; 4 | import Members from '../../data/members'; 5 | import Log from '../../utils/log'; 6 | import { ContentValidator } from './validators/content-validator'; 7 | import { promisify } from 'util'; 8 | import fs from 'fs'; 9 | import { MemberDocument } from '../../data/models/member'; 10 | import Emit from '../../handlers/emit'; 11 | 12 | const readdir = promisify(fs.readdir); 13 | 14 | export default class AutoMod { 15 | private validators = new Map(); 16 | 17 | constructor( 18 | private emit = Deps.get(Emit), 19 | private members = Deps.get(Members)) {} 20 | 21 | public async init() { 22 | const files = await readdir(`${__dirname}/validators`); 23 | 24 | for (const file of files) { 25 | const { default: Validator } = await import(`./validators/${file}`); 26 | if (!Validator) continue; 27 | 28 | const validator = new Validator(); 29 | this.validators.set(validator.filter, validator); 30 | } 31 | Log.info(`Loaded: ${this.validators.size} validators`, `automod`); 32 | } 33 | 34 | public async validate(msg: Message, guild: GuildDocument) { 35 | const activeFilters = guild.autoMod.filters; 36 | for (const filter of activeFilters) 37 | try { 38 | await this.validators 39 | .get(filter) 40 | ?.validate(this, msg.content, guild); 41 | } catch (validation) { 42 | if (guild.autoMod.autoDeleteMessages) 43 | await msg.delete({ reason: validation.message }); 44 | 45 | if (guild.autoMod.autoWarnUsers && msg.member) 46 | await this.warn(msg.member, { 47 | instigator: msg.client.user, 48 | reason: validation.message 49 | }); 50 | throw validation; 51 | } 52 | } 53 | 54 | public async warn(target: GuildMember, args: PunishmentArgs) { 55 | this.validateAction(target, args.instigator); 56 | 57 | const savedMember = await this.members.get(target); 58 | 59 | this.emit.warning(args, target, savedMember); 60 | await this.saveWarning(args, savedMember); 61 | } 62 | private async saveWarning(args: PunishmentArgs, savedMember: MemberDocument) { 63 | savedMember.warnings.push({ 64 | at: new Date(), 65 | instigatorId: args.instigator.id, 66 | reason: args.reason 67 | }); 68 | return this.members.save(savedMember); 69 | } 70 | 71 | public validateAction(target: GuildMember, instigator: User) { 72 | if (target.id === instigator.id) 73 | throw new TypeError('You cannot punish yourself.'); 74 | 75 | const instigatorMember = target.guild.members.cache 76 | .get(instigator.id); 77 | if (instigatorMember.roles.highest.position <= target.roles.highest.position) 78 | throw new TypeError('User has the same or higher role.'); 79 | } 80 | } 81 | 82 | export interface PunishmentArgs { 83 | instigator: User; 84 | reason: string; 85 | } 86 | export class ValidationError extends Error { 87 | constructor(message: string, public filter: MessageFilter) { 88 | super(message); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/modules/auto-mod/validators/bad-link.validator.ts: -------------------------------------------------------------------------------- 1 | import { GuildDocument, MessageFilter } from '../../../data/models/guild'; 2 | import { ContentValidator } from './content-validator'; 3 | import AutoMod, { ValidationError } from '../auto-mod'; 4 | 5 | export default class BadLinkValidator implements ContentValidator { 6 | filter = MessageFilter.Links; 7 | 8 | validate(_, content: string, guild: GuildDocument) { 9 | const isExplicit = guild.autoMod.banLinks 10 | .some(l => content.includes(l)); 11 | 12 | if (isExplicit) { 13 | throw new ValidationError('Message contains banned links.', this.filter); 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /src/modules/auto-mod/validators/bad-word.validator.ts: -------------------------------------------------------------------------------- 1 | import { GuildDocument, MessageFilter } from '../../../data/models/guild'; 2 | import { ContentValidator } from './content-validator'; 3 | import AutoMod, { ValidationError } from '../auto-mod'; 4 | 5 | export default class BadWordValidator implements ContentValidator { 6 | filter = MessageFilter.Words; 7 | 8 | validate(autoMod: AutoMod, content: string, guild: GuildDocument) { 9 | const msgWords = content.split(' '); 10 | for (const word of msgWords) { 11 | const isExplicit = guild.autoMod.banWords 12 | .some(w => w.toLowerCase() === word.toLowerCase()); 13 | if (isExplicit) { 14 | throw new ValidationError('Message contains banned words.', this.filter); 15 | } 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /src/modules/auto-mod/validators/content-validator.ts: -------------------------------------------------------------------------------- 1 | import { GuildDocument, MessageFilter } from '../../../data/models/guild'; 2 | import AutoMod from '../auto-mod'; 3 | 4 | export interface ContentValidator { 5 | filter: MessageFilter; 6 | 7 | validate(autoMod: AutoMod, content: string, guild: GuildDocument): void | Promise; 8 | } -------------------------------------------------------------------------------- /src/modules/auto-mod/validators/mass-caps.validator.ts: -------------------------------------------------------------------------------- 1 | import { GuildDocument, MessageFilter } from '../../../data/models/guild'; 2 | import { ContentValidator } from './content-validator'; 3 | import { ValidationError } from '../auto-mod'; 4 | 5 | export default class MassCapsValidator implements ContentValidator { 6 | filter = MessageFilter.MassCaps; 7 | 8 | validate(_, content: string, guild: GuildDocument) { 9 | const pattern = /[A-Z]/g; 10 | const severity = guild.autoMod.filterThreshold; 11 | 12 | const invalid = content.length > 5 13 | && (content.match(pattern)?.length / content.length) >= (severity / 10); 14 | if (invalid) 15 | throw new ValidationError('Message contains too many capital letters.', this.filter); 16 | } 17 | } -------------------------------------------------------------------------------- /src/modules/auto-mod/validators/mass-mention.validator.ts: -------------------------------------------------------------------------------- 1 | import { GuildDocument, MessageFilter } from '../../../data/models/guild'; 2 | import { ContentValidator } from './content-validator'; 3 | import { ValidationError } from '../auto-mod'; 4 | 5 | export default class MassMentionValidator implements ContentValidator { 6 | filter = MessageFilter.MassMention; 7 | 8 | validate(_, content: string, guild: GuildDocument) { 9 | const pattern = /<@![0-9]{18}>/gm; 10 | const severity = guild.autoMod.filterThreshold; 11 | 12 | const invalid = content.match(pattern)?.length >= severity; 13 | if (invalid) 14 | throw new ValidationError('Message contains too many mentions.', this.filter); 15 | } 16 | } -------------------------------------------------------------------------------- /src/modules/general/reaction-roles.ts: -------------------------------------------------------------------------------- 1 | import { GuildDocument, SavedGuild } from '../../data/models/guild'; 2 | import { Client, MessageReaction, TextChannel, User } from 'discord.js'; 3 | import Log from '../../utils/log'; 4 | import Deps from '../../utils/deps'; 5 | 6 | export default class ReactionRoles { 7 | constructor( 8 | private bot = Deps.get(Client) 9 | ) {} 10 | 11 | async init() { 12 | let channelCount = 0; 13 | const savedGuilds = await SavedGuild.find(); 14 | 15 | for (const savedGuild of savedGuilds) 16 | for (const config of savedGuild.reactionRoles.configs) { 17 | channelCount++; 18 | const channel = this.bot.channels.cache.get(config.channel) as TextChannel; 19 | if (!channel) return; 20 | 21 | channel.messages.cache.set( 22 | config.messageId, 23 | await channel.messages.fetch(config.messageId) 24 | ); 25 | } 26 | Log.info(`Cached ${channelCount} text channels.`, 'rr'); 27 | } 28 | 29 | async checkToAdd(user: User, reaction: MessageReaction, savedGuild: GuildDocument) { 30 | const config = this.getReactionRole(reaction, savedGuild); 31 | if (!config) return; 32 | 33 | const { guild } = reaction.message; 34 | const member = await guild.members.fetch(user.id); 35 | if (!member) return; 36 | 37 | const role = guild.roles.cache.get(config.role); 38 | if (role) 39 | await member.roles.add(role); 40 | } 41 | 42 | async checkToRemove(user: User, reaction: MessageReaction, savedGuild: GuildDocument) { 43 | const config = this.getReactionRole(reaction, savedGuild); 44 | if (!config) return; 45 | 46 | const { guild } = reaction.message; 47 | const member = await guild.members.fetch(user.id); 48 | const role = guild.roles.cache.get(config.role); 49 | if (role) 50 | await member.roles.remove(role); 51 | } 52 | 53 | private getReactionRole({ message, emoji }: MessageReaction, savedGuild: GuildDocument) { 54 | const toHex = (a: string) => a.codePointAt(0).toString(16); 55 | 56 | return (savedGuild.reactionRoles.enabled) 57 | ? savedGuild.reactionRoles.configs 58 | .find(r => r.channel === message.channel.id 59 | && r.messageId === message.id 60 | && toHex(r.emote) === toHex(emoji.name)) 61 | : null; 62 | } 63 | } -------------------------------------------------------------------------------- /src/modules/music/music.ts: -------------------------------------------------------------------------------- 1 | import { TextChannel, VoiceChannel } from 'discord.js'; 2 | import { MusicClient, Player, Track } from '@2pg/music'; 3 | 4 | export default class Music { 5 | private _client = {} as MusicClient; 6 | get client() { return this._client; } 7 | 8 | constructor() { 9 | this._client = new MusicClient(); 10 | 11 | this.hookEvents(); 12 | } 13 | 14 | private hookEvents() { 15 | this.client.on('trackStart', (player, track) => player.textChannel?.send(`> **Now Playing**: \`${track.title}\` 🎵`)); 16 | this.client.on('queueEnd', (player) => player.textChannel?.send(`> **Queue has Ended** 🎵`)); 17 | } 18 | 19 | joinAndGetPlayer(voiceChannel?: VoiceChannel, textChannel?: TextChannel) { 20 | if (!voiceChannel) 21 | throw new TypeError('You must be in a voice channel to play music.'); 22 | 23 | return this.client.get(voiceChannel.guild.id) 24 | ?? this.client.create(voiceChannel.guild.id, { textChannel, voiceChannel }); 25 | } 26 | 27 | getDuration(player: Player, track?: Track) { 28 | if (!player.isPlaying) 29 | throw new TypeError('No track is currently playing.'); 30 | 31 | const positionInSeconds = (track === player.q.peek()) 32 | ? player.position / 1000 33 | : 0; 34 | track = (track ?? player.q.peek()) as Track; 35 | 36 | return `${Math.floor(positionInSeconds / 60)}:${Math.floor(positionInSeconds % 60).toString().padStart(2, '0')} / ` + 37 | `${Math.floor(track.duration.seconds / 60)}:${Math.floor(track.duration.seconds % 60).toString().padStart(2, '0')}`; 38 | } 39 | 40 | async findTrack(query: string, maxTrackLength: number) { 41 | const track: Track = await this.searchForTrack(query); 42 | 43 | const maxHoursInSeconds = maxTrackLength * 60 * 60; 44 | if (track.duration.seconds > maxHoursInSeconds) 45 | throw new TypeError(`Track length must be less than or equal to \`${maxTrackLength} hours\`.`); 46 | return track; 47 | } 48 | 49 | private async searchForTrack(query: string) { 50 | const videos = await this.client.search(query); 51 | return videos[0]; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/modules/stats/dbots.service.ts: -------------------------------------------------------------------------------- 1 | import { PostStats } from '@dbots/stats'; 2 | 3 | import Log from '../../utils/log'; 4 | 5 | export class DBotsService { 6 | private dbots: PostStats; 7 | 8 | constructor() { 9 | if (!process.env.DBOTS_AUTH) return; 10 | 11 | this.dbots = new PostStats({ 12 | apiToken: process.env.DBOTS_AUTH, 13 | botToken: process.env.BOT_TOKEN 14 | }); 15 | 16 | this.dbots.on('postStats', () => Log.info('Posted stats to DBots', 'dbots')); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/modules/xp/leveling.ts: -------------------------------------------------------------------------------- 1 | import { Message, GuildMember } from 'discord.js'; 2 | import { GuildDocument } from '../../data/models/guild'; 3 | import Members from '../../data/members'; 4 | import Deps from '../../utils/deps'; 5 | import { MemberDocument } from '../../data/models/member'; 6 | import Emit from '../../handlers/emit'; 7 | 8 | export default class Leveling { 9 | constructor( 10 | private emit = Deps.get(Emit), 11 | private members = Deps.get(Members) 12 | ) {} 13 | 14 | async validateXPMsg(msg: Message, savedGuild: GuildDocument) { 15 | if (!msg?.member || !savedGuild 16 | || this.hasIgnoredXPRole(msg.member, savedGuild)) 17 | throw new TypeError('User cannot earn XP'); 18 | 19 | const savedMember = await this.members.get(msg.member); 20 | 21 | this.handleCooldown(savedMember, savedGuild); 22 | 23 | const oldLevel = this.getLevel(savedMember.xp); 24 | savedMember.xp += savedGuild.leveling.xpPerMessage; 25 | const newLevel = this.getLevel(savedMember.xp); 26 | 27 | if (newLevel > oldLevel) { 28 | this.emit.levelUp({ newLevel, oldLevel }, msg, savedMember); 29 | this.checkLevelRoles(msg, newLevel, savedGuild); 30 | } 31 | await savedMember.save(); 32 | } 33 | 34 | private handleCooldown(savedMember: MemberDocument, savedGuild: GuildDocument) { 35 | const inCooldown = savedMember.recentMessages 36 | .filter(m => m.getMinutes() === new Date().getMinutes()) 37 | .length > savedGuild.leveling.maxMessagesPerMinute; 38 | if (inCooldown) 39 | throw new TypeError('User is in cooldown'); 40 | 41 | const lastMessage = savedMember.recentMessages[savedMember.recentMessages.length - 1]; 42 | if (lastMessage && lastMessage.getMinutes() !== new Date().getMinutes()) 43 | savedMember.recentMessages = []; 44 | 45 | savedMember.recentMessages.push(new Date()); 46 | } 47 | 48 | private hasIgnoredXPRole(member: GuildMember, guild: GuildDocument) { 49 | for (const entry of member.roles.cache) { 50 | const role = entry[1]; 51 | if (guild.leveling.ignoredRoles.some(id => id === role.id)) 52 | return true; 53 | } 54 | return false; 55 | } 56 | 57 | private checkLevelRoles(msg: Message, newLevel: number, guild: GuildDocument) { 58 | const levelRole = this.getLevelRole(newLevel, guild); 59 | if (levelRole) 60 | msg.member?.roles.add(levelRole); 61 | } 62 | private getLevelRole(level: number, guild: GuildDocument) { 63 | return guild.leveling.levelRoles.find(r => r.level === level)?.role; 64 | } 65 | 66 | getLevel(xp: number) { 67 | const preciseLevel = (-75 + Math.sqrt(Math.pow(75, 2) - 300 * (-150 - xp))) / 150; 68 | return Math.floor(preciseLevel); 69 | } 70 | static xpInfo(xp: number) { 71 | const preciseLevel = (-75 + Math.sqrt(Math.pow(75, 2) - 300 * (-150 - xp))) / 150; 72 | const level = Math.floor(preciseLevel); 73 | 74 | const xpForNextLevel = this.xpForNextLevel(level, xp); 75 | const nextLevelXP = xp + xpForNextLevel; 76 | 77 | const levelCompletion = preciseLevel - level; 78 | 79 | return { level, xp, xpForNextLevel, levelCompletion, nextLevelXP }; 80 | } 81 | private static xpForNextLevel(currentLevel: number, xp: number) { 82 | return ((75 * Math.pow(currentLevel + 1, 2)) + (75 * (currentLevel + 1)) - 150) - xp; 83 | } 84 | 85 | static getRank(member: MemberDocument, members: MemberDocument[]) { 86 | return members 87 | .sort((a, b) => b.xp - a.xp) 88 | .findIndex(m => m.id === member.id) + 1; 89 | } 90 | } -------------------------------------------------------------------------------- /src/utils/command-utils.ts: -------------------------------------------------------------------------------- 1 | import { GuildMember, Guild } from 'discord.js'; 2 | 3 | export function getMemberFromMention(mention: string, guild: Guild): GuildMember { 4 | const id = getIdFromMention(mention); 5 | const member = guild.members.cache.get(id); 6 | if (!member) 7 | throw new TypeError('Member not found.'); 8 | 9 | return member; 10 | } 11 | 12 | function getIdFromMention(mention: string) { 13 | return mention?.match(/\d+/g)[0]; 14 | } 15 | 16 | export function getRoleFromMention(mention: string, guild: Guild) { 17 | const id = getIdFromMention(mention); 18 | const role = guild.roles.cache.get(id); 19 | if (!role) 20 | throw new TypeError('Role not found.'); 21 | 22 | return role; 23 | } 24 | 25 | export function generateUUID() { 26 | let time = new Date().getTime(); 27 | let uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { 28 | let random = (time + Math.random() * 16) % 16 | 0; 29 | time = Math.floor(time / 16); 30 | return ((c == 'x') ? random :(random&0x3|0x8)).toString(16); 31 | }); 32 | return uuid; 33 | } 34 | 35 | export function parseDuration(str: string) { 36 | if (!str || str == '-1' || str.toLowerCase() == 'forever') 37 | return -1; 38 | 39 | const letters = str.match(/[a-z]/g).join(''); 40 | const time = Number(str.match(/[0-9]/g).join('')); 41 | 42 | switch (letters) { 43 | case 'y': return time * 1000 * 60 * 60 * 24 * 365; 44 | case 'mo': return time * 1000 * 60 * 60 * 24 * 30; 45 | case 'w': return time * 1000 * 60 * 60 * 24 * 7; 46 | case 'd': return time * 1000 * 60 * 60 * 24; 47 | case 'h': return time * 1000 * 60 * 60; 48 | case 'm': return time * 1000 * 60; 49 | case 's': return time * 1000; 50 | } 51 | throw new TypeError('Could not parse duration. Make sure you typed the duration correctly.'); 52 | } 53 | 54 | 55 | export function toNeatList(arr: any[]) { 56 | if (arr.length === 1) 57 | return arr[0]; 58 | else if (arr.length === 2) 59 | return `${arr[0]}, and ${arr[1]}`; 60 | return arr 61 | .slice(arr.length - 1) 62 | .join(', ') 63 | .concat(`and ${arr[arr.length - 1]}`); 64 | } -------------------------------------------------------------------------------- /src/utils/deps.ts: -------------------------------------------------------------------------------- 1 | export default class Deps { 2 | private static deps = new Map(); 3 | 4 | public static get(type: any): T { 5 | return this.deps.get(type) 6 | ?? this.add(type, new type()); 7 | } 8 | 9 | public static add(type: any, instance: T): T { 10 | return this.deps 11 | .set(type, instance) 12 | .get(type); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/keep-alive.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import Log from './log'; 3 | 4 | let count = 0; 5 | 6 | setInterval(async () => { 7 | try { 8 | await fetch(process.env.DASHBOARD_URL); 9 | Log.info(`[${++count}] Kept ${process.env.DASHBOARD_URL} alive.`, 'live'); 10 | } catch { 11 | Log.error(`[${++count}] Error keeping ${process.env.DASHBOARD_URL} alive.`, 'live'); 12 | } 13 | }, 5 * 60 * 1000); 14 | -------------------------------------------------------------------------------- /src/utils/log.ts: -------------------------------------------------------------------------------- 1 | import 'colors'; 2 | 3 | export default class Log { 4 | static getSource(src?: string) { 5 | return src?.toUpperCase() || 'OTHER'; 6 | } 7 | 8 | static info(message?: any, src?: string) { 9 | console.log(`[${this.toHHMMSS(new Date())}] INFO [${this.getSource(src)}] ${message}`) 10 | } 11 | static error(err?: any, src?: string) { 12 | const message = err?.message || err || 'Unknown error'; 13 | console.error(`[${this.toHHMMSS(new Date())}] ERROR [${this.getSource(src)}] ${message}`) 14 | } 15 | 16 | private static toHHMMSS(time: Date) { 17 | let hours = time.getHours().toString().padStart(2, '0'); 18 | let minutes = time.getMinutes().toString().padStart(2, '0'); 19 | let seconds = time.getSeconds().toString().padStart(2, '0'); 20 | return `${hours}:${minutes}:${seconds}`; 21 | } 22 | 23 | public static twoPG() { 24 | // Low on development time? Why not console.log(a giant bot)... 25 | console.log(` (((( 26 | /((((((((((((((((((((((((((( 27 | (###((((((((((((((((((((((((((((((###( 28 | (((((###((((((((((((((((((((((((((((((###((((( 29 | (################################################### 30 | (####&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&###(( 31 | (((##&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&###((( 32 | (((((##&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&###((((( 33 | (((((((##&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&###(((((( 34 | ((((((((##&&&&&&&&&& &&&&&&&&&&&&&&&&&&&&&&& &&&&&&&&&###((((((( 35 | (((((((((##&&&&&&&&&& &&&&&&&&&&&&&&&&&&&&& &&&&&&&&&###(((((((( 36 | ((((((((((##&&&&&&&&&&% &&&&&&&&&&&&&&&&&&&&&&& &&&&&&&&&&###((((((((( 37 | ((((((((((##&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&###(((((((((( 38 | (((((((((((##&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&###(((((((((( 39 | (((#(((((((##&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&###(((((((#((( 40 | ((###((((((####&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&###((((((####(( 41 | (#####(((((((###################################################(((((((#####( 42 | (######(((((((((((((###((((((((((((((((((((((((((((((###((((((((((((((######( 43 | #######(((((((((((((###((((((((((((((((((((((((((((((###(((((((((((((######## 44 | ########((((((((((((###((((((((((((((((((((((((((((((###(((((((((((((######## 45 | ########((((((((((((###((((((((((((((((((((((((((((((###(((((((((((((######## 46 | ##########(((((((((((###((((((((((((((((((((((((((((((###((((((((((((########## 47 | ##########(((((((((((###((((((((((((((((((((((((((((((###((((((((((((########## 48 | ###########((((((((((###((((((((((((((((((((((((((((((###(((((((((((########### 49 | ###########((((((((((###((((((((((((((((((((((((((((((###(((((((((((########### 50 | #############((((((((###((((((((((((((((((((((((((((((###(((((((((############# 51 | #################(((((###((((((((((((((((((((((((((((((###(((((################# 52 | ########### ##########((((((((((((((((((((((((((((((###(###### ########### 53 | ##### .##########(((((((((((((((((((((((########## ##### 54 | ################################### 55 | ############################, 56 | ################( `.bgWhite 57 | .replace(/\(/g, '('.gray) 58 | .replace(/\#/g, '#'.green) 59 | .replace(/\&/g, '&'.black) 60 | ); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/utils/validate-env.ts: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv'; 2 | config({ path: '.env' }); 3 | 4 | const env = new Map any>(); 5 | 6 | env.set('API_URL', (value: string) => { 7 | if (value.endsWith('/')) 8 | throw new TypeError(`API URL should not end with a '/'`); 9 | if (!value.endsWith('/api')) 10 | throw new TypeError(`API URL should end with a '/api'`); 11 | if (!value.startsWith('http')) 12 | throw new TypeError(`API URL should start with 'http'`); 13 | }); 14 | 15 | env.set('DASHBOARD_URL', (value: string) => { 16 | if (value.endsWith('/')) 17 | throw new TypeError(`Dashboard URL should not end with a '/'`); 18 | if (!value.startsWith('http')) 19 | throw new TypeError(`Dashboard URL should start with 'http'`); 20 | }); 21 | 22 | env.set('CLIENT_ID', (value: string) => { 23 | if (!value) 24 | throw new TypeError('Client ID should not be empty'); 25 | 26 | const pattern = /\d{18}/; 27 | if (!pattern.test(value)) 28 | throw new TypeError('Client ID does not match snowflake ID format'); 29 | }); 30 | 31 | export function validateEnv() { 32 | for (const key in process.env) 33 | if (env.has(key)) { 34 | try { 35 | env.get(key)(process.env[key]); 36 | } catch (error) { 37 | console.log('\x1b[31m%s\x1b[0m', `${key} is not setup correctly.`); 38 | console.log('\x1b[36m%s\x1b[0m', `Setup guide: https://help.codea.live/projects/2pg/setup/config`); 39 | 40 | throw error; 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /test/integration/auto-mod.tests.ts: -------------------------------------------------------------------------------- 1 | import { GuildDocument, MessageFilter } from '../../src/data/models/guild'; 2 | import { mock } from 'ts-mockito'; 3 | import AutoMod from '../../src/modules/auto-mod/auto-mod'; 4 | import { Message } from 'discord.js'; 5 | import { SavedMember } from '../../src/data/models/member'; 6 | import Members from '../../src/data/members'; 7 | import Emit from '../../src/handlers/emit'; 8 | 9 | describe('modules/auto-mod', () => { 10 | let autoMod: AutoMod; 11 | 12 | beforeEach(() => { 13 | const members = mock(); 14 | members.get = (): any => new SavedMember(); 15 | 16 | autoMod = new AutoMod(mock(), members); 17 | }); 18 | 19 | describe('validateMsg', () => { 20 | it('contains ban word, has filter, error thrown', async() => { 21 | const guild = mock(); 22 | const msg = mock(); 23 | 24 | guild.autoMod.filters = [MessageFilter.Words]; 25 | guild.autoMod.banWords = ['a']; 26 | msg.content = 'a'; 27 | 28 | const result = () => autoMod.validate(msg, guild); 29 | 30 | result().should.eventually.throw(); 31 | }); 32 | 33 | it('contains ban word, has filter, auto deleted, error thrown', async() => { 34 | const guild = mock(); 35 | const msg = mock(); 36 | 37 | guild.autoMod.filters = [MessageFilter.Words]; 38 | guild.autoMod.banWords = ['a']; 39 | msg.content = 'a'; 40 | msg.delete = () => { throw new TypeError('deleted'); } 41 | 42 | const result = () => autoMod.validate(msg, guild); 43 | 44 | result().should.eventually.throw('deleted'); 45 | }); 46 | 47 | it('contains ban word, no filter, ignored', async() => { 48 | const guild = mock(); 49 | const msg = mock(); 50 | 51 | guild.autoMod.filters = []; 52 | guild.autoMod.banWords = []; 53 | msg.content = 'a'; 54 | 55 | const result = () => autoMod.validate(msg, guild); 56 | 57 | result().should.not.eventually.throw(); 58 | }); 59 | 60 | it('contains ban link, has filter, error thrown', async() => { 61 | const guild = mock(); 62 | const msg = mock(); 63 | 64 | guild.autoMod.filters = [MessageFilter.Links]; 65 | guild.autoMod.banLinks = ['a']; 66 | msg.content = 'a'; 67 | 68 | const result = () => autoMod.validate(msg, guild); 69 | 70 | result().should.eventually.throw(); 71 | }); 72 | 73 | it('contains ban link, no filter, ignored', async() => { 74 | const guild = mock(); 75 | const msg = mock(); 76 | 77 | guild.autoMod.filters = []; 78 | guild.autoMod.banLinks = ['a']; 79 | msg.content = 'a'; 80 | 81 | const result = () => autoMod.validate(msg, guild); 82 | 83 | result().should.not.eventually.throw(); 84 | }); 85 | }); 86 | 87 | describe('warnMember', () => { 88 | it('warn member, message sent to user', async() => { 89 | const member: any = { id: '123', send: () => { throw new TypeError() }, user: { bot: false }}; 90 | const instigator: any = { id: '321' }; 91 | 92 | const result = () => autoMod.warn(member, instigator); 93 | 94 | result().should.eventually.throw(); 95 | }); 96 | 97 | it('warn self member, error thrown', async() => { 98 | const member: any = { id: '123', user: { bot: false } }; 99 | const instigator: any = { id: '123' }; 100 | 101 | const result = () => autoMod.warn(member, instigator); 102 | 103 | result().should.eventually.throw(); 104 | }); 105 | 106 | it('warn bot member, error thrown', async() => { 107 | const member: any = { id: '123', user: { bot: true }}; 108 | const instigator: any = { id: '321' }; 109 | 110 | const result = () => autoMod.warn(member, instigator); 111 | 112 | result().should.eventually.throw(); 113 | }); 114 | }); 115 | }); -------------------------------------------------------------------------------- /test/integration/command.service.tests.ts: -------------------------------------------------------------------------------- 1 | import { expect, use, should } from 'chai'; 2 | import CommandService from '../../src/services/command.service'; 3 | import { mock } from 'ts-mockito'; 4 | import chaiAsPromised from 'chai-as-promised'; 5 | import Deps from '../../src/utils/deps'; 6 | import Logs from '../../src/data/logs'; 7 | import Commands from '../../src/data/commands'; 8 | import { SavedGuild, GuildDocument } from '../../src/data/models/guild' 9 | import Cooldowns from '../../src/services/cooldowns'; 10 | import Validators from '../../src/services/validators'; 11 | 12 | should(); 13 | use(chaiAsPromised); 14 | 15 | describe('services/command-service', () => { 16 | let savedGuild: GuildDocument; 17 | let service: CommandService; 18 | 19 | beforeEach(() => { 20 | 21 | savedGuild = new SavedGuild(); 22 | savedGuild.general.prefix = '.'; 23 | 24 | service = new CommandService( 25 | mock(), 26 | mock(), 27 | mock(), 28 | mock()); 29 | }); 30 | 31 | describe('handle', () => { 32 | it('empty message gets ignored', () => { 33 | const msg: any = { content: '', channel: { reply: () => { throw Error() }}}; 34 | 35 | const result = () => service.handle(msg, savedGuild); 36 | 37 | expect(result()).to.eventually.throw(); 38 | }); 39 | 40 | it('no found command message gets ignored', () => { 41 | const msg: any = { content: '.pong', reply: () => { throw Error(); }}; 42 | 43 | const result = () => service.handle(msg, savedGuild); 44 | 45 | expect(result()).to.eventually.throw(); 46 | }); 47 | 48 | it('found command gets executed', () => { 49 | const msg: any = { content: '.ping', reply: () => { throw Error(); }}; 50 | 51 | const result = () => service.handle(msg, savedGuild); 52 | 53 | expect(result()).to.eventually.throw(); 54 | }); 55 | 56 | it('found command, with extra args, gets executed', async () => { 57 | const msg: any = { content: '.ping pong', reply: () => { throw Error(); }}; 58 | 59 | const result = () => service.handle(msg, savedGuild); 60 | 61 | expect(result()).to.eventually.throw(); 62 | }); 63 | 64 | it('found command, with unmet precondition, gets ignored', async () => { 65 | const msg: any = { content: '.warnings', reply: () => { throw Error(); }}; 66 | 67 | await service.handle(msg, savedGuild); 68 | }); 69 | 70 | it('command override disabled command, throws error', () => { 71 | const msg: any = { content: '.ping', reply: () => { throw Error(); }}; 72 | 73 | savedGuild.commands.configs.push({ name: 'ping', enabled: false }); 74 | 75 | const result = () => service.handle(msg, savedGuild); 76 | 77 | expect(result).to.eventually.throw(); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /test/integration/logs.tests.ts: -------------------------------------------------------------------------------- 1 | import { use, should } from 'chai'; 2 | import { SavedGuild, EventType } from '../../src/data/models/guild'; 3 | import { mock } from 'ts-mockito'; 4 | import { TextChannel, GuildMember } from 'discord.js'; 5 | import chaiAsPromised from 'chai-as-promised'; 6 | import MemberJoinHandler from '../../src/services/handlers/member-join.handler'; 7 | import Guilds from '../../src/data/guilds'; 8 | 9 | use(chaiAsPromised); 10 | should(); 11 | 12 | describe('modules/logs', () => { 13 | let guilds: Guilds; 14 | 15 | beforeEach(() => { 16 | guilds = mock(); 17 | guilds.get = (): any => new SavedGuild(); 18 | }); 19 | 20 | describe('member join handler', () => { 21 | let member: GuildMember; 22 | 23 | it('member join, member undefined, returns', () => { 24 | const result = () => new MemberJoinHandler(guilds).invoke(member); 25 | 26 | result().should.eventually.not.throw(); 27 | }); 28 | 29 | it('member join, event not active, returns', () => { 30 | const result = () => new MemberJoinHandler(guilds).invoke(member); 31 | 32 | result().should.eventually.not.throw(); 33 | }); 34 | 35 | it('member join, channel not found, returns', () => { 36 | guilds.get = (): any => { 37 | const guild = new SavedGuild(); 38 | guild.logs.events.push({ 39 | enabled: true, 40 | event: EventType.MemberJoin, 41 | message: 'test', 42 | channel: '321' 43 | }); 44 | } 45 | 46 | const result = () => new MemberJoinHandler(guilds).invoke(member); 47 | 48 | result().should.eventually.not.throw(); 49 | }); 50 | 51 | it('member join, event active, message is sent', () => { 52 | guilds.get = (): any => { 53 | const guild = new SavedGuild(); 54 | guild.logs.events.push({ 55 | enabled: true, 56 | event: EventType.MemberJoin, 57 | message: 'test', 58 | channel: '123' 59 | }); 60 | } 61 | 62 | const result = () => new MemberJoinHandler(guilds).invoke(member); 63 | 64 | result().should.eventually.throw('test'); 65 | }); 66 | 67 | it('member join, event active, message is sent with applied guild variables', () => { 68 | guilds.get = (): any => { 69 | const guild = new SavedGuild(); 70 | guild.logs.events.push({ 71 | enabled: true, 72 | event: EventType.MemberJoin, 73 | message: '[USER] joined!', 74 | channel: '123' 75 | }); 76 | } 77 | 78 | const result = () => new MemberJoinHandler(guilds).invoke(member); 79 | 80 | result().should.eventually.throws(new TypeError('<@!123> joined!')); 81 | }); 82 | }); 83 | }); -------------------------------------------------------------------------------- /test/integration/routes.tests.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import { app } from '../../src/api/server'; 3 | 4 | const testConfig = { 5 | guildId: '' 6 | }; 7 | 8 | describe('routes/api', () => { 9 | describe('/', () => { 10 | it('returns 200', (done) => { 11 | request(app).get('/api') 12 | .expect(200) 13 | .end(done); 14 | }); 15 | }); 16 | 17 | describe('/commands', () => { 18 | const url = '/api/commands'; 19 | 20 | it('returns 200', (done) => { 21 | request(app).get(url) 22 | .expect(200) 23 | .end(done); 24 | }); 25 | }); 26 | 27 | describe('/auth', () => { 28 | const url = '/api/auth'; 29 | 30 | it('no code, returns 400', (done) => { 31 | request(app).get(url) 32 | .expect(400) 33 | .end(done); 34 | }); 35 | }); 36 | 37 | describe('/user', () => { 38 | const url = '/api/user'; 39 | 40 | it('no key, returns 400', (done) => { 41 | request(app).get(url) 42 | .expect(400) 43 | .end(done); 44 | }); 45 | }); 46 | 47 | it('any url returns 404', (done) => { 48 | request(app).get('/api/a') 49 | .expect(404) 50 | .end(done); 51 | }); 52 | }); 53 | 54 | describe('routes/api/guilds', () => { 55 | let url: string; 56 | 57 | beforeEach(() => url = '/api/guilds'); 58 | 59 | describe('GET /:id/log', () => { 60 | it('found guild, returns guild', (done) => { 61 | url += `/${testConfig.guildId}/public`; 62 | 63 | request(app).get(url) 64 | .expect(200) 65 | .end(done); 66 | }); 67 | }); 68 | 69 | describe('GET /:id/public', () => { 70 | it('found guild, returns guild', (done) => { 71 | url += `/${testConfig.guildId}/public`; 72 | 73 | request(app).get(url) 74 | .expect(200) 75 | .end(done); 76 | }); 77 | 78 | it('unknown guild, returns undefined', (done) => { 79 | url += '/321/public'; 80 | 81 | request(app).get(url) 82 | .expect(200) 83 | .expect(undefined) 84 | .end(done); 85 | }); 86 | }); 87 | 88 | describe('GET /', () => { 89 | it('no key, returns 400', (done) => { 90 | request(app).get(url) 91 | .expect(400) 92 | .end(done); 93 | }); 94 | }); 95 | 96 | describe('POST /', () => { 97 | it('no key, returns 400', (done) => { 98 | request(app).get(url) 99 | .expect(400) 100 | .end(done); 101 | }); 102 | }); 103 | 104 | describe('GET /:id/users', () => { 105 | url += '/123/users'; 106 | 107 | it('unknown guild, returns 404', (done) => { 108 | request(app).get(url) 109 | .expect(404) 110 | .end(done); 111 | }); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /test/integration/user-warn.tests.ts: -------------------------------------------------------------------------------- 1 | import { expect, spy } from 'chai'; 2 | import { AutoPunishment, SavedGuild } from '../../src/data/models/guild'; 3 | import { MemberDocument, SavedMember } from '../../src/data/models/member'; 4 | import UserWarnHandler from '../../src/handlers/events/custom/user-warn.handler'; 5 | import { Mock } from '../mocks/mock'; 6 | 7 | describe('handlers/user-warn', () => { 8 | let event: UserWarnHandler; 9 | let guild: any; 10 | let savedMember: MemberDocument; 11 | let user: any; 12 | let member: any; 13 | 14 | beforeEach(async() => { 15 | event = new UserWarnHandler(); 16 | guild = Mock.guild('test_guild_123'); 17 | user = Mock.user('test_user_123'); 18 | savedMember = new SavedMember(); 19 | member = { 20 | id: 'test_user_123', 21 | kick: () => {}, 22 | ban: () => {} 23 | }; 24 | 25 | guild.members.cache.set('test_user_123', member); 26 | 27 | await SavedGuild.create({ 28 | _id: 'test_guild_123', 29 | autoMod: { 30 | punishments: [ 31 | { warnings: 3, minutes: 1, type: 'KICK' } as AutoPunishment, 32 | { warnings: 5, minutes: 1, type: 'BAN' } as AutoPunishment, 33 | ] 34 | } 35 | }); 36 | }); 37 | 38 | afterEach(async() => await SavedGuild.deleteMany({})); 39 | 40 | it('no warnings, ban or kick not called', async () => { 41 | const kick = spy.on(member, 'kick'); 42 | const ban = spy.on(member, 'ban'); 43 | 44 | await event.invoke({ 45 | guild, user, warnings: 0, instigator: null, reason: '' 46 | }, new SavedMember()); 47 | 48 | expect(kick).to.not.be.called(); 49 | expect(ban).to.not.be.called(); 50 | }); 51 | 52 | it('member warned once, ban or kick not called', async () => { 53 | const kick = spy.on(member, 'kick'); 54 | const ban = spy.on(member, 'ban'); 55 | 56 | await event.invoke({ 57 | guild, user, warnings: 1, instigator: null, reason: '' 58 | }, savedMember); 59 | 60 | expect(kick).to.not.be.called(); 61 | expect(ban).to.not.be.called(); 62 | }); 63 | 64 | it('member warned 3 times in 1 min, 1 trigger, kick is called, but not ban', async () => { 65 | savedMember = new SavedMember({ 66 | warnings: [ 67 | { at: new Date() }, 68 | { at: new Date() }, 69 | { at: new Date() }, 70 | ] 71 | }); 72 | const kick = spy.on(member, 'kick'); 73 | const ban = spy.on(member, 'ban'); 74 | 75 | await event.invoke({ 76 | guild, user, warnings: 1, instigator: null, reason: '' 77 | }, savedMember); 78 | 79 | expect(kick).to.be.called(); 80 | expect(ban).to.not.be.called(); 81 | }); 82 | 83 | it('member warned 6 times in 1 min, 2 triggers, ban is called, but not kick', async () => { 84 | savedMember = new SavedMember({ 85 | warnings: [ 86 | { at: new Date() }, 87 | { at: new Date() }, 88 | { at: new Date() }, 89 | { at: new Date() }, 90 | { at: new Date() }, 91 | { at: new Date() }, 92 | ] 93 | }); 94 | const kick = spy.on(member, 'kick'); 95 | const ban = spy.on(member, 'ban'); 96 | 97 | await event.invoke({ 98 | guild, user, warnings: 6, instigator: null, reason: '' 99 | }, savedMember); 100 | 101 | expect(ban).to.be.called(); 102 | expect(kick).to.not.be.called(); 103 | }); 104 | 105 | it('member warned, has 3 total warnings, no punishment', async () => { 106 | savedMember = new SavedMember({ 107 | warnings: [ 108 | { at: new Date() }, 109 | { at: new Date(0) }, 110 | { at: new Date(0) }, 111 | ] 112 | }); 113 | const kick = spy.on(member, 'kick'); 114 | const ban = spy.on(member, 'ban'); 115 | 116 | await event.invoke({ 117 | guild, user, warnings: 6, instigator: null, reason: '' 118 | }, savedMember); 119 | 120 | expect(ban).to.not.be.called(); 121 | expect(kick).to.not.be.called(); 122 | }); 123 | 124 | it('member warned, has 6 total warnings, no punishment', async () => { 125 | savedMember = new SavedMember({ 126 | warnings: [ 127 | { at: new Date() }, 128 | { at: new Date(0) }, 129 | { at: new Date(0) }, 130 | { at: new Date(0) }, 131 | { at: new Date(0) }, 132 | { at: new Date(0) }, 133 | ] 134 | }); 135 | const kick = spy.on(member, 'kick'); 136 | const ban = spy.on(member, 'ban'); 137 | 138 | await event.invoke({ 139 | guild, user, warnings: 6, instigator: null, reason: '' 140 | }, savedMember); 141 | 142 | expect(ban).to.not.be.called(); 143 | expect(kick).to.not.be.called(); 144 | }); 145 | }); 146 | -------------------------------------------------------------------------------- /test/integration/users.tests.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { SavedUser, UserDocument } from '../../src/data/models/user'; 3 | import Users from '../../src/data/users'; 4 | 5 | describe('data/users', () => { 6 | let users: Users; 7 | let user: UserDocument; 8 | 9 | beforeEach(async () => { 10 | users = new Users(); 11 | 12 | await SavedUser.deleteMany({}); 13 | user = await SavedUser.create({ _id: 'test_user_123' }); 14 | }); 15 | 16 | afterEach(async () => { 17 | await SavedUser.deleteMany({}); 18 | }); 19 | 20 | it('give plus, user now has premium', async () => { 21 | await users.givePlus(user.id, '1_month'); 22 | 23 | user = await SavedUser.findById('test_user_123'); 24 | 25 | expect(user.premium).to.be.true; 26 | }); 27 | 28 | it('give plus, 1 month, adds 1 month to expiration', async () => { 29 | await users.givePlus(user.id, '1_month'); 30 | 31 | user = await SavedUser.findById('test_user_123'); 32 | const difference = user.premiumExpiration.getTime() - new Date().getTime(); 33 | const days = difference / 1000 / 60 / 60 / 24; 34 | 35 | expect(Math.round(days)).to.equal(30); 36 | }); 37 | 38 | it('give plus, 3 month, adds 3 month to expiration', async () => { 39 | await users.givePlus(user.id, '3_month'); 40 | 41 | user = await SavedUser.findById('test_user_123'); 42 | const difference = user.premiumExpiration.getTime() - new Date().getTime(); 43 | const days = difference / 1000 / 60 / 60 / 24; 44 | 45 | expect(Math.round(days)).to.equal(90); 46 | }); 47 | 48 | it('give plus, 1 year, adds 1 year to expiration', async () => { 49 | await users.givePlus(user.id, '1_year'); 50 | 51 | user = await SavedUser.findById('test_user_123'); 52 | const thisYear = new Date().getFullYear(); 53 | const year = user.premiumExpiration.getFullYear(); 54 | 55 | expect(year).to.be.greaterThan(thisYear); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /test/mocks/mock.ts: -------------------------------------------------------------------------------- 1 | import { User, GuildMember, Guild, Collection } from 'discord.js'; 2 | import { mock } from 'ts-mockito'; 3 | 4 | export class Mock { 5 | static guild(id = '533947001578979322') { 6 | const guild = mock(); 7 | 8 | guild.id = id; 9 | guild.name = 'Test Server'; 10 | guild.members.cache = new Collection(); 11 | 12 | return guild; 13 | } 14 | 15 | static member(id = '533947001578979328', guild?: Guild) { 16 | const member = mock(); 17 | 18 | member.guild = guild ?? Mock.guild(); 19 | member.user = Mock.user(id); 20 | 21 | return member; 22 | } 23 | 24 | static user(id = '533947001578979328') { 25 | const user = mock(); 26 | 27 | user.username = 'User'; 28 | user.discriminator = '0001'; 29 | user.id = id; 30 | 31 | return user; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/test.ts: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv'; 2 | config({ path: 'test/.env' }); 3 | 4 | import { use } from 'chai'; 5 | import chaiAsPromised from 'chai-as-promised'; 6 | import chaiSpies from 'chai-spies'; 7 | import { connect } from 'mongoose'; 8 | 9 | use(chaiAsPromised); 10 | use(chaiSpies); 11 | 12 | connect(process.env.MONGO_URI, { 13 | useUnifiedTopology: true, 14 | useNewUrlParser: true, 15 | useFindAndModify: false 16 | }); 17 | 18 | (async() => { 19 | // await import('./integration/auto-mod.tests'); 20 | await import('./integration/users.tests'); 21 | await import('./integration/user-warn.tests'); 22 | 23 | await import('./unit/utils/validate-env.tests'); 24 | })(); 25 | -------------------------------------------------------------------------------- /test/unit/audit-logger.tests.ts: -------------------------------------------------------------------------------- 1 | import AuditLogger from '../../src/api/modules/audit-logger'; 2 | import { expect } from 'chai'; 3 | 4 | describe('api/modules/audit-logger', () => { 5 | it('no changes, empty array returned', () => { 6 | const values = { 7 | old: { a: 'a', b: 'b' }, 8 | new: { a: 'a', b: 'b' } 9 | }; 10 | 11 | const expected = { old: {}, new: {} }; 12 | const result = AuditLogger.getChanges(values, 'a', '123').changes; 13 | 14 | expect(result).to.deep.equal(expected); 15 | }); 16 | 17 | it('1 change, 1 change returned', () => { 18 | const values = { 19 | old: { a: 'a', b: 'b' }, 20 | new: { a: 'b', b: 'b' } 21 | }; 22 | 23 | const expected = { 24 | old: { a: 'a' }, 25 | new: { a: 'b' } 26 | }; 27 | const result = AuditLogger.getChanges(values, 'a', '123').changes; 28 | 29 | expect(result).to.deep.equal(expected); 30 | }); 31 | 32 | it('3 changes, 3 changes returned', () => { 33 | const values = { 34 | old: { 35 | a: 'a', 36 | b: 'b', 37 | c: 'c' 38 | }, 39 | new: { 40 | a: '1', 41 | b: '2', 42 | c: '3' 43 | } 44 | }; 45 | 46 | const expected = values; 47 | const result = AuditLogger.getChanges(values, 'a', '123').changes; 48 | 49 | expect(result).to.deep.equal(expected); 50 | }); 51 | }); -------------------------------------------------------------------------------- /test/unit/commands/play.tests.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { mock } from 'ts-mockito'; 3 | import { CommandContext } from '../../../src/commands/command'; 4 | import PlayCommand from '../../../src/commands/play'; 5 | import XPCommand from '../../../src/commands/xp'; 6 | import WarningsCommand from '../../../src/commands/warnings'; 7 | 8 | describe('commands/play', () => { 9 | it('null query, throws error', () => { 10 | const ctx = mock(); 11 | ctx.member = { voice: { channel: null }} as any; 12 | 13 | const result = () => new PlayCommand().execute(ctx); 14 | 15 | result().should.eventually.throw(); 16 | }); 17 | 18 | it('null channel, throws error', () => { 19 | const ctx = mock(); 20 | ctx.member = { voice: { channel: null }} as any; 21 | 22 | const result = () => new PlayCommand().execute(ctx, 'a'); 23 | 24 | result().should.eventually.throw(); 25 | }); 26 | }); 27 | 28 | describe('commands/warnings', () => { 29 | it('null channel, throws error', () => 30 | { 31 | const ctx = mock(); 32 | 33 | const result = () => new WarningsCommand().execute(ctx, '1'); 34 | 35 | result().should.eventually.throw(); 36 | }); 37 | }); 38 | 39 | describe('commands/xp', () => { 40 | let command: XPCommand; 41 | 42 | beforeEach(() => command = new XPCommand()); 43 | 44 | it('mentioned user not found, error thrown', () => { 45 | const result = () => command.execute({} as any, '<@!>'); 46 | 47 | expect(result).to.throw(); 48 | }); 49 | 50 | it('xp bot user, error thrown', () => { 51 | const ctx = { member: { user: { bot: true }}} as any; 52 | 53 | const result = () => command.execute(ctx, ''); 54 | 55 | expect(result).to.throw(); 56 | }); 57 | }); 58 | 59 | -------------------------------------------------------------------------------- /test/unit/cooldowns.tests.ts: -------------------------------------------------------------------------------- 1 | import Cooldowns from '../../src/services/cooldowns'; 2 | import { User } from 'discord.js'; 3 | import { mock } from 'ts-mockito'; 4 | import { Command } from '../../src/commands/command'; 5 | import { expect } from 'chai'; 6 | 7 | describe('services/cooldowns', () => { 8 | let cooldowns: Cooldowns; 9 | let user: User; 10 | let command: Command; 11 | 12 | beforeEach(() => { 13 | cooldowns = new Cooldowns(); 14 | user = mock(User); 15 | command = mock(command); 16 | 17 | user.id = '123'; 18 | command.name = 'ping'; 19 | }); 20 | 21 | it('no cooldowns, active', () => { 22 | const result = cooldowns.active(user, command); 23 | 24 | expect(result).to.be.false; 25 | }); 26 | 27 | it('user in cooldown, inactive', () => { 28 | cooldowns.add(user, command); 29 | 30 | const result = cooldowns.active(user, command); 31 | 32 | expect(result).to.be.true; 33 | }); 34 | 35 | it('user cooldown removed, inactive', () => { 36 | cooldowns.add(user, command); 37 | cooldowns.remove(user, command); 38 | 39 | const result = cooldowns.active(user, command); 40 | 41 | expect(result).to.be.false; 42 | }); 43 | }); -------------------------------------------------------------------------------- /test/unit/data.tests.ts: -------------------------------------------------------------------------------- 1 | import Commands from '../../src/data/commands'; 2 | import { expect } from 'chai'; 3 | 4 | describe('data/commands', () => { 5 | it('getCommandUsage returns valid command usage with no args', () => { 6 | const result = new Commands().getCommandUsage({ 7 | name: 'ping', 8 | execute: (ctx: any) => {} 9 | } as any); 10 | 11 | expect(result).to.equal('ping'); 12 | }); 13 | 14 | it('getCommandUsage returns valid command with args', () => { 15 | const result = new Commands().getCommandUsage({ 16 | name: 'a', 17 | execute: (ctx: any, b: any) => {} 18 | } as any); 19 | 20 | expect(result).to.equal('a b'); 21 | }); 22 | }); -------------------------------------------------------------------------------- /test/unit/event-variables.tests.ts: -------------------------------------------------------------------------------- 1 | import EventVariables from '../../src/modules/announce/event-variables'; 2 | import { expect } from 'chai'; 3 | 4 | describe('modules/announce/event-variables', () => { 5 | it('GUILD', () => { 6 | const variables = new EventVariables('[GUILD] is good server'); 7 | 8 | const user = { name: 'test' } as any; 9 | const result = variables.guild(user).toString(); 10 | 11 | expect(result).to.equal('test is good server'); 12 | }); 13 | 14 | it('INSTIGATOR', () => { 15 | const variables = new EventVariables('[INSTIGATOR] banned User'); 16 | 17 | const user = { id: '123' } as any; 18 | const result = variables.instigator(user).toString(); 19 | 20 | expect(result).to.equal('<@!123> banned User'); 21 | }); 22 | 23 | it('MEMBER_COUNT', () => { 24 | const variables = new EventVariables('[MEMBER_COUNT] member(s)'); 25 | 26 | const guild = { memberCount: 1 } as any; 27 | const result = variables.memberCount(guild).toString(); 28 | 29 | expect(result).to.equal('1 member(s)'); 30 | }); 31 | 32 | it('MESSAGE', () => { 33 | const variables = new EventVariables('Message: `[MESSAGE]`'); 34 | 35 | const message = { content: 'hi' } as any; 36 | const result = variables.message(message).toString(); 37 | 38 | expect(result).to.equal('Message: `hi`'); 39 | }); 40 | 41 | it('NEW_LEVEL', () => { 42 | const variables = new EventVariables('New: `[NEW_LEVEL]`'); 43 | 44 | const level = 2; 45 | const result = variables.newLevel(level).toString(); 46 | 47 | expect(result).to.equal('New: `2`'); 48 | }); 49 | 50 | it('NEW_value', () => { 51 | const variables = new EventVariables('New: [NEW_VALUE]'); 52 | 53 | const change = { a: 'b' }; 54 | const result = variables.newValue(change).toString(); 55 | 56 | expect(result).to.equal(`New: ${JSON.stringify(change, null, 2)}`); 57 | }); 58 | 59 | it('OLD_LEVEL', () => { 60 | const variables = new EventVariables('Old: `[OLD_LEVEL]`'); 61 | 62 | const level = 1; 63 | const result = variables.oldLevel(level).toString(); 64 | 65 | expect(result).to.equal('Old: `1`'); 66 | }); 67 | 68 | it('OLD_VALUE', () => { 69 | const variables = new EventVariables('Old: [OLD_VALUE]'); 70 | 71 | const change = { a: 'a' }; 72 | const result = variables.oldValue(change).toString(); 73 | 74 | expect(result).to.equal(`Old: ${JSON.stringify(change, null, 2)}`); 75 | }); 76 | 77 | it('REASON', () => { 78 | const variables = new EventVariables('User was banned for `[REASON]`'); 79 | 80 | const reason = 'hacking'; 81 | const result = variables.reason(reason).toString(); 82 | 83 | expect(result).to.equal('User was banned for `hacking`'); 84 | }); 85 | 86 | it('USER', () => { 87 | const variables = new EventVariables('[USER] = trash'); 88 | 89 | const user = { id: '123' } as any; 90 | const result = variables.user(user).toString(); 91 | 92 | expect(result).to.equal('<@!123> = trash'); 93 | }); 94 | 95 | it('WARNINGS', () => { 96 | const variables = new EventVariables('User has [WARNINGS] warnings'); 97 | 98 | const warnings = 4; 99 | const result = variables.warnings(warnings).toString(); 100 | 101 | expect(result).to.equal('User has 4 warnings'); 102 | }); 103 | }); -------------------------------------------------------------------------------- /test/unit/leveling.tests.ts: -------------------------------------------------------------------------------- 1 | import { should, use, expect } from 'chai'; 2 | import { mock } from 'ts-mockito'; 3 | import Leveling from '../../src/modules/xp/leveling'; 4 | import { GuildDocument } from '../../src/data/models/guild'; 5 | import chaiAsPromised from 'chai-as-promised'; 6 | 7 | use(chaiAsPromised); 8 | should(); 9 | 10 | describe('modules/leveling', () => { 11 | let leveling: Leveling; 12 | 13 | beforeEach(() => { 14 | leveling = new Leveling(); 15 | }); 16 | 17 | describe('validateXPMsg', () => { 18 | it('null message member throws exception', () => { 19 | const guild = mock(); 20 | let msg: any = { member: null }; 21 | 22 | const result = () => leveling.validateXPMsg(msg, guild); 23 | 24 | result().should.eventually.throw(); 25 | }); 26 | 27 | it('member with ignored role throws exception', () => { 28 | const guild = mock(); 29 | let msg: any = { member: { roles: { cache: [{ id: '123' }] }}}; 30 | guild.leveling.ignoredRoles = ['123']; 31 | 32 | const result = () => leveling.validateXPMsg(msg, guild); 33 | 34 | result().should.eventually.throw(); 35 | }); 36 | }); 37 | 38 | describe('getLevel', () => { 39 | it('0 returns level 1', () => { 40 | const result = Leveling.xpInfo(0).level; 41 | 42 | expect(result).to.deep.equal(1); 43 | }); 44 | 45 | it('floored level returned, min level messages', () => { 46 | const result = Leveling.xpInfo(300).level; 47 | 48 | expect(result).to.equal(2); 49 | }); 50 | 51 | it('floored level returned, greater than min level messages', () => { 52 | const result = Leveling.xpInfo(400).level; 53 | 54 | expect(result).to.equal(2); 55 | }); 56 | }); 57 | 58 | describe('xpForNextLevel', () => { 59 | it('0 xp returns max xp for next level', () => { 60 | const result = Leveling.xpInfo(0).xpForNextLevel; 61 | 62 | expect(result).to.equal(300); 63 | }); 64 | 65 | it('minimum level xp returns max xp for next level', () => { 66 | const result = Leveling.xpInfo(300).xpForNextLevel; 67 | 68 | expect(result).to.equal(450); 69 | }); 70 | 71 | it('250XP returns 50XP for next level', () => { 72 | const result = Leveling.xpInfo(250).xpForNextLevel; 73 | 74 | expect(result).to.equal(50); 75 | }); 76 | }); 77 | 78 | describe('levelCompletion', () => { 79 | it('no level completion, returns 0', () => { 80 | const result = Leveling.xpInfo(0).levelCompletion; 81 | 82 | expect(result).to.equal(0); 83 | }); 84 | 85 | it('250/300 level completion, returns 0.83333...', () => { 86 | const result = Leveling.xpInfo(250).levelCompletion; 87 | 88 | expect(result).to.be.approximately(0.833, 0.05); 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /test/unit/logs.tests.ts: -------------------------------------------------------------------------------- 1 | import EventVariables from '../../src/modules/announce/event-variables'; 2 | import { expect } from 'chai'; 3 | 4 | describe('modules/logs/event-variables', () => { 5 | it('GUILD', () => { 6 | const variables = new EventVariables('[GUILD] is good server'); 7 | 8 | const user = { name: 'test' } as any; 9 | const result = variables.guild(user).toString(); 10 | 11 | expect(result).to.equal('test is good server'); 12 | }); 13 | 14 | it('INSTIGATOR', () => { 15 | const variables = new EventVariables('[INSTIGATOR] banned User'); 16 | 17 | const user = { id: '123' } as any; 18 | const result = variables.instigator(user).toString(); 19 | 20 | expect(result).to.equal('<@!123> banned User'); 21 | }); 22 | 23 | it('MEMBER_COUNT', () => { 24 | const variables = new EventVariables('[MEMBER_COUNT] member(s)'); 25 | 26 | const guild = { memberCount: 1 } as any; 27 | const result = variables.memberCount(guild).toString(); 28 | 29 | expect(result).to.equal('1 member(s)'); 30 | }); 31 | 32 | it('MESSAGE', () => { 33 | const variables = new EventVariables('Message: `[MESSAGE]`'); 34 | 35 | const message = { content: 'hi' } as any; 36 | const result = variables.message(message).toString(); 37 | 38 | expect(result).to.equal('Message: `hi`'); 39 | }); 40 | 41 | it('NEW_LEVEL', () => { 42 | const variables = new EventVariables('New: `[NEW_LEVEL]`'); 43 | 44 | const level = 2; 45 | const result = variables.newLevel(level).toString(); 46 | 47 | expect(result).to.equal('New: `2`'); 48 | }); 49 | 50 | it('NEW_VALUE', () => { 51 | const variables = new EventVariables('New: [NEW_VALUE]'); 52 | 53 | const change = { a: 'b' }; 54 | const result = variables.newValue(change).toString(); 55 | 56 | expect(result).to.equal(`New: ${JSON.stringify(change, null, 2)}`); 57 | }); 58 | 59 | it('OLD_LEVEL', () => { 60 | const variables = new EventVariables('Old: `[OLD_LEVEL]`'); 61 | 62 | const level = 1; 63 | const result = variables.oldLevel(level).toString(); 64 | 65 | expect(result).to.equal('Old: `1`'); 66 | }); 67 | 68 | it('OLD_VALUE', () => { 69 | const variables = new EventVariables('Old: [OLD_VALUE]'); 70 | 71 | const change = { a: 'a' }; 72 | const result = variables.oldValue(change).toString(); 73 | 74 | expect(result).to.equal(`Old: ${JSON.stringify(change, null, 2)}`); 75 | }); 76 | 77 | it('REASON', () => { 78 | const variables = new EventVariables('User was banned for `[REASON]`'); 79 | 80 | const reason = 'hacking'; 81 | const result = variables.reason(reason).toString(); 82 | 83 | expect(result).to.equal('User was banned for `hacking`'); 84 | }); 85 | 86 | it('USER', () => { 87 | const variables = new EventVariables('[USER] = trash'); 88 | 89 | const user = { id: '123' } as any; 90 | const result = variables.user(user).toString(); 91 | 92 | expect(result).to.equal('<@!123> = trash'); 93 | }); 94 | 95 | it('WARNINGS', () => { 96 | const variables = new EventVariables('User has [WARNINGS] warnings'); 97 | 98 | const warnings = 4; 99 | const result = variables.warnings(warnings).toString(); 100 | 101 | expect(result).to.equal('User has 4 warnings'); 102 | }); 103 | }); -------------------------------------------------------------------------------- /test/unit/ranks.tests.ts: -------------------------------------------------------------------------------- 1 | import Ranks from '../../src/api/modules/ranks'; 2 | import { expect } from 'chai'; 3 | 4 | describe('api/ranks', () => { 5 | it('lowest xp messages returns lowest rank', () => { 6 | const members = [ 7 | { xp: '100', userId: '1' }, 8 | { xp: '200', userId: '2' }, 9 | { xp: '300', userId: '3' } 10 | ] as any; 11 | 12 | const result = Ranks.get({ id: '1' } as any, members); 13 | 14 | expect(result).to.equal(3); 15 | }); 16 | 17 | it('highest xp messages returns highest rank', () => { 18 | const members = [ 19 | { xp: '100', userId: '1' }, 20 | { xp: '999', userId: '2' }, 21 | { xp: '300', userId: '3' } 22 | ] as any; 23 | 24 | const result = Ranks.get({ id: '2' } as any, members); 25 | 26 | expect(result).to.equal(1); 27 | }); 28 | 29 | it('medium xp messages returns middle rank', () => { 30 | const members = [ 31 | { xp: '100', userId: '1' }, 32 | { xp: '999', userId: '2' }, 33 | { xp: '300', userId: '3' } 34 | ] as any; 35 | 36 | const result = Ranks.get({ id: '3' } as any, members); 37 | 38 | expect(result).to.equal(2); 39 | }); 40 | }); -------------------------------------------------------------------------------- /test/unit/stats.tests.ts: -------------------------------------------------------------------------------- 1 | import { LogDocument, SavedLog } from '../../src/data/models/log'; 2 | import Stats from '../../src/api/modules/stats'; 3 | import Logs from '../../src/data/logs'; 4 | import { mock } from 'ts-mockito'; 5 | import { expect } from 'chai'; 6 | 7 | describe('api/modules/stats', () => { 8 | let savedLog: LogDocument; 9 | let stats: Stats; 10 | 11 | beforeEach(async() => { 12 | let logs = mock(); 13 | logs.getAll = (): any => [savedLog]; 14 | 15 | savedLog = new SavedLog(); 16 | stats = new Stats(logs); 17 | 18 | savedLog.changes.push( 19 | { 20 | module: 'general', 21 | at: new Date(), 22 | by: '', 23 | changes: { old: { prefix: '/' }, new: { prefix: '.' } } 24 | }, 25 | { 26 | module: 'general', 27 | at: new Date(), 28 | by: '', 29 | changes: { old: { prefix: '/' }, new: { prefix: '.' } } 30 | }, 31 | { 32 | module: 'xp', 33 | at: new Date(), 34 | by: '', 35 | changes: { old: { xpPerMessage: 50 }, new: { xpPerMessage: 100 } } 36 | } 37 | ); 38 | 39 | savedLog.commands.push( 40 | { name: 'ping', by: '', at: new Date() }, 41 | { name: 'ping', by: '', at: new Date() }, 42 | { name: 'dashboard', by: '', at: new Date() } 43 | ); 44 | 45 | await stats.init(); 46 | }); 47 | 48 | it('get commands, returns correct count', () => { 49 | const result = stats.commands[0].count; 50 | 51 | expect(result).to.equal(2); 52 | }); 53 | 54 | it('get commands, returns correct sorted item', () => { 55 | const result = stats.commands[0].name; 56 | 57 | expect(result).to.equal('ping'); 58 | }); 59 | 60 | it('get inputs, returns correct count', () => { 61 | const result = stats.inputs[0].count; 62 | 63 | expect(result).to.equal(2); 64 | }); 65 | 66 | it('get inputs, returns correct sorted item', () => { 67 | const result = stats.inputs[0].path; 68 | 69 | expect(result).to.equal('general.prefix'); 70 | }); 71 | 72 | it('get modules, returns correct count', () => { 73 | const result = stats.modules[0].count; 74 | 75 | expect(result).to.equal(2); 76 | }); 77 | 78 | it('get modules, returns correct sorted item', () => { 79 | const result = stats.modules[0].name; 80 | 81 | expect(result).to.equal('general'); 82 | }); 83 | }); -------------------------------------------------------------------------------- /test/unit/utils/validate-env.tests.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { validateEnv } from '../../../src/utils/validate-env'; 3 | 4 | describe('utils/validate-env', () => { 5 | const ogEnv = { ...process.env }; 6 | 7 | beforeEach(() => { 8 | process.env = ogEnv; 9 | }) 10 | 11 | it('CLIENT_ID is not defined, error thrown', async () => { 12 | process.env.CLIENT_ID = undefined; 13 | 14 | expect(validateEnv).to.throw(); 15 | }); 16 | 17 | it('CLIENT_ID is not 18 digits, error thrown', async () => { 18 | process.env.CLIENT_ID = 'testing_123'; 19 | 20 | expect(validateEnv).to.throw(); 21 | }); 22 | 23 | it('CLIENT_ID is 18 digits, accepted', async () => { 24 | process.env.CLIENT_ID = '012345678901234567'; 25 | 26 | expect(validateEnv).to.not.throw(); 27 | }); 28 | 29 | it('API_URL ends with /, error thrown', async () => { 30 | process.env.API_URL = '.../'; 31 | 32 | expect(validateEnv).to.throw(); 33 | }); 34 | 35 | it('API_URL does not start with http, error thrown', async () => { 36 | process.env.API_URL = '2pg.xyz'; 37 | 38 | expect(validateEnv).to.throw(); 39 | }); 40 | 41 | it('API_URL does not end with /api, error thrown', async () => { 42 | process.env.API_URL = 'https://2pg.xyz'; 43 | 44 | expect(validateEnv).to.throw(); 45 | }); 46 | 47 | it('API_URL valid, accepted', async () => { 48 | process.env.API_URL = 'https://2pg.xyz/api'; 49 | 50 | expect(validateEnv).to.not.throw(); 51 | }); 52 | 53 | it('DASHBOARD_URL ends with /, error thrown', async () => { 54 | process.env.DASHBOARD_URL = '.../'; 55 | 56 | expect(validateEnv).to.throw(); 57 | }); 58 | 59 | it('DASHBOARD_URL does not start with http, error thrown', async () => { 60 | process.env.DASHBOARD_URL = '2pg.xyz'; 61 | 62 | expect(validateEnv).to.throw(); 63 | }); 64 | 65 | it('DASHBOARD_URL valid, accepted', async () => { 66 | process.env.DASHBOARD_URL = 'https://2pg.xyz'; 67 | 68 | expect(validateEnv).to.not.throw(); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": true, 3 | "compilerOptions": { 4 | "declaration": true, 5 | "downlevelIteration": true, 6 | "esModuleInterop": true, 7 | "inlineSourceMap": true, 8 | "lib": ["ES2019"], 9 | "module": "CommonJS", 10 | "noImplicitAny": false, 11 | "resolveJsonModule": true, 12 | "moduleResolution": "node", 13 | "outDir": "lib", 14 | "strict": true, 15 | "strictNullChecks": false, 16 | "target": "ES5", 17 | "watch": true 18 | }, 19 | "include": ["src"] 20 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint:recommended" 4 | ], 5 | "jsRules": {}, 6 | "rules": { 7 | "max-line-length": { 8 | "options": [80] 9 | }, 10 | "new-parens": true, 11 | "no-arg": true, 12 | "no-bitwise": true, 13 | "no-conditional-assignment": true, 14 | "no-consecutive-blank-lines": false 15 | }, 16 | "rulesDirectory": [] 17 | } --------------------------------------------------------------------------------