├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── bot ├── .dockerignore ├── .eslintrc ├── Dockerfile ├── package-lock.json ├── package.json ├── src │ ├── client │ │ ├── commands │ │ │ ├── ctf │ │ │ │ ├── addcred.ts │ │ │ │ ├── addctf.ts │ │ │ │ ├── archive.ts │ │ │ │ ├── rmvcred.ts │ │ │ │ └── upcoming.ts │ │ │ └── misc │ │ │ │ ├── bingo.ts │ │ │ │ ├── kick.ts │ │ │ │ └── test.ts │ │ └── index.ts │ ├── data │ │ ├── entities │ │ │ └── ctf.ts │ │ └── index.ts │ ├── index.ts │ ├── jobs │ │ ├── NotifyCTFEnd.ts │ │ ├── NotifyCTFReactors.ts │ │ ├── RepeatedNotifyNewWriteups.ts │ │ ├── RepeatedUpcoming.ts │ │ ├── base.ts │ │ └── index.ts │ ├── services │ │ └── ctftime.ts │ └── util │ │ ├── config.ts │ │ ├── embed.ts │ │ ├── format.ts │ │ └── logging.ts ├── tests │ └── ctftime.spec.ts └── tsconfig.json └── docker-compose.yml /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Build 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [14.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - name: Install dependencies 28 | run: npm ci 29 | working-directory: ./bot 30 | - name: Build Project 31 | run: npm run build 32 | working-directory: ./bot 33 | - name: Run tests 34 | run: npm test 35 | working-directory: ./bot 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | config.json 3 | .vs 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Compiled binary addons (https://nodejs.org/api/addons.html) 36 | build/Release 37 | 38 | # Dependency directories 39 | node_modules/ 40 | jspm_packages/ 41 | 42 | # TypeScript v1 declaration files 43 | typings/ 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | 63 | # next.js build output 64 | .next 65 | 66 | # ignore database file 67 | db -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Chandrasekaran Akash 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 | # ctfbot 2 | CTF Bot for Discord 3 | -------------------------------------------------------------------------------- /bot/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | -------------------------------------------------------------------------------- /bot/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": ["plugin:@typescript-eslint/recommended"], 4 | "parserOptions": { 5 | "ecmaVersion": 2018, 6 | "sourceType": "module" 7 | }, 8 | "rules": { 9 | "semi": ["error", "always"], 10 | "quotes": ["error", "double"], 11 | "indent": ["error", 2], 12 | "@typescript-eslint/explicit-function-return-type": "off", 13 | "@typescript-eslint/no-explicit-any": 1, 14 | "@typescript-eslint/no-inferrable-types": [ 15 | "warn", { 16 | "ignoreParameters": true 17 | } 18 | ], 19 | "@typescript-eslint/no-unused-vars": "warn" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /bot/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node 2 | 3 | WORKDIR /usr/bot 4 | 5 | COPY package*.json ./ 6 | RUN npm install 7 | 8 | COPY . . 9 | RUN npm run build 10 | 11 | CMD npm run start -------------------------------------------------------------------------------- /bot/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ctfbot", 3 | "version": "1.0.0", 4 | "description": "CTFBot for Discord", 5 | "main": "build/index.js", 6 | "scripts": { 7 | "build": "tsc && link-module-alias", 8 | "start": "node build/index.js", 9 | "dev": "nodemon -x 'npm run build && npm start' -e ts", 10 | "lint": "tsc --noEmit && eslint \"**/*.ts\" --quiet --fix", 11 | "test": "mocha -r ts-node/register \"tests/**/*.spec.ts\"" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/Enigmatrix/ctfbot.git" 16 | }, 17 | "keywords": [ 18 | "ctf", 19 | "bot", 20 | "discord", 21 | "hatssg" 22 | ], 23 | "author": "Enigmatrix", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/Enigmatrix/ctfbot/issues" 27 | }, 28 | "_moduleAliasIgnoreWarning": true, 29 | "_moduleAliases": { 30 | "@": "build" 31 | }, 32 | "homepage": "https://github.com/Enigmatrix/ctfbot#readme", 33 | "dependencies": { 34 | "agenda": "^4.0.1", 35 | "axios": "^0.21.1", 36 | "cheerio": "^1.0.0-rc.5", 37 | "discord.js": "^12.5.1", 38 | "discord.js-commando": "git+https://git@github.com/discordjs/Commando.git", 39 | "dotenv": "^8.2.0", 40 | "jimp": "^0.16.1", 41 | "luxon": "^1.26.0", 42 | "mongodb": "^3.6.4", 43 | "reflect-metadata": "^0.1.13", 44 | "typeorm": "^0.2.31", 45 | "typescript": "^3.9.9", 46 | "winston": "^3.3.3" 47 | }, 48 | "devDependencies": { 49 | "@types/agenda": "^3.0.2", 50 | "@types/chai": "^4.2.15", 51 | "@types/luxon": "^1.25.3", 52 | "@types/mocha": "^8.2.0", 53 | "@types/node": "^14.14.28", 54 | "@types/ws": "^7.4.0", 55 | "@typescript-eslint/eslint-plugin": "^4.15.1", 56 | "@typescript-eslint/parser": "^4.15.1", 57 | "chai": "^4.3.0", 58 | "eslint": "^7.20.0", 59 | "link-module-alias": "^1.2.0", 60 | "mocha": "^8.3.0", 61 | "nodemon": "^2.0.7", 62 | "ts-node": "^9.1.1" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /bot/src/client/commands/ctf/addcred.ts: -------------------------------------------------------------------------------- 1 | import { fetchChannelMessage } from "@/client"; 2 | import { CTF } from "@/data/entities/ctf"; 3 | import { ctfMainMessageEmbed } from "@/util/embed"; 4 | import { Command, CommandoClient, CommandoMessage, FriendlyError } from "discord.js-commando"; 5 | 6 | export default class AddCred extends Command { 7 | constructor(client: CommandoClient) { 8 | super(client, { 9 | name: "addcred", 10 | group: "ctf", 11 | memberName: "addcred", 12 | description: "Add credentials to the main message of the CTF", 13 | argsSingleQuotes: true, 14 | args: [{ 15 | key: "key", 16 | prompt: "please provide key", 17 | type: "string", 18 | error: "no key", 19 | }, 20 | { 21 | key: "value", 22 | prompt: "please provide value", 23 | type: "string", 24 | error: "no value", 25 | }] 26 | }); 27 | } 28 | 29 | async run(message: CommandoMessage, args: {key: string, value: string}) { 30 | const ctf = await CTF.findOne({ where: { "discord.channel": message.channel.id, archived: false } }); 31 | if(!ctf) { 32 | throw new FriendlyError("This channel is not a valid CTF channel."); 33 | } 34 | ctf.credentials[args.key] = args.value; 35 | await ctf.save(); 36 | 37 | const [_, mainMessage] = await fetchChannelMessage(ctf.discord.channel, ctf.discord.mainMessage); 38 | await mainMessage.edit(ctfMainMessageEmbed(ctf)); 39 | 40 | return null; 41 | } 42 | } -------------------------------------------------------------------------------- /bot/src/client/commands/ctf/addctf.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandoClient, CommandoMessage } from "discord.js-commando"; 2 | import ctftime from "@/services/ctftime"; 3 | import { CTF } from "@/data/entities/ctf"; 4 | import { ctfMainMessageEmbed } from "@/util/embed"; 5 | import config from "@/util/config"; 6 | import NotifyCTFReactors from "@/jobs/NotifyCTFReactors"; 7 | import { DateTime } from "luxon"; 8 | import NotifyCTFEnd from "@/jobs/NotifyCTFEnd"; 9 | 10 | export default class AddCTF extends Command { 11 | constructor(client: CommandoClient) { 12 | super(client, { 13 | name: "addctf", 14 | group: "ctf", 15 | memberName: "addctf", 16 | description: "Creates a channel to represent the CTF. Posts a message to keep track of when the CTF starts.", 17 | args: [{ 18 | key: "url", 19 | prompt: "please provide the CTFTime URL", 20 | type: "string", 21 | error: "the CTFTime URL is invalid", 22 | validate: (url: string) => ctftime.isValidUrl(url) 23 | }] 24 | }); 25 | } 26 | 27 | async run(message: CommandoMessage, args: {url: string}) { 28 | const event = await ctftime.eventForUrl(args.url); 29 | const channel = await message.guild.channels.create(event.title, { 30 | type: "text", 31 | topic: "SEE :pushpin: FOR INFO", 32 | parent: config.get("DISCORD_CTFS_CHANNEL"), 33 | reason: `Channel for ${event.title}` 34 | }); 35 | 36 | const ctf = new CTF(event); 37 | 38 | const mainMessage = await channel.send(ctfMainMessageEmbed(ctf)); 39 | ctf.discord = { channel: channel.id, mainMessage: mainMessage.id }; 40 | 41 | await mainMessage.pin(); 42 | await mainMessage.react("👌"); 43 | 44 | await ctf.save(); 45 | 46 | const time = DateTime.fromISO(ctf.info.start).minus({ hour: 1 }).toJSDate(); // 1 hour before start 47 | NotifyCTFReactors.schedule(time, { ctf_id: ctf.id }); 48 | const end = DateTime.fromISO(ctf.info.finish).toJSDate(); 49 | NotifyCTFEnd.schedule(end, { ctf_id: ctf.id }); 50 | 51 | return message.say(`Done! Head over to ${channel} for more info.`); 52 | } 53 | } -------------------------------------------------------------------------------- /bot/src/client/commands/ctf/archive.ts: -------------------------------------------------------------------------------- 1 | import { fetchChannelMessage, findChannel } from "@/client"; 2 | import { CTF } from "@/data/entities/ctf"; 3 | import { CategoryChannel } from "discord.js"; 4 | import { Command, CommandoClient, CommandoMessage, FriendlyError } from "discord.js-commando"; 5 | 6 | export default class Archive extends Command { 7 | constructor(client: CommandoClient) { 8 | super(client, { 9 | name: "archive", 10 | group: "ctf", 11 | memberName: "archive", 12 | description: "Archive CTF channel", 13 | }); 14 | } 15 | 16 | async run(message: CommandoMessage) { 17 | // TODO move all this common code into one file. 18 | const ctf = await CTF.findOne({ where: { "discord.channel": message.channel.id, archived: false } }); 19 | if(!ctf) { 20 | throw new FriendlyError("This channel is not a valid CTF channel."); 21 | } 22 | 23 | const [channel, _] = await fetchChannelMessage(ctf.discord.channel, ctf.discord.mainMessage); 24 | const archive = await findChannel(chan => chan.name === `archives-${new Date().getFullYear()}`); 25 | if(!archive) { 26 | throw new Error(`Archive channel for year ${new Date().getFullYear()} not found`); 27 | } 28 | await channel.setParent(archive as CategoryChannel); 29 | 30 | ctf.archived = true; 31 | await ctf.save(); 32 | 33 | return message.say("CTF Channel Archived!"); 34 | } 35 | } -------------------------------------------------------------------------------- /bot/src/client/commands/ctf/rmvcred.ts: -------------------------------------------------------------------------------- 1 | import { fetchChannelMessage } from "@/client"; 2 | import { CTF } from "@/data/entities/ctf"; 3 | import { ctfMainMessageEmbed } from "@/util/embed"; 4 | import { Command, CommandoClient, CommandoMessage, FriendlyError } from "discord.js-commando"; 5 | 6 | export default class RmvCred extends Command { 7 | constructor(client: CommandoClient) { 8 | super(client, { 9 | name: "rmvcred", 10 | group: "ctf", 11 | memberName: "rmvcred", 12 | description: "Remove credentials from the main message of the CTF", 13 | argsSingleQuotes: true, 14 | args: [{ 15 | key: "key", 16 | prompt: "please provide key", 17 | type: "string", 18 | error: "no key", 19 | }] 20 | }); 21 | } 22 | 23 | async run(message: CommandoMessage, args: {key: string, value: string}) { 24 | const ctf = await CTF.findOne({ where: { "discord.channel": message.channel.id, archived: false } }); 25 | if(!ctf) { 26 | throw new FriendlyError("This channel is not a valid CTF channel."); 27 | } 28 | delete ctf.credentials[args.key]; 29 | await ctf.save(); 30 | 31 | const [_, mainMessage] = await fetchChannelMessage(ctf.discord.channel, ctf.discord.mainMessage); 32 | await mainMessage.edit(ctfMainMessageEmbed(ctf)); 33 | 34 | return null; 35 | } 36 | } -------------------------------------------------------------------------------- /bot/src/client/commands/ctf/upcoming.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandoClient, CommandoMessage } from "discord.js-commando"; 2 | import RepeatedUpcoming from "@/jobs/RepeatedUpcoming"; 3 | 4 | export default class Upcoming extends Command { 5 | constructor(client: CommandoClient) { 6 | super(client, { 7 | name: "upcoming", 8 | group: "ctf", 9 | memberName: "upcoming", 10 | description: "Fetches upcoming CTFs for the next 7 days", 11 | }); 12 | } 13 | 14 | async run(message: CommandoMessage) { 15 | await RepeatedUpcoming.schedule("now", { channel: message.channel.id }); 16 | return null; 17 | } 18 | } -------------------------------------------------------------------------------- /bot/src/client/commands/misc/bingo.ts: -------------------------------------------------------------------------------- 1 | import { fetchChannelMessage, findChannel } from "@/client"; 2 | import { CTF } from "@/data/entities/ctf"; 3 | import Jimp from "jimp"; 4 | import { Command, CommandoClient, CommandoMessage, FriendlyError } from "discord.js-commando"; 5 | import { MessageAttachment } from "discord.js"; 6 | 7 | export default class Bingo extends Command { 8 | constructor(client: CommandoClient) { 9 | super(client, { 10 | name: "bingo", 11 | group: "misc", 12 | memberName: "bingo", 13 | description: "Play Bad CTF Bingo", 14 | args: [ 15 | { 16 | key: "x", 17 | prompt: "Enter zero-indexed x-index of the bingo card (0-4)", 18 | type: "integer", 19 | default: -1 20 | }, 21 | { 22 | key: "y", 23 | prompt: "Enter zero-indexed y-index of the bingo card (0-4)", 24 | type: "integer", 25 | default: -1 26 | }, 27 | ] 28 | }); 29 | } 30 | 31 | async image() { 32 | const img = await Jimp.read(Buffer.from("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", 'base64')); 33 | return img; 34 | } 35 | 36 | async write(bingo: Record[][]) { 37 | const font = await Jimp.loadFont(Jimp.FONT_SANS_16_BLACK); 38 | const img = await this.image(); 39 | const idx = [ 40 | [[70, 195], [156, 195], [250, 195], [335, 195], [431, 195]], 41 | [[70, 226], [156, 296], [250, 296], [335, 296], [427, 226]], 42 | [[70, 385], [156, 385], [250, 385], [335, 385], [431, 385]], 43 | [[70, 485], [156, 485], [250, 485], [335, 485], [431, 485]], 44 | [[70, 593], [156, 529], [250, 593], [335, 593], [431, 593]], 45 | ]; 46 | for(let i = 0 ; i < 5; i++) { 47 | for(let j = 0; j < 5; j++) { 48 | const n = Object.keys(bingo[i][j]).length; 49 | const [x, y] = idx[i][j]; 50 | if (n !== 0) { 51 | 52 | img.print(font, x, y, n) 53 | } 54 | } 55 | } 56 | return await img.getBufferAsync(Jimp.MIME_PNG); 57 | } 58 | 59 | async run(message: CommandoMessage, {x, y}: {x:number, y: number}) { 60 | // TODO move all this common code into one file. 61 | const ctf = await CTF.findOne({ where: { "discord.channel": message.channel.id, archived: false } }); 62 | if(!ctf) { 63 | throw new FriendlyError("This channel is not a valid CTF channel."); 64 | } 65 | if (x >= 0 && x < 5 && y >= 0 && y < 5) { 66 | ctf.bingo[y][x][message.author.id] = 1; 67 | await ctf.save(); 68 | } 69 | // TODO marking here 70 | const img = await this.write(ctf.bingo); 71 | return await message.say("Use `!bingo {x} {y}` to set the bingo", new MessageAttachment(img, "nani.png")); 72 | } 73 | } 74 | 75 | -------------------------------------------------------------------------------- /bot/src/client/commands/misc/kick.ts: -------------------------------------------------------------------------------- 1 | import { User } from "discord.js"; 2 | import { Command, CommandoClient, CommandoMessage } from "discord.js-commando"; 3 | 4 | export default class Kick extends Command { 5 | constructor(client: CommandoClient) { 6 | super(client, { 7 | name: "kick", 8 | group: "misc", 9 | memberName: "kick", 10 | description: "Kick a user. Metaphysically =D", 11 | args: [{ 12 | key: "target", 13 | prompt: "provide the target of my Altama boots", 14 | type: "user" 15 | }] 16 | }); 17 | } 18 | 19 | async run(message: CommandoMessage, args: { target: User }) { 20 | return message.say(`:boot: ${args.target}`); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /bot/src/client/commands/misc/test.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandoClient, CommandoMessage } from "discord.js-commando"; 2 | 3 | export default class Test extends Command { 4 | constructor(client: CommandoClient) { 5 | super(client, { 6 | name: "test", 7 | group: "misc", 8 | memberName: "test", 9 | description: "Primary test command", 10 | }); 11 | } 12 | 13 | async run(message: CommandoMessage, args: string) { 14 | return null; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /bot/src/client/index.ts: -------------------------------------------------------------------------------- 1 | import { GuildChannel, GuildChannelResolvable, Message, TextChannel } from "discord.js"; 2 | import {CommandoClient} from "discord.js-commando"; 3 | import path from "path"; 4 | import config from "@/util/config"; 5 | import log from "@/util/logging"; 6 | 7 | const client = new CommandoClient({ 8 | commandPrefix: "!", 9 | owner: config.get("DISCORD_OWNER") 10 | }); 11 | 12 | client.registry 13 | .registerDefaultTypes() 14 | .registerGroups([ 15 | ["ctf", "CTF Channel management"], 16 | ["misc", "Misc commands"], 17 | ]) 18 | .registerDefaultGroups() 19 | .registerDefaultCommands() 20 | .registerCommandsIn(path.join(__dirname, "./commands")); 21 | 22 | client.once("ready", () => { 23 | log.info("logged in!", { id: client.user?.id, tag: client.user?.tag }); 24 | }); 25 | 26 | client.on("commandRun", (cmd, _, msg, args, fromPattern) => { 27 | log.debug("command executed", { cmd: cmd.name, msg: msg.id, args, fromPattern }); 28 | }); 29 | 30 | client.on("commandError", (cmd, err, msg, args, fromPattern) => { 31 | log.error("error in command", err, { cmd: cmd.name, msg: msg.id, args, fromPattern }); 32 | }); 33 | 34 | export async function fetchChannelMessage(channelFind: GuildChannelResolvable, messageFind: string): Promise<[TextChannel, Message]> { 35 | const guild = client.guilds.cache.first(); 36 | const channel = guild?.channels.resolve(channelFind) as TextChannel|undefined; 37 | if(!channel) { 38 | throw new Error(`Channel ${channelFind} not found.`); 39 | } 40 | const message = await channel.messages.fetch(messageFind); 41 | if(!message) { 42 | throw new Error(`Message ${messageFind} not found.`); 43 | } 44 | return [channel, message]; 45 | } 46 | 47 | export async function fetchChannel(channelFind: GuildChannelResolvable): Promise { 48 | const guild = client.guilds.cache.first(); 49 | const channel = guild?.channels.resolve(channelFind) as TextChannel|undefined; 50 | if(!channel) { 51 | throw new Error(`Channel ${channelFind} not found.`); 52 | } 53 | return channel; 54 | } 55 | 56 | export async function findChannel(fn: (c: GuildChannel) => boolean): Promise { 57 | const guild = client.guilds.cache.first(); 58 | const channel = guild?.channels.cache.find(fn); 59 | return channel; 60 | } 61 | 62 | export default client; -------------------------------------------------------------------------------- /bot/src/data/entities/ctf.ts: -------------------------------------------------------------------------------- 1 | import { BaseEntity, Column, Entity, ObjectID, ObjectIdColumn } from "typeorm"; 2 | import { Event, Writeup } from "@/services/ctftime"; 3 | 4 | @Entity() 5 | export class CTF extends BaseEntity { 6 | @ObjectIdColumn() 7 | public id!: ObjectID; 8 | 9 | @Column() 10 | public info!: Event; 11 | 12 | @Column() 13 | public discord!: { channel: string, mainMessage: string }; 14 | 15 | @Column() 16 | public credentials!: { [key: string]: string }; 17 | 18 | @Column() 19 | public writeups!: { [task: string]: Writeup[] } 20 | 21 | @Column() 22 | public archived!: boolean; 23 | 24 | @Column() 25 | public bingo!: Record[][]; 26 | 27 | constructor(info: Event) { 28 | super(); 29 | this.archived = false; 30 | this.credentials = {}; 31 | this.writeups = {}; 32 | this.bingo = [ 33 | [{}, {}, {}, {}, {}], 34 | [{}, {}, {}, {}, {}], 35 | [{}, {}, {}, {}, {}], 36 | [{}, {}, {}, {}, {}], 37 | [{}, {}, {}, {}, {}] 38 | ]; 39 | this.info = info; 40 | } 41 | } -------------------------------------------------------------------------------- /bot/src/data/index.ts: -------------------------------------------------------------------------------- 1 | import { Connection, createConnection } from "typeorm"; 2 | import config from "@/util/config"; 3 | import log from "@/util/logging"; 4 | 5 | export class Database { 6 | static instance: Database; 7 | 8 | static async init() { 9 | const conn = await createConnection({ 10 | type: "mongodb", 11 | url: config.get("DATA_CONNECTION_URI"), 12 | entities: [__dirname + "/entities/*.js"], 13 | useNewUrlParser: true, 14 | useUnifiedTopology: true 15 | }); 16 | log.info("connected to database"); 17 | this.instance = new Database(conn); 18 | } 19 | 20 | constructor(conn: Connection) { 21 | this.conn = conn; 22 | } 23 | 24 | private conn: Connection; 25 | } 26 | 27 | export default Database.instance; -------------------------------------------------------------------------------- /bot/src/index.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | import {Database as data} from "@/data"; 3 | import agenda from "@/jobs"; 4 | import client from "@/client"; 5 | import config from "@/util/config"; 6 | 7 | (async () => { 8 | await data.init(); 9 | await client.login(config.get("DISCORD_TOKEN")); 10 | await agenda.start(); 11 | })(); -------------------------------------------------------------------------------- /bot/src/jobs/NotifyCTFEnd.ts: -------------------------------------------------------------------------------- 1 | import { ObjectID } from "typeorm"; 2 | import { Job } from "@/jobs/base"; 3 | import { CTF } from "@/data/entities/ctf"; 4 | import client, { fetchChannelMessage } from "@/client"; 5 | import { MessageEmbed } from "discord.js"; 6 | import { EMBED_INFO1 } from "@/util/embed"; 7 | 8 | class NotifyCTFEnd extends Job<{ ctf_id: ObjectID }> { 9 | ID: string = "notifyCtfEndv1.0"; 10 | 11 | async run(args: { ctf_id: ObjectID; }) { 12 | const ctf = await CTF.findOne(args.ctf_id); 13 | if(!ctf) { 14 | throw new Error(`CTF not found ${args.ctf_id}`); 15 | } 16 | 17 | const [channel, _] = await fetchChannelMessage(ctf.discord.channel, ctf.discord.mainMessage); 18 | 19 | await channel.send(new MessageEmbed({ 20 | color: EMBED_INFO1, 21 | author: { 22 | name: `:tada: ${ctf.info.title} has ended! :tada:`, 23 | icon_url: ctf.info.logo 24 | }, 25 | description: "Wanna play bad CTF Bingo while waiting for writeups? `!bingo` to start", 26 | url: ctf.info.url, 27 | })); 28 | } 29 | } 30 | 31 | export default new NotifyCTFEnd(); 32 | -------------------------------------------------------------------------------- /bot/src/jobs/NotifyCTFReactors.ts: -------------------------------------------------------------------------------- 1 | import { ObjectID } from "typeorm"; 2 | import { Job } from "@/jobs/base"; 3 | import { CTF } from "@/data/entities/ctf"; 4 | import client, { fetchChannelMessage } from "@/client"; 5 | import { MessageEmbed } from "discord.js"; 6 | import { EMBED_INFO1 } from "@/util/embed"; 7 | 8 | class NotifyCTFReactors extends Job<{ ctf_id: ObjectID }> { 9 | ID: string = "notifyCtfReactorsv1.0"; 10 | 11 | async run(args: { ctf_id: ObjectID; }) { 12 | const ctf = await CTF.findOne(args.ctf_id); 13 | if(!ctf) { 14 | throw new Error(`CTF not found ${args.ctf_id}`); 15 | } 16 | 17 | const [channel, mainMessage] = await fetchChannelMessage(ctf.discord.channel, ctf.discord.mainMessage); 18 | 19 | const thumbsup = mainMessage.reactions.resolve("👌"); 20 | const users = await thumbsup!.users.fetch(); 21 | 22 | for (const [id, user] of users) { 23 | if(client.user?.id === id) { 24 | continue; 25 | } 26 | 27 | const dm = await user.createDM(); 28 | await dm.send(new MessageEmbed({ 29 | color: EMBED_INFO1, 30 | author: { 31 | name: `Reminder for ${ctf.info.title}`, 32 | icon_url: ctf.info.logo 33 | }, 34 | description: `This is a reminder that ${ctf.info.title} (${channel}) starts in 1 hour. Good Luck!`, 35 | url: ctf.info.url, 36 | })); 37 | } 38 | } 39 | } 40 | 41 | export default new NotifyCTFReactors(); -------------------------------------------------------------------------------- /bot/src/jobs/RepeatedNotifyNewWriteups.ts: -------------------------------------------------------------------------------- 1 | import { fetchChannelMessage } from "@/client"; 2 | import { CTF } from "@/data/entities/ctf"; 3 | import { Job } from "@/jobs/base"; 4 | import ctftime, { Writeup } from "@/services/ctftime"; 5 | import { EMBED_INFO3 } from "@/util/embed"; 6 | import logging from "@/util/logging"; 7 | import { MessageEmbed } from "discord.js"; 8 | 9 | class RepeatedNotifyNewWriteups extends Job { 10 | ID: string = "repeated_notifyNewWriteupsv1.0"; 11 | 12 | private prevNewestWriteupUrl: string | undefined; 13 | 14 | async run() { 15 | const writeups = await ctftime.recentWriteups(); 16 | logging.info("writeups fetched: ", writeups); 17 | const newestWriteupUrl = writeups[0].url; 18 | if (this.prevNewestWriteupUrl === newestWriteupUrl) { 19 | logging.info("prev == newest", this.prevNewestWriteupUrl, newestWriteupUrl); 20 | return; 21 | } 22 | const ctfs = await CTF.find({ archived: false }); 23 | const newWriteups: {[ctfUrl: string]: Writeup[] } = {}; 24 | for(const writeup of writeups) { 25 | if(writeup.url === this.prevNewestWriteupUrl) { 26 | break; 27 | } 28 | if(!newWriteups[writeup.ctf.url]) { 29 | newWriteups[writeup.ctf.url] = []; 30 | } 31 | newWriteups[writeup.ctf.url].push(writeup); 32 | logging.info("adding writeup: " + JSON.stringify(writeup.ctf.name) + " " + JSON.stringify(writeup)); 33 | } 34 | 35 | for(const ctf of ctfs) { 36 | const writeups = newWriteups[ctf.info.url]; 37 | if (!writeups) { 38 | continue; 39 | } 40 | const [channel, _] = await fetchChannelMessage(ctf.discord.channel, ctf.discord.mainMessage); 41 | for(const writeup of writeups) { 42 | if(!ctf.writeups[writeup.task.name]) { 43 | ctf.writeups[writeup.task.name] = []; 44 | } 45 | ctf.writeups[writeup.task.name].push(writeup); 46 | await channel.send(this.writeupEmbed(writeup)); 47 | } 48 | await ctf.save(); 49 | } 50 | 51 | this.prevNewestWriteupUrl = newestWriteupUrl; 52 | } 53 | writeupEmbed(writeup: Writeup) { 54 | return new MessageEmbed({ 55 | color: EMBED_INFO3, 56 | author: { 57 | name: `New writeup for ${writeup.task.name} by ${writeup.author.name}`, 58 | }, 59 | description: writeup.url, 60 | url: writeup.url 61 | }); 62 | } 63 | } 64 | 65 | export default new RepeatedNotifyNewWriteups(); 66 | -------------------------------------------------------------------------------- /bot/src/jobs/RepeatedUpcoming.ts: -------------------------------------------------------------------------------- 1 | import { MessageEmbed, TextChannel } from "discord.js"; 2 | import { Job } from "@/jobs/base"; 3 | import ctftime, { Event } from "@/services/ctftime"; 4 | import { EMBED_INFO, EMBED_SUCCESS, EMBED_WARN } from "@/util/embed"; 5 | import { formatSGT } from "@/util/format"; 6 | import { DateTime } from "luxon"; 7 | import { fetchChannel } from "@/client"; 8 | import config from "@/util/config"; 9 | 10 | class RepeatedUpcoming extends Job<{ channel: string }|undefined> { 11 | ID = "repeated_upcomingv1.0"; 12 | emptyCTFEmbed() { 13 | return new MessageEmbed({ 14 | color: EMBED_WARN, 15 | title: "There are no upcoming online CTFs for this week", 16 | }); 17 | } 18 | 19 | numberOfCTFsEmbed(len: number) { 20 | let numberOfCTFsText; 21 | if (len === 1) { 22 | numberOfCTFsText = "There is 1 upcoming online CTF for this week:"; 23 | } else { 24 | numberOfCTFsText = `There are ${len} upcoming online CTFs for this week:`; 25 | } 26 | return new MessageEmbed({ 27 | color: EMBED_SUCCESS, 28 | title: numberOfCTFsText, 29 | }); 30 | } 31 | 32 | ctfEmbed(event: Event) { 33 | const addCtfText = 34 | `${event.ctftime_url}\nRun \`!addctf ${event.ctftime_url}\`` + "to add this CTF"; 35 | return new MessageEmbed( 36 | { 37 | color: EMBED_INFO, 38 | author: { 39 | name: `${event.title} (${event.format}, ${event.restrictions 40 | })`, 41 | icon_url: event.logo === "" ? undefined : event.logo, 42 | }, 43 | description: event.description, 44 | fields: [ 45 | { name: "URL", value: !!event.url ? event.url : "Unknown event URL" }, 46 | { 47 | name: "Timing", 48 | value: `${formatSGT(event.start)} - ${formatSGT(event.finish)}`, 49 | }, 50 | { name: "CTFtime URL", value: addCtfText }, 51 | ], 52 | url: event.url, 53 | footer: { 54 | text: `Hosted by ${event.organizers 55 | .map((x) => x.name) 56 | .join(", ")}.`, 57 | }, 58 | }); 59 | } 60 | 61 | async run(args: { channel: string }|undefined) { 62 | const start = DateTime.now(); 63 | const end = start.plus({ weeks: 1 }); 64 | 65 | const events = await ctftime.events(start, end); 66 | const ctfEmbeds = events 67 | .filter((x) => x.finish > x.start && !x.onsite) 68 | .map((x) => this.ctfEmbed(x)); 69 | 70 | const channel = await fetchChannel(args?.channel || config.get("DISCORD_UPCOMING_CHANNEL")) as TextChannel; 71 | 72 | if (ctfEmbeds.length === 0) { 73 | await channel.send(this.emptyCTFEmbed()); 74 | } else { 75 | 76 | await channel.send(this.numberOfCTFsEmbed(ctfEmbeds.length)); 77 | 78 | for (const embed of ctfEmbeds) { 79 | await channel.send(embed); 80 | } 81 | } 82 | 83 | } 84 | } 85 | 86 | export default new RepeatedUpcoming(); -------------------------------------------------------------------------------- /bot/src/jobs/base.ts: -------------------------------------------------------------------------------- 1 | import Agenda from "agenda"; 2 | import log from "@/util/logging"; 3 | import config from "@/util/config"; 4 | 5 | const agenda = new Agenda({ 6 | db: { address: config.get("DATA_CONNECTION_URI"), 7 | options: { 8 | useNewUrlParser: true, 9 | useUnifiedTopology: true 10 | } 11 | } 12 | }); 13 | 14 | export abstract class Job { 15 | abstract ID: string; 16 | 17 | abstract run(args: T): Promise; 18 | 19 | register() { 20 | agenda.define(this.ID, async (job, done) => { 21 | log.info(`running job ${this.ID}`); 22 | await this.run(job.attrs.data) 23 | .then(() => { 24 | log.info(`job ${this.ID} success`); 25 | done(); 26 | }) 27 | .catch(err => { 28 | log.error(`job ${this.ID} failed`, err); 29 | done(err); 30 | }); 31 | }); 32 | } 33 | 34 | async schedule(when: string|Date, args: T) { 35 | return await agenda.schedule(when, this.ID, args); 36 | } 37 | 38 | async every(interval: string|number, args: T) { 39 | return await agenda.every(interval, this.ID, args); 40 | } 41 | } 42 | 43 | export default agenda; -------------------------------------------------------------------------------- /bot/src/jobs/index.ts: -------------------------------------------------------------------------------- 1 | import NotifyCTFReactors from "@/jobs/NotifyCTFReactors"; 2 | import RepeatedNotifyNewWriteups from "@/jobs/RepeatedNotifyNewWriteups"; 3 | import RepeatedUpcoming from "@/jobs/RepeatedUpcoming"; 4 | import agenda from "@/jobs/base"; 5 | import log from "@/util/logging"; 6 | import NotifyCTFEnd from "./NotifyCTFEnd"; 7 | 8 | for(const job of [NotifyCTFReactors, RepeatedNotifyNewWriteups, RepeatedUpcoming, NotifyCTFEnd]) { 9 | job.register(); 10 | } 11 | 12 | export default agenda.on("ready", async () => { 13 | await agenda.purge(); 14 | 15 | await Promise.all(( 16 | await agenda.jobs({name: {$regex: "repeated_.*"}})) 17 | .map(job => job.remove())); 18 | 19 | await RepeatedNotifyNewWriteups.every("15 minutes"); 20 | await agenda.create(RepeatedUpcoming.ID) 21 | .repeatEvery("1 week", { timezone: "Asia/Singapore", skipImmediate: true }) 22 | .schedule("monday at 8am") 23 | .save(); 24 | 25 | log.info("jobs ready"); 26 | }) 27 | .on("error", e => log.error("agenda error", e)); -------------------------------------------------------------------------------- /bot/src/services/ctftime.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosInstance } from "axios"; 2 | import * as cheerio from "cheerio"; 3 | import {DateTime} from "luxon"; 4 | 5 | export interface Organizer { 6 | id: number; 7 | name: string; 8 | } 9 | 10 | export interface Duration { 11 | hours: number; 12 | days: number; 13 | } 14 | 15 | export interface Event { 16 | organizers: Organizer[]; 17 | onsite: boolean; 18 | finish: string; 19 | description: string; 20 | weight: number; 21 | title: string; 22 | url: string; 23 | is_votable_now: boolean; 24 | restrictions: string; 25 | format: string; 26 | start: string; 27 | participants: number; 28 | ctftime_url: string; 29 | location: string; 30 | live_feed: string; 31 | public_votable: boolean; 32 | duration: Duration; 33 | logo: string; 34 | format_id: number; 35 | id: number; 36 | ctf_id: number; 37 | } 38 | 39 | export interface LinkRef { 40 | url: string, 41 | name: string 42 | } 43 | 44 | export interface Writeup { 45 | ctf: LinkRef, 46 | task: LinkRef, 47 | author: LinkRef, 48 | url: string 49 | } 50 | 51 | class CTFTime { 52 | static CTFTIME_URL = /^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?ctftime.org\/event\/(?([0-9])+)(\/)?$/; 53 | 54 | private inner: AxiosInstance; 55 | 56 | constructor() { 57 | this.inner = axios.create({ 58 | baseURL: "https://ctftime.org", 59 | headers: { 60 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36" 61 | } 62 | }); 63 | } 64 | 65 | isValidUrl(url: string) { 66 | return CTFTime.CTFTIME_URL.test(url); 67 | } 68 | 69 | async events(start: DateTime|Date|number, finish: DateTime|Date|number, limit: number|undefined = undefined) { 70 | return await this.inner.get("/api/v1/events/", { params: { start: +start, finish: +finish, limit } }).then(x => x.data); 71 | } 72 | 73 | async eventForUrl(url: string) { 74 | const id = url.match(CTFTime.CTFTIME_URL)?.groups?.id; 75 | return await this.inner.get(`/api/v1/events/${id}/`).then(x => x.data); 76 | } 77 | 78 | async recentWriteups(): Promise { 79 | function linkRef(n: any) { 80 | const e = $(n); 81 | const url = e.attr("href"); 82 | if(!url) { 83 | throw new Error("a element does not have url"); 84 | } 85 | const name = e.text(); 86 | return { url: "https://ctftime.org" + url, name }; 87 | } 88 | 89 | const webpage = await this.inner.get("/writeups").then(x => x.data); 90 | const $ = cheerio.load(webpage); 91 | const trs = $("#writeups_table > tbody > tr").toArray(); 92 | const writeups = trs.map(tr => { 93 | const links = $(tr).find("td > a"); 94 | return { 95 | ctf: linkRef(links[0]), 96 | task: linkRef(links[1]), 97 | author: linkRef(links[2]), 98 | url: linkRef(links[3]).url, 99 | }; 100 | }); 101 | return writeups; 102 | } 103 | } 104 | 105 | export default new CTFTime(); -------------------------------------------------------------------------------- /bot/src/util/config.ts: -------------------------------------------------------------------------------- 1 | import {config} from "dotenv"; 2 | 3 | class Config { 4 | 5 | constructor() { 6 | config({ path: "../.env" }); 7 | } 8 | 9 | get(key: string) { 10 | // TODO split by debug and prod 11 | const value = process.env[key]; 12 | if (value === undefined) { 13 | throw new Error(`Config key ${key} not found`); 14 | } 15 | return value; 16 | } 17 | } 18 | 19 | export default new Config(); -------------------------------------------------------------------------------- /bot/src/util/embed.ts: -------------------------------------------------------------------------------- 1 | import { CTF } from "@/data/entities/ctf"; 2 | import { MessageEmbed } from "discord.js"; 3 | import { formatSGT } from "./format"; 4 | 5 | export const EMBED_SUCCESS = 0x00C851; 6 | export const EMBED_SUCCESS2 = 0x007E33; 7 | export const EMBED_INFO = 0x33b5e5; 8 | export const EMBED_INFO1 = 0x0099CC; 9 | export const EMBED_INFO2 = 0x4285F4; 10 | export const EMBED_INFO3 = 0x0d47a1; 11 | export const EMBED_WARN = 0xffbb33; 12 | export const EMBED_WARN2 = 0xFF8800; 13 | export const EMBED_ERROR = 0xff4444; 14 | export const EMBED_ERROR2 = 0xcc0000; 15 | 16 | export function ctfMainMessageEmbed(ctf: CTF) { 17 | return new MessageEmbed({ 18 | color: EMBED_INFO1, 19 | author: { 20 | name: `${ctf.info.title} (${ctf.info.format})`, 21 | icon_url: ctf.info.logo, 22 | }, 23 | description: ctf.info.description, 24 | fields: [ 25 | { name: "URL", value: ctf.info.url }, 26 | //{ name: "Trello", value: ctftimeEvent.trelloUrl }, 27 | { name: "Timing", value: `${formatSGT(ctf.info.start)} - ${formatSGT(ctf.info.finish)}` }, 28 | { name: "Credentials", value: 29 | Object.keys(ctf.credentials).length === 0 ? "None. Use `!addcred key1 value1` to add credentials" : 30 | Object.entries(ctf.credentials) 31 | .map(([key, value]) => "```" + ` ${key}: ${value} ` + "```").join(""), 32 | }], 33 | url: ctf.info.url, 34 | footer: { 35 | text: `Hosted by ${ctf.info.organizers.map(x => x.name).join(", ")}. React with 👌 to get a DM 1hr before the CTF starts`, 36 | }, 37 | }); 38 | } -------------------------------------------------------------------------------- /bot/src/util/format.ts: -------------------------------------------------------------------------------- 1 | import {DateTime} from "luxon"; 2 | 3 | 4 | export function formatSGT(d: DateTime|string) { 5 | if(typeof d === "string") { 6 | d = DateTime.fromISO(d as string); 7 | } 8 | return d.setZone("Asia/Singapore").toFormat("t EEE, d MMM"); 9 | } -------------------------------------------------------------------------------- /bot/src/util/logging.ts: -------------------------------------------------------------------------------- 1 | import winston from "winston"; 2 | 3 | export default winston.createLogger({ 4 | transports: [new winston.transports.Console({ 5 | format: winston.format.combine( 6 | winston.format.colorize(), 7 | winston.format.simple()), 8 | level: "silly", 9 | })], 10 | }); -------------------------------------------------------------------------------- /bot/tests/ctftime.spec.ts: -------------------------------------------------------------------------------- 1 | import ctftime from "@/services/ctftime"; 2 | import "mocha"; 3 | import {expect} from "chai"; 4 | 5 | 6 | describe("CTFTime", () => { 7 | describe("#events()", () => { 8 | it("should return the value as described in CTFTime API page", async () => { 9 | const expected = 10 | [ 11 | { 12 | "organizers": [ 13 | { 14 | "id": 10498, 15 | "name": "th3jackers" 16 | } 17 | ], 18 | "onsite": false, 19 | "finish": "2015-01-24T08:00:00+00:00", 20 | "description": "Registration will be open when CTF Start\r\n#WCTF #th3jackers\r\nhttp://ctf.th3jackers.com/", 21 | "weight": 5.00, 22 | "title": "WCTF - th3jackers", 23 | "url": "http://ctf.th3jackers.com/", 24 | "is_votable_now": false, 25 | "restrictions": "Open", 26 | "format": "Jeopardy", 27 | "start": "2015-01-23T20:00:00+00:00", 28 | "participants": 18, 29 | "ctftime_url": "https://ctftime.org/event/190/", 30 | "location": "", 31 | "live_feed": "", 32 | "public_votable": false, 33 | "duration": { 34 | "hours": 12, 35 | "days": 0 36 | }, 37 | "logo": "", 38 | "format_id": 1, 39 | "id": 190, 40 | "ctf_id": 93 41 | } 42 | ]; 43 | const start = 1422019499; 44 | const finish = 1423029499; 45 | const limit = 100; 46 | 47 | const actual = await ctftime.events(start, finish, limit); 48 | expect(actual).to.be.deep.equal(expected); 49 | }); 50 | }); 51 | }); -------------------------------------------------------------------------------- /bot/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es2017" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */, 5 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 6 | // "lib": [], /* Specify library files to be included in the compilation. */ 7 | // "allowJs": true, /* Allow javascript files to be compiled. */ 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 10 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 11 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 12 | "sourceMap": true /* Generates corresponding '.map' file. */, 13 | // "outFile": "./", /* Concatenate and emit output to single file. */ 14 | "outDir": "./build" /* Redirect output structure to the directory. */, 15 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 16 | // "composite": true, /* Enable project compilation */ 17 | // "removeComments": true, /* Do not emit comments to output. */ 18 | // "noEmit": true, /* Do not emit outputs. */ 19 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 20 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 21 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 22 | 23 | /* Strict Type-Checking Options */ 24 | "strict": true /* Enable all strict type-checking options. */, 25 | "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 26 | "strictNullChecks": true /* Enable strict null checks. */, 27 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 28 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 29 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 30 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 31 | 32 | /* Additional Checks */ 33 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 34 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 35 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 36 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 37 | 38 | /* Module Resolution Options */ 39 | "moduleResolution": "node" /* Specify mode resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 40 | "baseUrl": "./src", /* Base directory to resolve non-absolute module names. */ 41 | "paths": { /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 42 | "@/*": ["./*"] 43 | }, 44 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 45 | // "typeRoots": [], /* List of folders to include type definitions from. */ 46 | // "types": [], /* Type declaration files to be included in compilation. */ 47 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 48 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 49 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 50 | 51 | /* Source Map Options */ 52 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 53 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 54 | //"inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 55 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 56 | 57 | /* Experimental Options */ 58 | "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */, 59 | "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */, 60 | "resolveJsonModule": true, 61 | }, 62 | "include": ["src/**/*.ts"], 63 | "exclude": ["node_modules"] 64 | } 65 | 66 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.4" 2 | 3 | services: 4 | data: 5 | image: mongo 6 | restart: always 7 | volumes: 8 | - ./db:/data/db 9 | 10 | bot: 11 | build: ./bot 12 | restart: always 13 | depends_on: 14 | - data 15 | environment: 16 | - DISCORD_TOKEN 17 | - DISCORD_OWNER 18 | - DISCORD_CTFS_CHANNEL 19 | - DISCORD_UPCOMING_CHANNEL 20 | - DATA_CONNECTION_URI 21 | --------------------------------------------------------------------------------