meta: rewrite the bot in Typescript

This commit is contained in:
liushuyu 2020-05-01 23:35:33 -06:00
parent f29653e6a8
commit 7d54dc1a78
30 changed files with 1774 additions and 1251 deletions

2
.gitignore vendored
View file

@ -49,3 +49,5 @@ config/development.json
.vs/
.vscode/
CMakeLists.txt.user*
/dist

View file

@ -3,12 +3,20 @@ FROM mhart/alpine-node:latest
# Create app directory
WORKDIR /usr/src/app
# Install app dependencies
COPY package.json ./
RUN yarn install
# Install app dependencies and add source files
COPY package.json yarn.lock tsconfig.json ./
COPY src/ ./src
RUN yarn install && yarn build && rm -f dist/*.map
# Bundle app source
COPY . .
# Second stage
FROM mhart/alpine-node:latest
WORKDIR /usr/src/app
# Copy artifacts
COPY --from=0 /usr/src/app/dist/ ./
COPY --from=0 /usr/src/app/node_modules ./node_modules
COPY env.json ./
RUN addgroup -S app -g 50000 && \
adduser -S -g app -u 50000 app && \
@ -16,4 +24,4 @@ RUN addgroup -S app -g 50000 && \
USER app
ENTRYPOINT [ "node", "src/server.js" ]
ENTRYPOINT [ "node", "server.js" ]

View file

@ -10,17 +10,17 @@ Invite Bot to your Server (see **Creating a Bot User** below).
Make bot a Moderator.
# Install & Dependencies
Install Node.js and NPM.
Install Node.js and Yarn.
Install forever (task scheduler).
```sh
npm install -g forever
yarn global add forever
```
Clone repository and install package dependencies.
```sh
git clone https://github.com/citra-emu/discord-bot.git
cd discord-bot
npm install
yarn
```
Create new JSON file for bot config with the following contents in the directory specified below:
@ -48,11 +48,13 @@ Copy App Bot User token to `"clientLoginToken": ""`
##### For Production
First yo need to build the project by running `yarn build`.
`./start.sh` Requires a config/production.json file.
##### For Development
`node server.js` Requires a config/development.json file.
`yarn serve` Requires a config/development.json file.
# License
GNU General Public License v2.0

View file

@ -1,6 +1,6 @@
{
"name": "citra-discord-bot",
"version": "1.0.0",
"version": "2.0.0",
"description": "Citra bot for Discord",
"author": "chris062689 <chris062689@gmail.com>",
"preferGlobal": true,
@ -10,21 +10,32 @@
"license": "GPL-2.0+",
"dependencies": {
"checkenv": "^1.2.2",
"discord.js": "^11.3.0",
"discord.js": "^12.2.0",
"ip": "^1.1.5",
"logdna": "^1.2.3",
"node-schedule": "^1.2.3",
"request": "^2.79.0",
"request-promise-native": "^1.0.5",
"string-similarity": "^1.2.0",
"winston": "^2.3.0"
"logdna": "^3.5.0",
"logdna-winston": "^2.3.1",
"node-fetch": "^2.6.0",
"node-schedule": "^1.3.2",
"string-similarity": "^4.0.1",
"typescript": "^3.8.3",
"winston": "^3.2.1"
},
"devDependencies": {
"eslint": "^4.8.0",
"eslint-config-standard": "^10.2.1",
"eslint-plugin-import": "^2.7.0",
"eslint-plugin-node": "^5.2.0",
"eslint-plugin-promise": "^3.5.0",
"eslint-plugin-standard": "^3.0.1"
"@types/ip": "^1.1.0",
"@types/node": "^13.13.4",
"@types/node-fetch": "^2.5.7",
"@types/string-similarity": "^3.0.0",
"@types/ws": "^7.2.4",
"eslint": "^6.8.0",
"eslint-config-standard": "^14.1.1",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-standard": "^4.0.1",
"ts-node": "^8.9.1"
},
"scripts": {
"build": "yarn run tsc",
"serve": "yarn run ts-node ./src/server.ts"
}
}

View file

@ -1,10 +1,11 @@
const state = require('../state.js');
const data = require('../data.js');
const logger = require('../logging.js');
const UserBan = require('../models/UserBan.js');
import state from '../state';
import * as data from '../data';
import logger from '../logging';
import UserBan from '../models/UserBan';
import discord = require('discord.js');
exports.roles = ['Admins', 'Moderators', 'CitraBot'];
exports.command = function (message) {
export const roles = ['Admins', 'Moderators', 'CitraBot'];
export function command (message: discord.Message) {
message.mentions.users.map((user) => {
const count = state.warnings.filter(x => x.id === user.id && !x.cleared).length || 0;

View file

@ -1,9 +1,10 @@
const state = require('../state.js');
const data = require('../data.js');
const logger = require('../logging.js');
import state from '../state';
import * as data from '../data';
import logger from '../logging';
import discord = require('discord.js');
exports.roles = ['Admins', 'Moderators'];
exports.command = function (message) {
export const roles = ['Admins', 'Moderators'];
export function command (message: discord.Message) {
message.mentions.users.map((user) => {
const count = state.warnings.filter(x => x.id === user.id && !x.cleared);
if (count != null && count.length > 0) {
@ -14,7 +15,7 @@ exports.command = function (message) {
message.channel.send(`${user}, you have no warnings to clear.`);
}
logger.info(`${message.author.toString()} has cleared all warnings for ${user.toString()} [${count.length}].`);
logger.info(`${message.author.username} has cleared all warnings for ${user} ${user.username} [${count.length}].`);
state.logChannel.send(`${message.author.toString()} has cleared all warnings for ${user.toString()} [${count.length}].`);
});
};

View file

@ -1,99 +0,0 @@
const request = require('request-promise-native');
const discord = require('discord.js');
const stringSimilarity = require('string-similarity');
const logger = require('../logging.js');
const state = require('../state.js');
const targetServer = process.env.COMPAT_DB_SOURCE;
const refreshTime = process.env.COMPAT_REFRESH_TIME ? parseInt(process.env.COMPAT_REFRESH_TIME) : 1000 * 60 * 20;
const iconBase = process.env.COMPAT_ICON_BASE;
const urlBase = process.env.COMPAT_URL_BASE;
const compatStrings = {
0: { "key": "0", "name": "Perfect", "color": "#5c93ed", "description": "Game functions flawless with no audio or graphical glitches, all tested functionality works as intended without any workarounds needed." },
1: { "key": "1", "name": "Great", "color": "#47d35c", "description": "Game functions with minor graphical or audio glitches and is playable from start to finish. May require some workarounds." },
2: { "key": "2", "name": "Okay", "color": "#94b242", "description": "Game functions with major graphical or audio glitches, but game is playable from start to finish with workarounds." },
3: { "key": "3", "name": "Bad", "color": "#f2d624", "description": "Game functions, but with major graphical or audio glitches. Unable to progress in specific areas due to glitches even with workarounds." },
4: { "key": "4", "name": "Intro/Menu", "color": "red", "description": "Game is completely unplayable due to major graphical or audio glitches. Unable to progress past the Start Screen." },
5: { "key": "5", "name": "Won't Boot", "color": "#828282", "description": "The game crashes when attempting to startup." },
99: { "key": "99", "name": "Not Tested", "color": "black", "description": "The game has not yet been tested." }
};
async function updateDatabase () {
let body;
try {
body = await request(targetServer);
} catch (e) {
logger.error("Unable to download latest games list!");
throw e;
}
state.gameDB = JSON.parse(body).map(x => {
return {
directory: x.directory,
title: x.title,
compatibility: x.compatibility
};
});
state.lastGameDBUpdate = Date.now();
logger.info(`Updated games list (${state.gameDB.length} games)`);
state.gameDBPromise = null;
}
exports.command = async function (message) {
if (Date.now() - state.lastGameDBUpdate > refreshTime) {
// Update remote list of games locally.
const waitMessage = message.channel.send("This will take a second...");
if (state.gameDBPromise == null) {
state.gameDBPromise = updateDatabase(message);
}
try {
await state.gameDBPromise;
} catch (e) {
message.channel.send("Game compatibility feed temporarily unavailable.");
throw e;
} finally {
// We don't need this message anymore
waitMessage.then(waitMessageResult => waitMessageResult.delete());
}
}
const game = message.content.substr(message.content.indexOf(' ') + 1);
// Search all games. This is only linear time, so /shrug?
let bestGame = undefined;
let bestScore = 0.5; // Game names must have at least a 50% similarity to be matched
state.gameDB.forEach(testGame => {
const newDistance = stringSimilarity.compareTwoStrings(game.toLowerCase(), testGame.title.toLowerCase());
if (newDistance > bestScore) {
bestGame = testGame;
bestScore = newDistance;
}
});
if (bestGame === undefined) {
message.channel.send("Game could not be found.");
return;
}
const screenshot = `${iconBase}${bestGame.directory}.png`;
const url = `${urlBase}${bestGame.directory}/`;
const compat = compatStrings[bestGame.compatibility];
const embed = new discord.RichEmbed()
.addField("Status", compat.name, true)
.setTitle(bestGame.title)
.setColor(compat.color)
.setDescription(compat.description)
.setURL(url)
.setThumbnail(screenshot);
message.channel.send(embed);
};

101
src/commands/game.ts Normal file
View file

@ -0,0 +1,101 @@
import fetch from 'node-fetch';
import discord = require('discord.js');
import stringSimilarity = require('string-similarity');
import logger from '../logging';
import state from '../state';
import { IGameDBEntry, ICompatList } from '../models/interfaces';
const targetServer = process.env.COMPAT_DB_SOURCE;
const refreshTime = process.env.COMPAT_REFRESH_TIME ? parseInt(process.env.COMPAT_REFRESH_TIME) : 1000 * 60 * 20;
const iconBase = process.env.COMPAT_ICON_BASE;
const urlBase = process.env.COMPAT_URL_BASE;
const compatStrings: ICompatList = {
0: { key: '0', name: 'Perfect', color: '#5c93ed', description: 'Game functions flawless with no audio or graphical glitches, all tested functionality works as intended without any workarounds needed.' },
1: { key: '1', name: 'Great', color: '#47d35c', description: 'Game functions with minor graphical or audio glitches and is playable from start to finish. May require some workarounds.' },
2: { key: '2', name: 'Okay', color: '#94b242', description: 'Game functions with major graphical or audio glitches, but game is playable from start to finish with workarounds.' },
3: { key: '3', name: 'Bad', color: '#f2d624', description: 'Game functions, but with major graphical or audio glitches. Unable to progress in specific areas due to glitches even with workarounds.' },
4: { key: '4', name: 'Intro/Menu', color: 'red', description: 'Game is completely unplayable due to major graphical or audio glitches. Unable to progress past the Start Screen.' },
5: { key: '5', name: "Won't Boot", color: '#828282', description: 'The game crashes when attempting to startup.' },
99: { key: '99', name: 'Not Tested', color: 'black', description: 'The game has not yet been tested.' }
};
async function updateDatabase () {
let body;
try {
let response = await fetch(targetServer);
body = await response.json();
} catch (e) {
logger.error('Unable to download latest games list!');
throw e;
}
state.gameDB = body.map((x: IGameDBEntry) => {
return {
directory: x.directory,
title: x.title,
compatibility: x.compatibility
};
});
state.lastGameDBUpdate = Date.now();
logger.info(`Updated games list (${state.gameDB.length} games)`);
state.gameDBPromise = null;
}
export async function command (message: discord.Message) {
if (Date.now() - state.lastGameDBUpdate > refreshTime) {
// Update remote list of games locally.
const waitMessage = message.channel.send('This will take a second...');
if (state.gameDBPromise == null) {
state.gameDBPromise = updateDatabase();
}
try {
await state.gameDBPromise;
} catch (e) {
message.channel.send('Game compatibility feed temporarily unavailable.');
throw e;
} finally {
// We don't need this message anymore
waitMessage.then(waitMessageResult => waitMessageResult.delete());
}
}
const game = message.content.substr(message.content.indexOf(' ') + 1);
// Search all games. This is only linear time, so /shrug?
let bestGame: IGameDBEntry;
let bestScore = 0.5; // Game names must have at least a 50% similarity to be matched
state.gameDB.forEach(testGame => {
const newDistance = stringSimilarity.compareTwoStrings(game.toLowerCase(), testGame.title.toLowerCase());
if (newDistance > bestScore) {
bestGame = testGame;
bestScore = newDistance;
}
});
if (!bestGame) {
message.channel.send('Game could not be found.');
return;
}
const screenshot = `${iconBase}${bestGame.directory}.png`;
const url = `${urlBase}${bestGame.directory}/`;
const compat = compatStrings[bestGame.compatibility];
const embed = new discord.MessageEmbed()
.addField('Status', compat.name, true)
.setTitle(bestGame.title)
.setColor(compat.color)
.setDescription(compat.description)
.setURL(url)
.setThumbnail(screenshot);
message.channel.send(embed);
}

View file

@ -1,16 +0,0 @@
exports.roles = ['Admins', 'Moderators', 'CitraBot'];
exports.command = function (message) {
const role = process.env.DISCORD_DEVELOPER_ROLE;
message.mentions.users.map((user) => {
let member = message.guild.member(user);
let alreadyJoined = member.roles.has(role);
if (alreadyJoined) {
member.removeRole(role);
message.channel.send(`${user}'s speech has been revoked in the #development channel.`);
} else {
member.addRole(role);
message.channel.send(`${user} has been granted speech in the #development channel.`);
}
});
};

View file

@ -0,0 +1,18 @@
import discord = require('discord.js');
export const roles = ['Admins', 'Moderators', 'CitraBot'];
export function command (message: discord.Message) {
const role = process.env.DISCORD_DEVELOPER_ROLE;
message.mentions.users.map((user) => {
const member = message.guild.member(user);
const alreadyJoined = member.roles.cache.has(role);
if (alreadyJoined) {
member.roles.remove(role);
message.channel.send(`${user.toString()}'s speech has been revoked in the #development channel.`);
} else {
member.roles.remove(role);
message.channel.send(`${user.toString()} has been granted speech in the #development channel.`);
}
});
}

View file

@ -1,23 +0,0 @@
const state = require('../state.js');
exports.roles = ['Admins', 'Moderators'];
function formatWarnings(warnings) {
return warnings.map(x => `[${x.date}] ${x.warnedByUsername} warned ${x.username} [${x.priorWarnings} + 1]. ${x.silent ? '(silent)' : ''} ${x.cleared ? '(cleared)' : ''}`)
}
function formatBans(bans) {
return bans.map(x => `[${x.date}] ${x.warnedByUsername} banned ${x.username} [${x.priorWarnings} + 1].`)
}
exports.command = function (message) {
message.mentions.users.map((user) => {
const totalWarnings = state.warnings.filter(x => x.id === user.id && x.cleared == false).length;
let warns = state.warnings.filter(x => x.id == user.id)
let bans = state.bans.filter(x => x.id == user.id)
const warnsString = `Warns: \`\`\`${formatWarnings(warns).join('\n')}\`\`\``
const bansString = `Bans: \`\`\`${formatBans(bans).join('\n')}\`\`\``
message.channel.send(`\`${user.username} (${totalWarnings}) information:\`${warns.length != 0 ? warnsString : ''}${bans.length != 0 ? bansString : ''}`)
});
}

27
src/commands/info.ts Normal file
View file

@ -0,0 +1,27 @@
import state from '../state';
import UserBan from '../models/UserBan';
import UserWarning from '../models/UserWarning';
import discord = require('discord.js');
export const roles = ['Admins', 'Moderators'];
function formatWarnings (warnings: UserWarning[]) {
return warnings.map(x => `[${x.date}] ${x.warnedByUsername} warned ${x.username} [${x.priorWarnings} + 1]. ${x.silent ? '(silent)' : ''} ${x.cleared ? '(cleared)' : ''}`);
}
function formatBans (bans: UserBan[]) {
return bans.map(x => `[${x.date}] ${x.warnedByUsername} banned ${x.username} [${x.priorWarnings} + 1].`);
}
export function command (message: discord.Message) {
message.mentions.users.map((user) => {
const totalWarnings = state.warnings.filter(x => x.id === user.id && x.cleared === false).length;
const warns = state.warnings.filter(x => x.id === user.id);
const bans = state.bans.filter(x => x.id === user.id);
const warnsString = `Warns: \`\`\`${formatWarnings(warns).join('\n')}\`\`\``;
const bansString = `Bans: \`\`\`${formatBans(bans).join('\n')}\`\`\``;
message.channel.send(`\`${user.username} (${totalWarnings}) information:\`${warns.length !== 0 ? warnsString : ''}${bans.length !== 0 ? bansString : ''}`);
});
};

View file

@ -1,5 +1,7 @@
exports.roles = ['Admins', 'Moderators'];
exports.command = function (message, reply) {
import discord = require('discord.js');
export const roles = ['Admins', 'Moderators'];
export function command (message: discord.Message, reply: string) {
let replyMessage = 'Hello.';
if (reply == null) {
replyMessage = message.content.substr(message.content.indexOf(' ') + 1);
@ -8,4 +10,4 @@ exports.command = function (message, reply) {
}
message.channel.send(replyMessage);
};
}

View file

@ -1,27 +0,0 @@
const request = require('request');
exports.roles = ['Admins', 'Moderators', 'Developers'];
exports.command = function (message) {
let pr = message.content.substr(message.content.indexOf(' ') + 1).replace(/\n/g, '');
let repo = process.env.GITHUB_REPOSITORY || "citra-emu/citra";
let url = `https://api.github.com/repos/${repo}/pulls/${pr}`;
request({ url: url, headers: { 'User-Agent': 'Citra-Emu/CitraBot (Node.js)' } }, function (error, response, body) {
if (!error) {
const pr = JSON.parse(body);
request({ url: pr.statuses_url, headers: { 'User-Agent': 'Citra-Emu/CitraBot (Node.js)' } }, function (error, response, body) {
const statuses = JSON.parse(body);
if (statuses.length === 0) return;
// Travis CI will give you multiple, identical target URLs so we might as well just check the first one...
const status = statuses[0];
status.target_url = status.target_url.substr(0, status.target_url.indexOf('?'));
message.channel.sendMessage(`${status.context}: ${status.target_url}: **${status.state}**`);
});
} else {
message.channel.sendMessage('No such PR.');
}
});
};

27
src/commands/status.ts Normal file
View file

@ -0,0 +1,27 @@
import fetch from 'node-fetch';
import discord = require('discord.js');
const fetchOptions = {
headers: { 'User-Agent': 'Citra-Emu/CitraBot (Node.js)' }
};
const repo = process.env.GITHUB_REPOSITORY || 'citra-emu/citra';
export const roles = ['Admins', 'Moderators', 'Developers'];
export function command(message: discord.Message) {
const pr = message.content.substr(message.content.indexOf(' ') + 1).replace(/\n/g, '');
const url = `https://api.github.com/repos/${repo}/pulls/${pr}`;
fetch(url, fetchOptions).then(response => response.json()).then(pr => {
if (pr.documentation_url) throw new Error('PR not found');
fetch(pr.statuses_url, fetchOptions).then(response => response.json()).then(statuses => {
if (statuses.length === 0) return;
// Travis CI will give you multiple, identical target URLs so we might as well just check the first one...
const status = statuses[0];
status.target_url = status.target_url.substr(0, status.target_url.indexOf('?'));
message.channel.send(`${status.context}: ${status.target_url}: **${status.state}**`);
}).catch(() => {
message.channel.send('I wasn\'t able to get the status of that PR...')
});
}).catch(() => {
message.channel.send('No such PR.');
});
}

View file

@ -1,23 +0,0 @@
const state = require('../state.js');
const data = require('../data.js');
const logger = require('../logging.js');
const UserWarning = require('../models/UserWarning.js');
exports.roles = ['Admins', 'Moderators'];
exports.command = function (message) {
const silent = message.content.includes('silent')
message.mentions.users.map((user) => {
const count = state.warnings.filter(x => x.id === user.id && !x.cleared).length || 0;
if (silent == false) {
message.channel.send(`${user} You have been warned. Additional infractions may result in a ban.`);
}
logger.info(`${message.author.username} ${message.author} has warned ${user.username} ${user} [${count} + 1].`);
state.logChannel.send(`${message.author} has warned ${user} [${count} + 1].`);
state.warnings.push(new UserWarning(user.id, user.username, message.author.id, message.author.username, count, silent));
data.flushWarnings();
});
};

24
src/commands/warn.ts Normal file
View file

@ -0,0 +1,24 @@
import state from '../state';
import * as data from '../data';
import logger from '../logging';
import UserWarning from '../models/UserWarning';
import discord = require('discord.js');
exports.roles = ['Admins', 'Moderators'];
exports.command = function (message: discord.Message) {
const silent = message.content.includes('silent');
message.mentions.users.map((user) => {
const count = state.warnings.filter(x => x.id === user.id && !x.cleared).length || 0;
if (silent === false) {
message.channel.send(`${user.toString()} You have been warned. Additional infractions may result in a ban.`);
}
logger.info(`${message.author.username} ${message.author} has warned ${user.username} ${user} [${count} + 1].`);
state.logChannel.send(`${message.author.toString()} has warned ${user.toString()} [${count} + 1].`);
state.warnings.push(new UserWarning(user.id, user.username, message.author.id, message.author.username, count, silent));
data.flushWarnings();
});
};

View file

@ -1,6 +1,7 @@
const state = require('../state.js');
import state from '../state';
import discord = require('discord.js');
exports.command = function (message) {
exports.command = function (message: discord.Message) {
message.mentions.users.map((user) => {
const warnings = state.warnings.filter(x => x.id === user.id && !x.cleared);
message.channel.send(`${user}, you have ${warnings.length} total warnings.`);

View file

@ -1,8 +1,8 @@
const fs = require('fs');
const state = require('./state.js');
const logger = require('./logging.js');
import * as fs from 'fs';
import state from './state';
import logger from './logging';
function readWarnings () {
export function readWarnings () {
// Load the warnings file into the application state.
const readFilePath = '/data/discordWarnings.json';
fs.readFile(readFilePath, 'utf8', function (err, data) {
@ -16,7 +16,7 @@ function readWarnings () {
});
}
function readBans () {
export function readBans () {
// Load the ban file into the application state.
const readFilePath = '/data/discordBans.json';
fs.readFile(readFilePath, 'utf8', function (err, data) {
@ -30,24 +30,22 @@ function readBans () {
});
}
function readCustomResponses () {
export function readCustomResponses () {
// Load the responses file into the responses variable.
state.responses = require(`./responses/${process.env.TENANT}.json`);
logger.debug(`Loaded responses file for ${process.env.TENANT} from external source.`);
}
function flushWarnings () {
export function flushWarnings () {
const warningsJson = JSON.stringify(state.warnings, null, 4);
fs.writeFile('/data/discordWarnings.json', warningsJson, 'utf8', function (err) {
if (err) { throw err; }
});
}
function flushBans () {
export function flushBans () {
const bansJson = JSON.stringify(state.bans, null, 4);
fs.writeFile('/data/discordBans.json', bansJson, 'utf8', function (err) {
if (err) { throw err; }
});
}
module.exports = { readWarnings: readWarnings, readBans: readBans, readCustomResponses: readCustomResponses, flushWarnings: flushWarnings, flushBans: flushBans };

View file

@ -1,35 +1,36 @@
const winston = require('winston');
const ip = require('ip');
const os = require('os');
import winston = require('winston');
import * as ip from 'ip';
import * as os from 'os';
winston.emitErrs = true;
const logger = new winston.Logger({
const logger = winston.createLogger({
level: 'debug',
transports: [
new (winston.transports.Console)()
new (winston.transports.Console)({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
),
handleExceptions: true
})
],
handleExceptions: true,
humanReadableUnhandledException: true,
exitOnError: false,
meta: true
});
// Setup logging for LogDNA cloud logging.
if (process.env.LOGDNA_API_KEY) {
require('logdna');
const logdnaWinston = require('logdna-winston');
const logLevel = process.env.LOGDNA_LEVEL || 'info';
logger.add(winston.transports.Logdna, {
logger.add(new logdnaWinston({
level: logLevel,
app: process.env.LOGDNA_APPNAME,
index_meta: true,
key: process.env.LOGDNA_API_KEY,
ip: ip.address(),
hostname: os.hostname()
});
}));
logger.info(`[core] Started LogDNA winston transport. Running at log level ${logLevel}.`);
}
module.exports = logger;
export default logger;

View file

@ -1,12 +0,0 @@
class UserBan {
constructor (id, username, warnedBy, warnedByUsername, priorWarnings) {
this.id = id;
this.username = username;
this.date = new Date();
this.warnedBy = warnedBy;
this.warnedByUsername = warnedByUsername;
this.priorWarnings = priorWarnings;
}
}
module.exports = UserBan;

19
src/models/UserBan.ts Normal file
View file

@ -0,0 +1,19 @@
class UserBan {
id: string;
username: string;
date: Date;
warnedBy: string;
warnedByUsername: string;
priorWarnings: number;
constructor (id: string, username: string, warnedBy: string, warnedByUsername: string, priorWarnings: number) {
this.id = id;
this.username = username;
this.date = new Date();
this.warnedBy = warnedBy;
this.warnedByUsername = warnedByUsername;
this.priorWarnings = priorWarnings;
}
}
export default UserBan;

View file

@ -1,13 +0,0 @@
class UserWarning {
constructor (id, username, warnedBy, warnedByUsername, priorWarnings, silent) {
this.id = id
this.username = username
this.date = new Date()
this.warnedBy = warnedBy
this.warnedByUsername = warnedByUsername
this.priorWarnings = priorWarnings
this.silent = silent
}
}
module.exports = UserWarning;

23
src/models/UserWarning.ts Normal file
View file

@ -0,0 +1,23 @@
class UserWarning {
id: string;
username: string;
date: Date;
warnedBy: string;
warnedByUsername: string;
priorWarnings: number;
silent: boolean;
cleared: boolean;
constructor (id: string, username: string, warnedBy: string, warnedByUsername: string, priorWarnings: number, silent: boolean) {
this.id = id;
this.username = username;
this.date = new Date();
this.warnedBy = warnedBy;
this.warnedByUsername = warnedByUsername;
this.priorWarnings = priorWarnings;
this.silent = silent;
this.cleared = false;
}
}
export default UserWarning;

14
src/models/interfaces.ts Normal file
View file

@ -0,0 +1,14 @@
export interface IGameDBEntry {
directory: string;
title: string;
compatibility: number;
}
export interface ICompatList {
[key: number]: {
key: string,
name: string,
color: string,
description: string
}
}

View file

@ -1,50 +1,48 @@
// Check for environmental variables.
require('checkenv').check();
const discord = require('discord.js');
const path = require('path');
const schedule = require('node-schedule');
const fs = require('fs');
import discord = require('discord.js');
import path = require('path');
// const schedule = require('node-schedule');
import fs = require('fs');
const logger = require('./logging.js');
const state = require('./state.js');
const data = require('./data.js');
import logger from './logging';
import state from './state';
import * as data from './data';
state.responses = require('./responses.json');
let cachedModules = [];
let cachedTriggers = [];
interface IModuleMap {
[name: string]: any;
}
let cachedModules: IModuleMap = {};
let cachedTriggers: any[] = [];
const client = new discord.Client();
let mediaUsers = new Map();
const mediaUsers = new Map();
logger.info('Application startup. Configuring environment.');
process.on('unhandledRejection', (error, promise) => {
logger.error(`Unhandled promise rejection: ${error.message}.`, { meta: error });
});
process.on('uncaughtException', error => {
logger.error(`Unhandled exception: ${error.message}.`, { meta: error });
process.exit(-1);
});
function findArray (haystack, arr) {
return arr.some(function (v) {
function findArray(haystack: string | any[], arr: any[]) {
return arr.some(function (v: any) {
return haystack.indexOf(v) >= 0;
});
}
function IsIgnoredCategory (categoryName) {
function IsIgnoredCategory(categoryName: string) {
const IgnoredCategory = ['welcome', 'team', 'website-team'];
return IgnoredCategory.includes(categoryName);
}
client.on('ready', () => {
client.on('ready', async () => {
// Initialize app channels.
state.logChannel = client.channels.get(process.env.DISCORD_LOG_CHANNEL);
state.msglogChannel = client.channels.get(process.env.DISCORD_MSGLOG_CHANNEL);
state.guild = state.logChannel.guild;
let logChannel = await client.channels.fetch(process.env.DISCORD_LOG_CHANNEL) as discord.TextChannel;
let msglogChannel = await client.channels.fetch(process.env.DISCORD_MSGLOG_CHANNEL) as discord.TextChannel;
if (!logChannel.send) throw new Error('DISCORD_LOG_CHANNEL is not a text channel!');
if (!msglogChannel.send) throw new Error('DISCORD_MSGLOG_CHANNEL is not a text channel!');
state.logChannel = logChannel;
state.msglogChannel = msglogChannel;
logger.info('Bot is now online and connected to server.');
});
@ -63,19 +61,17 @@ client.on('debug', (x) => null);
client.on('disconnect', () => {
logger.warn('Disconnected from Discord server.');
});
client.on('reconnecting', () => {
logger.warn('Reconnecting...');
});
client.on('guildMemberAdd', (member) => {
member.addRole(process.env.DISCORD_RULES_ROLE);
member.roles.add(process.env.DISCORD_RULES_ROLE);
});
client.on('messageDelete', message => {
if (IsIgnoredCategory(message.channel.parent.name) == false) {
if (message.content && message.content.startsWith('.') == false && message.author.bot == false) {
const deletionEmbed = new discord.RichEmbed()
.setAuthor(message.author.tag, message.author.displayAvatarURL)
let parent = (message.channel as discord.TextChannel).parent;
if (parent && IsIgnoredCategory(parent.name) === false) {
if (message.content && message.content.startsWith('.') === false && message.author.bot === false) {
const deletionEmbed = new discord.MessageEmbed()
.setAuthor(message.author.tag, message.author.displayAvatarURL())
.setDescription(`Message deleted in ${message.channel}`)
.addField('Content', message.cleanContent, false)
.setTimestamp()
@ -89,13 +85,14 @@ client.on('messageDelete', message => {
client.on('messageUpdate', (oldMessage, newMessage) => {
const AllowedRoles = ['Administrators', 'Moderators', 'Team', 'VIP'];
if (!findArray(oldMessage.member.roles.map(function (x) { return x.name; }), AllowedRoles)) {
if (IsIgnoredCategory(oldMessage.channel.parent.name) == false) {
if (!findArray(oldMessage.member.roles.cache.map(x => x.name), AllowedRoles)) {
let parent = (oldMessage.channel as discord.TextChannel).parent;
if (parent && IsIgnoredCategory(parent.name) === false) {
const oldM = oldMessage.cleanContent;
const newM = newMessage.cleanContent;
if (oldMessage.content != newMessage.content && oldM && newM) {
const editedEmbed = new discord.RichEmbed()
.setAuthor(oldMessage.author.tag, oldMessage.author.displayAvatarURL)
if (oldMessage.content !== newMessage.content && oldM && newM) {
const editedEmbed = new discord.MessageEmbed()
.setAuthor(oldMessage.author.tag, oldMessage.author.displayAvatarURL())
.setDescription(`Message edited in ${oldMessage.channel} [Jump To Message](${newMessage.url})`)
.addField('Before', oldM, false)
.addField('After', newM, false)
@ -120,11 +117,11 @@ client.on('message', message => {
return;
}
logger.verbose(`${message.author.username} ${message.author} [Channel: ${message.channel.name} ${message.channel}]: ${message.content}`);
logger.verbose(`${message.author.username} ${message.author} [Channel: ${(message.channel as discord.TextChannel).name} ${message.channel}]: ${message.content}`);
if (message.channel.id === process.env.DISCORD_MEDIA_CHANNEL && !message.author.bot) {
const AllowedMediaRoles = ['Administrators', 'Moderators', 'Team', 'VIP'];
if (!findArray(message.member.roles.map(function (x) { return x.name; }), AllowedMediaRoles)) {
if (!findArray(message.member.roles.cache.map(x => x.name), AllowedMediaRoles)) {
const urlRegex = new RegExp(/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)/gi);
if (message.attachments.size > 0 || message.content.match(urlRegex)) {
mediaUsers.set(message.author.id, true);
@ -142,15 +139,14 @@ client.on('message', message => {
if (message.content.toLowerCase().includes(process.env.DISCORD_RULES_TRIGGER)) {
// We want to remove the 'Unauthorized' role from them once they agree to the rules.
logger.verbose(`${message.author.username} ${message.author} has accepted the rules, removing role ${process.env.DISCORD_RULES_ROLE}.`);
message.member.removeRole(process.env.DISCORD_RULES_ROLE, 'Accepted the rules.');
message.member.roles.remove(process.env.DISCORD_RULES_ROLE, 'Accepted the rules.');
}
// Delete the message in the channel to force a cleanup.
message.delete();
return;
} else if (message.content.startsWith('.') && message.content.startsWith('..') === false) {
// We want to make sure it's an actual command, not someone '...'-ing.
let cmd = message.content.split(' ')[0].slice(1);
const cmd = message.content.split(' ', 1)[0].slice(1);
// Check by the name of the command.
let cachedModule = cachedModules[`${cmd}.js`];
@ -158,44 +154,43 @@ client.on('message', message => {
// Check by the quotes in the configuration.
if (cachedModule == null) { cachedModule = state.responses.quotes[cmd]; cachedModuleType = 'Quote'; }
if (cachedModule) {
// Check access permissions.
if (cachedModule.roles !== undefined && findArray(message.member.roles.map(function (x) { return x.name; }), cachedModule.roles) === false) {
state.logChannel.send(`${message.author} attempted to use admin command: ${message.content}`);
logger.info(`${message.author.username} ${message.author} attempted to use admin command: ${message.content}`);
return false;
}
if (!cachedModule) return; // Not a valid command.
logger.info(`${message.author.username} ${message.author} [Channel: ${message.channel}] executed command: ${message.content}`);
message.delete();
try {
if (cachedModuleType === 'Command') {
cachedModule.command(message);
} else if (cachedModuleType === 'Quote') {
cachedModules['quote.js'].command(message, cachedModule.reply);
}
} catch (err) { logger.error(err); }
// Warn after running command?
try {
// Check if the command requires a warning.
if (cmd !== 'warn' && cachedModule.warn === true) {
// Access check to see if the user has privileges to warn.
let warnCommand = cachedModules['warn.js'];
if (findArray(message.member.roles.map(function (x) { return x.name; }), warnCommand.roles)) {
// They are allowed to warn because they are in warn's roles.
warnCommand.command(message);
}
}
} catch (err) { logger.error(err); }
} else {
// Not a valid command.
// Check access permissions.
if (cachedModule.roles !== undefined && findArray(message.member.roles.cache.map(x => x.name), cachedModule.roles) === false) {
state.logChannel.send(`${message.author} attempted to use admin command: ${message.content}`);
logger.info(`${message.author.username} ${message.author} attempted to use admin command: ${message.content}`);
return false;
}
logger.info(`${message.author.username} ${message.author} [Channel: ${message.channel}] executed command: ${message.content}`);
message.delete();
try {
if (cachedModuleType === 'Command') {
cachedModule.command(message);
} else if (cachedModuleType === 'Quote') {
cachedModules['quote.js'].command(message, cachedModule.reply);
}
} catch (err) { logger.error(err); }
// Warn after running command?
try {
// Check if the command requires a warning.
if (cmd !== 'warn' && cachedModule.warn === true) {
// Access check to see if the user has privileges to warn.
const warnCommand = cachedModules['warn.js'];
if (findArray(message.member.roles.cache.map(x => x.name), warnCommand.roles)) {
// They are allowed to warn because they are in warn's roles.
warnCommand.command(message);
}
}
} catch (err) { logger.error(err); }
} else if (message.author.bot === false) {
// This is a normal channel message.
cachedTriggers.forEach(function (trigger) {
if (trigger.roles === undefined || findArray(message.member.roles.map(function (x) { return x.name; }), trigger.roles)) {
if (trigger.roles === undefined || findArray(message.member.roles.cache.map(x => x.name), trigger.roles)) {
if (trigger.trigger(message) === true) {
logger.debug(`${message.author.username} ${message.author} [Channel: ${message.channel}] triggered: ${message.content}`);
try {
@ -208,8 +203,8 @@ client.on('message', message => {
});
// Cache all command modules.
cachedModules = [];
fs.readdirSync('./src/commands/').forEach(function (file) {
cachedModules = {};
fs.readdirSync('./commands/').forEach(function (file) {
// Load the module if it's a script.
if (path.extname(file) === '.js') {
if (file.includes('.disabled')) {

View file

@ -1,20 +0,0 @@
/* Application State */
const State = function () {
this.guild = null;
this.logChannel = null;
this.msglogChannel = null;
this.warnings = [];
this.responses = null;
this.bans = [];
this.stats = {
joins: 0,
ruleAccepts: 0,
leaves: 0,
warnings: 0
};
this.lastGameDBUpdate = 0;
this.gameDB = [];
this.gameDBPromise = null;
};
module.exports = new State();

