diff --git a/.nvmrc b/.nvmrc index 035f717..f326028 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v13.7 \ No newline at end of file +v14.13.1 \ No newline at end of file diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..4da5c30 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +nodejs 14.13.1 \ No newline at end of file diff --git a/README.md b/README.md index 4f05891..d00387d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,9 @@ |Staging Build|[![Build Status](https://travis-ci.org/osu-cascades/hackbot.svg?branch=dev)](https://travis-ci.org/osu-cascades/hackbot)| |Maintainability|[![Maintainability](https://api.codeclimate.com/v1/badges/96320fe592c30381915f/maintainability)](https://codeclimate.com/github/osu-cascades/hackbot)| |Test Coverage|[![Test Coverage](https://api.codeclimate.com/v1/badges/96320fe592c30381915f/test_coverage)](https://codeclimate.com/github/osu-cascades/hackbot)| -|GreenKeeper|[![Greenkeeper badge](https://badges.greenkeeper.io/osu-cascades/hackbot.svg)](https://greenkeeper.io/)| +|Snyk|[![Known Vulnerabilities](https://snyk.io/test/github/osu-cascades/hackbot/badge.svg?targetFile=package.json)](https://snyk.io/test/github/osu-cascades/hackbot?targetFile=package.json)| + + A Discord bot for the Cascades Tech Club [Discord](http://discordapp.com) server. To add a command, see the [Commands](#commands) section below. diff --git a/__tests__/__mocks__/axios.ts b/__tests__/__mocks__/axios.ts index ac5c2e9..c4bd9af 100644 --- a/__tests__/__mocks__/axios.ts +++ b/__tests__/__mocks__/axios.ts @@ -4,5 +4,8 @@ export default { }), request: jest.fn().mockResolvedValue({ data: {} - }) + }), + post: jest.fn().mockResolvedValue({ + data: {} + }), }; diff --git a/__tests__/commands/hacktoberfest.test.ts b/__tests__/commands/hacktoberfest.test.ts new file mode 100644 index 0000000..b70f39b --- /dev/null +++ b/__tests__/commands/hacktoberfest.test.ts @@ -0,0 +1,17 @@ +import Hacktoberfest from '@/commands/hacktoberfest'; + +import { message as mockMessage, MockedMessage } from '../mocks/discord'; + +let sendMock: MockedMessage; +beforeEach(() => { + sendMock = jest.fn(); + mockMessage.channel.send = sendMock; +}); + +describe('Hacktoberfest command', () => { + test('hacktoberfest', () => { + Hacktoberfest.execute([], mockMessage); + const sentMessage = sendMock.mock.calls[0][0]; + expect(sentMessage.includes('Hacktoberfest')).toEqual(true); + }); +}); diff --git a/__tests__/commands/run.test.ts b/__tests__/commands/run.test.ts new file mode 100644 index 0000000..65b073e --- /dev/null +++ b/__tests__/commands/run.test.ts @@ -0,0 +1,21 @@ +import Run from '@/commands/run'; +import { message as mockMessage, MockedMessage } from '../mocks/discord'; + +let sendMock: MockedMessage; +beforeEach(() => { + sendMock = jest.fn(); + mockMessage.channel.send = sendMock; + mockMessage.reply = sendMock; +}); + +test('Malformed message', () => { + mockMessage.content = "Wow this is nowhere near the correct content"; + Run.execute([], mockMessage); + expect(sendMock).lastCalledWith("Sorry, I ran into some problems understanding your message. Here is the error stopping me.\nError: Unable to extract code from Wow this is nowhere near the correct content"); +}); + +test('Unknown language', () => { + mockMessage.content = "```invalidLanguage\nblahblahblah```"; + Run.execute([], mockMessage); + expect(sendMock).lastCalledWith("Unknown language: invalidLanguage") +}); \ No newline at end of file diff --git a/__tests__/commands/source.test.ts b/__tests__/commands/source.test.ts index d42fe5c..5b86ba5 100644 --- a/__tests__/commands/source.test.ts +++ b/__tests__/commands/source.test.ts @@ -1,5 +1,5 @@ -import { message as mockMessage, MockedMessage } from '../mocks/discord'; import Source from '@/commands/source'; +import { message as mockMessage, MockedMessage } from '../mocks/discord'; let sendMock: MockedMessage; beforeEach(() => { diff --git a/__tests__/commands/version.test.ts b/__tests__/commands/version.test.ts index d6be445..1516926 100644 --- a/__tests__/commands/version.test.ts +++ b/__tests__/commands/version.test.ts @@ -1,6 +1,6 @@ +import Version from '@/commands/version'; import { Message } from 'discord.js'; import { message as mockMessage, MockedMessage } from '../mocks/discord'; -import Version from '@/commands/version'; let sendMock: MockedMessage; beforeEach(() => { diff --git a/__tests__/languages/rust.test.ts b/__tests__/languages/rust.test.ts new file mode 100644 index 0000000..aa5e0f0 --- /dev/null +++ b/__tests__/languages/rust.test.ts @@ -0,0 +1,21 @@ +import Rust from '@/runners/rust'; +import axiosMock from '../__mocks__/axios'; + +jest.mock('axios'); + +test('valid code', async () => { + const code = "testCode"; + let mockResponse = Promise.resolve({ data: { success: true, stdout: "test", stderr: " Compiling playground v0.0.1 (/playground)\n Finished dev [unoptimized + debuginfo] target(s) in 0.43s\n Running `target/debug/playground`\n" } }); + axiosMock.post.mockResolvedValueOnce(mockResponse); + let result = await Rust.execute(code); + expect(result).toEqual({ success: true, output: "test" }); +}) + +test('invalid code', async () => { + const code = "testCode"; + const errorResult = "bad code"; + let mockResponse = Promise.resolve({ data: { success: false, stdout: "", stderr: errorResult } }); + axiosMock.post.mockResolvedValueOnce(mockResponse); + let result = await Rust.execute(code); + expect(result).toEqual({ success: false, output: errorResult }); +}) \ No newline at end of file diff --git a/__tests__/library/commandLoader.test.ts b/__tests__/library/commandLoader.test.ts index eb82da8..8d2b4c6 100644 --- a/__tests__/library/commandLoader.test.ts +++ b/__tests__/library/commandLoader.test.ts @@ -1,5 +1,5 @@ -import glob from 'glob'; import CommandLoader, { ICommandClasses } from '@/library/commandLoader'; +import glob from 'glob'; describe('CommandLoader', () => { let commandClasses: ICommandClasses; diff --git a/__tests__/library/languages.test.ts b/__tests__/library/languages.test.ts new file mode 100644 index 0000000..9a5da00 --- /dev/null +++ b/__tests__/library/languages.test.ts @@ -0,0 +1,31 @@ +import Languages from '@/library/languages'; +import IRunner from '@/library/interfaces/iRunner'; +import { ILanguageRunners } from '@/library/languageLoader'; + +describe('Languages', () => { + let mockLanguageRunner: IRunner; + let languageRunners: ILanguageRunners; + let languages: Languages; + + beforeEach(() => { + mockLanguageRunner = { + execute: jest.fn() + }; + + languageRunners = { + testLang: mockLanguageRunner, + }; + + languages = new Languages(languageRunners); + }); + + test('.names returns language names', () => { + const languageNames = ['testLang']; + expect(languages.names).toEqual(languageNames); + }); + + test('Can fetch a command', () => { + const testLang = languages.get('testLang'); + expect(testLang).toBe(mockLanguageRunner); + }); +}) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 4001f52..e124be1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "hackbot", - "version": "2.2.0", + "version": "2.3.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -3047,9 +3047,9 @@ } }, "lodash": { - "version": "4.17.15", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", - "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==", + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==", "dev": true }, "lodash.camelcase": { diff --git a/package.json b/package.json index d471af3..00baa20 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hackbot", - "version": "2.2.0", + "version": "2.3.0", "description": "Discord bot for the Cascades Tech Club Discord server.", "repository": { "type": "git", @@ -22,7 +22,7 @@ "author": "osu-cascades", "license": "MIT", "engines": { - "node": "13.7.x" + "node": "14.13.x" }, "dependencies": { "@types/axios": "^0.14.0", diff --git a/src/commands/add.ts b/src/commands/add.ts index fdf810f..dfa3747 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -1,5 +1,5 @@ -import { Message } from "discord.js"; import ICommand from "@/library/interfaces/iCommand"; +import { Message } from "discord.js"; let Add: ICommand; diff --git a/src/commands/format.ts b/src/commands/format.ts index 3a4aaae..c43e9cd 100644 --- a/src/commands/format.ts +++ b/src/commands/format.ts @@ -1,5 +1,5 @@ -import { Message } from 'discord.js'; import ICommand from '@/library/interfaces/iCommand'; +import { Message } from 'discord.js'; // Hack for implementing with static properties/methods let Format: ICommand; diff --git a/src/commands/gitProfile.ts b/src/commands/gitProfile.ts index 2f5c40d..a336f07 100644 --- a/src/commands/gitProfile.ts +++ b/src/commands/gitProfile.ts @@ -1,7 +1,7 @@ +import ICommand from '@/library/interfaces/iCommand'; import axios from 'axios'; import { Message } from 'discord.js'; import moment from 'moment'; -import ICommand from '@/library/interfaces/iCommand'; import IGithubProfile from './interfaces/iGithubProfile'; let GitProfile: ICommand; diff --git a/src/commands/hacktoberfest.ts b/src/commands/hacktoberfest.ts new file mode 100644 index 0000000..3e1214f --- /dev/null +++ b/src/commands/hacktoberfest.ts @@ -0,0 +1,17 @@ +import ICommand from '@/library/interfaces/iCommand'; +import { Message } from 'discord.js'; + +// Hack for implementing with static properties/methods +let Hacktoberfest: ICommand; +export default Hacktoberfest = class { + + static get description(): string { + return 'Lists information on how to participate in Hacktoberfest 2020'; + } + + public static execute(args: string[], msg: Message) { + const content = "Hacktoberfest has officially begun! Find out more information at\nhttps://hacktoberfest.digitalocean.com\nand stay tuned for opportunities and workshops from tech club members."; + return msg.channel.send(content); + } + +}; diff --git a/src/commands/help.ts b/src/commands/help.ts index cfe5c66..906146b 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -1,7 +1,7 @@ -import { Message } from 'discord.js'; import config from '@/config'; import Commands from '@/library/commands'; import ICommand from '@/library/interfaces/iCommand'; +import { Message } from 'discord.js'; let Help: ICommand; diff --git a/src/commands/lmgtfy.ts b/src/commands/lmgtfy.ts index d1887cd..8e30b1c 100644 --- a/src/commands/lmgtfy.ts +++ b/src/commands/lmgtfy.ts @@ -1,5 +1,5 @@ -import { Message } from 'discord.js'; import ICommand from '@/library/interfaces/iCommand'; +import { Message } from 'discord.js'; let Lmgtfy: ICommand; diff --git a/src/commands/magic8ball.ts b/src/commands/magic8ball.ts index 1dab84d..b7624d7 100644 --- a/src/commands/magic8ball.ts +++ b/src/commands/magic8ball.ts @@ -1,5 +1,5 @@ -import { Message } from 'discord.js'; import ICommand from '@/library/interfaces/iCommand'; +import { Message } from 'discord.js'; let Magic8Ball: ICommand; diff --git a/src/commands/rules.ts b/src/commands/rules.ts index 59e22b4..b61e8de 100644 --- a/src/commands/rules.ts +++ b/src/commands/rules.ts @@ -1,5 +1,5 @@ -import { Message } from 'discord.js'; import ICommand from '@/library/interfaces/iCommand'; +import { Message } from 'discord.js'; let Rules: ICommand; diff --git a/src/commands/run.ts b/src/commands/run.ts new file mode 100644 index 0000000..0be6a09 --- /dev/null +++ b/src/commands/run.ts @@ -0,0 +1,61 @@ +import ICommand from '@/library/interfaces/iCommand'; +import Languages from '@/library/languages'; +import { Message } from 'discord.js'; + +const languages = new Languages(); + +// Hack for implementing with static properties/methods +let Run: ICommand; +export default Run = class { + + + /* istanbul ignore next */ + static get description(): string { + return 'Executes provided code using a LanguageRunner'; + } + + public static execute(args: string[], msg: Message) { + + let parseResponse; + + try { + parseResponse = this.parseCode(msg.content); + if (!languages.get(parseResponse.language)) { + msg.channel.send(`Unknown language: ${parseResponse.language}`); + return; + } + } catch (e) { + msg.channel.send("Sorry, I ran into some problems understanding your message. Here is the error stopping me.\n" + e); + return; + } + + const codeRunnerResponse = languages.get(parseResponse.language).execute(parseResponse.code); + + codeRunnerResponse.then((response: { success: any; output: string; }) => { + if (response.success) { + msg.channel.send("```" + response.output + "```"); + } else { + msg.channel.send( + "Unfortunately I was unable to run your code. Here is the error I received.\n```" + + response.output + + "```" + ); + } + }); + + } + + // Tries to pull language and source code out of message + private static parseCode(messageText: string): { language: string, code: string } { + const codeRegex = /(```(.[^\n]*))(\n(.*))(```)/s; + const match = codeRegex.exec(messageText); + + // Group 2 = language, group 4 = code + if (match && match[2] && match[4]) { + return { language: match[2], code: match[4] }; + } else { + throw new Error(`Unable to extract code from ${messageText}`); + } + } + +}; diff --git a/src/commands/say.ts b/src/commands/say.ts index c0af7c4..f3562ca 100644 --- a/src/commands/say.ts +++ b/src/commands/say.ts @@ -1,5 +1,5 @@ -import { Message } from 'discord.js'; import ICommand from '@/library/interfaces/iCommand'; +import { Message } from 'discord.js'; let Say: ICommand; diff --git a/src/commands/source.ts b/src/commands/source.ts index 518bb51..5b245e4 100644 --- a/src/commands/source.ts +++ b/src/commands/source.ts @@ -1,5 +1,5 @@ -import { Message } from 'discord.js'; import ICommand from '@/library/interfaces/iCommand'; +import { Message } from 'discord.js'; let Source: ICommand; diff --git a/src/commands/version.ts b/src/commands/version.ts index 98a253d..4298f58 100644 --- a/src/commands/version.ts +++ b/src/commands/version.ts @@ -1,7 +1,7 @@ import config from '@/config'; +import ICommand from '@/library/interfaces/iCommand'; import { version } from '@root/package.json'; import { Message } from 'discord.js'; -import ICommand from '@/library/interfaces/iCommand'; let Version: ICommand; diff --git a/src/commands/weather.ts b/src/commands/weather.ts index 3aa2406..2a819cc 100644 --- a/src/commands/weather.ts +++ b/src/commands/weather.ts @@ -1,7 +1,7 @@ -import axios, { AxiosResponse } from 'axios'; -import { Message } from 'discord.js'; import config from '@/config'; import ICommand from '@/library/interfaces/iCommand'; +import axios, { AxiosResponse } from 'axios'; +import { Message } from 'discord.js'; let Weather: ICommand; diff --git a/src/commands/xmas.ts b/src/commands/xmas.ts index a44e43d..af58bb8 100644 --- a/src/commands/xmas.ts +++ b/src/commands/xmas.ts @@ -1,5 +1,5 @@ -import { Message } from 'discord.js'; import ICommand from '@/library/interfaces/iCommand'; +import { Message } from 'discord.js'; let Xmas: ICommand; diff --git a/src/index.ts b/src/index.ts index 09530ec..8034849 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import { Client } from 'discord.js'; const client = new Client(); const core = new Core(client); -client.on('ready', () => core.ready); +client.on('ready', () => core.ready()); client.on('message', core.message); diff --git a/src/library/interfaces/iCommand.ts b/src/library/interfaces/iCommand.ts index ba06045..6445762 100644 --- a/src/library/interfaces/iCommand.ts +++ b/src/library/interfaces/iCommand.ts @@ -1,5 +1,6 @@ import { Client, Message } from "discord.js"; -import Commands from "@/commands"; +import Commands from "@/library/commands"; + /** * An interface for all commands to extend, representing the API that all diff --git a/src/library/interfaces/iRunner.ts b/src/library/interfaces/iRunner.ts new file mode 100644 index 0000000..00d143d --- /dev/null +++ b/src/library/interfaces/iRunner.ts @@ -0,0 +1,10 @@ +/** + * An interface for all code runners to extend, representing the API that all + * subclasses should implement. + * + * @class Runner + */ + + export default interface IRunner { + execute(code: string): Promise<{success: boolean, output: string}>; + } \ No newline at end of file diff --git a/src/library/languageLoader.ts b/src/library/languageLoader.ts new file mode 100644 index 0000000..66ac7d9 --- /dev/null +++ b/src/library/languageLoader.ts @@ -0,0 +1,30 @@ +import camelCase from 'lodash.camelcase'; +import path from 'path'; +import Runner from './interfaces/iRunner'; + +export interface ILanguageRunners { [key: string]: Runner; } + +export default class LanguageLoader { + public static getLanguageClasses(commandClassFiles: string[]): ILanguageRunners { + /** + * https://stackoverflow.com/questions/5364928/node-js-require-all-files-in-a-folder + * Load all commands in the commands folder besides _template.js + */ + const files = LanguageLoader.removeTemplateFile(commandClassFiles); + return files.reduce((prev: ILanguageRunners, file) => { + let key = path.basename(file, path.extname(file)); + // Convert the kebab file names to camel case + key = camelCase(key); + + const required = require(path.resolve(file)); + prev[key] = required.default; + + return prev; + }, {}); + } + + private static removeTemplateFile(files: string[]) { + const commandTemplateFile = './src/runners/_template.ts'; + return files.filter(file => file !== commandTemplateFile); + } +} diff --git a/src/library/languages.ts b/src/library/languages.ts new file mode 100644 index 0000000..14ea8c8 --- /dev/null +++ b/src/library/languages.ts @@ -0,0 +1,36 @@ +import LanguageRunner from './interfaces/iRunner'; +import { ILanguageRunners } from './languageLoader'; +import glob from 'glob'; +import LanguageLoader from './languageLoader'; + + +export default class Languages { + public readonly all: ILanguageRunners; + + constructor(languages?: ILanguageRunners) { + this.all = languages || this.fetchLanguages(); + } + + get names() { + return Object.keys(this.all); + } + + public get(languageName: string): LanguageRunner { + return this.all[languageName]; + } + + public longestNameLength() { + // Find the longest synopsis + const longest = this.names.sort((a, b) => b.length - a.length)[0]; + return longest.length; + } + + private fetchLanguages(): ILanguageRunners { + const languagesPathGlob = './src/runners/*.ts'; + const languageRunnerFiles = glob.sync(languagesPathGlob); + return LanguageLoader.getLanguageClasses(languageRunnerFiles); + } + +} + + diff --git a/src/runners/_template.ts b/src/runners/_template.ts new file mode 100644 index 0000000..711a0fe --- /dev/null +++ b/src/runners/_template.ts @@ -0,0 +1,8 @@ +import IRunner from '@/library/interfaces/iRunner'; + +let LanguageName: IRunner; +export default LanguageName = class { + public static execute(code: string): Promise<{success: boolean, output: string}> { + throw new Error(`LanguageRunner not yet implemented for ${this.name}`); + } +}; diff --git a/src/runners/rust.ts b/src/runners/rust.ts new file mode 100644 index 0000000..6c68a1e --- /dev/null +++ b/src/runners/rust.ts @@ -0,0 +1,38 @@ +import IRunner from '@/library/interfaces/iRunner'; +import axios, { AxiosResponse } from 'axios'; + +let Rust: IRunner; +export default Rust = class { + public static execute(code: string): Promise<{ success: boolean, output: string }> { + return this.runCode(code) + .then((response) => { + if (response.success) { + return { success: true, output: response.stdout }; + } else { + return { success: false, output: response.stderr }; + } + }) + .catch(error => { + return { success: false, output: "Rust LanguageRunner encountered an internal error" }; + }); + } + + // Sends code to the rust playground for execution + private static runCode(code: string): Promise<{ success: boolean, stdout: string, stderr: string }> { + const url = "https://play.rust-lang.org/execute"; + return axios.post(url, { + channel: "stable", + code, + crateType: "bin", + edition: "2018", + mode: "debug", + tests: false + }).then((response: AxiosResponse) => { + return { + success: response.data.success, + stdout: response.data.stdout, + stderr: response.data.stderr + }; + }); + } +};