├── .env.example ├── .eslintrc.js ├── .github ├── CODEOWNERS ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── bug.md │ ├── config.yml │ └── feature.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── coverage.yml │ └── main.yml ├── .gitignore ├── .nvmrc ├── .nycrc ├── Dockerfile ├── LICENSE ├── README.md ├── assets ├── codeblock.png └── programming_challenges_v4.0.png ├── captain-definition ├── package-lock.json ├── package.json ├── src ├── abstracts │ ├── Command.ts │ ├── EventHandler.ts │ └── LogMessageDeleteHandler.ts ├── app.ts ├── commands │ ├── AdventOfCodeCommand.ts │ ├── ChallengesCommand.ts │ ├── CodeblockCommand.ts │ ├── FeedbackCommand.ts │ ├── GitHubCommand.ts │ ├── HiringLookingCommand.ts │ ├── InspectCommand.ts │ ├── IssuesCommand.ts │ ├── NPMCommand.ts │ ├── ProjectCommand.ts │ ├── ResourcesCommand.ts │ ├── RuleCommand.ts │ ├── SearchCommand.ts │ └── WebsiteCommand.ts ├── config.json ├── decorators │ └── Schedule.ts ├── event │ └── handlers │ │ ├── AutomaticMemberRoleHandler.ts │ │ ├── CodeblocksOverFileUploadsHandler.ts │ │ ├── DiscordMessageLinkHandler.ts │ │ ├── GhostPingDeleteHandler.ts │ │ ├── GhostPingUpdateHandler.ts │ │ ├── LogMemberLeaveHandler.ts │ │ ├── LogMessageBulkDeleteHandler.ts │ │ ├── LogMessageSingleDeleteHandler.ts │ │ ├── LogMessageUpdateHandler.ts │ │ ├── NewUserAuthenticationHandler.ts │ │ ├── RaidDetectionHandler.ts │ │ ├── RegularMemberChangesHandler.ts │ │ └── ShowcaseDiscussionThreadHandler.ts ├── factories │ └── .gitkeep ├── index.ts ├── interfaces │ ├── AdventOfCode.ts │ ├── CodeSupportArticle.ts │ ├── CodeSupportArticleRevision.ts │ ├── CodeSupportRole.ts │ ├── CodeSupportUser.ts │ ├── CommandOptions.ts │ ├── GenericObject.ts │ ├── GitHubIssue.ts │ ├── GitHubPullRequest.ts │ ├── GitHubRepository.ts │ ├── InstantAnswer.ts │ └── Project.ts ├── logger.ts ├── services │ ├── AdventOfCodeService.ts │ ├── GitHubService.ts │ ├── InstantAnswerService.ts │ └── MessagePreviewService.ts ├── src-assets │ └── projects.json └── utils │ ├── DateUtils.ts │ ├── DirectoryUtils.ts │ ├── DiscordUtils.ts │ ├── NumberUtils.ts │ ├── StringUtils.ts │ ├── getConfigValue.ts │ └── getEnvironmentVariable.ts ├── test ├── MockCommand.ts ├── MockCommandWithAlias.ts ├── MockHandler.ts ├── appTest.ts ├── commands │ ├── AdventOfCodeCommandTest.ts │ ├── ChallengesCommandTest.ts │ ├── CodeblockCommandTest.ts │ ├── FeedbackCommandTest.ts │ ├── GitHubCommandTest.ts │ ├── HiringLookingCommandTest.ts │ ├── InspectCommandTest.ts │ ├── IssuesCommandTest.ts │ ├── NPMCommandTest.ts │ ├── ProjectCommandTest.ts │ ├── ResourcesCommandTest.ts │ ├── RuleCommandTest.ts │ ├── SearchCommandTest.ts │ └── WebsiteCommandTest.ts ├── decorators │ └── ScheduleTest.ts ├── event │ └── handlers │ │ ├── AutomaticMemberRoleHandlerTest.ts │ │ ├── CodeblocksOverFileUploadsHandlerTest.ts │ │ ├── DiscordMessageLinkHandlerTest.ts │ │ ├── GhostPingDeleteHandlerTest.ts │ │ ├── GhostPingUpdateHandlerTest.ts │ │ ├── LogMemberLeaveHandlerTest.ts │ │ ├── LogMessageBulkDeleteHandlerTest.ts │ │ ├── LogMessageSingleDeleteHandlerTest.ts │ │ ├── LogMessageUpdateHandlerTest.ts │ │ ├── NewUserAuthenticationHandlerTest.ts │ │ ├── RaidDetectionHandlerTest.ts │ │ ├── RegularMemberChangesHandlerTest.ts │ │ └── ShowcaseDiscussionThreadHandlerTest.ts ├── factories │ └── .gitkeep ├── services │ ├── AdventOfCodeServiceTest.ts │ ├── GitHubServiceTest.ts │ ├── InstantAnswerServiceTest.ts │ └── MessagePreviewServiceTest.ts ├── test-setup.ts └── utils │ ├── DateUtilsTest.ts │ ├── DirectoryUtilsTest.ts │ ├── DiscordUtilsTest.ts │ ├── NumberUtilsTest.ts │ ├── StringUtilsTest.ts │ ├── getConfigValue.ts │ └── getEnvironmentVariableTest.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | DISCORD_TOKEN=exampleToken 2 | ADVENT_OF_CODE_TOKEN=exampleCookieToken 3 | HEALTH_CHECK_URL=https://betteruptime.com/api/v1/heartbeat 4 | LOGTAIL_TOKEN=abcdefg12345 5 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | plugins: [ 5 | "@typescript-eslint", 6 | ], 7 | parserOptions: { 8 | ecmaVersion: 6, 9 | sourceType: "module", 10 | ecmaFeatures: { 11 | modules: true 12 | } 13 | }, 14 | rules: { 15 | "no-useless-constructor": "off", 16 | "no-empty-function": "off", 17 | "new-cap": "off", 18 | "no-unused-vars": "off", 19 | "no-invalid-this": "off", 20 | "multiline-ternary": 0, 21 | "curly": ["error", "multi-line"], 22 | "lines-between-class-members": "off", 23 | "space-before-function-paren": ["error", { 24 | "anonymous": "never", 25 | "named": "never", 26 | "asyncArrow": "always" 27 | }], 28 | }, 29 | extends: [ 30 | "eslint-config-codesupport" 31 | ], 32 | }; 33 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @lambocreeper @jason2605 @theboxmage @saramaebee 2 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Before contributing towards a pull request, please [raise an issue](https://github.com/codesupport/discord-bot/issues/new) stating: 4 | - What functionality you are adding? 5 | - Why you are adding this functionality (what's the benefit)? 6 | 7 | Discussion will then take place on the GitHub Issue by members of the community allowing them to express their thoughts. 8 | However, the final decision will be made by the [CodeSupport Discord](https://codesupport.dev/discord) moderation team. 9 | 10 | We advise you to only start working on the functionality once your Issue receives the "[accepted](https://github.com/codesupport/discord-bot/issues?q=is%3Aopen+is%3Aissue+label%3Aaccepted)" label. 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | name: Bug 4 | about: Report a bug 5 | title: '' 6 | labels: 'bug' 7 | assignees: '' 8 | --- 9 | 10 | ### Overview 11 | Please describe the bug below: 12 | 13 | ### Expected Behaviour 14 | Please descripe how you expect this functionaltiy to work: 15 | 16 | ### Actual Behaviour 17 | Please descripbe how this fucntionality is actually working: 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | 2 | blank_issues_enabled: false 3 | contact_links: 4 | - name: Discord Community 5 | url: https://codesupport.dev/discord 6 | about: For general bot discussion and support please visit our Discord 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | name: Feature 4 | about: Suggest new functionality for the bot 5 | title: '' 6 | labels: 'enhancement' 7 | assignees: '' 8 | --- 9 | 10 | ### What Functionality Are You Suggesting? 11 | Please describe the functionality below: 12 | 13 | ### Why Will This Benefit The Community? 14 | Please explain why this functionality would benefit our community below: 15 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Resolves #ISSUE_NUMBER_HERE 2 | 3 | ### Overview 4 | Please bullet point the changes you have made below: 5 | - Change 1 6 | - Change 2 7 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: coverage 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | coverage: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v1 11 | - name: Send Coverage Report To Codacy 12 | run: | 13 | npm ci 14 | npm run coverage 15 | CODACY_PROJECT_TOKEN=${{ secrets.CODACY_SECRET }} bash <(curl -Ls https://coverage.codacy.com/get.sh) report -r coverage/lcov.info -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [pull_request] 3 | jobs: 4 | lint: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v1 8 | - name: Run Linter 9 | run: | 10 | npm ci 11 | npm run lint 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Run Tests 17 | run: | 18 | npm ci 19 | npm run test 20 | build: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v1 24 | - name: Build Source Code 25 | run: | 26 | npm ci 27 | npm run build -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | build/ 3 | node_modules/ 4 | .env 5 | .nyc_output/ 6 | coverage/ 7 | .codacy-coverage/ 8 | .vscode 9 | src/config.dev.json -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@istanbuljs/nyc-config-typescript", 3 | "all": true, 4 | "check-coverage": true, 5 | "include": [ 6 | "src/" 7 | ] 8 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18 2 | WORKDIR /usr/src/app 3 | COPY package*.json ./ 4 | RUN npm ci 5 | COPY . . 6 | RUN npm run build 7 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 CodeSupport 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 | # CodeSupport Discord Bot 2 | 3 | [![Codacy Code Quality Badge](https://api.codacy.com/project/badge/Grade/c4b521b72b784a1ca31b0ed058271656)](https://app.codacy.com/gh/codesupport/discord-bot?utm_source=github.com&utm_medium=referral&utm_content=codesupport/discord-bot&utm_campaign=Badge_Grade_Settings) 4 | [![Codacy Code Coverage Badge](https://app.codacy.com/project/badge/Coverage/e1a3878449c04c4ca227ecbb0377be04)](https://www.codacy.com/gh/codesupport/discord-bot?utm_source=github.com&utm_medium=referral&utm_content=codesupport/discord-bot&utm_campaign=Badge_Coverage) 5 | 6 | ## About 7 | This repository contains the code for the CodeSupport Discord Bot. The project is written in TypeScript using the Discord.js module for interaction with the Discord API. 8 | 9 | ## Setup 10 | 1. Navigate into the repository on your computer and run `npm i` 11 | 2. Build the source code with `npm run build` 12 | 3. Start the Discord bot with `npm start` 13 | - You will need to supply the `DISCORD_TOKEN` environment variable 14 | 15 | If you would like to use a `.env` file for storing your environment variables please create it in the root of the project. 16 | If you would like to overwrite values in `config.json` to better suit your local environment create a file named `config.dev.json` with any values you would like to overwrite `config.json` with. 17 | 18 | ## Structure 19 | - All source code lives inside `src/` 20 | - All tests live inside `test/` 21 | - Any static assets (i.e. images) live inside `assets/` 22 | - Commands live in `src/commands/` 23 | - Event handlers live in `src/event/handlers` 24 | 25 | Please name files (which aren't interfaces) with their type in, for example `RuleCommand` and `RuleCommandTest`. This helps make the file names more readable in your editor. Do not add a prefix or suffix of "I" or "Interface" to interfaces. 26 | 27 | ### Creating Commands 28 | 1. To create a command, add a new file in `src/commands` named `Command.ts` 29 | - DiscordX is used to register the commands as slash commands using decorators 30 | - Commands should have the `@Discord()` decorator above the class name 31 | 2. The command should have an `onInteract` `async function` that is decorated using `@Slash()` 32 | - In `@Slash()`'s parameters you have to pass in the name of the command 33 | - You also need to pass in a desciption 34 | - The `onInteract` function expects a `CommandInteraction` parameter, used for replying to the user the called the function 35 | - If the command accepts arguments, add one or more parameters decorated by the `@SlashOption()` or `@SlashChoice()` 36 | - `@SlashOption()` requires a name which will be shown in the client to the user when filling in the parameters 37 | - `@SlashChoice()` offers a way to have a user select from a predefined set of values 38 | 39 | #### Example Command 40 | ```ts 41 | @Discord() 42 | class CodeblockCommand { 43 | @Slash({ name: "example", description: "An example command!" }) 44 | async onInteract( 45 | @SlashOption("year", { type: "NUMBER" }) year: number, 46 | interaction: CommandInteraction 47 | ): Promise { 48 | const embed = new EmbedBuilder(); 49 | 50 | embed.setTitle("Happy new year!"); 51 | embed.setDescription(`Welcome to the year ${year}, may all your wishes come true!`); 52 | 53 | await interaction.reply({ embeds: [embed] }); 54 | } 55 | } 56 | ``` 57 | 58 | ### Creating Event Handlers 59 | To create an event handler, create a new file in `src/event/handlers` named `Handler.ts`. Event handlers should extend the `EventHandler` abstract and `super` the event constant they are triggered by. When an event handler is handled, it triggers the `handle` method. This method accepts any parameters that the event requires. Do not name event handlers after the event they handle, but what their functionality is (for example, `AutomaticMemberRoleHandler` not `GuildMemberAddHandler`. 60 | 61 | #### Example Event Handler 62 | ```ts 63 | class ExampleHandler extends EventHandler { 64 | constructor() { 65 | super(Events.MessageCreate); 66 | } 67 | 68 | async handle(message: Message): Promise { 69 | await message.channel.send("Hello!"); 70 | } 71 | } 72 | ``` 73 | 74 | ## Tests 75 | We are using [Mocha](https://mochajs.org) with [Sinon](https://sinonjs.org) and [Chai](https://www.chaijs.com) for our tests. **All code should be tested,** if you are unsure of how to test your code ask LamboCreeper#6510 on Discord. 76 | 77 | ## Scripts 78 | - To start the Discord bot use `npm start` 79 | - To build the source code use `npm run build` 80 | - To start the bot in developer mode (auto-reload + run) `npm run dev` 81 | - To test the code use `npm test` 82 | - To lint the code use `npm run lint` 83 | - To get coverage stats use `npm run coverage` 84 | 85 | **Any Questions?** Feel free to mention @LamboCreeper in the [CodeSupport Discord](https://discord.gg/Hn9SETt). 86 | -------------------------------------------------------------------------------- /assets/codeblock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codesupport/discord-bot/e62aed9eb3c9feb3ebe49d1fde97e2d81fa0f29d/assets/codeblock.png -------------------------------------------------------------------------------- /assets/programming_challenges_v4.0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codesupport/discord-bot/e62aed9eb3c9feb3ebe49d1fde97e2d81fa0f29d/assets/programming_challenges_v4.0.png -------------------------------------------------------------------------------- /captain-definition: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": 2, 3 | "dockerfilePath": "./Dockerfile" 4 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "discord-bot", 3 | "version": "3.1.0", 4 | "description": "The CodeSupport Discord bot", 5 | "main": "./build/app.js", 6 | "scripts": { 7 | "start": "node -r dotenv/config ./build/index.js", 8 | "build": "tsc", 9 | "dev": "cross-env NODE_ENV=dev nodemon --watch src --ext ts --exec 'ts-node -r dotenv/config ./src/index.ts'", 10 | "coverage": "nyc --reporter=lcov --reporter=text-summary npm test", 11 | "test": "ts-mocha test/**/*Test.ts test/**/**/*Test.ts test/appTest.ts --require=test/test-setup.ts --exit", 12 | "test:debug": "ts-mocha test/**/*Test.ts test/**/**/*Test.ts test/appTest.ts --timeout 999999999 --require=test/test-setup.ts --exit", 13 | "lint": "eslint src test --ext .js,.ts" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/codesupport/discord-bot.git" 18 | }, 19 | "keywords": [ 20 | "codesupport", 21 | "discord", 22 | "bot" 23 | ], 24 | "author": "The CodeSupport Community", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/codesupport/discord-bot/issues" 28 | }, 29 | "homepage": "https://github.com/codesupport/discord-bot#readme", 30 | "type": "commonjs", 31 | "engines": { 32 | "node": "18" 33 | }, 34 | "dependencies": { 35 | "@codesupport/inherited-config": "^1.0.2", 36 | "@logtail/node": "^0.4.17", 37 | "axios": "1.6.0", 38 | "axios-cache-interceptor": "^1.3.2", 39 | "discord.js": "^14.8.0", 40 | "discordx": "^11.7.6", 41 | "dotenv": "^16.3.1", 42 | "node-schedule": "^2.1.0", 43 | "reflect-metadata": "^0.1.13", 44 | "tsyringe": "^4.8.0" 45 | }, 46 | "devDependencies": { 47 | "@istanbuljs/nyc-config-typescript": "^1.0.1", 48 | "@lambocreeper/mock-discord.js": "^3.0.0", 49 | "@types/assert": "^1.5.2", 50 | "@types/chai": "^4.3.5", 51 | "@types/mocha": "^10.0.1", 52 | "@types/node": "^18.14.6", 53 | "@types/node-schedule": "^2.1.0", 54 | "@types/sinon": "^10.0.16", 55 | "@types/ws": "^8.5.5", 56 | "@typescript-eslint/eslint-plugin": "^6.4.1", 57 | "@typescript-eslint/parser": "^6.4.1", 58 | "chai": "^4.3.8", 59 | "cross-env": "^7.0.3", 60 | "eslint": "^8.48.0", 61 | "eslint-config-codesupport": "^1.0.2", 62 | "mocha": "^10.8.2", 63 | "nodemon": "^3.0.1", 64 | "nyc": "^15.1.0", 65 | "sinon": "^15.2.0", 66 | "ts-mocha": "^10.0.0", 67 | "ts-node": "^10.9.1", 68 | "typescript": "^4.9.5" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/abstracts/Command.ts: -------------------------------------------------------------------------------- 1 | import { Message } from "discord.js"; 2 | import CommandOptions from "../interfaces/CommandOptions"; 3 | 4 | abstract class Command { 5 | private readonly name: string; 6 | private readonly description: string; 7 | private readonly options: CommandOptions | undefined; 8 | 9 | protected constructor(name: string, description: string, options?: CommandOptions) { 10 | this.name = name; 11 | this.description = description; 12 | this.options = options; 13 | } 14 | 15 | abstract run(message: Message, args?: string[]): Promise; 16 | 17 | getName(): string { 18 | return this.name; 19 | } 20 | 21 | getDescription(): string { 22 | return this.description; 23 | } 24 | 25 | getAliases(): string[] { 26 | return this.options?.aliases || []; 27 | } 28 | 29 | isSelfDestructing(): boolean { 30 | return this.options?.selfDestructing || false; 31 | } 32 | } 33 | 34 | export default Command; -------------------------------------------------------------------------------- /src/abstracts/EventHandler.ts: -------------------------------------------------------------------------------- 1 | abstract class EventHandler { 2 | private readonly event: any; 3 | 4 | protected constructor(event: any) { 5 | this.event = event; 6 | } 7 | 8 | abstract handle(...args: any[]): Promise; 9 | 10 | getEvent(): any { 11 | return this.event; 12 | } 13 | } 14 | 15 | export default EventHandler; -------------------------------------------------------------------------------- /src/abstracts/LogMessageDeleteHandler.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder, Message, TextChannel, ColorResolvable } from "discord.js"; 2 | import EventHandler from "./EventHandler"; 3 | import getConfigValue from "../utils/getConfigValue"; 4 | import GenericObject from "../interfaces/GenericObject"; 5 | import { logger } from "../logger"; 6 | 7 | abstract class LogMessageDeleteHandler extends EventHandler { 8 | async sendLog(message: Message): Promise { 9 | if (message.content !== "") { 10 | try { 11 | const embed = new EmbedBuilder(); 12 | 13 | embed.setTitle("Message Deleted"); 14 | embed.setDescription(`Author: ${message.author} (${message.author.username})\nChannel: ${message.channel}`); 15 | embed.addFields([{ name: "Message", value: message.content }]); 16 | embed.setColor(getConfigValue>("EMBED_COLOURS").DEFAULT); 17 | 18 | const logsChannel = message.guild?.channels.cache.find( 19 | channel => channel.id === getConfigValue("LOG_CHANNEL_ID") 20 | ) as TextChannel; 21 | 22 | await logsChannel?.send({embeds: [embed]}); 23 | } catch (error) { 24 | logger.error("Failed to send message deletion log", { 25 | messageId: message.id, 26 | channelId: message.channelId 27 | }); 28 | } 29 | } 30 | } 31 | } 32 | 33 | export default LogMessageDeleteHandler; 34 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | import axios from "axios"; 3 | import {Client, DIService, tsyringeDependencyRegistryEngine} from "discordx"; 4 | import { TextChannel, Snowflake } from "discord.js"; 5 | import { config as env } from "dotenv"; 6 | import DirectoryUtils from "./utils/DirectoryUtils"; 7 | import DiscordUtils from "./utils/DiscordUtils"; 8 | import getConfigValue from "./utils/getConfigValue"; 9 | import Schedule from "./decorators/Schedule"; 10 | import { container } from "tsyringe"; 11 | import { setupCache } from "axios-cache-interceptor"; 12 | import EventHandler from "./abstracts/EventHandler"; 13 | 14 | if (process.env.NODE_ENV !== getConfigValue("PRODUCTION_ENV")) { 15 | env({ 16 | path: "../.env" 17 | }); 18 | } 19 | 20 | class App { 21 | private readonly client: Client; 22 | 23 | constructor() { 24 | if (!process.env.DISCORD_TOKEN) { 25 | throw new Error("You must supply the DISCORD_TOKEN environment variable."); 26 | } 27 | 28 | this.client = new Client({ 29 | botId: getConfigValue("BOT_ID"), 30 | botGuilds: [getConfigValue("GUILD_ID")], 31 | intents: DiscordUtils.getAllIntentsApartFromPresence(), 32 | silent: false 33 | }); 34 | 35 | container.register("AXIOS_CACHED_INSTANCE", setupCache(axios)); 36 | 37 | DIService.engine = tsyringeDependencyRegistryEngine.setInjector(container); 38 | } 39 | 40 | @Schedule("*/5 * * * *") 41 | async reportHealth(): Promise { 42 | await axios.get(process.env.HEALTH_CHECK_URL!); 43 | } 44 | 45 | async init(): Promise { 46 | this.client.once("ready", async () => { 47 | await this.client.initApplicationCommands(); 48 | }); 49 | 50 | await DirectoryUtils.getFilesInDirectory( 51 | `${__dirname}/${getConfigValue("commands_directory")}`, 52 | DirectoryUtils.appendFileExtension("Command") 53 | ); 54 | 55 | this.client.on("interactionCreate", interaction => { 56 | this.client.executeInteraction(interaction); 57 | }); 58 | 59 | await this.client.login(process.env.DISCORD_TOKEN!); 60 | 61 | const handlerFiles = await DirectoryUtils.getFilesInDirectory( 62 | `${__dirname}/${getConfigValue("handlers_directory")}`, 63 | DirectoryUtils.appendFileExtension("Handler") 64 | ); 65 | 66 | handlerFiles.forEach(handler => { 67 | const { default: Handler } = handler; 68 | 69 | const handlerInstance = container.resolve(Handler); 70 | 71 | this.client.on(handlerInstance.getEvent(), handlerInstance.handle); 72 | }); 73 | 74 | if (process.env.NODE_ENV === getConfigValue("PRODUCTION_ENV")) { 75 | const channelSnowflake = getConfigValue("AUTHENTICATION_MESSAGE_CHANNEL"); 76 | const messageSnowflake = getConfigValue("AUTHENTICATION_MESSAGE_ID"); 77 | 78 | if (channelSnowflake && messageSnowflake) { 79 | const authChannel = await this.client.channels.fetch(channelSnowflake) as TextChannel; 80 | 81 | await authChannel.messages.fetch(messageSnowflake); 82 | } 83 | } 84 | } 85 | } 86 | 87 | export default App; 88 | -------------------------------------------------------------------------------- /src/commands/AdventOfCodeCommand.ts: -------------------------------------------------------------------------------- 1 | import { ColorResolvable, EmbedBuilder, CommandInteraction, ButtonStyle, ActionRowBuilder, ButtonBuilder, ApplicationCommandOptionType } from "discord.js"; 2 | import AdventOfCodeService from "../services/AdventOfCodeService"; 3 | import { AOCMember } from "../interfaces/AdventOfCode"; 4 | import getConfigValue from "../utils/getConfigValue"; 5 | import GenericObject from "../interfaces/GenericObject"; 6 | import { Discord, Slash, SlashOption } from "discordx"; 7 | import { injectable as Injectable } from "tsyringe"; 8 | 9 | @Discord() 10 | @Injectable() 11 | class AdventOfCodeCommand { 12 | constructor( 13 | private readonly adventOfCodeService: AdventOfCodeService, 14 | ) {} 15 | 16 | @Slash({ name: "aoc", description: "Advent Of Code" }) 17 | async onInteract( 18 | @SlashOption({ name: "year", description: "AOC year", type: ApplicationCommandOptionType.Number, minValue: 2015, required: false }) year: number | undefined, 19 | @SlashOption({ name: "name", description: "User's name", type: ApplicationCommandOptionType.String, required: false }) name: string | undefined, 20 | interaction: CommandInteraction 21 | ): Promise { 22 | const embed = new EmbedBuilder(); 23 | const button = new ButtonBuilder(); 24 | let yearToQuery = this.getYear(); 25 | 26 | if (!!year && year <= yearToQuery) { 27 | yearToQuery = year; 28 | } else if (!!year) { 29 | await interaction.reply({embeds: [this.errorEmbed(`Year requested not available.\nPlease query a year between 2015 and ${yearToQuery}`)], ephemeral: true}); 30 | return; 31 | } 32 | 33 | const link = `https://adventofcode.com/${yearToQuery}/leaderboard/private/view/${getConfigValue("ADVENT_OF_CODE_LEADERBOARD")}`; 34 | const buttonLabel = "View Leaderboard"; 35 | const description = `Invite Code: \`${getConfigValue("ADVENT_OF_CODE_INVITE")}\``; 36 | 37 | button.setLabel(buttonLabel); 38 | button.setStyle(ButtonStyle.Link); 39 | button.setURL(link); 40 | 41 | const row = new ActionRowBuilder().addComponents(button); 42 | 43 | if (!!name) { 44 | try { 45 | const [position, user] = await this.adventOfCodeService.getSinglePlayer(getConfigValue("ADVENT_OF_CODE_LEADERBOARD"), yearToQuery, name); 46 | 47 | embed.setTitle("Advent Of Code"); 48 | embed.setDescription(description); 49 | embed.addFields([ 50 | { name: `Scores of ${user.name} in ${yearToQuery}`, value: "\u200B"}, 51 | { name: "Position", value: position.toString(), inline: true }, 52 | { name: "Stars", value: user.stars.toString(), inline: true }, 53 | { name: "Points", value: user.local_score.toString(), inline: true } 54 | ]); 55 | embed.setColor(getConfigValue>("EMBED_COLOURS").SUCCESS); 56 | 57 | await interaction.reply({embeds: [embed], components: [row] }); 58 | return; 59 | } catch { 60 | await interaction.reply({embeds: [this.errorEmbed("Could not get the user requested\nPlease make sure you typed the name correctly")], ephemeral: true}); 61 | return; 62 | } 63 | } 64 | 65 | try { 66 | const members = await this.adventOfCodeService.getSortedPlayerList(getConfigValue("ADVENT_OF_CODE_LEADERBOARD"), yearToQuery); 67 | const playerList = this.generatePlayerList(members, getConfigValue("ADVENT_OF_CODE_RESULTS_PER_PAGE")); 68 | 69 | embed.setTitle("Advent Of Code"); 70 | embed.setDescription(description); 71 | embed.addFields([{ name: `Top ${getConfigValue("ADVENT_OF_CODE_RESULTS_PER_PAGE")} in ${yearToQuery}`, value: playerList }]); 72 | embed.setColor(getConfigValue>("EMBED_COLOURS").SUCCESS); 73 | } catch { 74 | await interaction.reply({embeds: [this.errorEmbed("Could not get the leaderboard for Advent Of Code.")], ephemeral: true}); 75 | return; 76 | } 77 | 78 | await interaction.reply({embeds: [embed], components: [row]}); 79 | } 80 | 81 | getYear() { 82 | const date = new Date(); 83 | 84 | if (date.getMonth() >= 10) { 85 | return date.getFullYear(); 86 | } 87 | 88 | return date.getFullYear() - 1; 89 | } 90 | 91 | private getNameLength(members: AOCMember[]): number { 92 | const member = members.reduce((a, b) => { 93 | const nameLengthA = a.name?.length || `(anon) #${a.id}`.length; 94 | const nameLengthB = b.name?.length || `(anon) #${b.id}`.length; 95 | 96 | return nameLengthA > nameLengthB ? a : b; 97 | }); 98 | 99 | return member.name?.length || `(anon) #${member.id}`.length; 100 | } 101 | 102 | private getStarNumberLength(members: AOCMember[]): number { 103 | return members.reduce((a, b) => { 104 | if (a.stars.toString().length > b.stars.toString().length) { 105 | return a; 106 | } 107 | return b; 108 | }).stars.toString().length; 109 | } 110 | 111 | private generatePlayerList(members: AOCMember[], listLength: number): string { 112 | let list = "```java\n(Name, Stars, Points)\n"; 113 | 114 | const memberNameLength = this.getNameLength(members); 115 | const starLength = this.getStarNumberLength(members); 116 | 117 | for (let i = 0; i < listLength; i++) { 118 | const member = members[i]; 119 | 120 | if (member) { 121 | const name = !member.name ? `(anon) #${member.id}`.padEnd(memberNameLength) : member.name.padEnd(memberNameLength, " "); 122 | const score = member.local_score; 123 | const stars = member.stars.toString().padEnd(starLength); 124 | const position = (i + 1).toString().padStart(2, " "); 125 | 126 | list = list.concat(`${position}) ${name} | ${stars} | ${score}\n`); 127 | } 128 | } 129 | 130 | return list.concat("```"); 131 | } 132 | 133 | private errorEmbed(description: string): EmbedBuilder { 134 | const embed = new EmbedBuilder(); 135 | 136 | embed.setTitle("Error"); 137 | embed.setDescription(description); 138 | embed.setColor(getConfigValue>("EMBED_COLOURS").ERROR); 139 | 140 | return embed; 141 | } 142 | } 143 | 144 | export default AdventOfCodeCommand; 145 | -------------------------------------------------------------------------------- /src/commands/ChallengesCommand.ts: -------------------------------------------------------------------------------- 1 | import { Discord, Slash } from "discordx"; 2 | import { EmbedBuilder, AttachmentBuilder, ColorResolvable, CommandInteraction} from "discord.js"; 3 | import getConfigValue from "../utils/getConfigValue"; 4 | import GenericObject from "../interfaces/GenericObject"; 5 | 6 | @Discord() 7 | class ChallengesCommand { 8 | @Slash({ name: "challenges", description: "Shows a list of programming challenges" }) 9 | async onInteract(interaction: CommandInteraction): Promise { 10 | const embed = new EmbedBuilder(); 11 | const image = new AttachmentBuilder("./assets/programming_challenges_v4.0.png", { name: "programming_challenges_v4.0.png" }); 12 | 13 | embed.setTitle("Programming Challenges"); 14 | embed.setDescription("Try some of these!"); 15 | embed.setImage("attachment://programming_challenges_v4.0.png"); 16 | embed.setColor(getConfigValue>("EMBED_COLOURS").DEFAULT); 17 | 18 | await interaction.reply({ embeds: [embed], files: [image] }); 19 | } 20 | } 21 | 22 | export default ChallengesCommand; 23 | -------------------------------------------------------------------------------- /src/commands/CodeblockCommand.ts: -------------------------------------------------------------------------------- 1 | import { Discord, Slash } from "discordx"; 2 | import { EmbedBuilder, AttachmentBuilder, ColorResolvable, CommandInteraction} from "discord.js"; 3 | import getConfigValue from "../utils/getConfigValue"; 4 | import GenericObject from "../interfaces/GenericObject"; 5 | 6 | @Discord() 7 | class CodeblockCommand { 8 | @Slash({ name: "codeblock", description: "Shows how to use a Discord codeblock" }) 9 | async onInteract(interaction: CommandInteraction): Promise { 10 | const embed = new EmbedBuilder(); 11 | const image = new AttachmentBuilder("./assets/codeblock.png", { name: "codeblock-tutorial.png" }); 12 | 13 | embed.setTitle("Codeblock Tutorial"); 14 | embed.setDescription("Please use codeblocks when sending code."); 15 | embed.addFields([{ name: "Sending lots of code?", value: "Consider using a [GitHub Gist](http://gist.github.com)." }]); 16 | embed.setImage("attachment://codeblock-tutorial.png"); 17 | embed.setColor(getConfigValue>("EMBED_COLOURS").DEFAULT); 18 | 19 | await interaction.reply({ embeds: [embed], files: [image] }); 20 | } 21 | } 22 | 23 | export default CodeblockCommand; 24 | -------------------------------------------------------------------------------- /src/commands/FeedbackCommand.ts: -------------------------------------------------------------------------------- 1 | import { Discord, ModalComponent, Slash } from "discordx"; 2 | import { ActionRowBuilder, CommandInteraction, EmbedBuilder, ModalBuilder, ModalSubmitInteraction, TextChannel, TextInputBuilder } from "discord.js"; 3 | import getConfigValue from "../utils/getConfigValue"; 4 | 5 | @Discord() 6 | class FeedbackCommand { 7 | @Slash({ 8 | name: "feedback", 9 | description: "Leave anonymous feedback around the community" 10 | }) 11 | async onInteract( 12 | interaction: CommandInteraction 13 | ): Promise { 14 | const modal = new ModalBuilder() 15 | .setTitle("Anonymous Feedback") 16 | .setCustomId("feedback-form"); 17 | 18 | const feedbackInput = new TextInputBuilder() 19 | .setCustomId("feedback-input") 20 | .setLabel("Your Feedback") 21 | .setStyle(2); 22 | 23 | modal.addComponents( 24 | new ActionRowBuilder().addComponents( 25 | feedbackInput 26 | ) 27 | ); 28 | 29 | await interaction.showModal(modal); 30 | } 31 | 32 | @ModalComponent({ 33 | id: "feedback-form" 34 | }) 35 | async onModalSubmit(interaction: ModalSubmitInteraction): Promise { 36 | const [feedbackInput] = ["feedback-input"].map(id => interaction.fields.getTextInputValue(id)); 37 | 38 | const feedbackChannelId = getConfigValue("FEEDBACK_CHANNEL"); 39 | const feedbackChannel = await interaction.guild?.channels.fetch(feedbackChannelId) as TextChannel; 40 | 41 | const embed = new EmbedBuilder(); 42 | 43 | embed.setTitle("New Anonymous Feedback"); 44 | embed.setDescription(feedbackInput); 45 | 46 | const message = await feedbackChannel?.send({ 47 | embeds: [embed] 48 | }); 49 | 50 | await message.startThread({ 51 | name: "Discuss Anonymous Feedback" 52 | }); 53 | 54 | await interaction.reply({ 55 | content: "Thank you, the following feedback has been submitted:", 56 | ephemeral: true, 57 | embeds: [embed] 58 | }); 59 | } 60 | } 61 | 62 | export default FeedbackCommand; 63 | -------------------------------------------------------------------------------- /src/commands/GitHubCommand.ts: -------------------------------------------------------------------------------- 1 | import {ColorResolvable, CommandInteraction, EmbedBuilder, ApplicationCommandOptionType} from "discord.js"; 2 | import GitHubService from "../services/GitHubService"; 3 | import getConfigValue from "../utils/getConfigValue"; 4 | import GenericObject from "../interfaces/GenericObject"; 5 | import {Discord, Slash, SlashOption} from "discordx"; 6 | import { injectable as Injectable } from "tsyringe"; 7 | 8 | @Discord() 9 | @Injectable() 10 | class GitHubCommand { 11 | constructor( 12 | private readonly githubService: GitHubService 13 | ) {} 14 | 15 | @Slash({ name: "github", description: "Shows information about a GitHub repository" }) 16 | async onInteract( 17 | @SlashOption({ name: "user", description: "Github user/account", type: ApplicationCommandOptionType.String, required: true }) user: string, 18 | @SlashOption({ name: "repository", description: "Github repository", type: ApplicationCommandOptionType.String, required: true }) repo: string, 19 | interaction: CommandInteraction 20 | ): Promise { 21 | const embed = new EmbedBuilder(); 22 | 23 | try { 24 | const res = await this.githubService.getRepository(user, repo); 25 | const resPR = await this.githubService.getPullRequest(user, repo); 26 | 27 | let desc = `[View on GitHub](${res.url})`; 28 | 29 | if (res.description) desc = `${res.description}\n\n${desc}`; 30 | 31 | embed.setTitle(`GitHub Repository: ${res.user}/${res.repo}`); 32 | embed.setDescription(desc); 33 | embed.addFields([ 34 | { name: "Language", value: res.language, inline: true }, 35 | { name: "Open Issues", value: (res.issues_and_pullrequests_count - resPR.length).toString(), inline: true }, 36 | { name: "Open Pull Requests", value: resPR.length.toString(), inline: true }, 37 | { name: "Forks", value: res.forks.toString(), inline: true }, 38 | { name: "Stars", value: res.stars.toString(), inline: true }, 39 | { name: "Watchers", value: res.watchers.toString(), inline: true} 40 | ]); 41 | embed.setColor(getConfigValue>("EMBED_COLOURS").SUCCESS); 42 | await interaction.reply({embeds: [embed]}); 43 | } catch (error) { 44 | embed.setTitle("Error"); 45 | embed.setDescription("There was a problem with the request to GitHub."); 46 | embed.addFields([{ name: "Correct Usage", value: "?github /" }]); 47 | embed.setColor(getConfigValue>("EMBED_COLOURS").ERROR); 48 | await interaction.reply({embeds: [embed], ephemeral: true}); 49 | } 50 | } 51 | } 52 | 53 | export default GitHubCommand; 54 | -------------------------------------------------------------------------------- /src/commands/HiringLookingCommand.ts: -------------------------------------------------------------------------------- 1 | import {ColorResolvable, CommandInteraction, EmbedBuilder} from "discord.js"; 2 | import {Discord, Slash} from "discordx"; 3 | import getConfigValue from "../utils/getConfigValue"; 4 | import GenericObject from "../interfaces/GenericObject"; 5 | 6 | @Discord() 7 | class HiringLookingCommand { 8 | @Slash({ name: "hl", description: "Shows the rules for the hiring/looking section" }) 9 | async onInteract(interaction: CommandInteraction): Promise { 10 | const embed = new EmbedBuilder(); 11 | 12 | embed.setTitle("Hiring or Looking Posts"); 13 | embed.setDescription(` 14 | CodeSupport offers a free to use hiring or looking section.\n 15 | Here you can find people to work for you and offer your services, 16 | as long as it fits in with the rules. If you get scammed in hiring or looking there is 17 | nothing we can do, however, we do ask that you let a moderator know. 18 | `); 19 | embed.addFields([ 20 | { 21 | name: "Payment", 22 | value: "If you are trying to hire people for a project, and that project is not open source, your post must state how much you will pay them (or a percentage of profits they will receive)." 23 | }, 24 | { 25 | name: "Post Frequency", 26 | value: `Please only post in <#${getConfigValue>("BOTLESS_CHANNELS").HIRING_OR_LOOKING}> once per week to keep the channel clean and fair. Posting multiple times per week will lead to your access to the channel being revoked.` 27 | }, 28 | { 29 | name: "Example Post", 30 | value: ` 31 | Please use the example below as a template to base your post on.\n 32 | \`\`\` 33 | [HIRING] 34 | Full Stack Website Developer 35 | We are looking for a developer who is willing to bring our video streaming service to life. 36 | Pay: $20/hour 37 | Requirements: 38 | - Solid knowledge of HTML, CSS and JavaScript 39 | - Knowledge of Node.js, Express and EJS. 40 | - Able to turn Adobe XD design documents into working web pages. 41 | - Able to stick to deadlines and work as a team. 42 | \`\`\` 43 | ` 44 | } 45 | ]); 46 | embed.setColor(getConfigValue>("EMBED_COLOURS").DEFAULT); 47 | 48 | await interaction.reply({ embeds: [embed]}); 49 | } 50 | } 51 | 52 | export default HiringLookingCommand; 53 | -------------------------------------------------------------------------------- /src/commands/InspectCommand.ts: -------------------------------------------------------------------------------- 1 | import {Discord, Slash, SlashOption} from "discordx"; 2 | import {EmbedBuilder, ColorResolvable, CommandInteraction, GuildMember, time, TimestampStyles, ApplicationCommandOptionType} from "discord.js"; 3 | import getConfigValue from "../utils/getConfigValue"; 4 | import GenericObject from "../interfaces/GenericObject"; 5 | import DiscordUtils from "../utils/DiscordUtils"; 6 | 7 | @Discord() 8 | class InspectCommand { 9 | @Slash({ name: "inspect", description: "Inspect a user" }) 10 | async onInteract( 11 | @SlashOption({ name: "user", description: "User to inspect", type: ApplicationCommandOptionType.Mentionable, required: false }) userID: GuildMember | undefined, 12 | interaction: CommandInteraction 13 | ): Promise { 14 | const userObj = await DiscordUtils.getGuildMember(userID === undefined ? interaction.user.id : userID.id, interaction.guild!); 15 | 16 | const embed = userObj === undefined 17 | ? this.buildNoMatchEmbed() 18 | : this.buildInspectEmbed(userObj!); 19 | 20 | await interaction.reply({embeds: [embed]}); 21 | } 22 | 23 | private buildNoMatchEmbed(): EmbedBuilder { 24 | const embed = new EmbedBuilder(); 25 | 26 | embed.setTitle("Error"); 27 | embed.setDescription("No match found."); 28 | embed.addFields([{ name: "Correct Usage", value: "?inspect [username|userID]" }]); 29 | embed.setColor(getConfigValue>("EMBED_COLOURS").ERROR); 30 | 31 | return embed; 32 | } 33 | 34 | private buildInspectEmbed(memberObj: GuildMember): EmbedBuilder { 35 | const embed = new EmbedBuilder(); 36 | 37 | embed.setTitle(`Inspecting ${memberObj?.user.username}`); 38 | embed.setColor((memberObj?.displayColor || getConfigValue>("EMBED_COLOURS").DEFAULT)); 39 | embed.setThumbnail(memberObj?.user.displayAvatarURL()); 40 | embed.addFields([ 41 | { name: "User ID", value: memberObj?.user.id }, 42 | { name: "Username", value: memberObj?.user.username } 43 | ]); 44 | 45 | if (memberObj?.nickname !== null) embed.addFields([{ name: "Nickname", value: memberObj?.nickname }]); 46 | 47 | if (memberObj?.joinedAt !== null) { 48 | const shortDateTime = time(memberObj?.joinedAt!, TimestampStyles.ShortDateTime); 49 | const relativeTime = time(memberObj?.joinedAt!, TimestampStyles.RelativeTime); 50 | 51 | embed.addFields([{ name: "Joined At", value: `${shortDateTime} ${relativeTime}` }]); 52 | } 53 | 54 | if (memberObj?.roles.cache.size > 1) { 55 | embed.addFields([{ name: "Roles", value: `${memberObj.roles.cache.filter(role => role.id !== memberObj?.guild!.id).map(role => ` ${role.toString()}`)}` }]); 56 | } else { 57 | embed.addFields([{ name: "Roles", value: "No roles" }]); 58 | } 59 | 60 | return embed; 61 | } 62 | } 63 | 64 | export default InspectCommand; 65 | -------------------------------------------------------------------------------- /src/commands/IssuesCommand.ts: -------------------------------------------------------------------------------- 1 | import { ColorResolvable, CommandInteraction, EmbedBuilder, ApplicationCommandOptionType } from "discord.js"; 2 | import { Discord, Slash, SlashOption } from "discordx"; 3 | import { injectable as Injectable } from "tsyringe"; 4 | import GitHubService from "../services/GitHubService"; 5 | import GitHubIssue from "../interfaces/GitHubIssue"; 6 | import DateUtils from "../utils/DateUtils"; 7 | import StringUtils from "../utils/StringUtils"; 8 | import getConfigValue from "../utils/getConfigValue"; 9 | import GenericObject from "../interfaces/GenericObject"; 10 | 11 | @Discord() 12 | @Injectable() 13 | class IssuesCommand { 14 | constructor( 15 | private readonly gitHubService: GitHubService 16 | ) {} 17 | 18 | @Slash({ name: "issues", description: "Shows the open issues on a GitHub repository" }) 19 | async onInteract( 20 | @SlashOption({ name: "user", description: "Github user/account", type: ApplicationCommandOptionType.String, required: true }) user: string, 21 | @SlashOption({ name: "repository", description: "Github repository", type: ApplicationCommandOptionType.String, required: true }) repoName: string, 22 | interaction: CommandInteraction 23 | ): Promise { 24 | const embed = new EmbedBuilder(); 25 | 26 | try { 27 | const resIssues = await this.gitHubService.getIssues(user, repoName); 28 | const resRep = await this.gitHubService.getRepository(user, repoName); 29 | 30 | if (resIssues.length) { 31 | const issues = resIssues.slice(0, 3); 32 | 33 | embed.setTitle(`GitHub Issues: ${user}/${repoName}`); 34 | embed.setDescription(`${resRep.description}\n\n[View Issues on GitHub](${resRep.url}/issues) - [Create An Issue](${resRep.url}/issues/new)`); 35 | 36 | issues.forEach((issue: GitHubIssue) => { 37 | const days = DateUtils.getDaysBetweenDates(new Date(Date.now()), issue.created_at); 38 | const daysText = StringUtils.capitalise(DateUtils.formatDaysAgo(days)); 39 | 40 | embed.addFields([ 41 | { 42 | name: `#${issue.number} - ${issue.title}`, 43 | value: `View on [GitHub](${issue.issue_url}) - ${daysText} by [${issue.author}](${issue.author_url})` 44 | } 45 | ]); 46 | }); 47 | 48 | embed.setColor(getConfigValue>("EMBED_COLOURS").SUCCESS); 49 | } else { 50 | embed.setTitle("No Issues found"); 51 | embed.setDescription(`This repository has no issues. [Create one](${resRep.url}/issues/new)`); 52 | embed.setColor(getConfigValue>("EMBED_COLOURS").SUCCESS); 53 | } 54 | } catch (error) { 55 | embed.setTitle("Error"); 56 | embed.setDescription("There was a problem with the request to GitHub."); 57 | embed.addFields([{ name: "Correct Usage", value: "/issues /" }]); 58 | embed.setColor(getConfigValue>("EMBED_COLOURS").ERROR); 59 | } 60 | await interaction.reply({ embeds: [embed] }); 61 | } 62 | } 63 | 64 | export default IssuesCommand; 65 | -------------------------------------------------------------------------------- /src/commands/NPMCommand.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import {ColorResolvable, CommandInteraction, EmbedBuilder, ApplicationCommandOptionType} from "discord.js"; 3 | import {Discord, Slash, SlashOption} from "discordx"; 4 | import getConfigValue from "../utils/getConfigValue"; 5 | import GenericObject from "../interfaces/GenericObject"; 6 | 7 | @Discord() 8 | class NPMCommand { 9 | @Slash({ name: "npm", description: "Get an npm package url" }) 10 | async onInteract( 11 | @SlashOption({ name: "package", description: "Package name", type: ApplicationCommandOptionType.String, required: true }) packageName: string, 12 | interaction: CommandInteraction 13 | ): Promise { 14 | const embed = new EmbedBuilder(); 15 | 16 | try { 17 | const url = `https://www.npmjs.com/package/${packageName}`; 18 | const {status} = await axios.get(url); 19 | 20 | if (status === 200) { 21 | await interaction.reply(url); 22 | } 23 | } catch (error) { 24 | embed.setTitle("Error"); 25 | embed.setDescription("That is not a valid NPM package."); 26 | embed.setColor(getConfigValue>("EMBED_COLOURS").ERROR); 27 | 28 | await interaction.reply({embeds: [embed], ephemeral: true}); 29 | } 30 | } 31 | } 32 | 33 | export default NPMCommand; 34 | -------------------------------------------------------------------------------- /src/commands/ProjectCommand.ts: -------------------------------------------------------------------------------- 1 | import {Discord, Slash, SlashOption} from "discordx"; 2 | import {ColorResolvable, CommandInteraction, EmbedBuilder, ApplicationCommandOptionType} from "discord.js"; 3 | import Project from "../interfaces/Project"; 4 | import projects from "../src-assets/projects.json"; 5 | import StringUtils from "../utils/StringUtils"; 6 | import getConfigValue from "../utils/getConfigValue"; 7 | import GenericObject from "../interfaces/GenericObject"; 8 | 9 | @Discord() 10 | class ProjectCommand { 11 | private readonly defaultSearchTags = ["easy", "medium", "hard"]; 12 | 13 | readonly provideProjects: () => Array = () => projects; 14 | 15 | @Slash({ name: "project", description: "Get a random project idea" }) 16 | async onInteract( 17 | @SlashOption({ name: "query", description: "Query search", type: ApplicationCommandOptionType.String, required: true }) queryString: string, 18 | interaction: CommandInteraction 19 | ): Promise { 20 | const embed = new EmbedBuilder(); 21 | let ephemeralFlag = false; 22 | const query = queryString.split(" ").map((arg: string) => arg.toLowerCase()).filter((arg: string) => arg.trim().length > 0); 23 | const usageDescription = `Use a difficulty or try out a tag to find a random project! The available difficulties are ${this.defaultSearchTags.map(tag => `\`${tag}\``).join(", ")}. A possible tag to use can be \`frontend\`, \`backend\`, \`spa\`, etc.`; 24 | 25 | if (query.length === 0) { 26 | embed.setTitle("Error"); 27 | embed.setDescription("You must provide a search query/tag."); 28 | embed.addFields([{ name: "Usage", value: usageDescription }]); 29 | embed.setColor(getConfigValue>("EMBED_COLOURS").ERROR); 30 | ephemeralFlag = true; 31 | } else { 32 | const displayProject = this.provideProjects() 33 | .filter(this.removeTooLongDescriptions) 34 | .filter(project => this.filterTags(project, query)) 35 | .sort(() => 0.5 - Math.random()).pop(); 36 | 37 | if (displayProject) { 38 | const difficulty = this.retrieveFirstFoundTag(displayProject, this.defaultSearchTags); 39 | 40 | embed.setTitle(`Project: ${displayProject.title}`); 41 | embed.setDescription(displayProject.description); 42 | if (difficulty) embed.addFields([{ name: "Difficulty", value: StringUtils.capitalise(difficulty), inline: true }]); 43 | embed.addFields([{ name: "Tags", value: displayProject.tags.map(tag => `#${tag}`).join(", "), inline: true }]); 44 | embed.setColor(getConfigValue>("EMBED_COLOURS").DEFAULT); 45 | } else { 46 | embed.setTitle("Error"); 47 | embed.setColor(getConfigValue>("EMBED_COLOURS").ERROR); 48 | embed.setDescription("Could not find a project result for the given query."); 49 | embed.addFields([{ name: "Usage", value: usageDescription }]); 50 | ephemeralFlag = true; 51 | } 52 | } 53 | 54 | await interaction.reply({embeds: [embed], ephemeral: ephemeralFlag}); 55 | } 56 | 57 | private readonly removeTooLongDescriptions: (project: Project) => boolean = ({description}) => description.length <= 2048; 58 | 59 | private readonly filterTags: (project: Project, requestedTags: Array) => boolean = 60 | ({tags}, requestedTags: Array) => requestedTags.every(tag => tags.includes(tag)); 61 | 62 | private readonly retrieveFirstFoundTag: (project: Project, tagsToRetrieve: Array) => string | undefined = 63 | ({tags}, tagsToRetrieve: Array) => tagsToRetrieve.filter(tag => tags.map((tag: string) => tag.toLowerCase()).includes(tag)).pop(); 64 | } 65 | export default ProjectCommand; -------------------------------------------------------------------------------- /src/commands/ResourcesCommand.ts: -------------------------------------------------------------------------------- 1 | import { Discord, Slash, SlashOption } from "discordx"; 2 | import { CommandInteraction, ApplicationCommandOptionType } from "discord.js"; 3 | 4 | @Discord() 5 | class ResourcesCommand { 6 | @Slash({ name: "resources", description: "Resources on the CodeSupport site" }) 7 | async onInteract( 8 | @SlashOption({ name: "category", description: "Resource category", type: ApplicationCommandOptionType.String, required: false }) category: string | undefined, 9 | interaction: CommandInteraction 10 | ): Promise { 11 | let url = "https://codesupport.dev/resources"; 12 | 13 | if (category) { 14 | url += `?category=${category}`; 15 | } 16 | 17 | await interaction.reply({ 18 | content: url 19 | }); 20 | } 21 | } 22 | 23 | export default ResourcesCommand; 24 | -------------------------------------------------------------------------------- /src/commands/RuleCommand.ts: -------------------------------------------------------------------------------- 1 | import {ColorResolvable, CommandInteraction, EmbedBuilder, ApplicationCommandOptionType} from "discord.js"; 2 | import {Discord, Slash, SlashChoice, SlashOption} from "discordx"; 3 | import getConfigValue from "../utils/getConfigValue"; 4 | import GenericObject from "../interfaces/GenericObject"; 5 | 6 | interface Rule { 7 | name: string; 8 | triggers: string[]; 9 | description: string; 10 | } 11 | 12 | const rules = getConfigValue("rules").map(it => ({name: it.name, value: it.triggers[0]})); 13 | 14 | @Discord() 15 | class RuleCommand { 16 | @Slash({ name: "rule", description: "Get info about a rule" }) 17 | async onInteract( 18 | @SlashChoice(...rules) @SlashOption({ name: "rule", description: "Name of a rule", type: ApplicationCommandOptionType.String, required: true }) ruleName: string, 19 | interaction: CommandInteraction 20 | ): Promise { 21 | const embed = new EmbedBuilder(); 22 | 23 | const rule = getConfigValue("rules").find(rule => rule.triggers.includes(ruleName)); 24 | 25 | const ruleHeader = getConfigValue("rules")[0] === rule ? "Info" : "Rule"; 26 | 27 | if (rule !== undefined) { 28 | embed.setTitle(`${ruleHeader}: ${rule.name}`); 29 | embed.setDescription(rule.description); 30 | embed.addFields([{ name: "To familiarise yourself with all of the server's rules please see", value: "<#240884566519185408>" }]); 31 | embed.setColor(getConfigValue>("EMBED_COLOURS").SUCCESS); 32 | } 33 | await interaction.reply({embeds: [embed]}); 34 | } 35 | } 36 | 37 | export default RuleCommand; -------------------------------------------------------------------------------- /src/commands/SearchCommand.ts: -------------------------------------------------------------------------------- 1 | import { ColorResolvable, CommandInteraction, EmbedBuilder, ApplicationCommandOptionType } from "discord.js"; 2 | import { Discord, Slash, SlashOption } from "discordx"; 3 | import { injectable as Injectable } from "tsyringe"; 4 | import InstantAnswerService from "../services/InstantAnswerService"; 5 | import getConfigValue from "../utils/getConfigValue"; 6 | import GenericObject from "../interfaces/GenericObject"; 7 | 8 | @Discord() 9 | @Injectable() 10 | class SearchCommand { 11 | constructor( 12 | private readonly instantAnswerService: InstantAnswerService 13 | ) {} 14 | 15 | @Slash({ name: "search", description: "Search using the DuckDuckGo API" }) 16 | async onInteract( 17 | @SlashOption({ name: "query", description: "Search query", type: ApplicationCommandOptionType.String, required: true }) query: string, 18 | interaction: CommandInteraction 19 | ): Promise { 20 | const embed = new EmbedBuilder(); 21 | 22 | try { 23 | const res = await this.instantAnswerService.query(query.replace(" ", "+")); 24 | 25 | if (res !== null) { 26 | const [baseURL] = res.url.match(/[a-z]*\.[a-z]*(\.[a-z]*)*/) || []; 27 | 28 | embed.setTitle(res.heading); 29 | embed.setDescription(`${res.description}\n\n[View on ${baseURL}](${res.url})`); 30 | embed.setFooter({ text: "Result powered by the DuckDuckGo API." }); 31 | embed.setColor(getConfigValue>("EMBED_COLOURS").SUCCESS); 32 | } else { 33 | embed.setTitle("Error"); 34 | embed.setDescription("No results found."); 35 | embed.setColor(getConfigValue>("EMBED_COLOURS").ERROR); 36 | } 37 | } catch (error) { 38 | embed.setTitle("Error"); 39 | embed.setDescription("There was a problem querying DuckDuckGo."); 40 | embed.addFields([{ name: "Correct Usage", value: "/search " }]); 41 | embed.setColor(getConfigValue>("EMBED_COLOURS").ERROR); 42 | } 43 | await interaction.reply({embeds: [embed]}); 44 | } 45 | } 46 | 47 | export default SearchCommand; 48 | -------------------------------------------------------------------------------- /src/commands/WebsiteCommand.ts: -------------------------------------------------------------------------------- 1 | import {CommandInteraction, ApplicationCommandOptionType} from "discord.js"; 2 | import {Discord, Slash, SlashOption} from "discordx"; 3 | 4 | @Discord() 5 | class WebsiteCommand { 6 | @Slash({ name: "website", description: "URL of the CodeSupport website" }) 7 | async onInteract( 8 | @SlashOption({ name: "path", description: "Path to add to the URL", type: ApplicationCommandOptionType.String, required: false }) path: string | undefined, 9 | interaction: CommandInteraction 10 | ): Promise { 11 | await interaction.reply(`https://codesupport.dev/${path || ""}`); 12 | } 13 | } 14 | 15 | export default WebsiteCommand; 16 | -------------------------------------------------------------------------------- /src/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "GUILD_ID": "240880736851329024", 3 | "BOT_ID": "545281816026677258", 4 | "COMMAND_PREFIX": "?", 5 | "MEMBER_ROLE": "592088198746996768", 6 | "REGULAR_ROLE": "700614448846733402", 7 | "MOD_ROLE": "490594428549857281", 8 | "REGULAR_ROLE_CHANGE_CHANNEL": "1241698863664988182", 9 | "SHOWCASE_CHANNEL_ID": "240892912186032129", 10 | "AUTHENTICATION_MESSAGE_ID": "592316062796873738", 11 | "AUTHENTICATION_MESSAGE_CHANNEL": "592087564295471105", 12 | "FEEDBACK_CHANNEL": "1270488816070692884", 13 | "INSTANT_ANSWER_HIGHLIGHTS": [ 14 | "javascript", 15 | "html", 16 | "css" 17 | ], 18 | "FIELD_SPACER_CHAR": "\u200B", 19 | "MEMBER_ROLE_COLOR": "#FFFFFE", 20 | "PRODUCTION_ENV": "production", 21 | "DEVELOPMENT_ENV": "dev", 22 | "commands_directory": "commands", 23 | "handlers_directory": "event/handlers", 24 | "LOG_CHANNEL_ID": "405068878151024640", 25 | "MOD_CHANNEL_ID": "495713774205141022", 26 | "GENERAL_CHANNEL_ID": "518817917438001152", 27 | "ADVENT_OF_CODE_LEADERBOARD": "490120", 28 | "ADVENT_OF_CODE_RESULTS_PER_PAGE": 20, 29 | "ADVENT_OF_CODE_INVITE": "490120-c246c110", 30 | "BOTLESS_CHANNELS": { 31 | "HIRING_OR_LOOKING": "1013063909156073493" 32 | }, 33 | "RAID_SETTINGS": { 34 | "TIME_TILL_REMOVAL": 60, 35 | "MAX_QUEUE_SIZE": 10 36 | }, 37 | "EMBED_COLOURS": { 38 | "SUCCESS": "#35BC31", 39 | "ERROR": "#BC3131", 40 | "DEFAULT": "#1555B7", 41 | "WARNING": "#F7CE61" 42 | }, 43 | "EXEMPT_CHANNELS_FILE_RESTRICTIONS": [ 44 | "495713774205141022" 45 | ], 46 | "ALLOWED_FILE_EXTENSIONS": [ 47 | "jpg", 48 | "jpeg", 49 | "png", 50 | "gif", 51 | "mp3", 52 | "mp4", 53 | "mov", 54 | "webm", 55 | "webp" 56 | ], 57 | "rules": [{ 58 | "name": "Asking For Help", 59 | "triggers": ["0", "ask", "help", "details"], 60 | "description": "Help us to help you, instead of just saying \"my code doesn't work\" or \"can someone help me.\" Be specific with your questions, and [don't ask to ask](https://dontasktoask.com)." 61 | }, 62 | { 63 | "name": "Be Patient", 64 | "triggers": ["1", "responses", "patience", "patient"], 65 | "description": "Responses to your questions are not guaranteed. The people here offer their expertise on their own time and for free." 66 | }, 67 | { 68 | "name": "Unsolicited Contact/Bumps", 69 | "triggers": ["2", "pinging", "mentioning", "ping", "bump", "dm"], 70 | "description": "Do not send unsolicited DMs, bump questions, or ping for questions outside of an established conversation." 71 | }, 72 | { 73 | "name": "Be Nice", 74 | "triggers": ["3", "respect", "nice"], 75 | "description": "Be respectful; no personal attacks, sexism, homophobia, transphobia, racism, hate speech or other disruptive behaviour." 76 | }, 77 | { 78 | "name": "No Advertising", 79 | "triggers": ["4", "advertising", "advertise", "advertisement", "ad"], 80 | "description": "Don't advertise. If you're not sure whether it would be considered advertising or not, ask a moderator." 81 | }, 82 | { 83 | "name": "Use The Right Channel", 84 | "triggers": ["5", "channel"], 85 | "description": "Stick to the correct channels. If you're unsure which channel to put your question in, you can ask in [#general](https://codesupport.dev/discord) which channel is best for your question." 86 | }, 87 | { 88 | "name": "Illegal/Immoral Tasks", 89 | "triggers": ["6", "illegal", "immoral"], 90 | "description": "Don't ask for help with illegal or immoral tasks. Doing so not only risks your continued participation in this community but is in violation of Discord's TOS and can get your account banned." 91 | }, 92 | { 93 | "name": "No Spoon-feeding", 94 | "triggers": ["7", "spoon", "spoonfeeding"], 95 | "description": "No spoon-feeding, it's not useful and won't help anyone learn." 96 | }, 97 | { 98 | "name": "Use Codeblocks", 99 | "triggers": ["8", "codeblocks"], 100 | "description": "When posting code, please use code blocks (see the command `/codeblock` for help)." 101 | }, 102 | { 103 | "name": "Keep it Clean", 104 | "triggers": ["9", "sfw", "clean", "appropriate"], 105 | "description": "Keep it appropriate, some people use this at school or at work." 106 | } 107 | ] 108 | } 109 | -------------------------------------------------------------------------------- /src/decorators/Schedule.ts: -------------------------------------------------------------------------------- 1 | import schedule from "node-schedule"; 2 | 3 | function Schedule(crontab: string) { 4 | return ( 5 | target: any, 6 | propertyKey: string, 7 | descriptor: PropertyDescriptor 8 | ) => { 9 | schedule.scheduleJob(crontab, descriptor.value); 10 | }; 11 | } 12 | 13 | export default Schedule; -------------------------------------------------------------------------------- /src/event/handlers/AutomaticMemberRoleHandler.ts: -------------------------------------------------------------------------------- 1 | import {Events, GuildMember, RoleResolvable} from "discord.js"; 2 | import EventHandler from "../../abstracts/EventHandler"; 3 | import getConfigValue from "../../utils/getConfigValue"; 4 | 5 | class AutomaticMemberRoleHandler extends EventHandler { 6 | constructor() { 7 | super(Events.GuildMemberAdd); 8 | } 9 | 10 | async handle(member: GuildMember): Promise { 11 | const currentDate = Date.now(); 12 | const memberCreatedDate = member.user.createdAt.getTime(); 13 | const dateDifference = currentDate - memberCreatedDate; 14 | const hasAvatar = member.user.avatar; 15 | const is30DaysOld = dateDifference / 2592000000 > 1; 16 | 17 | if (hasAvatar && is30DaysOld) { 18 | await member.roles.add(getConfigValue("MEMBER_ROLE"), "Appears to be a valid account."); 19 | } 20 | } 21 | } 22 | 23 | export default AutomaticMemberRoleHandler; -------------------------------------------------------------------------------- /src/event/handlers/CodeblocksOverFileUploadsHandler.ts: -------------------------------------------------------------------------------- 1 | import {Events, EmbedBuilder, Message, ColorResolvable} from "discord.js"; 2 | import EventHandler from "../../abstracts/EventHandler"; 3 | import getConfigValue from "../../utils/getConfigValue"; 4 | import GenericObject from "../../interfaces/GenericObject"; 5 | 6 | class CodeblocksOverFileUploadsHandler extends EventHandler { 7 | constructor() { 8 | super(Events.MessageCreate); 9 | } 10 | 11 | async handle(message: Message): Promise { 12 | if (getConfigValue("EXEMPT_CHANNELS_FILE_RESTRICTIONS").includes(message.channelId)) return; 13 | 14 | let invalidFileFlag = false; 15 | let invalidFileExtension: string = ""; 16 | 17 | if (message.attachments.size > 0) { 18 | message.attachments.forEach(attachment => { 19 | const fileExtension = attachment.name?.split(".").pop()!.toLowerCase() || ""; 20 | 21 | if (!getConfigValue("ALLOWED_FILE_EXTENSIONS").includes(fileExtension) || fileExtension === "") { 22 | invalidFileExtension = fileExtension; 23 | invalidFileFlag = true; 24 | } 25 | }); 26 | 27 | if (invalidFileFlag) { 28 | const embed = new EmbedBuilder(); 29 | 30 | embed.setTitle("Uploading Files"); 31 | embed.setDescription(`${message.author}, you tried to upload a \`.${invalidFileExtension}\` file, which is not allowed. Please use codeblocks over attachments when sending code.`); 32 | embed.setFooter({ text: "Type /codeblock for more information." }); 33 | embed.setColor(getConfigValue>("EMBED_COLOURS").DEFAULT); 34 | 35 | await message.channel.send({ embeds: [embed] }); 36 | await message.delete(); 37 | } 38 | } 39 | } 40 | } 41 | 42 | export default CodeblocksOverFileUploadsHandler; 43 | -------------------------------------------------------------------------------- /src/event/handlers/DiscordMessageLinkHandler.ts: -------------------------------------------------------------------------------- 1 | import EventHandler from "../../abstracts/EventHandler"; 2 | import { Message, Events } from "discord.js"; 3 | import { injectable as Injectable } from "tsyringe"; 4 | import MessagePreviewService from "../../services/MessagePreviewService"; 5 | 6 | @Injectable() 7 | class DiscordMessageLinkHandler extends EventHandler { 8 | constructor( 9 | private readonly messagePreviewService: MessagePreviewService 10 | ) { 11 | super(Events.MessageCreate); 12 | } 13 | 14 | handle = async (message: Message) => { 15 | const messageRegex = /https:\/\/(?:ptb\.)?discord(?:app)?\.com\/channels\/\d+\/\d+\/\d+/gm; 16 | const matches = message.content.matchAll(messageRegex); 17 | 18 | for (const match of matches) { 19 | const index = match.index; 20 | 21 | if (index !== undefined) { 22 | let [link] = match; 23 | 24 | if (message.content.charAt(index - 1) !== "<" || message.content.charAt(index + link.length) !== ">") { 25 | link = link.replace(/app/, "").replace(/ptb\./, ""); 26 | 27 | await this.messagePreviewService.generatePreview(link, message); 28 | } 29 | } 30 | } 31 | }; 32 | } 33 | 34 | export default DiscordMessageLinkHandler; 35 | -------------------------------------------------------------------------------- /src/event/handlers/GhostPingDeleteHandler.ts: -------------------------------------------------------------------------------- 1 | import {Events, EmbedBuilder, Message, User, ColorResolvable, ChannelType} from "discord.js"; 2 | import DateUtils from "../../utils/DateUtils"; 3 | import EventHandler from "../../abstracts/EventHandler"; 4 | import getConfigValue from "../../utils/getConfigValue"; 5 | import GenericObject from "../../interfaces/GenericObject"; 6 | 7 | class GhostPingDeleteHandler extends EventHandler { 8 | constructor() { 9 | super(Events.MessageDelete); 10 | } 11 | 12 | async handle(message: Message): Promise { 13 | if (message.mentions.users.first() || message.mentions.roles.first()) { 14 | if (!message.author?.bot) { 15 | const usersMentioned = message.mentions.users; 16 | 17 | if (usersMentioned.every(user => user.id === message.author.id || user.bot)) return; 18 | 19 | const embed = new EmbedBuilder(); 20 | 21 | let repliedToMessage : Message | null | undefined = null; 22 | let repliedToUser: User | null | undefined = null; 23 | 24 | if (message.reference?.messageId && message.reference.guildId === message.guild?.id) { 25 | const repliedToChannel = message.guild?.channels.resolve(message.reference.channelId); 26 | 27 | if (repliedToChannel?.type === ChannelType.GuildText) { 28 | repliedToMessage = await repliedToChannel.messages.fetch(message.reference.messageId); 29 | repliedToUser = repliedToMessage?.author; 30 | } 31 | } 32 | 33 | embed.setTitle("Ghost Ping Detected!"); 34 | 35 | if (repliedToUser !== null && repliedToUser !== undefined) { 36 | embed.addFields([ 37 | { name: "Author", value: message.author.toString(), inline: true }, 38 | { name: "Reply to", value: repliedToUser.toString(), inline: true } 39 | ]); 40 | } else { 41 | embed.addFields([{ name: "Author", value: message.author.toString() }]); 42 | } 43 | 44 | embed.addFields([{ name: "Message", value: message.content }]); 45 | 46 | if (repliedToMessage !== null && repliedToMessage !== undefined && repliedToMessage !== null) { 47 | embed.addFields([ 48 | { 49 | name: "Message replied to", 50 | value: `https://discord.com/channels/${repliedToMessage.guild?.id}/${repliedToMessage.channel.id}/${repliedToMessage.id}`, 51 | inline: true 52 | } 53 | ]); 54 | } 55 | 56 | embed.setFooter({ text: `Message sent at ${DateUtils.formatAsText(message.createdAt)}` }); 57 | embed.setColor(getConfigValue>("EMBED_COLOURS").DEFAULT); 58 | 59 | await message.channel.send({embeds: [embed]}); 60 | } 61 | } 62 | } 63 | } 64 | 65 | export default GhostPingDeleteHandler; 66 | -------------------------------------------------------------------------------- /src/event/handlers/GhostPingUpdateHandler.ts: -------------------------------------------------------------------------------- 1 | import {Events, ButtonStyle, EmbedBuilder, Message, ColorResolvable, ActionRowBuilder, ButtonBuilder} from "discord.js"; 2 | import EventHandler from "../../abstracts/EventHandler"; 3 | import DateUtils from "../../utils/DateUtils"; 4 | import getConfigValue from "../../utils/getConfigValue"; 5 | import GenericObject from "../../interfaces/GenericObject"; 6 | 7 | class GhostPingUpdateHandler extends EventHandler { 8 | constructor() { 9 | super(Events.MessageUpdate); 10 | } 11 | 12 | async handle(oldMessage: Message, newMessage: Message): Promise { 13 | if (oldMessage.author?.bot) return; 14 | 15 | const removedMentions = oldMessage.mentions.users.filter(user => !newMessage.mentions.users.has(user.id)); 16 | 17 | if (removedMentions.size === 0 || removedMentions.every(user => user.id === oldMessage.author.id || user.bot)) return; 18 | 19 | const button = new ButtonBuilder(); 20 | 21 | button.setLabel("View Edited Message"); 22 | button.setStyle(ButtonStyle.Link); 23 | button.setURL(newMessage.url); 24 | 25 | const row = new ActionRowBuilder().addComponents(button); 26 | const embed = new EmbedBuilder(); 27 | 28 | embed.setTitle("Ghost Ping Detected!"); 29 | embed.addFields([ 30 | { name: "Author", value: oldMessage.author.toString() }, 31 | { name: "Previous message", value: oldMessage.content }, 32 | { name: "Edited message", value: newMessage.content } 33 | ]); 34 | embed.setFooter({ text: `Message edited at ${DateUtils.formatAsText(newMessage.createdAt)}` }); 35 | embed.setColor(getConfigValue>("EMBED_COLOURS").DEFAULT); 36 | 37 | await oldMessage.channel.send({embeds: [embed], components: [row]}); 38 | } 39 | } 40 | 41 | export default GhostPingUpdateHandler; 42 | -------------------------------------------------------------------------------- /src/event/handlers/LogMemberLeaveHandler.ts: -------------------------------------------------------------------------------- 1 | import { ColorResolvable, Events, GuildMember, EmbedBuilder, TextChannel, Snowflake } from "discord.js"; 2 | import EventHandler from "../../abstracts/EventHandler"; 3 | import DateUtils from "../../utils/DateUtils"; 4 | import getConfigValue from "../../utils/getConfigValue"; 5 | import GenericObject from "../../interfaces/GenericObject"; 6 | 7 | class LogMemberLeaveHandler extends EventHandler { 8 | constructor() { 9 | super(Events.GuildMemberRemove); 10 | } 11 | 12 | async handle(guildMember: GuildMember): Promise { 13 | const embed = new EmbedBuilder(); 14 | 15 | embed.setTitle("Member Left"); 16 | embed.setDescription(`User: ${guildMember.user} (${guildMember.user.username})`); 17 | embed.setColor(getConfigValue>("EMBED_COLOURS").DEFAULT); 18 | embed.addFields([ 19 | { name: "Join Date", value: new Date(guildMember.joinedTimestamp!).toLocaleString(), inline: true }, 20 | { name: "Leave Date", value: new Date(Date.now()).toLocaleString(), inline: true }, 21 | { name: "Time In Server", value: DateUtils.getFormattedTimeSinceDate(guildMember.joinedAt!, new Date(Date.now()))! }, 22 | { name: "Authenticated", value: guildMember.roles.cache.has(getConfigValue("MEMBER_ROLE")) ? "True" : "False" } 23 | ]); 24 | 25 | const logsChannel = guildMember.guild?.channels.cache.find( 26 | channel => channel.id === getConfigValue("LOG_CHANNEL_ID") 27 | ) as TextChannel; 28 | 29 | await logsChannel?.send({embeds: [embed]}); 30 | } 31 | } 32 | 33 | export default LogMemberLeaveHandler; 34 | -------------------------------------------------------------------------------- /src/event/handlers/LogMessageBulkDeleteHandler.ts: -------------------------------------------------------------------------------- 1 | import { Collection, Events, Message, Snowflake } from "discord.js"; 2 | import LogMessageDeleteHandler from "../../abstracts/LogMessageDeleteHandler"; 3 | 4 | class LogMessageBulkDeleteHandler extends LogMessageDeleteHandler { 5 | constructor() { 6 | super(Events.MessageBulkDelete); 7 | } 8 | 9 | async handle(messages: Collection): Promise { 10 | await Promise.all(messages.map(super.sendLog)); 11 | } 12 | } 13 | 14 | export default LogMessageBulkDeleteHandler; 15 | -------------------------------------------------------------------------------- /src/event/handlers/LogMessageSingleDeleteHandler.ts: -------------------------------------------------------------------------------- 1 | import { Events, Message } from "discord.js"; 2 | import LogMessageDeleteHandler from "../../abstracts/LogMessageDeleteHandler"; 3 | 4 | class LogMessageSingleDeleteHandler extends LogMessageDeleteHandler { 5 | constructor() { 6 | super(Events.MessageDelete); 7 | } 8 | 9 | async handle(message: Message): Promise { 10 | await super.sendLog(message); 11 | } 12 | } 13 | 14 | export default LogMessageSingleDeleteHandler; -------------------------------------------------------------------------------- /src/event/handlers/LogMessageUpdateHandler.ts: -------------------------------------------------------------------------------- 1 | import { Events, EmbedBuilder, Message, TextChannel, ColorResolvable } from "discord.js"; 2 | import EventHandler from "../../abstracts/EventHandler"; 3 | import getConfigValue from "../../utils/getConfigValue"; 4 | import GenericObject from "../../interfaces/GenericObject"; 5 | import { logger } from "../../logger"; 6 | 7 | class LogMessageUpdateHandler extends EventHandler { 8 | constructor() { 9 | super(Events.MessageUpdate); 10 | } 11 | 12 | async handle(oldMessage: Message, newMessage: Message): Promise { 13 | if (oldMessage.content === newMessage.content) { 14 | return; 15 | } 16 | 17 | if (newMessage.content === "") { 18 | return; 19 | } 20 | 21 | try { 22 | const embed = new EmbedBuilder(); 23 | 24 | embed.setTitle("Message Updated"); 25 | embed.setDescription(`Author: ${oldMessage.author}\nChannel: ${oldMessage.channel}`); 26 | embed.addFields([ 27 | { name: "Old Message", value: oldMessage.content }, 28 | { name: "New Message", value: newMessage.content } 29 | ]); 30 | embed.setColor(getConfigValue>("EMBED_COLOURS").DEFAULT); 31 | 32 | const logsChannel = oldMessage.guild?.channels.cache.find( 33 | channel => channel.id === getConfigValue("LOG_CHANNEL_ID") 34 | ) as TextChannel; 35 | 36 | await logsChannel?.send({embeds: [embed]}); 37 | } catch (error) { 38 | logger.error("Failed to send message update log", { 39 | messageId: oldMessage.id, 40 | channelId: oldMessage.channelId 41 | }); 42 | } 43 | } 44 | } 45 | 46 | export default LogMessageUpdateHandler; 47 | -------------------------------------------------------------------------------- /src/event/handlers/NewUserAuthenticationHandler.ts: -------------------------------------------------------------------------------- 1 | import { Events, MessageReaction, RoleResolvable, User } from "discord.js"; 2 | import EventHandler from "../../abstracts/EventHandler"; 3 | import getConfigValue from "../../utils/getConfigValue"; 4 | import { logger } from "../../logger"; 5 | 6 | class NewUserAuthenticationHandler extends EventHandler { 7 | constructor() { 8 | super(Events.MessageReactionAdd); 9 | } 10 | 11 | async handle(reaction: MessageReaction, member: User): Promise { 12 | const { message, emoji } = reaction; 13 | const isAuthMessage = message.id === getConfigValue("AUTHENTICATION_MESSAGE_ID"); 14 | const isAuthEmoji = emoji.name === "🤖"; 15 | 16 | if (isAuthMessage && isAuthEmoji) { 17 | const guildMember = await reaction.message.guild?.members.fetch(member); 18 | 19 | logger.info("User has triggered authentication reaction. Applying member role.", { 20 | userId: guildMember?.id 21 | }); 22 | 23 | await guildMember?.roles.add(getConfigValue("MEMBER_ROLE"), "User has authenticated their account."); 24 | } 25 | } 26 | } 27 | 28 | export default NewUserAuthenticationHandler; 29 | -------------------------------------------------------------------------------- /src/event/handlers/RaidDetectionHandler.ts: -------------------------------------------------------------------------------- 1 | import {ColorResolvable, Events, GuildMember, EmbedBuilder, TextChannel} from "discord.js"; 2 | import EventHandler from "../../abstracts/EventHandler"; 3 | import getConfigValue from "../../utils/getConfigValue"; 4 | import GenericObject from "../../interfaces/GenericObject"; 5 | import {logger} from "../../logger"; 6 | 7 | class RaidDetectionHandler extends EventHandler { 8 | private joinQueue: GuildMember[] = []; 9 | private kickFlag = false; 10 | 11 | constructor() { 12 | super(Events.GuildMemberAdd); 13 | } 14 | 15 | handle = async (member: GuildMember): Promise => { 16 | const timeToWait = 1000 * getConfigValue>("RAID_SETTINGS").TIME_TILL_REMOVAL; 17 | 18 | this.joinQueue.push(member); 19 | 20 | if (this.joinQueue.length >= getConfigValue>("RAID_SETTINGS").MAX_QUEUE_SIZE && !this.kickFlag) { 21 | this.kickFlag = true; 22 | await this.kickArray(member); 23 | this.kickFlag = false; 24 | } 25 | 26 | setTimeout(() => { 27 | if (this.joinQueue.includes(member) && !this.kickFlag) { 28 | this.joinQueue.splice(this.joinQueue.indexOf(member), 1); 29 | } 30 | }, timeToWait); 31 | }; 32 | 33 | private async kickArray(member: GuildMember) { 34 | const modChannel = member.guild?.channels.cache.find(channel => channel.id === getConfigValue("LOG_CHANNEL_ID")) as TextChannel; 35 | const generalChannel = member.guild?.channels.cache.find(channel => channel.id === getConfigValue("GENERAL_CHANNEL_ID")) as TextChannel; 36 | 37 | try { 38 | while (this.joinQueue.length > 0) { 39 | const member = this.joinQueue.shift()!!; 40 | 41 | await member.kick("Detected as part of a raid."); 42 | await modChannel.send(`**RAID DETECTION** Kicked user ${member.displayName} (${member.id}).`); 43 | } 44 | 45 | const embed = new EmbedBuilder(); 46 | 47 | embed.setTitle(":warning: Raid Detected"); 48 | embed.setDescription(`**We have detected a raid is currently going on and are solving the issue.** 49 | Please refrain from notifying the moderators or spamming this channel. 50 | Thank you for your cooperation and we apologise for any inconvenience.`); 51 | embed.setColor(getConfigValue>("EMBED_COLOURS").WARNING); 52 | embed.setTimestamp(); 53 | 54 | await generalChannel.send({embeds: [embed]}); 55 | } catch (error) { 56 | await modChannel.send( 57 | `Failed to kick users or empty queue: \n\`${error instanceof Error && error.message}\`` 58 | ); 59 | 60 | logger.error("Failed to kick users or empty queue", { 61 | error 62 | }); 63 | } 64 | } 65 | } 66 | 67 | export default RaidDetectionHandler; 68 | -------------------------------------------------------------------------------- /src/event/handlers/RegularMemberChangesHandler.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder, Events, GuildMember, TextChannel } from "discord.js"; 2 | import EventHandler from "../../abstracts/EventHandler"; 3 | import getConfigValue from "../../utils/getConfigValue"; 4 | 5 | class RegularMemberChangesHandler extends EventHandler { 6 | constructor() { 7 | super(Events.GuildMemberUpdate); 8 | } 9 | 10 | async handle(oldMember: GuildMember, newMember: GuildMember) { 11 | if (oldMember.roles.cache.size === newMember.roles.cache.size) { 12 | return; 13 | } 14 | 15 | const roleId = getConfigValue("REGULAR_ROLE"); 16 | const hadRoleBefore = oldMember.roles.cache.has(roleId); 17 | const hasRoleNow = newMember.roles.cache.has(roleId); 18 | 19 | if (hadRoleBefore && hasRoleNow || !hadRoleBefore && !hasRoleNow) { 20 | return; 21 | } 22 | 23 | const channelId = getConfigValue("REGULAR_ROLE_CHANGE_CHANNEL"); 24 | const logChannel = await newMember.guild.channels.fetch(channelId) as TextChannel; 25 | 26 | const embed = new EmbedBuilder(); 27 | 28 | embed.setThumbnail(newMember.user.avatarURL()); 29 | embed.setDescription(`<@${newMember.user.id}>`); 30 | 31 | if (hadRoleBefore && !hasRoleNow) { 32 | embed.setTitle("No Longer Regular"); 33 | embed.setColor("#F71313"); 34 | } 35 | 36 | if (!hadRoleBefore && hasRoleNow) { 37 | embed.setTitle("New Regular Member"); 38 | embed.setColor("#6CEF0E"); 39 | } 40 | 41 | logChannel?.send({ embeds: [embed] }); 42 | } 43 | } 44 | 45 | export default RegularMemberChangesHandler; 46 | -------------------------------------------------------------------------------- /src/event/handlers/ShowcaseDiscussionThreadHandler.ts: -------------------------------------------------------------------------------- 1 | import { Events, Message } from "discord.js"; 2 | import EventHandler from "../../abstracts/EventHandler"; 3 | import getConfigValue from "../../utils/getConfigValue"; 4 | import { logger } from "../../logger"; 5 | 6 | class ShowcaseDiscussionThreadHandler extends EventHandler { 7 | constructor() { 8 | super(Events.MessageCreate); 9 | } 10 | 11 | async handle(message: Message): Promise { 12 | if (message.channelId !== getConfigValue("SHOWCASE_CHANNEL_ID")) return; 13 | 14 | const username = message.member?.nickname ?? message.member?.user.username; 15 | 16 | try { 17 | await message.startThread({ 18 | name: `Discuss ${username}'s Showcase Post` 19 | }); 20 | } catch (error) { 21 | logger.error("Failed to create thread for showcase post", { 22 | error 23 | }); 24 | } 25 | } 26 | } 27 | 28 | export default ShowcaseDiscussionThreadHandler; 29 | -------------------------------------------------------------------------------- /src/factories/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codesupport/discord-bot/e62aed9eb3c9feb3ebe49d1fde97e2d81fa0f29d/src/factories/.gitkeep -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import App from "./app"; 2 | import { logger } from "./logger"; 3 | 4 | async function app() { 5 | try { 6 | await new App().init(); 7 | } catch (error) { 8 | logger.error( 9 | error instanceof Error ? error.message : "An error occurred.", 10 | { error } 11 | ); 12 | } 13 | } 14 | 15 | app(); 16 | -------------------------------------------------------------------------------- /src/interfaces/AdventOfCode.ts: -------------------------------------------------------------------------------- 1 | export interface AOCCompletionDayLevel { 2 | get_star_ts: string; 3 | } 4 | 5 | export interface AOCMember { 6 | global_score: number; 7 | local_score: number; 8 | completion_day_level: { [key: string]: { [key: string]: AOCCompletionDayLevel } }; 9 | stars: number; 10 | last_star_ts: number | string; 11 | id: string; 12 | name: null | string; 13 | } 14 | 15 | export interface AOCLeaderBoard { 16 | event: string; 17 | owner_id: string; 18 | members: { [key: string]: AOCMember }; 19 | } 20 | 21 | -------------------------------------------------------------------------------- /src/interfaces/CodeSupportArticle.ts: -------------------------------------------------------------------------------- 1 | import CodeSupportUser from "./CodeSupportUser"; 2 | import CodeSupportArticleRevision from "./CodeSupportArticleRevision"; 3 | 4 | interface CodeSupportArticle { 5 | createdBy: CodeSupportUser; 6 | createdOn: number; 7 | id: number; 8 | revision: CodeSupportArticleRevision; 9 | title: string; 10 | titleId: string; 11 | updatedBy: CodeSupportUser; 12 | updatedOn: number; 13 | } 14 | 15 | export default CodeSupportArticle; -------------------------------------------------------------------------------- /src/interfaces/CodeSupportArticleRevision.ts: -------------------------------------------------------------------------------- 1 | import CodeSupportUser from "./CodeSupportUser"; 2 | 3 | interface CodeSupportArticleRevision { 4 | articleId: number; 5 | content: string; 6 | createdBy: CodeSupportUser; 7 | createdOn: number; 8 | description: string; 9 | id: number; 10 | } 11 | 12 | export default CodeSupportArticleRevision; -------------------------------------------------------------------------------- /src/interfaces/CodeSupportRole.ts: -------------------------------------------------------------------------------- 1 | interface CodeSupportRole { 2 | code: string; 3 | id: number; 4 | label: string; 5 | } 6 | 7 | export default CodeSupportRole; -------------------------------------------------------------------------------- /src/interfaces/CodeSupportUser.ts: -------------------------------------------------------------------------------- 1 | import CodeSupportRole from "./CodeSupportRole"; 2 | 3 | interface CodeSupportUser { 4 | alias: string; 5 | avatarLink: string; 6 | disabled: boolean; 7 | discordId: string; 8 | discordUsername: string; 9 | id: number; 10 | joinDate: number; 11 | role: CodeSupportRole; 12 | } 13 | 14 | export default CodeSupportUser; 15 | -------------------------------------------------------------------------------- /src/interfaces/CommandOptions.ts: -------------------------------------------------------------------------------- 1 | interface CommandOptions { 2 | selfDestructing?: boolean; 3 | aliases?: string[]; 4 | } 5 | 6 | export default CommandOptions; -------------------------------------------------------------------------------- /src/interfaces/GenericObject.ts: -------------------------------------------------------------------------------- 1 | interface GenericObject { 2 | [Key: string]: T; 3 | } 4 | 5 | export default GenericObject; -------------------------------------------------------------------------------- /src/interfaces/GitHubIssue.ts: -------------------------------------------------------------------------------- 1 | interface GitHubIssue { 2 | title: string; 3 | number: number; 4 | author: string; 5 | author_url: string; 6 | issue_url: string; 7 | created_at: Date; 8 | } 9 | 10 | export default GitHubIssue; -------------------------------------------------------------------------------- /src/interfaces/GitHubPullRequest.ts: -------------------------------------------------------------------------------- 1 | interface GitHubPullRequest { 2 | title: string; 3 | description: string; 4 | author: string; 5 | } 6 | 7 | export default GitHubPullRequest; -------------------------------------------------------------------------------- /src/interfaces/GitHubRepository.ts: -------------------------------------------------------------------------------- 1 | interface GitHubRepository { 2 | user: string; 3 | repo: string; 4 | description?: string; 5 | language: string; 6 | url: string; 7 | issues_and_pullrequests_count: number; 8 | forks: number; 9 | watchers: number; 10 | stars: number; 11 | } 12 | 13 | export default GitHubRepository; -------------------------------------------------------------------------------- /src/interfaces/InstantAnswer.ts: -------------------------------------------------------------------------------- 1 | interface InstantAnswer { 2 | heading: string; 3 | description: string; 4 | url: string; 5 | } 6 | 7 | export default InstantAnswer; -------------------------------------------------------------------------------- /src/interfaces/Project.ts: -------------------------------------------------------------------------------- 1 | interface Project { 2 | title: string, 3 | tags: Array, 4 | description: string, 5 | } 6 | 7 | export default Project; 8 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import { Logtail } from "@logtail/node"; 2 | 3 | interface ILogPayload { 4 | userId?: string; 5 | channelId?: string; 6 | messageId?: string; 7 | roleId?: string; 8 | error?: Error | unknown; 9 | 10 | [Key: string]: unknown; 11 | } 12 | 13 | export class Logger { 14 | private readonly logger: Logtail | Console; 15 | 16 | constructor() { 17 | const token = process.env.LOGTAIL_TOKEN; 18 | 19 | if (token) { 20 | this.logger = new Logtail(token); 21 | } else { 22 | this.logger = console; 23 | this.logger.warn("No LOGTAIL_TOKEN environment variable provided. Using console logger."); 24 | } 25 | } 26 | 27 | info(message: string, payload?: ILogPayload) { 28 | this.logger.info(message, payload); 29 | } 30 | 31 | warn(message: string, payload?: ILogPayload) { 32 | this.logger.warn(message, payload); 33 | } 34 | 35 | error(message: string, payload?: ILogPayload) { 36 | this.logger.error(message, payload); 37 | } 38 | 39 | debug(message: string, payload?: ILogPayload) { 40 | this.logger.debug(message, payload); 41 | } 42 | } 43 | 44 | export const logger = new Logger(); 45 | -------------------------------------------------------------------------------- /src/services/AdventOfCodeService.ts: -------------------------------------------------------------------------------- 1 | import {AxiosCacheInstance } from "axios-cache-interceptor"; 2 | import { injectable as Injectable, inject as Inject } from "tsyringe"; 3 | import { AOCLeaderBoard, AOCMember } from "../interfaces/AdventOfCode"; 4 | 5 | @Injectable() 6 | export default class AdventOfCodeService { 7 | constructor( 8 | @Inject("AXIOS_CACHED_INSTANCE") 9 | private readonly api: AxiosCacheInstance 10 | ) {} 11 | 12 | async getLeaderBoard(leaderBoard: string, year: number): Promise { 13 | const { ADVENT_OF_CODE_TOKEN } = process.env; 14 | const link = `https://adventofcode.com/${year}/leaderboard/private/view/${leaderBoard}.json`; 15 | 16 | const response = await this.api.get(link, { 17 | headers: { 18 | "Cookie": `session=${ADVENT_OF_CODE_TOKEN};`, 19 | "User-Agent": `github.com/codesupport/discord-bot by ${process.env.CONTACT_EMAIL}` 20 | }, 21 | cache: { 22 | ttl: 15 * 60 * 1000 23 | } 24 | }); 25 | 26 | if (!response.data.members) { 27 | throw Error("Advent Of code leaderboard not found"); 28 | } 29 | 30 | return response.data; 31 | } 32 | 33 | async getSortedPlayerList(leaderBoard: string, year: number): Promise { 34 | const data = await this.getLeaderBoard(leaderBoard, year); 35 | const members: AOCMember[] = Object.values(data.members); 36 | 37 | members.sort((a, b) => { 38 | const stars = b.stars - a.stars; 39 | 40 | return !stars ? b.local_score - a.local_score : stars; 41 | }); 42 | 43 | return members; 44 | } 45 | 46 | async getSinglePlayer(leaderBoard: string, year: number, name: string): Promise<[number, AOCMember]> { 47 | const board = await this.getSortedPlayerList(leaderBoard, year); 48 | const memberIndex = board.findIndex(member => member.name?.toLocaleLowerCase() === name.toLocaleLowerCase()); 49 | 50 | return [memberIndex + 1, board[memberIndex]]; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/services/GitHubService.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { injectable as Injectable } from "tsyringe"; 3 | import GitHubRepository from "../interfaces/GitHubRepository"; 4 | import GitHubPullRequest from "../interfaces/GitHubPullRequest"; 5 | import GitHubIssue from "../interfaces/GitHubIssue"; 6 | 7 | @Injectable() 8 | class GitHubService { 9 | async getRepository(user: string, repo: string): Promise { 10 | const url = `https://api.github.com/repos/${user}/${repo}`; 11 | const { status, data } = await axios.get(url); 12 | 13 | // GitHub API has the key subscribers_count as stars and the key stars as watchers 14 | if (status === 200) { 15 | return { 16 | user: data.owner.login, 17 | repo: data.name, 18 | description: data.description, 19 | language: data.language, 20 | url: data.html_url, 21 | issues_and_pullrequests_count: data.open_issues_count, 22 | forks: data.forks, 23 | watchers: data.subscribers_count, 24 | stars: data.watchers 25 | }; 26 | } else { 27 | throw new Error("There was a problem with the request to GitHub."); 28 | } 29 | } 30 | 31 | async getPullRequest(user: string, repo: string): Promise { 32 | const url = `https://api.github.com/repos/${user}/${repo}/pulls`; 33 | const { data } = await axios.get(url); 34 | 35 | if (data.length !== 0) { 36 | const pullRequests = data.map((pull: any) => ({ 37 | title: pull.title, 38 | description: pull.body, 39 | author: pull.user.login 40 | })); 41 | 42 | return pullRequests; 43 | } 44 | 45 | return []; 46 | } 47 | 48 | async getIssues(user: string, repo: string): Promise { 49 | const url = `https://api.github.com/repos/${user}/${repo}/issues`; 50 | 51 | const { data } = await axios.get(url); 52 | 53 | if (data.length !== 0) { 54 | const issues = data.filter((issueAndPr: any) => !issueAndPr.pull_request) 55 | .map((issue: any) => ({ 56 | title: issue.title, 57 | number: issue.number, 58 | author: issue.user.login, 59 | author_url: issue.user.html_url, 60 | issue_url: issue.html_url, 61 | created_at: new Date(issue.created_at) 62 | })); 63 | 64 | return issues; 65 | } 66 | 67 | return []; 68 | } 69 | } 70 | 71 | export default GitHubService; 72 | -------------------------------------------------------------------------------- /src/services/InstantAnswerService.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { injectable as Injectable } from "tsyringe"; 3 | import InstantAnswer from "../interfaces/InstantAnswer"; 4 | import getConfigValue from "../utils/getConfigValue"; 5 | 6 | @Injectable() 7 | class InstantAnswerService { 8 | async query(query: string): Promise { 9 | const url = `https://api.duckduckgo.com/?q=${query}&format=json&t=codesupport-discord-bot`; 10 | const { status, data } = await axios.get(url); 11 | 12 | if (status === 200) { 13 | if (data.Heading !== "") { 14 | const [language] = getConfigValue("INSTANT_ANSWER_HIGHLIGHTS").map(highlight => 15 | data.Heading.toLowerCase().includes(highlight) && highlight 16 | ); 17 | 18 | const description = data.AbstractText 19 | .replace(//g, `\`\`\`${language}\n`) 20 | .replace(/<\/code>/g, "```") 21 | .replace(/<\/?[^>]+(>|$)/g, "") 22 | .replace(/'/g, "\"") 23 | .replace(/</g, "<") 24 | .replace(/>/g, ">"); 25 | 26 | return { 27 | heading: data.Heading, 28 | description, 29 | url: data.AbstractURL 30 | }; 31 | } 32 | 33 | return null; 34 | } else { 35 | throw new Error("There was a problem with the DuckDuckGo API."); 36 | } 37 | } 38 | } 39 | 40 | export default InstantAnswerService; 41 | -------------------------------------------------------------------------------- /src/services/MessagePreviewService.ts: -------------------------------------------------------------------------------- 1 | import { Message, TextChannel, EmbedBuilder, ColorResolvable, Snowflake } from "discord.js"; 2 | import { injectable as Injectable } from "tsyringe"; 3 | import DateUtils from "../utils/DateUtils"; 4 | import getConfigValue from "../utils/getConfigValue"; 5 | import { logger } from "../logger"; 6 | 7 | @Injectable() 8 | class MessagePreviewService { 9 | async generatePreview(link: string, callingMessage: Message): Promise { 10 | const msgArray = this.stripLink(link); 11 | 12 | if (this.verifyGuild(callingMessage, msgArray[0])) { 13 | if (callingMessage.guild?.available) { 14 | const channel = callingMessage.guild.channels.cache.get(msgArray[1]) as TextChannel; 15 | const messageToPreview = await channel?.messages.fetch(msgArray[2]) 16 | .catch(error => logger.warn("Failed to fetch message to generate preview.", { 17 | channelId: msgArray[1], 18 | messageId: msgArray[2], 19 | error 20 | })); 21 | 22 | if (messageToPreview && !messageToPreview.author?.bot) { 23 | const embed = new EmbedBuilder(); 24 | const parsedContent = this.escapeHyperlinks(messageToPreview.content); 25 | 26 | embed.setAuthor({ name: this.getAuthorName(messageToPreview), iconURL: messageToPreview.author.avatarURL() || undefined, url: link }); 27 | embed.setDescription(`<#${channel.id}>\n\n${parsedContent}\n`); 28 | embed.addFields([{ name: getConfigValue("FIELD_SPACER_CHAR"), value: `[View Original Message](${link})` }]); 29 | embed.setFooter({ text: `Message sent at ${DateUtils.formatAsText(messageToPreview.createdAt)}` }); 30 | embed.setColor((messageToPreview.member?.displayColor || getConfigValue("MEMBER_ROLE_COLOR"))); 31 | 32 | await callingMessage.channel.send({embeds: [embed]}); 33 | } 34 | } 35 | } 36 | } 37 | 38 | escapeHyperlinks(content: string): string { 39 | return content?.replace(/\[[^\[]*\]\([^)]*\)/g, match => { 40 | const chars = ["[", "]", "(", ")"]; 41 | let output = match; 42 | 43 | for (let i = 0; i < output.length; i++) { 44 | if (chars.includes(output[i])) { 45 | output = `${output.slice(0, i)}\\${output.slice(i)}`; 46 | i++; 47 | } 48 | } 49 | 50 | return output; 51 | }); 52 | } 53 | 54 | getAuthorName(message: Message): string { 55 | return message.member?.nickname || message.author.username; 56 | } 57 | 58 | verifyGuild(message: Message, guildId: string): boolean { 59 | return guildId === message.guild?.id; 60 | } 61 | 62 | stripLink(link: string): string[] { 63 | return link.substring(29).split("/"); 64 | } 65 | } 66 | 67 | export default MessagePreviewService; 68 | -------------------------------------------------------------------------------- /src/utils/DateUtils.ts: -------------------------------------------------------------------------------- 1 | class DateUtils { 2 | static getDaysBetweenDates(date1: Date, date2: Date): number { 3 | const diff = date1.getTime() - date2.getTime(); 4 | 5 | return Math.floor(diff / (1000 * 3600 * 24)); 6 | } 7 | 8 | static formatDaysAgo(days: number): string { 9 | if (days < 0) throw new Error("Number has to be positive"); 10 | if (days === 0) return "today"; 11 | if (days === 1) return "yesterday"; 12 | return `${days} days ago`; 13 | } 14 | 15 | static formatAsText(date: Date): string { 16 | const time = `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`; 17 | 18 | const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; 19 | const month = months[date.getMonth()]; 20 | 21 | return `${time} on ${date.getDate()} ${month} ${date.getFullYear()}`; 22 | } 23 | 24 | static getFormattedTimeSinceDate(startDate: Date, endDate: Date): string | null { 25 | if (endDate.getTime() < startDate.getTime()) return null; 26 | 27 | let difference = endDate.getTime() - startDate.getTime(); 28 | let daysDifference = Math.floor(difference / 1000 / 60 / 60 / 24); 29 | 30 | difference -= daysDifference * 1000 * 60 * 60 * 24; 31 | 32 | const hoursDifference = Math.floor(difference / 1000 / 60 / 60); 33 | 34 | difference -= hoursDifference * 1000 * 60 * 60; 35 | 36 | const minutesDifference = Math.floor(difference / 1000 / 60); 37 | 38 | difference -= minutesDifference * 1000 * 60; 39 | 40 | const secondDifference = Math.floor(difference / 1000); 41 | const yearsDifference = Math.floor(daysDifference / 365); 42 | 43 | daysDifference -= yearsDifference * 365; 44 | 45 | let formattedString = ""; 46 | 47 | if (yearsDifference > 0) formattedString += `${yearsDifference} ${yearsDifference > 1 ? "years" : "year"}, `; 48 | if (daysDifference > 0) formattedString += `${daysDifference} ${daysDifference > 1 ? "days" : "day"}, `; 49 | if (hoursDifference > 0) formattedString += `${hoursDifference} ${hoursDifference > 1 ? "hours" : "hour"}, `; 50 | if (minutesDifference > 0) formattedString += `${minutesDifference} ${minutesDifference > 1 ? "minutes" : "minute"} and `; 51 | 52 | formattedString += `${secondDifference} ${secondDifference > 1 ? "seconds" : "second"}`; 53 | 54 | return formattedString; 55 | } 56 | } 57 | 58 | export default DateUtils; -------------------------------------------------------------------------------- /src/utils/DirectoryUtils.ts: -------------------------------------------------------------------------------- 1 | import { readdir } from "fs"; 2 | import { promisify } from "util"; 3 | import getConfigValue from "./getConfigValue"; 4 | 5 | class DirectoryUtils { 6 | static readDirectory = promisify(readdir); 7 | 8 | private static require = require; 9 | 10 | /* eslint-disable */ 11 | static async getFilesInDirectory(directory: string, ending: string): Promise { 12 | const directoryContents = await this.readDirectory(directory); 13 | 14 | return directoryContents 15 | .filter(file => file.endsWith(ending)) 16 | .map(file => this.require(`${directory}/${file}`)); 17 | } 18 | /* eslint-enable */ 19 | 20 | static appendFileExtension(fileName: string): string { 21 | const extension = process.env.NODE_ENV === getConfigValue("DEVELOPMENT_ENV") ? ".ts" : ".js"; 22 | 23 | return fileName + extension; 24 | } 25 | } 26 | 27 | export default DirectoryUtils; 28 | -------------------------------------------------------------------------------- /src/utils/DiscordUtils.ts: -------------------------------------------------------------------------------- 1 | import { BitFieldResolvable, Guild, GuildMember, IntentsBitField, GatewayIntentBits, GatewayIntentsString, Snowflake } from "discord.js"; 2 | 3 | class DiscordUtils { 4 | static async getGuildMember(value: string, guild: Guild): Promise { 5 | if (value === "") return; 6 | 7 | // UserID 8 | if ((/^[0-9]+$/g).test(value)) { 9 | try { 10 | return await guild.members.fetch(value); 11 | } catch { 12 | return undefined; 13 | } 14 | } 15 | 16 | // Username and/or discriminator 17 | if ((/^.*#[0-9]{4}$/g).test(value)) { 18 | const [username, discriminator] = value.split("#"); 19 | const memberList = await guild.members.fetch({query: username, limit: guild.memberCount}); 20 | 21 | return memberList?.find(memberObject => memberObject.user.discriminator === discriminator); 22 | } 23 | 24 | // Everything else (Username without discriminator or nickname) 25 | const fetchedMembers = await guild.members.fetch({query: value}); 26 | 27 | return fetchedMembers.first(); 28 | } 29 | 30 | static getAllIntents(): BitFieldResolvable { 31 | // Stole... copied from an older version of discord.js... 32 | // https://github.com/discordjs/discord.js/blob/51551f544b80d7d27ab0b315da01dfc560b2c115/src/util/Intents.js#L75 33 | return Object.values(IntentsBitField.Flags).reduce((acc, p) => acc | p as GatewayIntentBits, 0); 34 | } 35 | 36 | static getAllIntentsApartFromPresence(): BitFieldResolvable { 37 | // Stole... copied from an older version of discord.js... 38 | // https://github.com/discordjs/discord.js/blob/51551f544b80d7d27ab0b315da01dfc560b2c115/src/util/Intents.js#L75 39 | return Object.values(IntentsBitField.Flags).reduce((acc, p) => { 40 | // Presence updates seem to send GuildMembers without joinedAt, we assume 41 | // It's being cached without this field making it null and causing issues down the line. 42 | // If we do not listen on this intent, it *may* not get partially cached 43 | // https://github.com/discordjs/discord.js/issues/3533 44 | if (p === IntentsBitField.Flags.GuildPresences) { 45 | return acc; 46 | } 47 | 48 | return acc | p as GatewayIntentBits; 49 | }, 0); 50 | } 51 | } 52 | 53 | export default DiscordUtils; 54 | -------------------------------------------------------------------------------- /src/utils/NumberUtils.ts: -------------------------------------------------------------------------------- 1 | class NumberUtils { 2 | static getRandomNumberInRange(min: number, max: number): number { 3 | return Math.floor(Math.random() * (max - min + 1)) + min; 4 | } 5 | 6 | static hexadecimalToInteger(hex: string): number { 7 | return parseInt(hex.replace("#", ""), 16); 8 | } 9 | } 10 | 11 | export default NumberUtils; -------------------------------------------------------------------------------- /src/utils/StringUtils.ts: -------------------------------------------------------------------------------- 1 | class StringUtils { 2 | static capitalise(string: string): string { 3 | return string.length ? string[0].toUpperCase() + string.slice(1) : string; 4 | } 5 | } 6 | 7 | export default StringUtils; -------------------------------------------------------------------------------- /src/utils/getConfigValue.ts: -------------------------------------------------------------------------------- 1 | import InheritedConfig from "@codesupport/inherited-config"; 2 | 3 | const config = new InheritedConfig({ 4 | path: "src" 5 | }); 6 | 7 | function getConfigValue(key: string): T { 8 | const value = config.getValue(key); 9 | 10 | if (!value) throw new Error(`Config for '${key}' is not found.`); 11 | 12 | return value; 13 | } 14 | 15 | export default getConfigValue; -------------------------------------------------------------------------------- /src/utils/getEnvironmentVariable.ts: -------------------------------------------------------------------------------- 1 | function getEnvironmentVariable(name: string): string { 2 | const value = process.env[name]; 3 | 4 | if (value) return value; 5 | 6 | throw new Error(`The environment variable "${name}" is not set.`); 7 | } 8 | 9 | export default getEnvironmentVariable; -------------------------------------------------------------------------------- /test/MockCommand.ts: -------------------------------------------------------------------------------- 1 | import Command from "../src/abstracts/Command"; 2 | import { Message } from "discord.js"; 3 | 4 | export default class MockCommand extends Command { 5 | constructor() { 6 | super("mock", "Mock Command"); 7 | } 8 | 9 | async run(message: Message, args: string[]): Promise { 10 | return; 11 | } 12 | } -------------------------------------------------------------------------------- /test/MockCommandWithAlias.ts: -------------------------------------------------------------------------------- 1 | import Command from "../src/abstracts/Command"; 2 | import { Message } from "discord.js"; 3 | 4 | export default class MockCommandWithAlias extends Command { 5 | constructor() { 6 | super( 7 | "mock-alias", 8 | "Mock Command with Aliases", 9 | { 10 | aliases: ["mocky", "mocko"] 11 | } 12 | ); 13 | } 14 | 15 | async run(message: Message, args: string[]): Promise { 16 | return; 17 | } 18 | } -------------------------------------------------------------------------------- /test/MockHandler.ts: -------------------------------------------------------------------------------- 1 | import { Events } from "discord.js"; 2 | import EventHandler from "../src/abstracts/EventHandler"; 3 | 4 | class MockHandler extends EventHandler { 5 | constructor() { 6 | super(Events.MessageCreate); 7 | } 8 | 9 | // eslint-disable-next-line no-empty-function 10 | handle = async function() {}; 11 | } 12 | 13 | export default MockHandler; 14 | -------------------------------------------------------------------------------- /test/appTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { SinonSandbox, createSandbox, SinonStub } from "sinon"; 3 | import { Client, ChannelManager } from "discord.js"; 4 | import { BaseMocks } from "@lambocreeper/mock-discord.js"; 5 | import axios from "axios"; 6 | import { container } from "tsyringe"; 7 | import App from "../src/app"; 8 | import DirectoryUtils from "../src/utils/DirectoryUtils"; 9 | import { AUTHENTICATION_MESSAGE_CHANNEL, AUTHENTICATION_MESSAGE_ID, PRODUCTION_ENV } from "../src/config.json"; 10 | import type { AxiosCacheInstance } from "axios-cache-interceptor"; 11 | 12 | // @ts-ignore 13 | import MockHandler from "./MockHandler"; 14 | 15 | describe("App", () => { 16 | let sandbox: SinonSandbox; 17 | let loginStub: SinonStub; 18 | let getStub: SinonStub; 19 | 20 | beforeEach(() => { 21 | sandbox = createSandbox(); 22 | 23 | // @ts-ignore 24 | (axios as unknown as AxiosCacheInstance).defaults.cache = undefined; 25 | 26 | loginStub = sandbox.stub(Client.prototype, "login"); 27 | getStub = sandbox.stub(axios, "get").resolves(); 28 | 29 | process.env.DISCORD_TOKEN = "FAKE_TOKEN"; 30 | process.env.HEALTH_CHECK_URL = "https://health-check.com"; 31 | }); 32 | 33 | describe("constructor()", () => { 34 | it("should throw error if DISCORD_TOKEN is not set", async () => { 35 | process.env.DISCORD_TOKEN = undefined; 36 | 37 | try { 38 | await new App(); 39 | } catch ({ message }) { 40 | expect(message).to.equal("You must supply the DISCORD_TOKEN environment variable."); 41 | } 42 | }); 43 | }); 44 | 45 | describe("reportHealth()", () => { 46 | it("sends a GET request to a health check endpoint", () => { 47 | new App().reportHealth(); 48 | 49 | expect(getStub.calledOnce).to.be.true; 50 | expect(getStub.calledWith("https://health-check.com")).to.be.true; 51 | }); 52 | }); 53 | 54 | describe("init()", () => { 55 | it("should login with the provided DISCORD_TOKEN", async () => { 56 | sandbox.stub(DirectoryUtils, "getFilesInDirectory").resolves([]); 57 | 58 | await new App().init(); 59 | 60 | expect(loginStub.calledWith("FAKE_TOKEN")).to.be.true; 61 | }); 62 | 63 | it("should look for slash commands", async () => { 64 | const getFilesStub = sandbox.stub(DirectoryUtils, "getFilesInDirectory").resolves([]); 65 | 66 | await new App().init(); 67 | 68 | expect(getFilesStub.args[0][1]).to.equal("Command.js"); 69 | }); 70 | 71 | it("should look for handler files", async () => { 72 | const getFilesStub = sandbox.stub(DirectoryUtils, "getFilesInDirectory").resolves([]); 73 | 74 | await new App().init(); 75 | 76 | expect(getFilesStub.args[1][1]).to.equal("Handler.js"); 77 | }); 78 | 79 | it("should bind handlers to events", async () => { 80 | // eslint-disable-next-line global-require 81 | sandbox.stub(DirectoryUtils, "getFilesInDirectory").callsFake(async () => [require("./MockHandler")]); 82 | const onStub = sandbox.stub(Client.prototype, "on"); 83 | 84 | const mockHandler = new MockHandler(); 85 | 86 | const resolveStub = sandbox.stub(container, "resolve").returns(mockHandler); 87 | 88 | await new App().init(); 89 | 90 | expect(onStub.calledWith(mockHandler.getEvent())).to.be.true; 91 | expect(resolveStub.calledWith(MockHandler)).to.be.true; 92 | }); 93 | 94 | it("should fetch auth channel and messages in production environment", async () => { 95 | const testEnv = process.env.NODE_ENV; 96 | 97 | process.env.NODE_ENV = PRODUCTION_ENV; 98 | 99 | sandbox.stub(DirectoryUtils, "getFilesInDirectory").callsFake(() => []); 100 | 101 | const textChannel = BaseMocks.getTextChannel(); 102 | 103 | const fetchChannelsStub = sandbox.stub(ChannelManager.prototype, "fetch").callsFake(async () => textChannel); 104 | const fetchMessagesStub = sandbox.stub(textChannel.messages, "fetch"); 105 | 106 | await new App().init(); 107 | 108 | expect(fetchChannelsStub.calledWith(AUTHENTICATION_MESSAGE_CHANNEL)).to.be.true; 109 | expect(fetchMessagesStub.calledWith(AUTHENTICATION_MESSAGE_ID)).to.be.true; 110 | 111 | process.env.NODE_ENV = testEnv; 112 | }); 113 | }); 114 | 115 | afterEach(() => { 116 | sandbox.restore(); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /test/commands/ChallengesCommandTest.ts: -------------------------------------------------------------------------------- 1 | import { createSandbox, SinonSandbox } from "sinon"; 2 | import { expect } from "chai"; 3 | import { BaseMocks } from "@lambocreeper/mock-discord.js"; 4 | 5 | import ChallengesCommand from "../../src/commands/ChallengesCommand"; 6 | import { EMBED_COLOURS } from "../../src/config.json"; 7 | import NumberUtils from "../../src/utils/NumberUtils"; 8 | 9 | describe("ChallengesCommand", () => { 10 | describe("onInteract()", () => { 11 | let sandbox: SinonSandbox; 12 | let command: ChallengesCommand; 13 | let interaction: any; 14 | let replyStub: sinon.SinonStub; 15 | 16 | beforeEach(() => { 17 | sandbox = createSandbox(); 18 | command = new ChallengesCommand(); 19 | replyStub = sandbox.stub().resolves(); 20 | interaction = { 21 | reply: replyStub, 22 | user: BaseMocks.getGuildMember() 23 | }; 24 | }); 25 | 26 | it("sends a message to the channel", async () => { 27 | await command.onInteract(interaction); 28 | 29 | expect(replyStub.calledOnce).to.be.true; 30 | }); 31 | 32 | it("posts the image properly", async () => { 33 | await command.onInteract(interaction); 34 | 35 | // @ts-ignore - firstArg does not live on getCall() 36 | const embed = replyStub.getCall(0).firstArg.embeds[0]; 37 | 38 | expect(replyStub.calledOnce).to.be.true; 39 | expect(embed.data.title).to.equal("Programming Challenges"); 40 | expect(embed.data.description).to.equal("Try some of these!"); 41 | expect(embed.data.color).to.equal(NumberUtils.hexadecimalToInteger(EMBED_COLOURS.DEFAULT.toLowerCase())); 42 | 43 | expect(embed.data.image.url).to.equal("attachment://programming_challenges_v4.0.png"); 44 | }); 45 | 46 | afterEach(() => { 47 | sandbox.restore(); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /test/commands/CodeblockCommandTest.ts: -------------------------------------------------------------------------------- 1 | import { createSandbox, SinonSandbox } from "sinon"; 2 | import { expect } from "chai"; 3 | import { BaseMocks } from "@lambocreeper/mock-discord.js"; 4 | 5 | import CodeblockCommand from "../../src/commands/CodeblockCommand"; 6 | import { EMBED_COLOURS } from "../../src/config.json"; 7 | import NumberUtils from "../../src/utils/NumberUtils"; 8 | 9 | describe("CodeblockCommand", () => { 10 | describe("onInteract()", () => { 11 | let sandbox: SinonSandbox; 12 | let command: CodeblockCommand; 13 | let interaction: any; 14 | let replyStub: sinon.SinonStub; 15 | 16 | beforeEach(() => { 17 | sandbox = createSandbox(); 18 | command = new CodeblockCommand(); 19 | replyStub = sandbox.stub().resolves(); 20 | interaction = { 21 | reply: replyStub, 22 | user: BaseMocks.getGuildMember() 23 | }; 24 | }); 25 | 26 | it("sends a message to the channel", async () => { 27 | await command.onInteract(interaction); 28 | 29 | expect(replyStub.calledOnce).to.be.true; 30 | }); 31 | 32 | it("states how to create a codeblock", async () => { 33 | await command.onInteract(interaction); 34 | 35 | // @ts-ignore - firstArg does not live on getCall() 36 | const embed = replyStub.getCall(0).firstArg.embeds[0]; 37 | 38 | expect(replyStub.calledOnce).to.be.true; 39 | expect(embed.data.title).to.equal("Codeblock Tutorial"); 40 | expect(embed.data.description).to.equal("Please use codeblocks when sending code."); 41 | expect(embed.data.color).to.equal(NumberUtils.hexadecimalToInteger(EMBED_COLOURS.DEFAULT.toLowerCase())); 42 | 43 | expect(embed.data.fields[0].name).to.equal("Sending lots of code?"); 44 | expect(embed.data.fields[0].value).to.equal("Consider using a [GitHub Gist](http://gist.github.com)."); 45 | 46 | expect(embed.data.image.url).to.equal("attachment://codeblock-tutorial.png"); 47 | }); 48 | 49 | afterEach(() => { 50 | sandbox.restore(); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /test/commands/FeedbackCommandTest.ts: -------------------------------------------------------------------------------- 1 | import {createSandbox, SinonSandbox, SinonStub} from "sinon"; 2 | import FeedbackCommand from "../../src/commands/FeedbackCommand"; 3 | import {CommandInteraction, ModalSubmitInteraction} from "discord.js"; 4 | import {expect} from "chai"; 5 | 6 | describe("FeedbackCommand", () => { 7 | let sandbox: SinonSandbox; 8 | let command: FeedbackCommand; 9 | 10 | beforeEach(() => { 11 | sandbox = createSandbox(); 12 | command = new FeedbackCommand(); 13 | }); 14 | 15 | afterEach(() => sandbox.restore()); 16 | 17 | describe("onInteract()", () => { 18 | it("sends a feedback modal to the user", async () => { 19 | const showModalMock = sandbox.stub(); 20 | 21 | await command.onInteract({ 22 | showModal: showModalMock 23 | } as unknown as CommandInteraction); 24 | 25 | expect(showModalMock.calledOnce).to.be.true; 26 | expect(JSON.stringify(showModalMock.args[0][0])).to.equal(JSON.stringify({ 27 | title: "Anonymous Feedback", 28 | custom_id: "feedback-form", 29 | components: [ 30 | { 31 | type: 1, 32 | components: [ 33 | { 34 | type: 4, 35 | custom_id: "feedback-input", 36 | label: "Your Feedback", 37 | style: 2 38 | } 39 | ] 40 | } 41 | ] 42 | })); 43 | }); 44 | }); 45 | 46 | describe("onModalSubmit()", () => { 47 | let interaction: ModalSubmitInteraction; 48 | let startThreadMock: SinonStub; 49 | let sendMock: SinonStub; 50 | let replyMock: SinonStub; 51 | let fetchMock: SinonStub; 52 | 53 | beforeEach(() => { 54 | startThreadMock = sandbox.stub(); 55 | replyMock = sandbox.stub(); 56 | 57 | sendMock = sandbox.stub().resolves({ 58 | startThread: startThreadMock 59 | }); 60 | 61 | fetchMock = sandbox.stub().resolves({ 62 | send: sendMock 63 | }); 64 | 65 | interaction = { 66 | fields: { 67 | getTextInputValue: (_id: string) => "Feedback submitted goes here" 68 | }, 69 | guild: { 70 | channels: { 71 | fetch: fetchMock 72 | } 73 | }, 74 | reply: replyMock 75 | } as unknown as ModalSubmitInteraction; 76 | }); 77 | 78 | it("sends a copy of the feedback to the feedback channel and creates a thread", async () => { 79 | await command.onModalSubmit(interaction); 80 | 81 | expect(fetchMock.calledOnce).to.be.true; 82 | 83 | expect(sendMock.calledOnce).to.be.true; 84 | expect(JSON.stringify(sendMock.args[0][0])).to.equal(JSON.stringify({ 85 | embeds: [ 86 | { 87 | title: "New Anonymous Feedback", 88 | description: "Feedback submitted goes here" 89 | } 90 | ] 91 | })); 92 | 93 | expect(startThreadMock.calledOnce).to.be.true; 94 | }); 95 | 96 | it("sends a copy of the feedback to the submitter", async () => { 97 | await command.onModalSubmit(interaction); 98 | 99 | expect(fetchMock.calledOnce).to.be.true; 100 | 101 | expect(replyMock.calledOnce).to.be.true; 102 | expect(JSON.stringify(replyMock.args[0][0])).to.equal(JSON.stringify({ 103 | content: "Thank you, the following feedback has been submitted:", 104 | ephemeral: true, 105 | embeds: [ 106 | { 107 | title: "New Anonymous Feedback", 108 | description: "Feedback submitted goes here" 109 | } 110 | ] 111 | })); 112 | }); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /test/commands/GitHubCommandTest.ts: -------------------------------------------------------------------------------- 1 | import {createSandbox, SinonSandbox, SinonStubbedInstance} from "sinon"; 2 | import { expect } from "chai"; 3 | import { BaseMocks } from "@lambocreeper/mock-discord.js"; 4 | 5 | import GitHubCommand from "../../src/commands/GitHubCommand"; 6 | import GitHubService from "../../src/services/GitHubService"; 7 | import { EMBED_COLOURS } from "../../src/config.json"; 8 | import NumberUtils from "../../src/utils/NumberUtils"; 9 | 10 | describe("GitHubCommand", () => { 11 | describe("onInteract()", () => { 12 | let sandbox: SinonSandbox; 13 | let command: GitHubCommand; 14 | let gitHub: SinonStubbedInstance; 15 | let replyStub: sinon.SinonStub; 16 | let interaction: any; 17 | 18 | beforeEach(() => { 19 | sandbox = createSandbox(); 20 | gitHub = sandbox.createStubInstance(GitHubService); 21 | command = new GitHubCommand(gitHub); 22 | replyStub = sandbox.stub().resolves(); 23 | interaction = { 24 | reply: replyStub, 25 | user: BaseMocks.getGuildMember() 26 | }; 27 | }); 28 | 29 | it("sends a message to the channel", async () => { 30 | await command.onInteract("user", "repo", interaction); 31 | 32 | expect(replyStub.calledOnce).to.be.true; 33 | }); 34 | 35 | it("states it had a problem with the request to GitHub", async () => { 36 | gitHub.getRepository.resolves(undefined); 37 | gitHub.getPullRequest.resolves(undefined); 38 | 39 | await command.onInteract("thisuserdoesnotexist", "thisrepodoesnotexist", interaction); 40 | 41 | // @ts-ignore - firstArg does not live on getCall() 42 | const embed = replyStub.getCall(0).firstArg.embeds[0]; 43 | 44 | expect(replyStub.calledOnce).to.be.true; 45 | expect(embed.data.title).to.equal("Error"); 46 | expect(embed.data.description).to.equal("There was a problem with the request to GitHub."); 47 | expect(embed.data.fields[0].name).to.equal("Correct Usage"); 48 | expect(embed.data.fields[0].value).to.equal("?github /"); 49 | expect(embed.data.color).to.equal(NumberUtils.hexadecimalToInteger(EMBED_COLOURS.ERROR.toLowerCase())); 50 | }); 51 | 52 | it("states the result from the github service", async () => { 53 | gitHub.getRepository.resolves({ 54 | user: "user", 55 | repo: "repo", 56 | description: "This is the description", 57 | language: "TypeScript", 58 | url: "https://github.com/codesupport/discord-bot", 59 | issues_and_pullrequests_count: 3, 60 | forks: 5, 61 | stars: 10, 62 | watchers: 3 63 | }); 64 | 65 | gitHub.getPullRequest.resolves( 66 | [{ 67 | title: "This is the title", 68 | description: "This is the description", 69 | author: "user" 70 | }] 71 | ); 72 | 73 | await command.onInteract("user", "repo", interaction); 74 | 75 | // @ts-ignore - firstArg does not live on getCall() 76 | const embed = replyStub.getCall(0).firstArg.embeds[0]; 77 | 78 | expect(replyStub.calledOnce).to.be.true; 79 | expect(embed.data.title).to.equal("GitHub Repository: user/repo"); 80 | expect(embed.data.description).to.equal("This is the description\n\n[View on GitHub](https://github.com/codesupport/discord-bot)"); 81 | expect(embed.data.fields[0].name).to.equal("Language"); 82 | expect(embed.data.fields[0].value).to.equal("TypeScript"); 83 | expect(embed.data.fields[1].name).to.equal("Open Issues"); 84 | expect(embed.data.fields[1].value).to.equal("2"); 85 | expect(embed.data.fields[2].name).to.equal("Open Pull Requests"); 86 | expect(embed.data.fields[2].value).to.equal("1"); 87 | expect(embed.data.fields[3].name).to.equal("Forks"); 88 | expect(embed.data.fields[3].value).to.equal("5"); 89 | expect(embed.data.fields[4].name).to.equal("Stars"); 90 | expect(embed.data.fields[4].value).to.equal("10"); 91 | expect(embed.data.fields[5].name).to.equal("Watchers"); 92 | expect(embed.data.fields[5].value).to.equal("3"); 93 | expect(embed.data.color).to.equal(NumberUtils.hexadecimalToInteger(EMBED_COLOURS.SUCCESS.toLowerCase())); 94 | }); 95 | 96 | it("states the result from the github service with an empty repo description", async () => { 97 | gitHub.getRepository.resolves({ 98 | user: "user", 99 | repo: "repo", 100 | description: undefined, 101 | language: "TypeScript", 102 | url: "https://github.com/codesupport/discord-bot", 103 | issues_and_pullrequests_count: 3, 104 | forks: 5, 105 | stars: 10, 106 | watchers: 3 107 | }); 108 | 109 | gitHub.getPullRequest.resolves( 110 | [{ 111 | title: "This is the title", 112 | description: "This is the description", 113 | author: "user" 114 | }] 115 | ); 116 | 117 | await command.onInteract("user", "repo", interaction); 118 | 119 | // @ts-ignore - firstArg does not live on getCall() 120 | const embed = replyStub.getCall(0).firstArg.embeds[0]; 121 | 122 | expect(replyStub.calledOnce).to.be.true; 123 | expect(embed.data.description).to.equal("[View on GitHub](https://github.com/codesupport/discord-bot)"); 124 | }); 125 | 126 | afterEach(() => { 127 | sandbox.restore(); 128 | }); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /test/commands/HiringLookingCommandTest.ts: -------------------------------------------------------------------------------- 1 | import { createSandbox, SinonSandbox } from "sinon"; 2 | import { expect } from "chai"; 3 | import { BaseMocks } from "@lambocreeper/mock-discord.js"; 4 | 5 | import { EMBED_COLOURS } from "../../src/config.json"; 6 | import HiringLookingCommand from "../../src/commands/HiringLookingCommand"; 7 | import getConfigValue from "../../src/utils/getConfigValue"; 8 | import GenericObject from "../../src/interfaces/GenericObject"; 9 | import NumberUtils from "../../src/utils/NumberUtils"; 10 | 11 | describe("HiringLookingCommand", () => { 12 | describe("onInteract()", () => { 13 | let sandbox: SinonSandbox; 14 | let command: HiringLookingCommand; 15 | let replyStub: sinon.SinonStub; 16 | let interaction: any; 17 | 18 | beforeEach(() => { 19 | sandbox = createSandbox(); 20 | command = new HiringLookingCommand(); 21 | replyStub = sandbox.stub().resolves(); 22 | interaction = { 23 | reply: replyStub, 24 | user: BaseMocks.getGuildMember() 25 | }; 26 | }); 27 | 28 | it("sends a message to the channel", async () => { 29 | await command.onInteract(interaction); 30 | 31 | expect(replyStub.calledOnce).to.be.true; 32 | }); 33 | 34 | it("states how to format a post", async () => { 35 | await command.onInteract(interaction); 36 | 37 | // @ts-ignore - firstArg does not live on getCall() 38 | const embed = replyStub.getCall(0).firstArg.embeds[0]; 39 | 40 | expect(replyStub.calledOnce).to.be.true; 41 | expect(embed.data.title).to.equal("Hiring or Looking Posts"); 42 | expect(embed.data.color).to.equal(NumberUtils.hexadecimalToInteger(EMBED_COLOURS.DEFAULT.toLowerCase())); 43 | 44 | // The indentation on these is a mess due to the test comparing white space. 45 | expect(embed.data.description).to.equal(` 46 | CodeSupport offers a free to use hiring or looking section.\n 47 | Here you can find people to work for you and offer your services, 48 | as long as it fits in with the rules. If you get scammed in hiring or looking there is 49 | nothing we can do, however, we do ask that you let a moderator know. 50 | `); 51 | expect(embed.data.fields[0].name).to.equal("Payment"); 52 | expect(embed.data.fields[0].value).to.equal("If you are trying to hire people for a project, and that project is not open source, your post must state how much you will pay them (or a percentage of profits they will receive)."); 53 | expect(embed.data.fields[1].name).to.equal("Post Frequency"); 54 | expect(embed.data.fields[1].value).to.equal(`Please only post in <#${getConfigValue>("BOTLESS_CHANNELS").HIRING_OR_LOOKING}> once per week to keep the channel clean and fair. Posting multiple times per week will lead to your access to the channel being revoked.`); 55 | expect(embed.data.fields[2].name).to.equal("Example Post"); 56 | expect(embed.data.fields[2].value).to.equal(` 57 | Please use the example below as a template to base your post on.\n 58 | \`\`\` 59 | [HIRING] 60 | Full Stack Website Developer 61 | We are looking for a developer who is willing to bring our video streaming service to life. 62 | Pay: $20/hour 63 | Requirements: 64 | - Solid knowledge of HTML, CSS and JavaScript 65 | - Knowledge of Node.js, Express and EJS. 66 | - Able to turn Adobe XD design documents into working web pages. 67 | - Able to stick to deadlines and work as a team. 68 | \`\`\` 69 | `); 70 | }); 71 | 72 | afterEach(() => { 73 | sandbox.restore(); 74 | }); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /test/commands/InspectCommandTest.ts: -------------------------------------------------------------------------------- 1 | import { createSandbox, SinonSandbox } from "sinon"; 2 | import { expect } from "chai"; 3 | import { Collection, EmbedField, Role, time, TimestampStyles } from "discord.js"; 4 | import { BaseMocks } from "@lambocreeper/mock-discord.js"; 5 | 6 | import InspectCommand from "../../src/commands/InspectCommand"; 7 | import DiscordUtils from "../../src/utils/DiscordUtils"; 8 | import NumberUtils from "../../src/utils/NumberUtils"; 9 | 10 | const roleCollection = new Collection([ 11 | [ 12 | "12345", 13 | Reflect.construct(Role, 14 | [ 15 | BaseMocks.getClient(), 16 | { 17 | "id": "12345", 18 | "name": "TestRole", 19 | "permissions": 1 20 | }, 21 | BaseMocks.getGuild() 22 | ]) 23 | ], 24 | [ 25 | BaseMocks.getGuild().id, 26 | Reflect.construct(Role, 27 | [ 28 | BaseMocks.getClient(), 29 | { 30 | "id": BaseMocks.getGuild().id, 31 | "name": "@everyone", 32 | "permissions": 1 33 | }, 34 | BaseMocks.getGuild() 35 | ]) 36 | ] 37 | ]); 38 | 39 | describe("InspectCommand", () => { 40 | describe("onInteract()", () => { 41 | let sandbox: SinonSandbox; 42 | let command: InspectCommand; 43 | let interaction: any; 44 | let replyStub: sinon.SinonStub; 45 | 46 | beforeEach(() => { 47 | sandbox = createSandbox(); 48 | command = new InspectCommand(); 49 | replyStub = sandbox.stub().resolves(); 50 | interaction = { 51 | reply: replyStub, 52 | user: BaseMocks.getGuildMember() 53 | }; 54 | }); 55 | 56 | it("sends a message to the channel", async () => { 57 | await command.onInteract(BaseMocks.getGuildMember(), interaction); 58 | 59 | expect(replyStub.calledOnce).to.be.true; 60 | }); 61 | 62 | it("sends a message with information if an argument was provided", async () => { 63 | const member = BaseMocks.getGuildMember(); 64 | 65 | sandbox.stub(member, "displayColor").get(() => NumberUtils.hexadecimalToInteger("#ffffff")); 66 | sandbox.stub(DiscordUtils, "getGuildMember").resolves(member); 67 | sandbox.stub(member, "roles").get(() => ({ cache: roleCollection })); 68 | 69 | // @ts-ignore 70 | await command.onInteract(member, interaction); 71 | 72 | // @ts-ignore - firstArg does not live on getCall() 73 | const embed = replyStub.getCall(0).firstArg.embeds[0]; 74 | const shortDateTime = time(member?.joinedAt!, TimestampStyles.ShortDateTime); 75 | const relativeTime = time(member?.joinedAt!, TimestampStyles.RelativeTime); 76 | 77 | expect(replyStub.calledOnce).to.be.true; 78 | expect(embed.data.title).to.equal(`Inspecting ${member.user.username}`); 79 | expect(embed.data.fields[0].name).to.equal("User ID"); 80 | expect(embed.data.fields[0].value).to.equal(member.user.id); 81 | expect(embed.data.fields[1].name).to.equal("Username"); 82 | expect(embed.data.fields[1].value).to.equal(member.user.username); 83 | expect(embed.data.fields[2].name).to.equal("Nickname"); 84 | expect(embed.data.fields[2].value).to.equal("my name"); 85 | expect(embed.data.fields[3].name).to.equal("Joined At"); 86 | expect(embed.data.fields[3].value).to.equal(`${shortDateTime} ${relativeTime}`); 87 | expect(embed.data.fields[4].name).to.equal("Roles"); 88 | expect(embed.data.fields[4].value).to.equal(" <@&12345>"); 89 | expect(embed.data.color).to.equal(member.displayColor); 90 | }); 91 | 92 | it("sends a message with information if no argument was provided", async () => { 93 | const member = BaseMocks.getGuildMember(); 94 | 95 | sandbox.stub(member, "displayColor").get(() => NumberUtils.hexadecimalToInteger("#ffffff")); 96 | sandbox.stub(DiscordUtils, "getGuildMember").resolves(member); 97 | sandbox.stub(member, "roles").get(() => ({ cache: roleCollection })); 98 | 99 | // @ts-ignore 100 | await command.onInteract(member, interaction); 101 | 102 | const embed = replyStub.getCall(0).firstArg.embeds[0]; 103 | const shortDateTime = time(member?.joinedAt!, TimestampStyles.ShortDateTime); 104 | const relativeTime = time(member?.joinedAt!, TimestampStyles.RelativeTime); 105 | 106 | expect(replyStub.calledOnce).to.be.true; 107 | expect(embed.data.title).to.equal(`Inspecting ${member.user.username}`); 108 | expect(embed.data.fields.find((field: EmbedField) => field.name === "User ID")?.value).to.equal(member.user.id); 109 | expect(embed.data.fields.find((field: EmbedField) => field.name === "Username")?.value).to.equal(member.user.username); 110 | expect(embed.data.fields.find((field: EmbedField) => field.name === "Nickname")?.value ?? null).to.equal(member?.nickname); 111 | expect(embed.data.fields.find((field: EmbedField) => field.name === "Joined At")?.value ?? null).to.equal(`${shortDateTime} ${relativeTime}`); 112 | expect(embed.data.fields.find((field: EmbedField) => field.name === "Roles")?.value).to.equal(" <@&12345>"); 113 | expect(embed.data.color).to.equal(member.displayColor); 114 | }); 115 | 116 | it("handles role field correctly if member has no role", async () => { 117 | const member = BaseMocks.getGuildMember(); 118 | 119 | sandbox.stub(member, "displayColor").get(() => NumberUtils.hexadecimalToInteger("#1555b7")); 120 | sandbox.stub(DiscordUtils, "getGuildMember").resolves(member); 121 | sandbox.stub(member, "roles").get(() => ({ cache: new Collection([]) })); 122 | 123 | await command.onInteract(member, interaction); 124 | 125 | const embed = replyStub.getCall(0).firstArg.embeds[0]; 126 | const shortDateTime = time(member?.joinedAt!, TimestampStyles.ShortDateTime); 127 | const relativeTime = time(member?.joinedAt!, TimestampStyles.RelativeTime); 128 | 129 | expect(replyStub.calledOnce).to.be.true; 130 | expect(embed.data.title).to.equal(`Inspecting ${member.user.username}`); 131 | expect(embed.data.fields[0].name).to.equal("User ID"); 132 | expect(embed.data.fields[0].value).to.equal(member.user.id); 133 | expect(embed.data.fields[1].name).to.equal("Username"); 134 | expect(embed.data.fields[1].value).to.equal(member.user.username); 135 | expect(embed.data.fields[2].name).to.equal("Nickname"); 136 | expect(embed.data.fields[2].value).to.equal("my name"); 137 | expect(embed.data.fields[3].name).to.equal("Joined At"); 138 | expect(embed.data.fields[3].value).to.equal(`${shortDateTime} ${relativeTime}`); 139 | expect(embed.data.fields[4].name).to.equal("Roles"); 140 | expect(embed.data.fields[4].value).to.equal("No roles"); 141 | expect(embed.data.color).to.equal(member.displayColor); 142 | }); 143 | 144 | afterEach(() => { 145 | sandbox.restore(); 146 | }); 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /test/commands/IssuesCommandTest.ts: -------------------------------------------------------------------------------- 1 | import { createSandbox, SinonSandbox, SinonStubbedInstance } from "sinon"; 2 | import {CommandInteraction} from "discord.js"; 3 | import { expect } from "chai"; 4 | import { BaseMocks } from "@lambocreeper/mock-discord.js"; 5 | import IssuesCommand from "../../src/commands/IssuesCommand"; 6 | import GitHubService from "../../src/services/GitHubService"; 7 | import { EMBED_COLOURS } from "../../src/config.json"; 8 | import NumberUtils from "../../src/utils/NumberUtils"; 9 | 10 | describe("IssuesCommand", () => { 11 | describe("onInteract()", () => { 12 | let sandbox: SinonSandbox; 13 | let command: IssuesCommand; 14 | let interaction: CommandInteraction; 15 | let replyStub: sinon.SinonStub; 16 | let gitHub: SinonStubbedInstance; 17 | 18 | beforeEach(() => { 19 | sandbox = createSandbox(); 20 | gitHub = sandbox.createStubInstance(GitHubService); 21 | command = new IssuesCommand(gitHub); 22 | replyStub = sandbox.stub().resolves(); 23 | interaction = { 24 | reply: replyStub, 25 | user: BaseMocks.getGuildMember() 26 | }; 27 | }); 28 | 29 | it("sends a message to the channel", async () => { 30 | gitHub.getIssues; 31 | gitHub.getRepository; 32 | 33 | await command.onInteract("user", "repo", interaction); 34 | 35 | expect(replyStub.calledOnce).to.be.true; 36 | }); 37 | 38 | it("states it had a problem with the request to GitHub", async () => { 39 | gitHub.getIssues.resolves(undefined); 40 | gitHub.getRepository.resolves(undefined); 41 | 42 | await command.onInteract("thisuserdoesnotexist", "thisrepodoesnotexist", interaction); 43 | 44 | const embed = replyStub.getCall(0).firstArg.embeds[0]; 45 | 46 | expect(replyStub.calledOnce).to.be.true; 47 | expect(embed.data.title).to.equal("Error"); 48 | expect(embed.data.description).to.equal("There was a problem with the request to GitHub."); 49 | expect(embed.data.fields[0].name).to.equal("Correct Usage"); 50 | expect(embed.data.fields[0].value).to.equal("/issues /"); 51 | expect(embed.data.color).to.equal(NumberUtils.hexadecimalToInteger(EMBED_COLOURS.ERROR.toLowerCase())); 52 | }); 53 | 54 | it("states no issues have been found", async () => { 55 | gitHub.getIssues.resolves( 56 | [] 57 | ); 58 | 59 | gitHub.getRepository.resolves({ 60 | user: "user", 61 | repo: "repo", 62 | description: "This is the description", 63 | language: "TypeScript", 64 | url: "https://github.com/codesupport/discord-bot", 65 | issues_and_pullrequests_count: 3, 66 | forks: 5, 67 | stars: 10, 68 | watchers: 3 69 | }); 70 | 71 | await command.onInteract("user", "repo", interaction); 72 | 73 | const embed = replyStub.getCall(0).firstArg.embeds[0]; 74 | 75 | expect(replyStub.calledOnce).to.be.true; 76 | expect(embed.data.title).to.equal("No Issues found"); 77 | expect(embed.data.description).to.equal("This repository has no issues. [Create one](https://github.com/codesupport/discord-bot/issues/new)"); 78 | expect(embed.data.color).to.equal(NumberUtils.hexadecimalToInteger(EMBED_COLOURS.SUCCESS.toLowerCase())); 79 | }); 80 | 81 | it("states the result from the github service", async () => { 82 | gitHub.getIssues.resolves( 83 | [{ 84 | title: "This is the title", 85 | number: 69, 86 | author: "user", 87 | author_url: "https://github.com/user", 88 | issue_url: "https://github.com/codesupport/discord-bot/issues/69", 89 | created_at: new Date(Date.now() - 1000) 90 | }] 91 | ); 92 | 93 | gitHub.getRepository.resolves({ 94 | user: "user", 95 | repo: "repo", 96 | description: "This is the description", 97 | language: "TypeScript", 98 | url: "https://github.com/codesupport/discord-bot", 99 | issues_and_pullrequests_count: 3, 100 | forks: 5, 101 | stars: 10, 102 | watchers: 3 103 | }); 104 | 105 | await command.onInteract("user", "repo", interaction); 106 | 107 | const embed = replyStub.getCall(0).firstArg.embeds[0]; 108 | 109 | expect(replyStub.calledOnce).to.be.true; 110 | expect(embed.data.title).to.equal("GitHub Issues: user/repo"); 111 | expect(embed.data.description).to.equal("This is the description\n\n[View Issues on GitHub](https://github.com/codesupport/discord-bot/issues) - [Create An Issue](https://github.com/codesupport/discord-bot/issues/new)"); 112 | expect(embed.data.fields[0].name).to.equal("#69 - This is the title"); 113 | expect(embed.data.fields[0].value).to.equal("View on [GitHub](https://github.com/codesupport/discord-bot/issues/69) - Today by [user](https://github.com/user)"); 114 | expect(embed.data.color).to.equal(NumberUtils.hexadecimalToInteger(EMBED_COLOURS.SUCCESS.toLowerCase())); 115 | }); 116 | 117 | afterEach(() => { 118 | sandbox.restore(); 119 | }); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /test/commands/NPMCommandTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { SinonSandbox, createSandbox } from "sinon"; 3 | import axios from "axios"; 4 | import { BaseMocks } from "@lambocreeper/mock-discord.js"; 5 | import NPMCommand from "../../src/commands/NPMCommand"; 6 | import { EMBED_COLOURS } from "../../src/config.json"; 7 | import NumberUtils from "../../src/utils/NumberUtils"; 8 | 9 | describe("NPMCommand", () => { 10 | describe("run()", () => { 11 | let sandbox: SinonSandbox; 12 | let command: NPMCommand; 13 | let interaction: any; 14 | let replyStub: sinon.SinonStub; 15 | 16 | beforeEach(() => { 17 | sandbox = createSandbox(); 18 | command = new NPMCommand(); 19 | replyStub = sandbox.stub().resolves(); 20 | interaction = { 21 | reply: replyStub, 22 | user: BaseMocks.getGuildMember() 23 | }; 24 | }); 25 | 26 | it("sends a message to the channel", async () => { 27 | sandbox.stub(axios, "get"); 28 | 29 | await command.onInteract("discord.js", interaction); 30 | 31 | expect(replyStub.calledOnce).to.be.true; 32 | }); 33 | 34 | it("states the package name is not valid if it doesn't find a package", async () => { 35 | sandbox.stub(axios, "get").rejects({ status: 404 }); 36 | 37 | await command.onInteract("mongoboy", interaction); 38 | 39 | const embed = replyStub.getCall(0).lastArg.embeds[0]; 40 | 41 | expect(replyStub.calledOnce).to.be.true; 42 | expect(embed.data.title).to.equal("Error"); 43 | expect(embed.data.description).to.equal("That is not a valid NPM package."); 44 | expect(embed.data.color).to.equal(NumberUtils.hexadecimalToInteger(EMBED_COLOURS.ERROR.toLowerCase())); 45 | }); 46 | 47 | it("sends a message with the package URL if you provide a valid package", async () => { 48 | sandbox.stub(axios, "get").resolves({ status: 200 }); 49 | 50 | await command.onInteract("factory-girl", interaction); 51 | 52 | const url = replyStub.getCall(0).lastArg; 53 | 54 | expect(replyStub.calledOnce).to.be.true; 55 | expect(url).to.equal("https://www.npmjs.com/package/factory-girl"); 56 | }); 57 | 58 | afterEach(() => { 59 | sandbox.restore(); 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /test/commands/ProjectCommandTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { createSandbox, SinonSandbox } from "sinon"; 3 | import { BaseMocks } from "@lambocreeper/mock-discord.js"; 4 | import { EMBED_COLOURS } from "../../src/config.json"; 5 | import ProjectCommand from "../../src/commands/ProjectCommand"; 6 | import NumberUtils from "../../src/utils/NumberUtils"; 7 | 8 | describe("ProjectCommand", () => { 9 | describe("onInteract()", () => { 10 | let command: ProjectCommand; 11 | let sandbox: SinonSandbox; 12 | let interaction: any; 13 | let replyStub: sinon.SinonStub; 14 | 15 | const mockProjects: Array = [ 16 | { 17 | title: Math.random().toString(36), 18 | tags: ["1", "hard"], 19 | description: Math.random().toString(36) 20 | }, 21 | { 22 | title: Math.random().toString(36), 23 | tags: ["2", "3", "default"], 24 | description: Math.random().toString(36) 25 | }, 26 | { 27 | title: Math.random().toString(36), 28 | tags: ["4", "medium"], 29 | description: Math.random().toString(36) 30 | }, 31 | { 32 | title: Math.random().toString(36), 33 | tags: ["5", "easy"], 34 | description: Math.random().toString(36) 35 | }, 36 | { 37 | title: Math.random().toString(36), 38 | tags: ["6"], 39 | description: [...Array(100)].map(() => Math.random().toString(36)).join(Math.random().toString(36)) 40 | } 41 | ]; 42 | 43 | beforeEach(() => { 44 | sandbox = createSandbox(); 45 | command = new ProjectCommand(); 46 | replyStub = sandbox.stub().resolves(); 47 | interaction = { 48 | reply: replyStub, 49 | user: BaseMocks.getGuildMember() 50 | }; 51 | sandbox.stub(command as ProjectCommand, "provideProjects").callsFake(() => mockProjects); 52 | }); 53 | 54 | it("returns an embed to request the user to search with less args if no result is found for given args", async () => { 55 | await command.onInteract(Math.random().toString(36), interaction); 56 | 57 | // @ts-ignore - firstArg does not live on getCall() 58 | const embed = replyStub.getCall(0).firstArg.embeds[0]; 59 | 60 | expect(replyStub.calledOnce).to.be.true; 61 | expect(embed.data.title).to.equal("Error"); 62 | expect(embed.data.description).to.equal("Could not find a project result for the given query."); 63 | expect(embed.data.color).to.equal(NumberUtils.hexadecimalToInteger(EMBED_COLOURS.ERROR.toLowerCase())); 64 | }); 65 | 66 | it("should assign the correct colors for difficulty grade if difficulty grade is specified", async () => { 67 | await command.onInteract("default", interaction); 68 | await command.onInteract("easy", interaction); 69 | await command.onInteract("medium", interaction); 70 | await command.onInteract("hard", interaction); 71 | 72 | const firstCall = replyStub.getCall(0).firstArg.embeds[0]; 73 | const secondCall = replyStub.getCall(1).firstArg.embeds[0]; 74 | const thirdCall = replyStub.getCall(2).firstArg.embeds[0]; 75 | const lastCall = replyStub.getCall(3).firstArg.embeds[0]; 76 | 77 | expect(firstCall.data.color).to.equal(NumberUtils.hexadecimalToInteger(EMBED_COLOURS.DEFAULT.toLowerCase())); 78 | expect(secondCall.data.color).to.equal(NumberUtils.hexadecimalToInteger(EMBED_COLOURS.DEFAULT.toLowerCase())); 79 | expect(thirdCall.data.color).to.equal(NumberUtils.hexadecimalToInteger(EMBED_COLOURS.DEFAULT.toLowerCase())); 80 | expect(lastCall.data.color).to.equal(NumberUtils.hexadecimalToInteger(EMBED_COLOURS.DEFAULT.toLowerCase())); 81 | }); 82 | 83 | it("should filter out too long descriptions out of the resultset", async () => { 84 | await command.onInteract("6", interaction); 85 | 86 | const firstCall = replyStub.getCall(0).firstArg.embeds[0]; 87 | 88 | expect(replyStub.calledOnce).to.be.true; 89 | expect(firstCall.data.title).to.equal("Error"); 90 | expect(firstCall.data.description).to.equal("Could not find a project result for the given query."); 91 | expect(firstCall.data.color).to.equal(NumberUtils.hexadecimalToInteger(EMBED_COLOURS.ERROR.toLowerCase())); 92 | }); 93 | 94 | afterEach(() => { 95 | sandbox.restore(); 96 | }); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /test/commands/ResourcesCommandTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { SinonSandbox, createSandbox } from "sinon"; 3 | import { BaseMocks } from "@lambocreeper/mock-discord.js"; 4 | 5 | import Command from "../../src/abstracts/Command"; 6 | import ResourcesCommand from "../../src/commands/ResourcesCommand"; 7 | 8 | describe("ResourcesCommand", () => { 9 | describe("onInteract()", () => { 10 | let sandbox: SinonSandbox; 11 | let command: ResourcesCommand; 12 | let replyStub: sinon.SinonStub; 13 | let interaction: any; 14 | 15 | beforeEach(() => { 16 | sandbox = createSandbox(); 17 | command = new ResourcesCommand(); 18 | replyStub = sandbox.stub().resolves(); 19 | interaction = { 20 | reply: replyStub, 21 | user: BaseMocks.getGuildMember() 22 | }; 23 | }); 24 | 25 | it("sends a message to the channel", async () => { 26 | await command.onInteract(undefined, interaction); 27 | 28 | expect(replyStub.calledOnce).to.be.true; 29 | }); 30 | 31 | it("sends the link to resources page if no argument is given", async () => { 32 | await command.onInteract(undefined, interaction); 33 | 34 | const { content: url } = replyStub.firstCall.lastArg; 35 | 36 | expect(replyStub.calledOnce).to.be.true; 37 | expect(url).to.equal("https://codesupport.dev/resources"); 38 | }); 39 | 40 | it("sends link to the category page if an argument is given", async () => { 41 | await command.onInteract("javascript", interaction); 42 | 43 | const { content: url } = replyStub.firstCall.lastArg; 44 | 45 | expect(replyStub.calledOnce).to.be.true; 46 | expect(url).to.equal("https://codesupport.dev/resources?category=javascript"); 47 | }); 48 | 49 | afterEach(() => { 50 | sandbox.restore(); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /test/commands/SearchCommandTest.ts: -------------------------------------------------------------------------------- 1 | import { createSandbox, SinonSandbox, SinonStubbedInstance } from "sinon"; 2 | import { expect } from "chai"; 3 | import { CommandInteraction } from "discord.js"; 4 | import { BaseMocks } from "@lambocreeper/mock-discord.js"; 5 | import SearchCommand from "../../src/commands/SearchCommand"; 6 | import InstantAnswerService from "../../src/services/InstantAnswerService"; 7 | import { EMBED_COLOURS } from "../../src/config.json"; 8 | import NumberUtils from "../../src/utils/NumberUtils"; 9 | 10 | describe("SearchCommand", () => { 11 | describe("onInteract()", () => { 12 | let sandbox: SinonSandbox; 13 | let command: SearchCommand; 14 | let interaction: CommandInteraction; 15 | let replyStub: sinon.SinonStub; 16 | let instantAnswer: SinonStubbedInstance; 17 | 18 | beforeEach(() => { 19 | sandbox = createSandbox(); 20 | instantAnswer = sandbox.createStubInstance(InstantAnswerService); 21 | command = new SearchCommand(instantAnswer); 22 | replyStub = sandbox.stub().resolves(); 23 | interaction = { 24 | reply: replyStub, 25 | user: BaseMocks.getGuildMember() 26 | }; 27 | }); 28 | 29 | it("sends a message to the channel", async () => { 30 | instantAnswer.query; 31 | 32 | await command.onInteract("1", interaction); 33 | 34 | expect(replyStub.calledOnce).to.be.true; 35 | }); 36 | 37 | it("states it can not query duckduckgo if the result isn't found", async () => { 38 | instantAnswer.query.resolves(null); 39 | 40 | await command.onInteract("thisruledoesnotexist", interaction); 41 | 42 | const embed = replyStub.getCall(0).firstArg.embeds[0]; 43 | 44 | expect(replyStub.calledOnce).to.be.true; 45 | expect(embed.data.title).to.equal("Error"); 46 | expect(embed.data.description).to.equal("No results found."); 47 | expect(embed.data.color).to.equal(NumberUtils.hexadecimalToInteger(EMBED_COLOURS.ERROR.toLowerCase())); 48 | }); 49 | 50 | it("states the result from the instant answer service", async () => { 51 | instantAnswer.query.resolves({ 52 | heading: "Example Heading", 53 | description: "Example Description", 54 | url: "https://example.com" 55 | }); 56 | 57 | await command.onInteract("thisruledoesnotexist", interaction); 58 | 59 | const embed = replyStub.getCall(0).firstArg.embeds[0]; 60 | 61 | expect(replyStub.calledOnce).to.be.true; 62 | expect(embed.data.title).to.equal("Example Heading"); 63 | expect(embed.data.description).to.equal("Example Description\n\n[View on example.com](https://example.com)"); 64 | expect(embed.data.footer.text).to.equal("Result powered by the DuckDuckGo API."); 65 | expect(embed.data.color).to.equal(NumberUtils.hexadecimalToInteger(EMBED_COLOURS.SUCCESS.toLowerCase())); 66 | }); 67 | 68 | it("correctly renders URLs from websites with subdomains", async () => { 69 | instantAnswer.query.resolves({ 70 | heading: "Capybara", 71 | description: "The capybara is an adorable rodent.", 72 | url: "https://en.wikipedia.org/wiki/Capybara" 73 | }); 74 | 75 | await command.onInteract("thisruledoesnotexist", interaction); 76 | 77 | const embed = replyStub.getCall(0).firstArg.embeds[0]; 78 | 79 | expect(embed.data.description).to.equal("The capybara is an adorable rodent.\n\n[View on en.wikipedia.org](https://en.wikipedia.org/wiki/Capybara)"); 80 | }); 81 | 82 | afterEach(() => { 83 | sandbox.restore(); 84 | }); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /test/commands/WebsiteCommandTest.ts: -------------------------------------------------------------------------------- 1 | import { createSandbox, SinonSandbox } from "sinon"; 2 | import { expect } from "chai"; 3 | import { BaseMocks } from "@lambocreeper/mock-discord.js"; 4 | import {Interaction} from "discord.js"; 5 | 6 | import WebsiteCommand from "../../src/commands/WebsiteCommand"; 7 | 8 | describe("WebsiteCommand", () => { 9 | describe("oninteract()", () => { 10 | let sandbox: SinonSandbox; 11 | let command: WebsiteCommand; 12 | let interaction: Interaction; 13 | let replyStub: sinon.SinonStub; 14 | 15 | beforeEach(() => { 16 | sandbox = createSandbox(); 17 | command = new WebsiteCommand(); 18 | replyStub = sandbox.stub().resolves(); 19 | interaction = { 20 | reply: replyStub, 21 | user: BaseMocks.getGuildMember() 22 | }; 23 | }); 24 | 25 | it("sends a message to the channel", async () => { 26 | await command.onInteract(undefined, interaction); 27 | 28 | expect(replyStub.calledOnce).to.be.true; 29 | }); 30 | 31 | it("sends default link to website if no argument is given", async () => { 32 | await command.onInteract(undefined, interaction); 33 | 34 | expect(replyStub.firstCall.firstArg).to.equal("https://codesupport.dev/"); 35 | expect(replyStub.calledOnce).to.be.true; 36 | }); 37 | 38 | it("sends the link to website + addon if argument is given", async () => { 39 | await command.onInteract("test", interaction); 40 | 41 | expect(replyStub.firstCall.firstArg).to.equal("https://codesupport.dev/test"); 42 | expect(replyStub.calledOnce).to.be.true; 43 | }); 44 | 45 | afterEach(() => { 46 | sandbox.restore(); 47 | }); 48 | }); 49 | }); -------------------------------------------------------------------------------- /test/decorators/ScheduleTest.ts: -------------------------------------------------------------------------------- 1 | import { createSandbox, SinonSandbox, SinonStub } from "sinon"; 2 | import { expect } from "chai"; 3 | import schedule from "node-schedule"; 4 | import Schedule from "../../src/decorators/Schedule"; 5 | 6 | describe("@Schedule()", () => { 7 | let sandbox: SinonSandbox; 8 | let scheduleJobStub: SinonStub; 9 | 10 | beforeEach(() => { 11 | sandbox = createSandbox(); 12 | scheduleJobStub = sandbox.stub(schedule, "scheduleJob").returns({}); 13 | }); 14 | 15 | afterEach(() => sandbox.restore()); 16 | 17 | it("schedules a job with the given crontab", () => { 18 | class Test { 19 | @Schedule("0 0 * * *") 20 | doSomething() { 21 | console.log("test"); 22 | } 23 | } 24 | 25 | expect(scheduleJobStub.calledOnce).to.be.true; 26 | expect(scheduleJobStub.args[0][0]).to.equal("0 0 * * *"); 27 | }); 28 | 29 | it("schedules a job to run the decorated method", () => { 30 | class Test { 31 | @Schedule("0 0 * * *") 32 | doSomething() { 33 | console.log("test"); 34 | } 35 | } 36 | 37 | expect(scheduleJobStub.args[0][1]).to.equal(Test.prototype.doSomething); 38 | }); 39 | }); -------------------------------------------------------------------------------- /test/event/handlers/AutomaticMemberRoleHandlerTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { Events, GuildMember } from "discord.js"; 3 | import { SinonSandbox, createSandbox } from "sinon"; 4 | import { BaseMocks } from "@lambocreeper/mock-discord.js"; 5 | 6 | import AutomaticMemberRoleHandler from "../../../src/event/handlers/AutomaticMemberRoleHandler"; 7 | import EventHandler from "../../../src/abstracts/EventHandler"; 8 | 9 | describe("AutomaticMemberRoleHandler", () => { 10 | describe("constructor()", () => { 11 | it("creates a handler for guildMemberAdd", () => { 12 | const handler = new AutomaticMemberRoleHandler(); 13 | 14 | expect(handler.getEvent()).to.equal(Events.GuildMemberAdd); 15 | }); 16 | }); 17 | 18 | describe("handle()", () => { 19 | let sandbox: SinonSandbox; 20 | let handler: EventHandler; 21 | let member: GuildMember; 22 | 23 | beforeEach(() => { 24 | sandbox = createSandbox(); 25 | handler = new AutomaticMemberRoleHandler(); 26 | member = BaseMocks.getGuildMember(); 27 | }); 28 | 29 | it("doesn't give the member the role if they don't have an avatar", async () => { 30 | member.user.avatar = null; 31 | 32 | const addMock = sandbox.stub(member.roles, "add"); 33 | 34 | await handler.handle(member); 35 | 36 | expect(addMock.calledOnce).to.be.false; 37 | }); 38 | 39 | afterEach(() => { 40 | sandbox.restore(); 41 | }); 42 | }); 43 | }); -------------------------------------------------------------------------------- /test/event/handlers/CodeblocksOverFileUploadsHandlerTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { Collection, Events, Message, Attachment, APIAttachment } from "discord.js"; 3 | import { SinonSandbox, createSandbox } from "sinon"; 4 | import { BaseMocks, CustomMocks } from "@lambocreeper/mock-discord.js"; 5 | 6 | import { EMBED_COLOURS, MOD_CHANNEL_ID } from "../../../src/config.json"; 7 | import EventHandler from "../../../src/abstracts/EventHandler"; 8 | import CodeblocksOverFileUploadsHandler from "../../../src/event/handlers/CodeblocksOverFileUploadsHandler"; 9 | import NumberUtils from "../../../src/utils/NumberUtils"; 10 | 11 | describe("CodeblocksOverFileUploadsHandler", () => { 12 | describe("constructor()", () => { 13 | it("creates a handler for messageCreate", () => { 14 | const handler = new CodeblocksOverFileUploadsHandler(); 15 | 16 | expect(handler.getEvent()).to.equal(Events.MessageCreate); 17 | }); 18 | }); 19 | 20 | describe("handle()", () => { 21 | let sandbox: SinonSandbox; 22 | let handler: EventHandler; 23 | let message: Message; 24 | 25 | beforeEach(() => { 26 | sandbox = createSandbox(); 27 | handler = new CodeblocksOverFileUploadsHandler(); 28 | message = CustomMocks.getMessage({ 29 | id: "1234", 30 | author: BaseMocks.getUser() 31 | }); 32 | message.client.user = BaseMocks.getUser(); 33 | message.attachments = new Collection(); 34 | }); 35 | 36 | it("does nothing when there are no attachments.", async () => { 37 | const addMock = sandbox.stub(message.channel, "send"); 38 | 39 | await handler.handle(message); 40 | 41 | expect(addMock.calledOnce).to.be.false; 42 | }); 43 | 44 | it("does nothing when there is a valid attachment.", async () => { 45 | const attachment: APIAttachment = { 46 | id: "720390958847361064", 47 | filename: "test.png", 48 | size: 500, 49 | url: "test.png", 50 | proxy_url: "test.png" 51 | }; 52 | 53 | message.attachments.set("720390958847361064", Reflect.construct(Attachment, [attachment])); 54 | const addMockSend = sandbox.stub(message.channel, "send"); 55 | 56 | await handler.handle(message); 57 | expect(addMockSend.notCalled).to.be.true; 58 | }); 59 | 60 | it("does nothing when a not allowed extension is uploaded in an exempt channel.", async () => { 61 | const attachment: APIAttachment = { 62 | id: "720390958847361065", 63 | filename: "test.exe", 64 | size: 500, 65 | url: "test.exe", 66 | proxy_url: "test.exe" 67 | }; 68 | 69 | message.attachments.set("720390958847361065", Reflect.construct(Attachment, [attachment])); 70 | message.channelId = MOD_CHANNEL_ID; 71 | const addMockSend = sandbox.stub(message.channel, "send"); 72 | 73 | await handler.handle(message); 74 | expect(addMockSend.notCalled).to.be.true; 75 | }); 76 | 77 | it("isn't case sensitive", async () => { 78 | const attachment: APIAttachment = { 79 | id: "720390958847361064", 80 | filename: "test.PNG", 81 | size: 500, 82 | url: "test.PNG", 83 | proxy_url: "test.PNG" 84 | }; 85 | 86 | message.attachments.set("720390958847361064", Reflect.construct(Attachment, [attachment])); 87 | const addMockSend = sandbox.stub(message.channel, "send"); 88 | 89 | await handler.handle(message); 90 | expect(addMockSend.notCalled).to.be.true; 91 | }); 92 | 93 | it("sends a message and deletes the user's upload when there is an invalid attachment.", async () => { 94 | const attachment: APIAttachment = { 95 | id: "720390958847361064", 96 | filename: "test.cpp", 97 | size: 500, 98 | url: "test.cpp", 99 | proxy_url: "test.cpp" 100 | }; 101 | 102 | message.attachments.set("720390958847361064", Reflect.construct(Attachment, [attachment])); 103 | const addMockSend = sandbox.stub(message.channel, "send"); 104 | const addMockDelete = sandbox.stub(message, "delete"); 105 | 106 | await handler.handle(message); 107 | const embed = addMockSend.getCall(0).firstArg.embeds[0]; 108 | 109 | expect(addMockSend.calledOnce).to.be.true; 110 | expect(addMockDelete.calledOnce).to.be.true; 111 | expect(embed.data.title).to.equal("Uploading Files"); 112 | expect(embed.data.description).to.equal("<@010101010101010101>, you tried to upload a \`.cpp\` file, which is not allowed. Please use codeblocks over attachments when sending code."); 113 | expect(embed.data.color).to.equal(NumberUtils.hexadecimalToInteger(EMBED_COLOURS.DEFAULT.toLowerCase())); 114 | }); 115 | 116 | it("deletes the message when any attachment on the message is invalid.", async () => { 117 | const attachment: APIAttachment = { 118 | id: "720390958847361064", 119 | filename: "test.png", 120 | size: 500, 121 | url: "test.png", 122 | proxy_url: "test.png" 123 | }; 124 | 125 | const attachment2: APIAttachment = { 126 | id: "72039095884736105", 127 | filename: "test.cpp", 128 | size: 500, 129 | url: "test.cpp", 130 | proxy_url: "test.cpp" 131 | }; 132 | 133 | message.attachments.set("720390958847361064", Reflect.construct(Attachment, [attachment])); 134 | message.attachments.set("72039095884736104", Reflect.construct(Attachment, [attachment2])); 135 | const addMockSend = sandbox.stub(message.channel, "send"); 136 | const addMockDelete = sandbox.stub(message, "delete"); 137 | 138 | await handler.handle(message); 139 | 140 | const embed = addMockSend.getCall(0).firstArg.embeds[0]; 141 | 142 | expect(addMockSend.calledOnce).to.be.true; 143 | expect(addMockDelete.calledOnce).to.be.true; 144 | expect(embed.data.title).to.equal("Uploading Files"); 145 | expect(embed.data.description).to.equal("<@010101010101010101>, you tried to upload a \`.cpp\` file, which is not allowed. Please use codeblocks over attachments when sending code."); 146 | expect(embed.data.color).to.equal(NumberUtils.hexadecimalToInteger(EMBED_COLOURS.DEFAULT.toLowerCase())); 147 | }); 148 | 149 | afterEach(() => { 150 | sandbox.restore(); 151 | }); 152 | }); 153 | }); 154 | -------------------------------------------------------------------------------- /test/event/handlers/DiscordMessageLinkHandlerTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { Events, Message, TextChannel } from "discord.js"; 3 | import {SinonSandbox, createSandbox, SinonStubbedInstance} from "sinon"; 4 | import { CustomMocks } from "@lambocreeper/mock-discord.js"; 5 | 6 | import EventHandler from "../../../src/abstracts/EventHandler"; 7 | import MessagePreviewService from "../../../src/services/MessagePreviewService"; 8 | import DiscordMessageLinkHandler from "../../../src/event/handlers/DiscordMessageLinkHandler"; 9 | 10 | describe("DiscordMessageLinkHandler", () => { 11 | describe("Constructor()", () => { 12 | it("creates a handler for messageCreate", () => { 13 | const sandbox = createSandbox(); 14 | const handler = new DiscordMessageLinkHandler(sandbox.createStubInstance(MessagePreviewService)); 15 | 16 | expect(handler.getEvent()).to.equal(Events.MessageCreate); 17 | sandbox.restore(); 18 | }); 19 | }); 20 | 21 | describe("handle()", () => { 22 | let sandbox: SinonSandbox; 23 | let handler: EventHandler; 24 | let messagePreviewServiceMock: SinonStubbedInstance; 25 | let message: Message; 26 | 27 | beforeEach(() => { 28 | sandbox = createSandbox(); 29 | messagePreviewServiceMock = sandbox.createStubInstance(MessagePreviewService); 30 | handler = new DiscordMessageLinkHandler(messagePreviewServiceMock); 31 | message = CustomMocks.getMessage({}, { 32 | channel: CustomMocks.getTextChannel() 33 | }); 34 | }); 35 | 36 | it("sends a message in message channel when contains discord message link mid sentence", async () => { 37 | message.content = "aaaaaaaaa\nhttps://ptb.discordapp.com/channels/240880736851329024/518817917438001152/732711501345062982 aaaa"; 38 | 39 | await handler.handle(message); 40 | 41 | expect(messagePreviewServiceMock.generatePreview.called).to.be.true; 42 | }); 43 | 44 | it("sends a message in message channel when contains discord message link", async () => { 45 | message.content = "https://ptb.discordapp.com/channels/240880736851329024/518817917438001152/732711501345062982"; 46 | 47 | await handler.handle(message); 48 | 49 | expect(messagePreviewServiceMock.generatePreview.called).to.be.true; 50 | }); 51 | 52 | it("sends a single message in message channel when contains multiple discord message links however one is escaped", async () => { 53 | message.content = "https://ptb.discordapp.com/channels/240880736851329024/518817917438001152/732711501345062982 "; 54 | 55 | await handler.handle(message); 56 | 57 | expect(messagePreviewServiceMock.generatePreview.called).to.be.true; 58 | }); 59 | 60 | it("sends multiple messages in message channel when contains multiple discord message link", async () => { 61 | message.content = "https://ptb.discordapp.com/channels/240880736851329024/518817917438001152/732711501345062982 https://ptb.discordapp.com/channels/240880736851329024/518817917438001152/732711501345062982"; 62 | 63 | await handler.handle(message); 64 | 65 | expect(messagePreviewServiceMock.generatePreview.called).to.be.true; 66 | }); 67 | 68 | it("does not send a message if the message starts with < and ends with >", async () => { 69 | message.content = ""; 70 | 71 | await handler.handle(message); 72 | 73 | expect(messagePreviewServiceMock.generatePreview.called).to.be.false; 74 | }); 75 | 76 | it("does not send a message if the url was escaped mid sentence", async () => { 77 | message.content = "placeholderText placeholderText"; 78 | 79 | await handler.handle(message); 80 | 81 | expect(messagePreviewServiceMock.generatePreview.called).to.be.false; 82 | }); 83 | 84 | afterEach(() => { 85 | sandbox.restore(); 86 | }); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /test/event/handlers/GhostPingDeleteHandlerTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { Collection, Events, Guild, Message, EmbedBuilder, MessageMentions, TextChannel } from "discord.js"; 3 | import { SinonSandbox, createSandbox } from "sinon"; 4 | import { BaseMocks, CustomMocks } from "@lambocreeper/mock-discord.js"; 5 | 6 | import EventHandler from "../../../src/abstracts/EventHandler"; 7 | import GhostPingDeleteHandler from "../../../src/event/handlers/GhostPingDeleteHandler"; 8 | 9 | describe("GhostPingDeleteHandler", () => { 10 | describe("constructor()", () => { 11 | it("creates a handler for messageDelete", () => { 12 | const handler = new GhostPingDeleteHandler(); 13 | 14 | expect(handler.getEvent()).to.equal(Events.MessageDelete); 15 | }); 16 | }); 17 | 18 | describe("handle()", () => { 19 | let sandbox: SinonSandbox; 20 | let handler: EventHandler; 21 | 22 | beforeEach(() => { 23 | sandbox = createSandbox(); 24 | handler = new GhostPingDeleteHandler(); 25 | }); 26 | 27 | it("sends a message when a message is deleted that pinged a user", async () => { 28 | const message = CustomMocks.getMessage(); 29 | const messageMock = sandbox.stub(message.channel, "send"); 30 | 31 | message.mentions = Reflect.construct(MessageMentions, [message, [CustomMocks.getUser({ id: "328194044587147278" })], [], false]); 32 | message.content = "Hey <@328194044587147278>!"; 33 | 34 | await handler.handle(message); 35 | 36 | expect(messageMock.calledOnce).to.be.true; 37 | }); 38 | 39 | it("does not send a message when a message is deleted that didn't ping a user", async () => { 40 | const message = CustomMocks.getMessage(); 41 | const messageMock = sandbox.stub(message.channel, "send"); 42 | 43 | message.mentions = Reflect.construct(MessageMentions, [message, [], [], false]); 44 | message.content = "Hey everybody!"; 45 | 46 | await handler.handle(message); 47 | 48 | expect(messageMock.calledOnce).to.be.false; 49 | }); 50 | 51 | it("does not send a message when it's author is a bot", async () => { 52 | const message = CustomMocks.getMessage(); 53 | const messageMock = sandbox.stub(message.channel, "send"); 54 | 55 | const author = BaseMocks.getUser(); 56 | 57 | author.bot = true; 58 | 59 | message.author = author; 60 | message.mentions = Reflect.construct(MessageMentions, [message, [BaseMocks.getUser()], [], false]); 61 | message.content = "Hey <@328194044587147278>, stop spamming or we'll arrest you!"; 62 | 63 | await handler.handle(message); 64 | 65 | expect(messageMock.called).to.be.false; 66 | }); 67 | 68 | it("does not send a message when author only mentions himself", async () => { 69 | const message = CustomMocks.getMessage(); 70 | const messageMock = sandbox.stub(message.channel, "send"); 71 | 72 | message.author = BaseMocks.getUser(); 73 | message.mentions = Reflect.construct(MessageMentions, [message, [CustomMocks.getUser()], [], false]); 74 | message.content = `<@${message.author.id}>`; 75 | 76 | await handler.handle(message); 77 | 78 | expect(messageMock.called).to.be.false; 79 | }); 80 | 81 | it("sends a message when message author and someone else is being mentioned", async () => { 82 | const message = CustomMocks.getMessage(); 83 | const messageMock = sandbox.stub(message.channel, "send"); 84 | 85 | const author = CustomMocks.getUser(); 86 | 87 | message.author = author; 88 | message.mentions = Reflect.construct(MessageMentions, [message, [author, CustomMocks.getUser({ id: "328194044587147278" })], [], false]); 89 | message.content = `<@${message.author.id}> <@328194044587147278>`; 90 | 91 | await handler.handle(message); 92 | expect(messageMock.called).to.be.true; 93 | }); 94 | 95 | it("provides additional info if message is a reply to another message", async () => { 96 | const message = CustomMocks.getMessage({guild: CustomMocks.getGuild()}); 97 | const messageMock = sandbox.stub(message.channel, "send"); 98 | const channelMock = CustomMocks.getTextChannel() as TextChannel; 99 | const repliedToMessage = CustomMocks.getMessage({ id: "328194044587147280", guild: CustomMocks.getGuild()}, { 100 | channel: CustomMocks.getTextChannel({ id: "328194044587147278"}) 101 | }); 102 | const resolveChannelStub = sandbox.stub(message.guild.channels, "resolve").returns(channelMock); 103 | const fetchMessageStub = sandbox.stub(channelMock.messages, "fetch").returns(Promise.resolve(repliedToMessage)); 104 | const author = CustomMocks.getUser(); 105 | 106 | message.author = author; 107 | message.mentions = Reflect.construct(MessageMentions, [message, [CustomMocks.getUser({ id: "328194044587147278" })], [], false]); 108 | message.guild.id = "328194044587147279"; 109 | message.content = "this is a reply"; 110 | message.reference = { 111 | channelId: "328194044587147278", 112 | guildId: "328194044587147279", 113 | messageId: "328194044587147280" 114 | }; 115 | 116 | await handler.handle(message); 117 | 118 | expect(messageMock.called).to.be.true; 119 | expect(resolveChannelStub.called).to.be.true; 120 | expect(fetchMessageStub.called).to.be.true; 121 | const sentEmbed = messageMock.getCall(0).args[0].embeds[0]; 122 | 123 | expect(sentEmbed).to.be.an.instanceOf(EmbedBuilder); 124 | if (sentEmbed instanceof EmbedBuilder) { 125 | const replyToField = sentEmbed.data.fields?.find(field => field.name === "Reply to"); 126 | 127 | expect(replyToField).to.not.be.null; 128 | 129 | const messageLinkField = sentEmbed.data.fields?.find(field => field.name === "Message replied to"); 130 | 131 | expect(messageLinkField).to.not.be.null; 132 | expect(messageLinkField?.value ?? "").to.equal("https://discord.com/channels/328194044587147279/328194044587147278/328194044587147280"); 133 | } 134 | }); 135 | 136 | it("does not send a message when author only mentions himself and bots", async () => { 137 | const message = CustomMocks.getMessage(); 138 | const messageMock = sandbox.stub(message.channel, "send"); 139 | 140 | const botUser = CustomMocks.getUser({id: "328194044587147278"}); 141 | 142 | botUser.bot = true; 143 | 144 | message.author = BaseMocks.getUser(); 145 | message.mentions = Reflect.construct(MessageMentions, [message, [message.author, botUser], [], false]); 146 | message.content = `<@${message.author.id}> <@${botUser.id}>`; 147 | 148 | await handler.handle(message); 149 | 150 | expect(messageMock.called).to.be.false; 151 | }); 152 | 153 | it("does not send a message when author only mentions bots", async () => { 154 | const message = CustomMocks.getMessage(); 155 | const messageMock = sandbox.stub(message.channel, "send"); 156 | 157 | const botUser = CustomMocks.getUser({id: "328194044587147278"}); 158 | const botUser2 = CustomMocks.getUser({id: "328194044587147279"}); 159 | 160 | botUser.bot = true; 161 | botUser2.bot = true; 162 | 163 | message.author = BaseMocks.getUser(); 164 | message.mentions = Reflect.construct(MessageMentions, [message, [botUser, botUser2], [], false]); 165 | message.content = `<@${botUser.id}> <@${botUser2.id}>`; 166 | 167 | await handler.handle(message); 168 | 169 | expect(messageMock.called).to.be.false; 170 | }); 171 | 172 | it("does not send a message when author replies to a bot", async () => { 173 | const message = CustomMocks.getMessage({guild: CustomMocks.getGuild()}); 174 | const messageMock = sandbox.stub(message.channel, "send"); 175 | const channelMock = CustomMocks.getTextChannel(); 176 | const repliedToMessage = CustomMocks.getMessage({ id: "328194044587147280", guild: CustomMocks.getGuild() }, { 177 | channel: CustomMocks.getTextChannel({ id: "328194044587147278"}) 178 | }); 179 | const resolveChannelStub = sandbox.stub(message.guild.channels, "resolve").returns(channelMock); 180 | const fetchMessageStub = sandbox.stub(channelMock.messages, "fetch").returns(Promise.resolve(repliedToMessage)); 181 | const author = BaseMocks.getUser(); 182 | const botUser = CustomMocks.getUser({id: "328194044587147276"}); 183 | 184 | botUser.bot = true; 185 | 186 | message.author = author; 187 | message.mentions = Reflect.construct(MessageMentions, [message, [botUser], [], false]); 188 | message.guild.id = "328194044587147279"; 189 | message.content = "this is a reply"; 190 | message.reference = { 191 | channelId: "328194044587147278", 192 | guildId: "328194044587147279", 193 | messageId: "328194044587147280" 194 | }; 195 | 196 | await handler.handle(message); 197 | expect(messageMock.called).to.be.false; 198 | expect(resolveChannelStub.called).to.be.false; 199 | expect(fetchMessageStub.called).to.be.false; 200 | }); 201 | 202 | afterEach(() => { 203 | sandbox.restore(); 204 | }); 205 | }); 206 | }); 207 | 208 | -------------------------------------------------------------------------------- /test/event/handlers/LogMemberLeaveHandlerTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { Collection, Events, GuildMemberRoleManager, Role } from "discord.js"; 3 | import { SinonSandbox, createSandbox } from "sinon"; 4 | import { BaseMocks, CustomMocks } from "@lambocreeper/mock-discord.js"; 5 | import { MEMBER_ROLE } from "../../../src/config.json"; 6 | 7 | import EventHandler from "../../../src/abstracts/EventHandler"; 8 | import LogMemberLeaveHandler from "../../../src/event/handlers/LogMemberLeaveHandler"; 9 | import DateUtils from "../../../src/utils/DateUtils"; 10 | 11 | describe("LogMemberLeaveHandler", () => { 12 | describe("constructor()", () => { 13 | it("creates a handler for guildMemberRemove", () => { 14 | const handler = new LogMemberLeaveHandler(); 15 | 16 | expect(handler.getEvent()).to.equal(Events.GuildMemberRemove); 17 | }); 18 | }); 19 | 20 | describe("handle()", () => { 21 | let sandbox: SinonSandbox; 22 | let handler: EventHandler; 23 | 24 | beforeEach(() => { 25 | sandbox = createSandbox(); 26 | handler = new LogMemberLeaveHandler(); 27 | }); 28 | 29 | it("sends a message in logs channel when a member leaves", async () => { 30 | const message = CustomMocks.getMessage(); 31 | const messageMock = sandbox.stub(message.guild!.channels.cache, "find"); 32 | 33 | const guildMember = CustomMocks.getGuildMember({joined_at: new Date(1610478967732).toISOString()}); 34 | 35 | const roleCollection = new Collection([ 36 | [ 37 | "12345", 38 | Reflect.construct(Role, [ 39 | BaseMocks.getClient(), 40 | { 41 | id: MEMBER_ROLE.toString(), 42 | name: "member", 43 | permissions: "1" 44 | }, 45 | BaseMocks.getGuild() 46 | ]) 47 | ], 48 | [ 49 | BaseMocks.getGuild().id, 50 | Reflect.construct(Role, [ 51 | BaseMocks.getClient(), 52 | { 53 | id: BaseMocks.getGuild().id, 54 | name: "@everyone", 55 | permissions: "1" 56 | }, 57 | BaseMocks.getGuild() 58 | ]) 59 | ] 60 | ]); 61 | 62 | sandbox.stub(DateUtils, "getFormattedTimeSinceDate").returns("10 seconds"); 63 | sandbox.stub(guildMember, "roles").get(() => ({ cache: roleCollection })); 64 | 65 | await handler.handle(guildMember); 66 | 67 | expect(messageMock.calledOnce).to.be.true; 68 | }); 69 | 70 | afterEach(() => { 71 | sandbox.restore(); 72 | }); 73 | }); 74 | }); 75 | 76 | -------------------------------------------------------------------------------- /test/event/handlers/LogMessageBulkDeleteHandlerTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { Collection, Events, Message, Snowflake } from "discord.js"; 3 | import { SinonSandbox, createSandbox } from "sinon"; 4 | import { CustomMocks } from "@lambocreeper/mock-discord.js"; 5 | 6 | import EventHandler from "../../../src/abstracts/EventHandler"; 7 | import LogMessageBulkDeleteHandler from "../../../src/event/handlers/LogMessageBulkDeleteHandler"; 8 | 9 | function messageFactory(amount: number, options: { content: string; } | undefined = undefined) { 10 | const collection = new Collection(); 11 | 12 | for (let i = 0; i < amount; i++) { 13 | if (options) { 14 | collection.set(i.toString(), CustomMocks.getMessage(options)); 15 | } else { 16 | collection.set(i.toString(), CustomMocks.getMessage()); 17 | } 18 | } 19 | 20 | return collection; 21 | } 22 | 23 | describe("LogMessageBulkDeleteHandler", () => { 24 | describe("constructor()", () => { 25 | it("creates a handler for messageBulkDelete", () => { 26 | const handler = new LogMessageBulkDeleteHandler(); 27 | 28 | expect(handler.getEvent()).to.equal(Events.MessageBulkDelete); 29 | }); 30 | }); 31 | 32 | describe("handle()", () => { 33 | let sandbox: SinonSandbox; 34 | let handler: EventHandler; 35 | let collection: Collection; 36 | 37 | beforeEach(() => { 38 | sandbox = createSandbox(); 39 | handler = new LogMessageBulkDeleteHandler(); 40 | collection = new Collection(); 41 | }); 42 | 43 | it("sends a message in logs channel when a message is deleted", async () => { 44 | const message = CustomMocks.getMessage({ content: "Test message" }); 45 | const sendLogMock = sandbox.stub(message.guild.channels.cache, "find"); 46 | 47 | collection.set("1", message); 48 | await handler.handle(collection); 49 | 50 | expect(sendLogMock.calledOnce).to.be.true; 51 | }); 52 | 53 | it("sends messages in logs channel when multiple messages are deleted", async () => { 54 | const message = CustomMocks.getMessage({ content: "Test message" }); 55 | const message2 = CustomMocks.getMessage({ content: "Test message" }); 56 | const message3 = CustomMocks.getMessage({ content: "Test message" }); 57 | const message4 = CustomMocks.getMessage({ content: "Test message" }); 58 | const message5 = CustomMocks.getMessage({ content: "Test message" }); 59 | const sendLogMock = sandbox.stub(message.guild.channels.cache, "find"); 60 | 61 | collection 62 | .set("1", message) 63 | .set("2", message2) 64 | .set("3", message3) 65 | .set("4", message4) 66 | .set("5", message5); 67 | await handler.handle(collection); 68 | 69 | expect(sendLogMock.callCount).to.be.equal(5); 70 | }); 71 | 72 | it("does not send a message in logs channel when message is deleted but content is empty - only image", async () => { 73 | const sendLogMock = sandbox.stub(Collection.prototype, "find"); 74 | 75 | await handler.handle(messageFactory(1, { 76 | content: "" 77 | })); 78 | 79 | expect(sendLogMock.calledOnce).to.be.false; 80 | }); 81 | 82 | it("does not send a message in logs channel when multiple messages are deleted but content is empty - only image", async () => { 83 | const sendLogMock = sandbox.stub(Collection.prototype, "find"); 84 | 85 | await handler.handle(messageFactory(5, { 86 | content: "" 87 | })); 88 | 89 | expect(sendLogMock.calledOnce).to.be.false; 90 | }); 91 | 92 | afterEach(() => { 93 | sandbox.restore(); 94 | }); 95 | }); 96 | }); 97 | 98 | -------------------------------------------------------------------------------- /test/event/handlers/LogMessageSingleDeleteHandlerTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { Events } from "discord.js"; 3 | import { SinonSandbox, createSandbox } from "sinon"; 4 | import { CustomMocks } from "@lambocreeper/mock-discord.js"; 5 | 6 | import EventHandler from "../../../src/abstracts/EventHandler"; 7 | import LogMessageSingleDeleteHandler from "../../../src/event/handlers/LogMessageSingleDeleteHandler"; 8 | 9 | describe("LogMessageSingleDeleteHandler", () => { 10 | describe("constructor()", () => { 11 | it("creates a handler for messageDelete", () => { 12 | const handler = new LogMessageSingleDeleteHandler(); 13 | 14 | expect(handler.getEvent()).to.equal(Events.MessageDelete); 15 | }); 16 | }); 17 | 18 | describe("handle()", () => { 19 | let sandbox: SinonSandbox; 20 | let handler: EventHandler; 21 | 22 | beforeEach(() => { 23 | sandbox = createSandbox(); 24 | handler = new LogMessageSingleDeleteHandler(); 25 | }); 26 | 27 | it("sends a message in logs channel when a message is deleted", async () => { 28 | const message = CustomMocks.getMessage({ 29 | content: "message content" 30 | }); 31 | const messageMock = sandbox.stub(message.guild.channels.cache, "find"); 32 | 33 | await handler.handle(message); 34 | 35 | expect(messageMock.calledOnce).to.be.true; 36 | }); 37 | 38 | it("does not send a message in logs channel when message is deleted but content is empty - only image", async () => { 39 | const message = CustomMocks.getMessage({ 40 | content: "" 41 | }); 42 | const messageMock = sandbox.stub(message.guild.channels.cache, "find"); 43 | 44 | await handler.handle(message); 45 | 46 | expect(messageMock.calledOnce).to.be.false; 47 | }); 48 | 49 | afterEach(() => { 50 | sandbox.restore(); 51 | }); 52 | }); 53 | }); 54 | 55 | -------------------------------------------------------------------------------- /test/event/handlers/LogMessageUpdateHandlerTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { Events, Message } from "discord.js"; 3 | import { SinonSandbox, createSandbox } from "sinon"; 4 | import { CustomMocks } from "@lambocreeper/mock-discord.js"; 5 | 6 | import EventHandler from "../../../src/abstracts/EventHandler"; 7 | import LogMessageUpdateHandler from "../../../src/event/handlers/LogMessageUpdateHandler"; 8 | 9 | describe("LogMessageUpdateHandler", () => { 10 | describe("constructor()", () => { 11 | it("creates a handler for messageUpdate", () => { 12 | const handler = new LogMessageUpdateHandler(); 13 | 14 | expect(handler.getEvent()).to.equal(Events.MessageUpdate); 15 | }); 16 | }); 17 | 18 | describe("handle()", () => { 19 | let sandbox: SinonSandbox; 20 | let handler: EventHandler; 21 | 22 | beforeEach(() => { 23 | sandbox = createSandbox(); 24 | handler = new LogMessageUpdateHandler(); 25 | }); 26 | 27 | it("doesn't send a message if the old message content is the same as the new message content", async () => { 28 | const oldMessage = CustomMocks.getMessage(); 29 | const newMessage = CustomMocks.getMessage(); 30 | const messageMock = sandbox.stub(oldMessage.guild.channels.cache, "find"); 31 | 32 | oldMessage.content = "example message"; 33 | newMessage.content = "example message"; 34 | 35 | await handler.handle(oldMessage, newMessage); 36 | 37 | expect(messageMock.calledOnce).to.be.false; 38 | }); 39 | 40 | it("doesn't send a message if the new message content is empty", async () => { 41 | const oldMessage = CustomMocks.getMessage(); 42 | const newMessage = CustomMocks.getMessage(); 43 | const messageMock = sandbox.stub(oldMessage.guild.channels.cache, "find"); 44 | 45 | oldMessage.content = "asdf"; 46 | newMessage.content = ""; 47 | 48 | await handler.handle(oldMessage, newMessage); 49 | 50 | expect(messageMock.calledOnce).to.be.false; 51 | }); 52 | 53 | it("sends a message if the message contents are different", async () => { 54 | const oldMessage = CustomMocks.getMessage(); 55 | const newMessage = CustomMocks.getMessage(); 56 | const messageMock = sandbox.stub(oldMessage.guild.channels.cache, "find"); 57 | 58 | oldMessage.content = "oldMessage content"; 59 | newMessage.content = "newMessage content"; 60 | 61 | await handler.handle(oldMessage, newMessage); 62 | 63 | expect(messageMock.calledOnce).to.be.true; 64 | }); 65 | 66 | afterEach(() => { 67 | sandbox.restore(); 68 | }); 69 | }); 70 | }); 71 | 72 | -------------------------------------------------------------------------------- /test/event/handlers/NewUserAuthenticationHandlerTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { createSandbox, SinonSandbox } from "sinon"; 3 | import { Events, User } from "discord.js"; 4 | import { BaseMocks, CustomMocks } from "@lambocreeper/mock-discord.js"; 5 | 6 | import NewUserAuthenticationHandler from "../../../src/event/handlers/NewUserAuthenticationHandler"; 7 | import EventHandler from "../../../src/abstracts/EventHandler"; 8 | 9 | describe("NewUserAuthenticationHandler", () => { 10 | describe("constructor()", () => { 11 | it("creates a handler for messageReactionAdd", () => { 12 | const handler = new NewUserAuthenticationHandler(); 13 | 14 | expect(handler.getEvent()).to.equal(Events.MessageReactionAdd); 15 | }); 16 | }); 17 | 18 | describe("handle()", () => { 19 | let sandbox: SinonSandbox; 20 | let handler: EventHandler; 21 | let user: User; 22 | 23 | beforeEach(() => { 24 | sandbox = createSandbox(); 25 | handler = new NewUserAuthenticationHandler(); 26 | user = BaseMocks.getUser(); 27 | }); 28 | 29 | it("gives the user the member role if they meet the requirements", async () => { 30 | const message = CustomMocks.getMessage({ 31 | id: "592316062796873738" 32 | }); 33 | 34 | const reaction = CustomMocks.getMessageReaction({ 35 | emoji: { 36 | id: "3513548348434", 37 | name: "🤖" 38 | } 39 | }, { message }); 40 | 41 | // @ts-ignore 42 | const fetchMock = sandbox.stub(reaction.message.guild?.members, "fetch").resolves({ 43 | roles: { 44 | add: async (role: string, reason: string) => [role, reason] 45 | } 46 | }); 47 | 48 | await handler.handle(reaction, user); 49 | 50 | expect(fetchMock.calledOnce).to.be.true; 51 | }); 52 | 53 | it("does not give the user the member role if they react with the wrong emoji", async () => { 54 | const message = CustomMocks.getMessage({ 55 | id: "592316062796873738" 56 | }); 57 | 58 | const reaction = CustomMocks.getMessageReaction({ 59 | emoji: { 60 | id: "1351534543545", 61 | name: "😀" 62 | } 63 | }, { message }); 64 | 65 | // @ts-ignore 66 | const fetchMock = sandbox.stub(reaction.message.guild?.members, "fetch").resolves({ 67 | roles: { 68 | add: async (role: string, reason: string) => [role, reason] 69 | } 70 | }); 71 | 72 | await handler.handle(reaction, user); 73 | 74 | expect(fetchMock.calledOnce).to.be.false; 75 | }); 76 | 77 | it("does not give the user the member role if they react to the wrong message", async () => { 78 | const message = CustomMocks.getMessage({ 79 | id: "1234" 80 | }); 81 | 82 | const reaction = CustomMocks.getMessageReaction({ 83 | emoji: { 84 | id: "3513548348434", 85 | name: "🤖" 86 | } 87 | }, { message }); 88 | 89 | // @ts-ignore 90 | const fetchMock = sandbox.stub(reaction.message.guild?.members, "fetch").resolves({ 91 | roles: { 92 | add: async (role: string, reason: string) => [role, reason] 93 | } 94 | }); 95 | 96 | await handler.handle(reaction, user); 97 | 98 | expect(fetchMock.calledOnce).to.be.false; 99 | }); 100 | 101 | it("does not give the user the member role if they react to the wrong message with the wrong emoji", async () => { 102 | const message = CustomMocks.getMessage({ 103 | id: "1234" 104 | }); 105 | 106 | const reaction = CustomMocks.getMessageReaction({ 107 | emoji: { 108 | id: "1351534543545", 109 | name: "😀" 110 | } 111 | }, { message }); 112 | 113 | // @ts-ignore 114 | const fetchMock = sandbox.stub(reaction.message.guild?.members, "fetch").resolves({ 115 | roles: { 116 | add: async (role: string, reason: string) => [role, reason] 117 | } 118 | }); 119 | 120 | await handler.handle(reaction, user); 121 | 122 | expect(fetchMock.calledOnce).to.be.false; 123 | }); 124 | 125 | afterEach(() => { 126 | sandbox.restore(); 127 | }); 128 | }); 129 | }); -------------------------------------------------------------------------------- /test/event/handlers/RaidDetectionHandlerTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { Events } from "discord.js"; 3 | import { SinonSandbox, createSandbox } from "sinon"; 4 | import { BaseMocks, CustomMocks } from "@lambocreeper/mock-discord.js"; 5 | 6 | import { RAID_SETTINGS, MOD_CHANNEL_ID } from "../../../src/config.json"; 7 | import * as getConfigValue from "../../../src/utils/getConfigValue"; 8 | import RaidDetectionHandler from "../../../src/event/handlers/RaidDetectionHandler"; 9 | 10 | describe("RaidDetectionHandler", () => { 11 | describe("constructor()", () => { 12 | it("creates a handler for guildMemberAdd", () => { 13 | const handler = new RaidDetectionHandler(); 14 | 15 | expect(handler.getEvent()).to.equal(Events.GuildMemberAdd); 16 | }); 17 | }); 18 | 19 | describe("handle()", () => { 20 | let sandbox: SinonSandbox; 21 | let handler: RaidDetectionHandler; 22 | 23 | beforeEach(() => { 24 | sandbox = createSandbox(); 25 | handler = new RaidDetectionHandler(); 26 | }); 27 | 28 | it("adds a member to the joinQueue", async () => { 29 | const mockGuildMember = BaseMocks.getGuildMember(); 30 | 31 | await handler.handle(mockGuildMember); 32 | 33 | expect(handler.joinQueue.includes(mockGuildMember)).to.be.true; 34 | }); 35 | 36 | it("removes member from joinQueue", done => { 37 | const mockGuildMember = BaseMocks.getGuildMember(); 38 | 39 | sandbox.stub(getConfigValue, "default").returns(0.002); 40 | 41 | handler.handle(mockGuildMember).then(() => { 42 | expect(handler.joinQueue.includes(mockGuildMember)).to.be.true; 43 | 44 | setTimeout(() => { 45 | expect(handler.joinQueue.includes(mockGuildMember)).to.be.false; 46 | 47 | done(); 48 | }, 10); 49 | }); 50 | }).timeout(200); 51 | 52 | it("sends message to mods channel when raid is detected and kicks user", async () => { 53 | const mockMember = BaseMocks.getGuildMember(); 54 | const mockModChannel = BaseMocks.getTextChannel(); 55 | 56 | mockModChannel.id = MOD_CHANNEL_ID; 57 | sandbox.stub(mockMember.guild.channels.cache, "find").returns(mockModChannel); 58 | 59 | const messageMock = sandbox.stub(mockModChannel, "send"); 60 | const kickMocks = []; 61 | 62 | for (let i = 0; i < RAID_SETTINGS.MAX_QUEUE_SIZE; i++) { 63 | const member = CustomMocks.getGuildMember(); 64 | 65 | kickMocks.push(sandbox.stub(member, "kick")); 66 | 67 | await handler.handle(member); 68 | } 69 | 70 | await handler.handle(mockMember); 71 | 72 | expect(kickMocks.map(mock => mock.called)).not.to.contain(false); 73 | expect(messageMock.called).to.be.true; 74 | }); 75 | 76 | afterEach(() => { 77 | sandbox.restore(); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /test/event/handlers/RegularMemberChangesHandlerTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { 3 | Collection, 4 | Events, 5 | GuildMember, 6 | Role 7 | } from "discord.js"; 8 | import { createSandbox, SinonSandbox } from "sinon"; 9 | import { BaseMocks, CustomMocks } from "@lambocreeper/mock-discord.js"; 10 | import RegularMemberChangesHandler from "../../../src/event/handlers/RegularMemberChangesHandler"; 11 | import EventHandler from "../../../src/abstracts/EventHandler"; 12 | 13 | describe("RegularMemberChangesHandler", () => { 14 | describe("constructor()", () => { 15 | it("creates a handler for GuildMemberUpdate", () => { 16 | const handler = new RegularMemberChangesHandler(); 17 | 18 | expect(handler.getEvent()).to.equal(Events.GuildMemberUpdate); 19 | }); 20 | }); 21 | 22 | describe("handle()", () => { 23 | let sandbox: SinonSandbox; 24 | let handler: EventHandler; 25 | let oldMember: GuildMember; 26 | let newMember: GuildMember; 27 | 28 | beforeEach(() => { 29 | sandbox = createSandbox(); 30 | handler = new RegularMemberChangesHandler(); 31 | oldMember = BaseMocks.getGuildMember(); 32 | newMember = CustomMocks.getGuildMember(); 33 | }); 34 | 35 | afterEach(() => sandbox.restore()); 36 | 37 | it("does nothing if the user's roles have not changed", async () => { 38 | const getChannelSpy = sandbox.spy(newMember.guild.channels, "fetch"); 39 | 40 | sandbox.stub(oldMember, "roles").get(() => ({ 41 | cache: new Collection() 42 | })); 43 | 44 | sandbox.stub(newMember, "roles").get(() => ({ 45 | cache: new Collection() 46 | })); 47 | 48 | await handler.handle(oldMember, newMember); 49 | 50 | expect(getChannelSpy.called).to.be.false; 51 | }); 52 | 53 | it("sends a message saying the user has the role, if they now have it", async () => { 54 | const channel = BaseMocks.getTextChannel(); 55 | const sendMessageSpy = sandbox.stub(channel, "send").resolves(); 56 | 57 | // @ts-ignore 58 | const getChannelSpy = sandbox.stub(newMember.guild.channels, "fetch").resolves(channel); 59 | 60 | sandbox.stub(oldMember, "roles").get(() => ({ 61 | cache: new Collection() 62 | })); 63 | 64 | sandbox.stub(newMember, "roles").get(() => ({ 65 | cache: new Collection([ 66 | [ 67 | "700614448846733402", 68 | Reflect.construct(Role, 69 | [ 70 | BaseMocks.getClient(), 71 | { 72 | id: "700614448846733402", 73 | name: "Regular" 74 | }, 75 | BaseMocks.getGuild() 76 | ] 77 | ) 78 | ] 79 | ]) 80 | })); 81 | 82 | await handler.handle(oldMember, newMember); 83 | 84 | expect(getChannelSpy.called).to.be.true; 85 | expect(sendMessageSpy.called).to.be.true; 86 | 87 | const embed = sendMessageSpy.getCall(0).firstArg.embeds[0]; 88 | 89 | expect(embed.data.title).to.equal("New Regular Member"); 90 | expect(embed.data.color).to.equal(7139086); 91 | expect(embed.data.thumbnail.url).to.equal(newMember.user.avatarURL()); 92 | expect(embed.data.description).to.equal(`<@${newMember.user.id}>`); 93 | }); 94 | 95 | it("sends a message saying the user does not have the role, if they no longer have it", async () => { 96 | const channel = BaseMocks.getTextChannel(); 97 | const sendMessageSpy = sandbox.stub(channel, "send").resolves(); 98 | 99 | // @ts-ignore 100 | const getChannelSpy = sandbox.stub(newMember.guild.channels, "fetch").resolves(channel); 101 | 102 | sandbox.stub(oldMember, "roles").get(() => ({ 103 | cache: new Collection([ 104 | [ 105 | "700614448846733402", 106 | Reflect.construct(Role, 107 | [ 108 | BaseMocks.getClient(), 109 | { 110 | id: "700614448846733402", 111 | name: "Regular" 112 | }, 113 | BaseMocks.getGuild() 114 | ] 115 | ) 116 | ] 117 | ]) 118 | })); 119 | 120 | sandbox.stub(newMember, "roles").get(() => ({ 121 | cache: new Collection() 122 | })); 123 | 124 | await handler.handle(oldMember, newMember); 125 | 126 | expect(getChannelSpy.called).to.be.true; 127 | expect(sendMessageSpy.called).to.be.true; 128 | 129 | const embed = sendMessageSpy.getCall(0).firstArg.embeds[0]; 130 | 131 | expect(embed.data.title).to.equal("No Longer Regular"); 132 | expect(embed.data.color).to.equal(16192275); 133 | expect(embed.data.thumbnail.url).to.equal(newMember.user.avatarURL()); 134 | expect(embed.data.description).to.equal(`<@${newMember.user.id}>`); 135 | }); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /test/event/handlers/ShowcaseDiscussionThreadHandlerTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { createSandbox, SinonSandbox } from "sinon"; 3 | import { CustomMocks } from "@lambocreeper/mock-discord.js"; 4 | import { Events } from "discord.js"; 5 | import ShowcaseDiscussionThreadHandler from "../../../src/event/handlers/ShowcaseDiscussionThreadHandler"; 6 | import EventHandler from "../../../src/abstracts/EventHandler"; 7 | import getConfigValue from "../../../src/utils/getConfigValue"; 8 | 9 | describe("ShowcaseDiscussionThreadHandler", () => { 10 | describe("constructor()", () => { 11 | it("creates a handler for messageCreate", () => { 12 | const handler = new ShowcaseDiscussionThreadHandler(); 13 | 14 | expect(handler.getEvent()).to.equal(Events.MessageCreate); 15 | }); 16 | }); 17 | 18 | describe("handle()", () => { 19 | let sandbox: SinonSandbox; 20 | let handler: EventHandler; 21 | 22 | beforeEach(() => { 23 | sandbox = createSandbox(); 24 | handler = new ShowcaseDiscussionThreadHandler(); 25 | }); 26 | 27 | it("does nothing if the message is not sent in the showcase channel", async () => { 28 | const message = CustomMocks.getMessage({ channel_id: "not-the-showcase-channel" }); 29 | const startThreadStub = sandbox.stub(message, "startThread"); 30 | 31 | await handler.handle(message); 32 | 33 | expect(startThreadStub.called).to.be.false; 34 | }); 35 | 36 | it("creates a thread if the message is sent in the showcase channel", async () => { 37 | const message = CustomMocks.getMessage({ channel_id: getConfigValue("SHOWCASE_CHANNEL_ID") }); 38 | const startThreadStub = sandbox.stub(message, "startThread"); 39 | 40 | await handler.handle(message); 41 | 42 | expect(startThreadStub.called).to.be.true; 43 | }); 44 | 45 | afterEach(() => { 46 | sandbox.restore(); 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /test/factories/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codesupport/discord-bot/e62aed9eb3c9feb3ebe49d1fde97e2d81fa0f29d/test/factories/.gitkeep -------------------------------------------------------------------------------- /test/services/AdventOfCodeServiceTest.ts: -------------------------------------------------------------------------------- 1 | import {createSandbox, SinonSandbox, SinonStubbedInstance} from "sinon"; 2 | import { expect } from "chai"; 3 | 4 | import AdventOfCodeService from "../../src/services/AdventOfCodeService"; 5 | import {AxiosCacheInstance} from "axios-cache-interceptor"; 6 | import {Axios} from "axios"; 7 | 8 | const mockAPIData = { 9 | event: "2021", 10 | owner_id: "490120", 11 | members: { 12 | 452251: { 13 | id: "452251", 14 | completion_day_level: { 15 | 1: { 16 | 1: { 17 | "get_star_ts": "1606818269" 18 | }, 19 | 2: { 20 | "get_star_ts": "1606818426" 21 | } 22 | } 23 | }, 24 | local_score: 271, 25 | stars: 10, 26 | name: "JonaVDM", 27 | global_score: 0, 28 | last_star_ts: "1607148399" 29 | }, 30 | 490120: { 31 | last_star_ts: "1606910469", 32 | global_score: 0, 33 | name: "Lambo", 34 | local_score: 51, 35 | id: "490120", 36 | completion_day_level: { 37 | 1: { 38 | 1: { 39 | "get_star_ts": "1606816563" 40 | } 41 | } 42 | }, 43 | stars: 4 44 | }, 45 | 500120: { 46 | last_star_ts: "0", 47 | global_score: 0, 48 | name: "Bob Pieter", 49 | local_score: 0, 50 | id: "500120", 51 | completion_day_level: {}, 52 | stars: 0 53 | } 54 | } 55 | }; 56 | 57 | describe("AdventOfCodeService", () => { 58 | describe("::getInstance()", () => { 59 | it("creates an instance of AdventOfCodeService", () => { 60 | const sandbox = createSandbox(); 61 | const api = sandbox.createStubInstance(Axios as AxiosCacheInstance); 62 | const service = new AdventOfCodeService(api); 63 | 64 | expect(service).to.be.instanceOf(AdventOfCodeService); 65 | sandbox.restore(); 66 | }); 67 | }); 68 | 69 | describe("getLeaderBoard()", () => { 70 | let sandbox: SinonSandbox; 71 | let api: SinonStubbedInstance; 72 | let aoc: AdventOfCodeService; 73 | 74 | beforeEach(() => { 75 | sandbox = createSandbox(); 76 | api = sandbox.createStubInstance(Axios as AxiosCacheInstance); 77 | aoc = new AdventOfCodeService(api); 78 | }); 79 | 80 | it("performs a GET request to the Advent Of Code Api", async () => { 81 | const axiosGet = api.get.resolves({ 82 | status: 200, 83 | data: mockAPIData 84 | }); 85 | 86 | await aoc.getLeaderBoard("leaderboard", 2021); 87 | 88 | expect(axiosGet.called).to.be.true; 89 | expect(axiosGet.args[0][1]?.cache).to.deep.equal({ ttl: 900000 }); 90 | }); 91 | 92 | it("throws an error if the API responds when not authorized", async () => { 93 | const axiosGet = api.get.resolves({ 94 | status: 500, 95 | data: {} 96 | }); 97 | 98 | // Chai can't detect throws inside async functions. This is a hack to get it working. 99 | try { 100 | await aoc.getLeaderBoard("leaderboard", 2021); 101 | } catch ({ message }) { 102 | expect(message).to.equal("Advent Of code leaderboard not found"); 103 | } 104 | 105 | expect(axiosGet.called).to.be.true; 106 | }); 107 | 108 | afterEach(() => { 109 | sandbox.restore(); 110 | }); 111 | }); 112 | 113 | describe("getSinglePlayer()", () => { 114 | let sandbox: SinonSandbox; 115 | let apiMock: SinonStubbedInstance; 116 | let aoc: AdventOfCodeService; 117 | 118 | beforeEach(() => { 119 | sandbox = createSandbox(); 120 | apiMock = sandbox.createStubInstance(Axios as AxiosCacheInstance); 121 | aoc = new AdventOfCodeService(apiMock); 122 | }); 123 | 124 | it("performs a GET request to the Advent Of Code Api", async () => { 125 | const axiosGet = apiMock.get.resolves({ 126 | status: 200, 127 | data: mockAPIData 128 | }); 129 | 130 | await aoc.getLeaderBoard("leaderboard", 2021); 131 | 132 | expect(axiosGet.called).to.be.true; 133 | }); 134 | 135 | it("returns the position and the member when the user exist on the leaderboard", async () => { 136 | apiMock.get.resolves({ 137 | status: 200, 138 | data: mockAPIData 139 | }); 140 | 141 | const [position, member] = await aoc.getSinglePlayer("leaderboard", 2021, "JonaVDM"); 142 | 143 | expect(position).to.equal(1); 144 | expect(member.name).to.equal("JonaVDM"); 145 | }); 146 | 147 | it("finds the player if the name is weirdly capitalized", async () => { 148 | apiMock.get.resolves({ 149 | status: 200, 150 | data: mockAPIData 151 | }); 152 | 153 | const [position, member] = await aoc.getSinglePlayer("leaderboard", 2021, "lAmBo"); 154 | 155 | expect(position).to.equal(2); 156 | expect(member.name).to.equal("Lambo"); 157 | }); 158 | 159 | it("finds the player when there are spaces in the name", async () => { 160 | apiMock.get.resolves({ 161 | status: 200, 162 | data: mockAPIData 163 | }); 164 | 165 | const [position, member] = await aoc.getSinglePlayer("leaderboard", 2021, "Bob Pieter"); 166 | 167 | expect(position).to.equal(3); 168 | expect(member.name).to.equal("Bob Pieter"); 169 | }); 170 | 171 | it("returns 0 and undefined when the user does not exist on the leaderboard", async () => { 172 | apiMock.get.resolves({ 173 | status: 200, 174 | data: mockAPIData 175 | }); 176 | 177 | const [position, member] = await aoc.getSinglePlayer("leaderboard", 2021, "bob"); 178 | 179 | expect(position).to.equal(0); 180 | expect(member).to.be.undefined; 181 | }); 182 | 183 | afterEach(() => { 184 | sandbox.restore(); 185 | }); 186 | }); 187 | }); 188 | -------------------------------------------------------------------------------- /test/services/GitHubServiceTest.ts: -------------------------------------------------------------------------------- 1 | import { createSandbox, SinonSandbox } from "sinon"; 2 | import { expect } from "chai"; 3 | import axios from "axios"; 4 | 5 | import GitHubService from "../../src/services/GitHubService"; 6 | 7 | describe("GitHubService", () => { 8 | describe("::getInstance()", () => { 9 | it("returns an instance of GitHubService", () => { 10 | const service = new GitHubService(); 11 | 12 | expect(service).to.be.instanceOf(GitHubService); 13 | }); 14 | }); 15 | 16 | describe("getRepository()", () => { 17 | let sandbox: SinonSandbox; 18 | let gitHub: GitHubService; 19 | 20 | beforeEach(() => { 21 | sandbox = createSandbox(); 22 | gitHub = new GitHubService(); 23 | }); 24 | 25 | it("performs a GET request to the GitHub API", async () => { 26 | const axiosGet = sandbox.stub(axios, "get").resolves({ 27 | status: 200, 28 | data: { 29 | owner: { 30 | login: "user" 31 | }, 32 | name: "repo-github", 33 | description: "The repo description", 34 | language: "TypeScript", 35 | html_url: "https://github.com/codesupport/discord-bot", 36 | open_issues_count: 1, 37 | forks: 5, 38 | subscribers_count: 3, 39 | watchers: 10 40 | } 41 | }); 42 | 43 | await gitHub.getRepository("user", "repo"); 44 | 45 | expect(axiosGet.called).to.be.true; 46 | }); 47 | 48 | it("throws an error if the API responds with Not Found", async () => { 49 | const axiosGet = sandbox.stub(axios, "get").resolves({ 50 | status: 404, 51 | data: {} 52 | }); 53 | 54 | // Chai can't detect throws inside async functions. This is a hack to get it working. 55 | try { 56 | await gitHub.getRepository("user", "repo"); 57 | } catch ({ message }) { 58 | expect(message).to.equal("There was a problem with the request to GitHub."); 59 | } 60 | 61 | expect(axiosGet.called).to.be.true; 62 | }); 63 | 64 | afterEach(() => { 65 | sandbox.restore(); 66 | }); 67 | }); 68 | 69 | describe("getPullRequest()", () => { 70 | let sandbox: SinonSandbox; 71 | let gitHub: GitHubService; 72 | 73 | beforeEach(() => { 74 | sandbox = createSandbox(); 75 | gitHub = new GitHubService(); 76 | }); 77 | 78 | it("performs a GET request to the GitHub pulls API", async () => { 79 | const axiosGet = sandbox.stub(axios, "get").resolves({ 80 | status: 200, 81 | data: [{ 82 | title: "This is a title", 83 | body: "This is a description", 84 | user: { 85 | login: "user" 86 | } 87 | }] 88 | }); 89 | 90 | const result = await gitHub.getPullRequest("user", "repo"); 91 | 92 | expect(axiosGet.called).to.be.true; 93 | expect(result).to.have.length(1); 94 | }); 95 | 96 | it("returns an empty array if there is no data present", async () => { 97 | const axiosGet = sandbox.stub(axios, "get").resolves({ 98 | status: 200, 99 | data: [] 100 | }); 101 | 102 | const result = await gitHub.getPullRequest("user", "repo"); 103 | 104 | expect(axiosGet.called).to.be.true; 105 | expect(result).to.have.length(0); 106 | }); 107 | 108 | afterEach(() => { 109 | sandbox.restore(); 110 | }); 111 | }); 112 | 113 | describe("getIssues()", () => { 114 | let sandbox: SinonSandbox; 115 | let gitHub: GitHubService; 116 | 117 | beforeEach(() => { 118 | sandbox = createSandbox(); 119 | gitHub = new GitHubService(); 120 | }); 121 | 122 | it("performs a GET request to the GitHub issues API", async () => { 123 | const axiosGet = sandbox.stub(axios, "get").resolves({ 124 | status: 200, 125 | data: [{ 126 | title: "This is a title", 127 | number: 69, 128 | user: { 129 | login: "user", 130 | html_url: "https://github.com/user/" 131 | }, 132 | html_url: "https://github.com/codesupport/discord-bot", 133 | created_at: "2020-01-01T12:00:00Z" 134 | }] 135 | }); 136 | 137 | const result = await gitHub.getIssues("user", "repo"); 138 | 139 | expect(axiosGet.called).to.be.true; 140 | expect(result).to.have.length(1); 141 | }); 142 | 143 | it("returns an empty array if there are no issues", async () => { 144 | const axiosGet = sandbox.stub(axios, "get").resolves({ 145 | status: 200, 146 | data: [] 147 | }); 148 | 149 | const result = await gitHub.getIssues("user", "repo"); 150 | 151 | expect(axiosGet.called).to.be.true; 152 | expect(result).to.have.length(0); 153 | }); 154 | 155 | afterEach(() => { 156 | sandbox.restore(); 157 | }); 158 | }); 159 | }); 160 | -------------------------------------------------------------------------------- /test/services/InstantAnswerServiceTest.ts: -------------------------------------------------------------------------------- 1 | import { createSandbox, SinonSandbox } from "sinon"; 2 | import { expect } from "chai"; 3 | import axios from "axios"; 4 | 5 | import InstantAnswerService from "../../src/services/InstantAnswerService"; 6 | 7 | describe("InstantAnswerService", () => { 8 | describe("::getInstance()", () => { 9 | it("returns an instance of InstantAnswerService", () => { 10 | const service = new InstantAnswerService(); 11 | 12 | expect(service).to.be.instanceOf(InstantAnswerService); 13 | }); 14 | }); 15 | 16 | describe("query", () => { 17 | let sandbox: SinonSandbox; 18 | let instantAnswer: InstantAnswerService; 19 | 20 | beforeEach(() => { 21 | sandbox = createSandbox(); 22 | instantAnswer = new InstantAnswerService(); 23 | }); 24 | 25 | it("makes a GET request to the DuckDuckGo API", async () => { 26 | const axiosGet = sandbox.stub(axios, "get").resolves({ 27 | status: 200, 28 | data: { 29 | Heading: "This is a heading", 30 | AbstractText: "This is a description." 31 | } 32 | }); 33 | 34 | await instantAnswer.query("test"); 35 | 36 | expect(axiosGet.called).to.be.true; 37 | }); 38 | 39 | it("throws an error if the API does not return a success", async () => { 40 | const axiosGet = sandbox.stub(axios, "get").resolves({ 41 | status: 500, 42 | data: {} 43 | }); 44 | 45 | // Chai can't detect throws inside async functions. This is a hack to get it working. 46 | try { 47 | await instantAnswer.query("test"); 48 | } catch ({ message }) { 49 | expect(message).to.equal("There was a problem with the DuckDuckGo API."); 50 | } 51 | 52 | expect(axiosGet.called).to.be.true; 53 | }); 54 | 55 | afterEach(() => { 56 | sandbox.restore(); 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /test/services/MessagePreviewServiceTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { SinonSandbox, createSandbox, SinonStub } from "sinon"; 3 | import { Message, TextChannel } from "discord.js"; 4 | 5 | import MessagePreviewService from "../../src/services/MessagePreviewService"; 6 | import { BaseMocks, CustomMocks } from "@lambocreeper/mock-discord.js"; 7 | 8 | describe("MessagePreviewService", () => { 9 | describe("::getInstance()", () => { 10 | it("returns an instance of MessagePreviewService", () => { 11 | const service = new MessagePreviewService(); 12 | 13 | expect(service).to.be.instanceOf(MessagePreviewService); 14 | }); 15 | }); 16 | 17 | describe("generatePreview()", () => { 18 | let sandbox: SinonSandbox; 19 | let messagePreview: MessagePreviewService; 20 | let link: string; 21 | let callingMessage: Message; 22 | let channel: TextChannel; 23 | let getChannelMock: SinonStub; 24 | let sendMessageMock: SinonStub; 25 | let fetchMessageMock: SinonStub; 26 | 27 | beforeEach(() => { 28 | sandbox = createSandbox(); 29 | 30 | messagePreview = new MessagePreviewService(); 31 | 32 | const guild = CustomMocks.getGuild({ 33 | id: "guild-id", 34 | channels: [] 35 | }); 36 | 37 | channel = CustomMocks.getTextChannel({ 38 | id: "518817917438001152" 39 | }, guild); 40 | 41 | callingMessage = CustomMocks.getMessage({}, { 42 | channel 43 | }); 44 | 45 | link = "https://discord.com/channels/guild-id/518817917438001152/732711501345062982"; 46 | 47 | getChannelMock = sandbox.stub(callingMessage.guild.channels.cache, "get").returns(channel); 48 | sendMessageMock = sandbox.stub(callingMessage.channel, "send"); 49 | 50 | fetchMessageMock = sandbox.stub(channel.messages, "fetch").resolves(callingMessage); 51 | sandbox.stub(callingMessage.member, "displayColor").get(() => "#FFFFFF"); 52 | }); 53 | 54 | it("gets the channel from the link", async () => { 55 | await messagePreview.generatePreview(link, callingMessage); 56 | 57 | expect(getChannelMock.calledOnce).to.be.true; 58 | }); 59 | 60 | it("sends preview message", async () => { 61 | await messagePreview.generatePreview(link, callingMessage); 62 | 63 | expect(sendMessageMock.calledOnce).to.be.true; 64 | }); 65 | 66 | it("escapes hyperlinks", async () => { 67 | const escapeHyperlinksMock = sandbox.stub(messagePreview, "escapeHyperlinks").returns("Parsed message"); 68 | 69 | await messagePreview.generatePreview(link, callingMessage); 70 | 71 | expect(escapeHyperlinksMock.calledOnce); 72 | }); 73 | 74 | it("doesn't send preview message if it is a bot message", async () => { 75 | callingMessage.author.bot = true; 76 | 77 | await messagePreview.generatePreview(link, callingMessage); 78 | 79 | expect(sendMessageMock.called).to.be.false; 80 | }); 81 | 82 | it("doesn't send preview message if the channel ID is wrong", async () => { 83 | getChannelMock.restore(); 84 | getChannelMock = sandbox.stub(callingMessage.guild.channels.cache, "get").returns(undefined); 85 | 86 | await messagePreview.generatePreview(link, callingMessage); 87 | 88 | expect(sendMessageMock.called).to.be.false; 89 | }); 90 | 91 | it("doesn't send preview message if the message ID is wrong", async () => { 92 | fetchMessageMock.restore(); 93 | fetchMessageMock = sandbox.stub(channel.messages, "fetch").returns(Promise.reject()); 94 | 95 | await messagePreview.generatePreview(link, callingMessage); 96 | 97 | expect(sendMessageMock.called).to.be.false; 98 | }); 99 | 100 | afterEach(() => { 101 | sandbox.restore(); 102 | }); 103 | }); 104 | 105 | describe("verifyGuild()", () => { 106 | let sandbox: SinonSandbox; 107 | let messagePreview: MessagePreviewService; 108 | let message: Message; 109 | 110 | beforeEach(() => { 111 | sandbox = createSandbox(); 112 | messagePreview = new MessagePreviewService(); 113 | message = CustomMocks.getMessage(); 114 | }); 115 | 116 | it("should return true if message's guild and provided guild id match", () => { 117 | expect(messagePreview.verifyGuild(message, BaseMocks.getGuild().id)).to.be.true; 118 | }); 119 | 120 | it("should return false if message's guild and provided guild id don't match", () => { 121 | expect(messagePreview.verifyGuild(message, "OTHER_GUILD_ID")).to.be.false; 122 | }); 123 | 124 | afterEach(() => { 125 | sandbox.restore(); 126 | }); 127 | }); 128 | 129 | describe("stripLink()", () => { 130 | let sandbox: SinonSandbox; 131 | let messagePreview: MessagePreviewService; 132 | let link: string; 133 | 134 | beforeEach(() => { 135 | sandbox = createSandbox(); 136 | messagePreview = new MessagePreviewService(); 137 | link = "https://ptb.discordapp.com/channels/240880736851329024/518817917438001152/732711501345062982"; 138 | }); 139 | 140 | it("strips link of unnecessary details", () => { 141 | const array = messagePreview.stripLink(link); 142 | 143 | expect(array).to.include("240880736851329024"); 144 | expect(array).to.include("518817917438001152"); 145 | expect(array).to.include("732711501345062982"); 146 | }); 147 | 148 | afterEach(() => { 149 | sandbox.restore(); 150 | }); 151 | }); 152 | 153 | describe("escapeHyperlinks()", () => { 154 | let sandbox: SinonSandbox; 155 | let messagePreview: MessagePreviewService; 156 | 157 | beforeEach(() => { 158 | sandbox = createSandbox(); 159 | messagePreview = new MessagePreviewService(); 160 | }); 161 | 162 | it("should return the string as it is if there are no hyperlinks", () => { 163 | expect(messagePreview.escapeHyperlinks("I am the night")).to.equal("I am the night"); 164 | }); 165 | 166 | it("should escape hyperlinks", () => { 167 | expect(messagePreview.escapeHyperlinks("Do you feel lucky, [punk](punkrock.com)?")) 168 | .to.equal("Do you feel lucky, \\[punk\\]\\(punkrock.com\\)?"); 169 | }); 170 | 171 | it("should scape all hyperlinks if there is more than one", () => { 172 | expect(messagePreview.escapeHyperlinks("[Link1](l1.com) and [Link2](l2.com)")) 173 | .to.equal("\\[Link1\\]\\(l1.com\\) and \\[Link2\\]\\(l2.com\\)"); 174 | }); 175 | 176 | it("should escape hyperlinks even if they are empty", () => { 177 | expect(messagePreview.escapeHyperlinks("[]()")).to.equal("\\[\\]\\(\\)"); 178 | expect(messagePreview.escapeHyperlinks("[half]()")).to.equal("\\[half\\]\\(\\)"); 179 | expect(messagePreview.escapeHyperlinks("[](half)")).to.equal("\\[\\]\\(half\\)"); 180 | }); 181 | 182 | afterEach(() => { 183 | sandbox.restore(); 184 | }); 185 | }); 186 | }); 187 | -------------------------------------------------------------------------------- /test/test-setup.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | -------------------------------------------------------------------------------- /test/utils/DateUtilsTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import DateUtils from "../../src/utils/DateUtils"; 3 | 4 | describe("DateUtils", () => { 5 | describe("::getDaysBetweenDates()", () => { 6 | it("returns 0 if it's on the same date", () => { 7 | const days = DateUtils.getDaysBetweenDates(new Date("2020-01-01"), new Date("2020-01-01")); 8 | 9 | expect(days).to.be.equal(0); 10 | }); 11 | 12 | it("returns 1 if it's a day later", () => { 13 | const days = DateUtils.getDaysBetweenDates(new Date("2020-01-02"), new Date("2020-01-01")); 14 | 15 | expect(days).to.be.equal(1); 16 | }); 17 | }); 18 | 19 | describe("::formatDaysAgo()", () => { 20 | it("throws an error on a negative number", () => { 21 | try { 22 | DateUtils.formatDaysAgo(-1); 23 | } catch ({ message }) { 24 | expect(message).to.be.equal("Number has to be positive"); 25 | } 26 | }); 27 | 28 | it("returns Today if parameter is 0", () => { 29 | const text = DateUtils.formatDaysAgo(0); 30 | 31 | expect(text).to.be.equal("today"); 32 | }); 33 | 34 | it("returns Yesterday if parameter is 1", () => { 35 | const text = DateUtils.formatDaysAgo(1); 36 | 37 | expect(text).to.be.equal("yesterday"); 38 | }); 39 | 40 | it("returns 3 days ago if paramater is 3", () => { 41 | const text = DateUtils.formatDaysAgo(3); 42 | 43 | expect(text).to.be.equal("3 days ago"); 44 | }); 45 | }); 46 | 47 | describe("::formatAsText()", () => { 48 | it("returns a formatted date", () => { 49 | const date = new Date(); 50 | 51 | date.setHours(12); 52 | date.setMinutes(30); 53 | date.setDate(26); 54 | date.setMonth(0); 55 | date.setFullYear(2007); 56 | 57 | expect(DateUtils.formatAsText(date)).to.equal("12:30 on 26 Jan 2007"); 58 | }); 59 | 60 | it("formats numbers smaller than 10 correctly", () => { 61 | const date = new Date(); 62 | 63 | date.setHours(9); 64 | date.setMinutes(5); 65 | date.setDate(7); 66 | date.setMonth(1); 67 | date.setFullYear(2010); 68 | 69 | expect(DateUtils.formatAsText(date)).to.equal("09:05 on 7 Feb 2010"); 70 | }); 71 | }); 72 | 73 | describe("::getFormattedTimeSinceDate()", () => { 74 | it("returns a formatted string that contains the difference between two dates", () => { 75 | const expected = "1 year, 3 days, 6 hours, 30 minutes and 5 seconds"; 76 | const start = new Date("01/12/2021 00:00:00"); 77 | const end = new Date("01/15/2022 06:30:05"); 78 | const actual = DateUtils.getFormattedTimeSinceDate(start, end); 79 | 80 | expect(actual).to.equal(expected); 81 | }); 82 | 83 | it("returns null if endDate is earlier then startDate", () => { 84 | const start = new Date(Date.now() + 1800000); 85 | const end = new Date(Date.now()); 86 | const actual = DateUtils.getFormattedTimeSinceDate(start, end); 87 | 88 | expect(actual).to.equal(null); 89 | }); 90 | }); 91 | }); -------------------------------------------------------------------------------- /test/utils/DirectoryUtilsTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { createSandbox, SinonSandbox } from "sinon"; 3 | import DirectoryUtils from "../../src/utils/DirectoryUtils"; 4 | import { DEVELOPMENT_ENV, PRODUCTION_ENV } from "../../src/config.json"; 5 | 6 | describe("DirectoryUtils", () => { 7 | describe("::getFilesInDirectory()", () => { 8 | let sandbox: SinonSandbox; 9 | 10 | beforeEach(() => { 11 | sandbox = createSandbox(); 12 | }); 13 | 14 | it("should call readDirectory()", () => { 15 | const readDirectoryStub = sandbox.stub(DirectoryUtils, "readDirectory").returns(["FakeFile.js"]); 16 | 17 | sandbox.stub(Array.prototype, "map"); 18 | 19 | DirectoryUtils.getFilesInDirectory(".", ".js"); 20 | 21 | expect(readDirectoryStub.called).to.be.true; 22 | }); 23 | 24 | it("should filter files", async () => { 25 | sandbox.stub(DirectoryUtils, "readDirectory").returns(["FakeFile.js", "FakeCommand.js"]); 26 | sandbox.stub(DirectoryUtils, "require").callsFake(arg => arg); 27 | 28 | const files = await DirectoryUtils.getFilesInDirectory(".", "Command.js"); 29 | 30 | expect(files.includes("./FakeFile.js")).to.be.false; 31 | expect(files.includes("./FakeCommand.js")).to.be.true; 32 | }); 33 | 34 | it("should require the files", async () => { 35 | const requireStub = sandbox.stub(DirectoryUtils, "require"); 36 | 37 | sandbox.stub(DirectoryUtils, "readDirectory").returns(["FakeFile.js", "FakeCommand.js"]); 38 | 39 | await DirectoryUtils.getFilesInDirectory(".", "Command.js"); 40 | 41 | expect(requireStub.calledWith("./FakeCommand.js")).to.be.true; 42 | }); 43 | 44 | afterEach(() => { 45 | sandbox.restore(); 46 | }); 47 | }); 48 | 49 | it("Should return typescript file in development", async () => { 50 | const testEnv = process.env.NODE_ENV; 51 | 52 | process.env.NODE_ENV = DEVELOPMENT_ENV; 53 | 54 | const file = DirectoryUtils.appendFileExtension("test"); 55 | 56 | expect(file).to.include(".ts"); 57 | expect(file).to.not.include(".js"); 58 | 59 | process.env.NODE_ENV = testEnv; 60 | }); 61 | 62 | it("Should return javascript in non-development", async () => { 63 | const testEnv = process.env.NODE_ENV; 64 | 65 | process.env.NODE_ENV = PRODUCTION_ENV; 66 | 67 | const file = DirectoryUtils.appendFileExtension("test"); 68 | 69 | expect(file).to.include(".js"); 70 | expect(file).to.not.include(".ts"); 71 | 72 | process.env.NODE_ENV = testEnv; 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /test/utils/DiscordUtilsTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { createSandbox, SinonSandbox } from "sinon"; 3 | import DiscordUtils from "../../src/utils/DiscordUtils"; 4 | import { BaseMocks, CustomMocks } from "@lambocreeper/mock-discord.js"; 5 | import { Collection, GuildMemberManager } from "discord.js"; 6 | 7 | const user = CustomMocks.getUser({id: "123456789", username: "fakeUser", discriminator: "1234"}); 8 | const member = CustomMocks.getGuildMember({user: user}); 9 | 10 | describe("DiscordUtils", () => { 11 | describe("::getGuildMember()", () => { 12 | let sandbox: SinonSandbox; 13 | 14 | beforeEach(() => { 15 | sandbox = createSandbox(); 16 | }); 17 | 18 | it("returns GuildMember if value is a username + discriminator", async () => { 19 | const guild = BaseMocks.getGuild(); 20 | 21 | sandbox.stub(guild.members, "fetch").resolves(new Collection([["12345", member]])); 22 | 23 | expect(await DiscordUtils.getGuildMember("fakeUser#1234", guild)).to.equal(member); 24 | }); 25 | 26 | it("returns GuildMember if value is a username", async () => { 27 | const guild = BaseMocks.getGuild(); 28 | 29 | sandbox.stub(guild.members, "fetch").resolves(new Collection([["12345", member]])); 30 | 31 | expect(await DiscordUtils.getGuildMember("fakeUser", guild)).to.equal(member); 32 | }); 33 | 34 | it("returns GuildMember if value is a userID", async () => { 35 | const guild = BaseMocks.getGuild(); 36 | 37 | // @ts-ignore (the types aren't recognising the overloaded fetch function) 38 | sandbox.stub(guild.members, "fetch").resolves(member); 39 | 40 | expect(await DiscordUtils.getGuildMember("123456789", guild)).to.equal(member); 41 | }); 42 | 43 | it("returns GuildMember if value is a nickname", async () => { 44 | const guild = BaseMocks.getGuild(); 45 | const nicknameMember = CustomMocks.getGuildMember({nick: "Lambo", user: user}); 46 | 47 | sandbox.stub(guild.members, "fetch").resolves(new Collection([["12345", nicknameMember]])); 48 | 49 | expect(await DiscordUtils.getGuildMember("Lambo", guild)).to.equal(nicknameMember); 50 | }); 51 | 52 | afterEach(() => { 53 | sandbox.restore(); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /test/utils/NumberUtilsTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import NumberUtils from "../../src/utils/NumberUtils"; 3 | 4 | describe("NumberUtils", () => { 5 | describe("::getRandomNumberInRange()", () => { 6 | it("Picks a random value between given min and max value", () => { 7 | const value = NumberUtils.getRandomNumberInRange(1, 5); 8 | 9 | expect(value).to.be.oneOf([1, 2, 3, 4, 5]); 10 | }); 11 | }); 12 | 13 | describe("::hexadecimalToInteger()", () => { 14 | it("Returns the decimal number from a hexadecimal value", () => { 15 | const value = NumberUtils.hexadecimalToInteger("8AB54D"); 16 | 17 | expect(value).to.be.equal(9090381); 18 | }); 19 | 20 | it("Returns the decimal number from a hexadecimal colour value", () => { 21 | const value = NumberUtils.hexadecimalToInteger("#1555B7"); 22 | 23 | expect(value).to.be.equal(1398199); 24 | }); 25 | }); 26 | }); -------------------------------------------------------------------------------- /test/utils/StringUtilsTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import StringUtils from "../../src/utils/StringUtils"; 3 | 4 | describe("StringUtils", () => { 5 | describe("::capitalise()", () => { 6 | it("capitalises the first letter of the word", () => { 7 | const word = StringUtils.capitalise("hello"); 8 | 9 | expect(word).to.be.equal("Hello"); 10 | }); 11 | 12 | it("capitalises the first letter of the first word", () => { 13 | const string = StringUtils.capitalise("hello there sunshine"); 14 | 15 | expect(string).to.be.equal("Hello there sunshine"); 16 | }); 17 | 18 | it("returns the string if an empty string is the parameter", () => { 19 | const string = StringUtils.capitalise(""); 20 | 21 | expect(string).to.be.equal(""); 22 | }); 23 | }); 24 | }); -------------------------------------------------------------------------------- /test/utils/getConfigValue.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { getKeyValue } from "../../src/utils/getConfigValue"; 3 | 4 | describe("getConfigValue()", () => { 5 | describe("getKeyValue", () => { 6 | it("returns the requested key's value", () => { 7 | const data = { 8 | test: 1 9 | }; 10 | 11 | expect(getKeyValue("test", data)).to.equal(data.test); 12 | }); 13 | 14 | it("returns the requested key's value if not top level", () => { 15 | const data = { 16 | more_data: { 17 | test: 1 18 | } 19 | }; 20 | 21 | expect(getKeyValue("more_data.test", data)).to.equal(data.more_data.test); 22 | }); 23 | }); 24 | }); -------------------------------------------------------------------------------- /test/utils/getEnvironmentVariableTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import getEnvironmentVariable from "../../src/utils/getEnvironmentVariable"; 3 | 4 | describe("getEnvironmentVariable", () => { 5 | it("should return variable if it is set", () => { 6 | process.env.FAKE_VAR = "fake value"; 7 | expect(getEnvironmentVariable("FAKE_VAR")).to.equal("fake value"); 8 | }); 9 | 10 | it("should throw error if variable is not set", () => { 11 | expect(() => getEnvironmentVariable("FAKE_VAR")).to.throw("The environment variable \"FAKE_VAR\" is not set."); 12 | }); 13 | 14 | afterEach(() => { 15 | delete process.env.FAKE_VAR; 16 | }); 17 | }); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "commonjs", 5 | "outDir": "./build", 6 | "rootDir": "./src", 7 | "strict": true, 8 | "alwaysStrict": true, 9 | "esModuleInterop": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "resolveJsonModule": true, 12 | "noUnusedLocals": true, 13 | "lib": ["es2020.string"], 14 | "emitDecoratorMetadata": true, 15 | "experimentalDecorators": true, 16 | "importHelpers": true, 17 | "moduleResolution": "Node" 18 | }, 19 | "exclude": ["./test", "node_modules"] 20 | } --------------------------------------------------------------------------------