36
src/state.ts Normal file
View file

@ -0,0 +1,36 @@
import UserWarning from './models/UserWarning';
import UserBan from './models/UserBan';
import { IGameDBEntry } from './models/interfaces';
import discord = require('discord.js');
/* Application State */
class State {
logChannel: discord.TextChannel | discord.DMChannel;
msglogChannel: discord.TextChannel | discord.DMChannel;
warnings: UserWarning[];
responses: any;
bans: UserBan[];
stats: { joins: number; ruleAccepts: number; leaves: number; warnings: number; };
lastGameDBUpdate: number;
gameDB: IGameDBEntry[];
gameDBPromise: Promise<void>;
constructor () {
this.logChannel = null;
this.msglogChannel = null;
this.warnings = [];
this.responses = null;
this.bans = [];
this.stats = {
joins: 0,
ruleAccepts: 0,
leaves: 0,
warnings: 0
};
this.lastGameDBUpdate = 0;
this.gameDB = [];
this.gameDBPromise = null;
}
}
export default new State();

17
tsconfig.json Normal file
View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"module": "CommonJS",
"noImplicitAny": true,
"removeComments": true,
"preserveConstEnums": true,
"outDir": "dist/",
"sourceMap": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"**/*.spec.ts"
]
}

2176
yarn.lock

File diff suppressed because it is too large Load diff