initial convert to TS. No major logic changes, just type safety.

This commit is contained in:
Charlie Laabs
2019-12-22 23:18:43 -06:00
parent 3b44ab098d
commit 19edcc199c
8 changed files with 955 additions and 422 deletions

29
.eslintrc.js Normal file
View File

@@ -0,0 +1,29 @@
module.exports = {
root: true,
env: {
node: true
},
extends: [
'airbnb-base',
'plugin:@typescript-eslint/recommended',
'prettier/@typescript-eslint',
'plugin:prettier/recommended',
],
parser: '@typescript-eslint/parser',
parserOptions: {
project: './tsconfig.json',
sourceType: 'module'
},
plugins: ['@typescript-eslint'],
settings: {
'import/extensions': ['.js', '.ts',],
'import/parsers': {
'@typescript-eslint/parser': ['.ts']
},
'import/resolver': {
node: {
extensions: ['.js', '.ts',]
}
}
}
}

2
.gitignore vendored
View File

@@ -65,5 +65,3 @@ config.json
# error output file # error output file
error.json error.json
markovDB.json markovDB.json
.vscode/launch.json
.vscode/settings.json

5
.prettierrc.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
printWidth: 100,
singleQuote: true,
trailingComma: 'es5'
}

18
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,18 @@
{
"eslint.validate": [
"javascript",
"typescript"
],
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"editor.formatOnSave": true,
"[javascript]": {
"editor.formatOnSave": false,
},
"[typescript]": {
"editor.formatOnSave": false,
},
"eslint.enable": true,
"typescript.tsdk": "node_modules\\typescript\\lib",
}

View File

@@ -1,8 +1,27 @@
const Discord = require('discord.js'); // https://discord.js.org/#/docs/main/stable/general/welcome /* eslint-disable no-console */
const fs = require('fs'); import * as Discord from 'discord.js';
const Markov = require('markov-strings'); // https://discord.js.org/#/docs/main/stable/general/welcome
const schedule = require('node-schedule'); import * as fs from 'fs';
const { version } = require('./package.json');
import Markov, { MarkovGenerateOptions, MarkovResult } from 'markov-strings';
import * as schedule from 'node-schedule';
interface MessageRecord {
id: string;
string: string;
attachment?: string;
}
interface MarkbotMarkovResult extends MarkovResult {
refs: Array<MessageRecord>;
}
interface MessagesDB {
messages: MessageRecord[];
}
const version: string = JSON.parse(fs.readFileSync('./package.json', 'utf8')).version || '0.0.0';
const client = new Discord.Client(); const client = new Discord.Client();
// const ZEROWIDTH_SPACE = String.fromCharCode(parseInt('200B', 16)); // const ZEROWIDTH_SPACE = String.fromCharCode(parseInt('200B', 16));
@@ -14,15 +33,15 @@ const PAGE_SIZE = 100;
let GAME = 'GAME'; let GAME = 'GAME';
let PREFIX = '! '; let PREFIX = '! ';
const inviteCmd = 'invite'; const inviteCmd = 'invite';
const errors = []; const errors: string[] = [];
let fileObj = { let fileObj: MessagesDB = {
messages: [], messages: [],
}; };
let markovDB = []; let markovDB: MessageRecord[] = [];
let messageCache = []; let messageCache: MessageRecord[] = [];
let deletionCache = []; let deletionCache: string[] = [];
const markovOpts = { const markovOpts = {
stateSize: 2, stateSize: 2,
maxLength: 2000, maxLength: 2000,
@@ -32,18 +51,24 @@ const markovOpts = {
minScorePerWord: 0, minScorePerWord: 0,
maxTries: 10000, maxTries: 10000,
}; };
let markov; let markov: Markov;
// let markov = new Markov(markovDB, markovOpts); // let markov = new Markov(markovDB, markovOpts);
function uniqueBy(arr, propertyName) { // eslint-disable-next-line @typescript-eslint/no-explicit-any
const unique = []; function uniqueBy<Record extends { [key: string]: any }, K extends keyof Record>(
const found = {}; arr: Record[],
propertyName: K
): Record[] {
const unique: Record[] = [];
const found: { [key: string]: boolean } = {};
for (let i = 0; i < arr.length; i++) { for (let i = 0; i < arr.length; i += 1) {
const value = arr[i][propertyName]; if (arr[i][propertyName]) {
if (!found[value]) { const value = arr[i][propertyName];
found[value] = true; if (!found[value]) {
unique.push(arr[i]); found[value] = true;
unique.push(arr[i]);
}
} }
} }
return unique; return unique;
@@ -52,7 +77,7 @@ function uniqueBy(arr, propertyName) {
/** /**
* Regenerates the corpus and saves all cached changes to disk * Regenerates the corpus and saves all cached changes to disk
*/ */
function regenMarkov() { function regenMarkov(): void {
console.log('Regenerating Markov corpus...'); console.log('Regenerating Markov corpus...');
try { try {
fileObj = JSON.parse(fs.readFileSync('config/markovDB.json', 'utf8')); fileObj = JSON.parse(fs.readFileSync('config/markovDB.json', 'utf8'));
@@ -69,20 +94,20 @@ function regenMarkov() {
} }
// console.log("MessageCache", messageCache) // console.log("MessageCache", messageCache)
markovDB = fileObj.messages; markovDB = fileObj.messages;
markovDB = uniqueBy(markovDB.concat(messageCache), 'id'); markovDB = uniqueBy<MessageRecord, 'id'>(markovDB.concat(messageCache), 'id');
deletionCache.forEach((id) => { deletionCache.forEach(id => {
const removeIndex = markovDB.map(item => item.id).indexOf(id); const removeIndex = markovDB.map(item => item.id).indexOf(id);
// console.log('Remove Index:', removeIndex) // console.log('Remove Index:', removeIndex)
markovDB.splice(removeIndex, 1); markovDB.splice(removeIndex, 1);
}); });
deletionCache = []; deletionCache = [];
markov = new Markov(markovDB, markovOpts); markov = new Markov(markovDB, markovOpts);
markov.buildCorpusSync(); markov.buildCorpusAsync();
fileObj.messages = markovDB; fileObj.messages = markovDB;
// console.log("WRITING THE FOLLOWING DATA:") // console.log("WRITING THE FOLLOWING DATA:")
// console.log(fileObj) // console.log(fileObj)
fs.writeFileSync('config/markovDB.json', JSON.stringify(fileObj), 'utf-8'); fs.writeFileSync('config/markovDB.json', JSON.stringify(fileObj), 'utf-8');
fileObj = null; fileObj.messages = [];
messageCache = []; messageCache = [];
console.log('Done regenerating Markov corpus.'); console.log('Done regenerating Markov corpus.');
} }
@@ -90,7 +115,7 @@ function regenMarkov() {
/** /**
* Loads the config settings from disk * Loads the config settings from disk
*/ */
function loadConfig() { function loadConfig(): void {
// Move config if in legacy location // Move config if in legacy location
if (fs.existsSync('./config.json')) { if (fs.existsSync('./config.json')) {
console.log('Copying config.json to new location in ./config'); console.log('Copying config.json to new location in ./config');
@@ -102,15 +127,15 @@ function loadConfig() {
fs.renameSync('./markovDB.json', './config/markovDB.json'); fs.renameSync('./markovDB.json', './config/markovDB.json');
} }
try { try {
// eslint-disable-next-line global-require const cfg = JSON.parse(fs.readFileSync('./config/config.json', 'utf8'));
const cfg = require('./config/config.json');
PREFIX = cfg.prefix; PREFIX = cfg.prefix;
GAME = cfg.game; GAME = cfg.game;
client.login(cfg.token); client.login(cfg.token);
} catch (e) { } catch (e) {
console.warn('Failed to use config.json. using default configuration with token environment variable'); console.warn(
'Failed to use config.json. using default configuration with token environment variable'
);
PREFIX = '!mark'; PREFIX = '!mark';
GAME = '"!mark help" for help'; GAME = '"!mark help" for help';
client.login(process.env.TOKEN); client.login(process.env.TOKEN);
@@ -122,12 +147,14 @@ function loadConfig() {
* @param {Message} message Message object to get the sender of the message. * @param {Message} message Message object to get the sender of the message.
* @return {Boolean} True if the sender is a moderator. * @return {Boolean} True if the sender is a moderator.
*/ */
function isModerator(message) { function isModerator(message: Discord.Message): boolean {
const { member } = message; const { member } = message;
return member.hasPermission('ADMINISTRATOR') return (
|| member.hasPermission('MANAGE_CHANNELS') member.hasPermission('ADMINISTRATOR') ||
|| member.hasPermission('KICK_MEMBERS') member.hasPermission('MANAGE_CHANNELS') ||
|| member.hasPermission('MOVE_MEMBERS'); member.hasPermission('KICK_MEMBERS') ||
member.hasPermission('MOVE_MEMBERS')
);
} }
/** /**
@@ -135,7 +162,7 @@ function isModerator(message) {
* @param {Message} message Message to be interpreted as a command * @param {Message} message Message to be interpreted as a command
* @return {String} Command string * @return {String} Command string
*/ */
function validateMessage(message) { function validateMessage(message: Discord.Message): string | null {
const messageText = message.content.toLowerCase(); const messageText = message.content.toLowerCase();
let command = null; let command = null;
const thisPrefix = messageText.substring(0, PREFIX.length); const thisPrefix = messageText.substring(0, PREFIX.length);
@@ -166,20 +193,24 @@ function validateMessage(message) {
* @param {Message} message Message initiating the command, used for getting * @param {Message} message Message initiating the command, used for getting
* channel data * channel data
*/ */
async function fetchMessages(message) { async function fetchMessages(message: Discord.Message): Promise<void> {
let historyCache = []; let historyCache: MessageRecord[] = [];
let keepGoing = true; let keepGoing = true;
let oldestMessageID = null; let oldestMessageID;
while (keepGoing) { while (keepGoing) {
// eslint-disable-next-line no-await-in-loop const messages: Discord.Collection<
const messages = await message.channel.fetchMessages({ string,
Discord.Message
// eslint-disable-next-line no-await-in-loop
> = await message.channel.fetchMessages({
before: oldestMessageID, before: oldestMessageID,
limit: PAGE_SIZE, limit: PAGE_SIZE,
}); });
const nonBotMessageFormatted = messages const nonBotMessageFormatted = messages
.filter(elem => !elem.author.bot).map((elem) => { .filter(elem => !elem.author.bot)
const dbObj = { .map(elem => {
const dbObj: MessageRecord = {
string: elem.content, string: elem.content,
id: elem.id, id: elem.id,
}; };
@@ -200,7 +231,6 @@ async function fetchMessages(message) {
message.reply(`Finished training from past ${historyCache.length} messages.`); message.reply(`Finished training from past ${historyCache.length} messages.`);
} }
/** /**
* General Markov-chain response function * General Markov-chain response function
* @param {Message} message The message that invoked the action, used for channel info. * @param {Message} message The message that invoked the action, used for channel info.
@@ -209,12 +239,17 @@ async function fetchMessages(message) {
* invoking message. * invoking message.
* @param {Array<String>} filterWords Array of words that the message generated will be filtered on. * @param {Array<String>} filterWords Array of words that the message generated will be filtered on.
*/ */
function generateResponse(message, debug = false, tts = message.tts, filterWords) { function generateResponse(
message: Discord.Message,
debug = false,
tts = message.tts,
filterWords?: string[]
): void {
console.log('Responding...'); console.log('Responding...');
const options = {}; const options: MarkovGenerateOptions = {};
if (filterWords) { if (filterWords) {
options.filter = (result) => { options.filter = (result): boolean => {
for (let i = 0; i < filterWords.length; i++) { for (let i = 0; i < filterWords.length; i += 1) {
if (result.string.includes(filterWords[i])) { if (result.string.includes(filterWords[i])) {
return true; return true;
} }
@@ -223,51 +258,57 @@ function generateResponse(message, debug = false, tts = message.tts, filterWords
}; };
options.maxTries = 5000; options.maxTries = 5000;
} }
markov.generateSentence(options).then((result) => { markov
console.log('Generated Result:', result); .generateAsync(options)
const messageOpts = { tts }; .then(result => {
const attachmentRefs = result.refs.filter(ref => Object.prototype.hasOwnProperty.call(ref, 'attachment')); const myResult = result as MarkbotMarkovResult;
if (attachmentRefs.length > 0) { console.log('Generated Result:', myResult);
const randomRef = attachmentRefs[Math.floor(Math.random() * attachmentRefs.length)]; const messageOpts: Discord.MessageOptions = { tts };
messageOpts.files = [{ attachment: randomRef.attachment }]; const attachmentRefs = myResult.refs
} else { .filter(ref => Object.prototype.hasOwnProperty.call(ref, 'attachment'))
const randomMessage = markovDB[Math.floor(Math.random() * markovDB.length)]; .map(ref => ref.attachment as string);
if (Object.prototype.hasOwnProperty.call(randomMessage, 'attachment')) { if (attachmentRefs.length > 0) {
messageOpts.files = [{ attachment: randomMessage.attachment }]; const randomRefAttachment =
attachmentRefs[Math.floor(Math.random() * attachmentRefs.length)];
messageOpts.files = [randomRefAttachment];
} else {
const randomMessage = markovDB[Math.floor(Math.random() * markovDB.length)];
if (randomMessage.attachment) {
messageOpts.files = [{ attachment: randomMessage.attachment }];
}
} }
}
result.string.replace(/@everyone/g, '@everyοne'); // Replace @everyone with a homoglyph 'o' myResult.string.replace(/@everyone/g, '@everyοne'); // Replace @everyone with a homoglyph 'o'
message.channel.send(result.string, messageOpts); message.channel.send(result.string, messageOpts);
if (debug) message.channel.send(`\`\`\`\n${JSON.stringify(result, null, 2)}\n\`\`\``); if (debug) message.channel.send(`\`\`\`\n${JSON.stringify(myResult, null, 2)}\n\`\`\``);
}).catch((err) => { })
console.log(err); .catch(err => {
if (debug) message.channel.send(`\n\`\`\`\nERROR${err}\n\`\`\``); console.log(err);
if (err.message.includes('Cannot build sentence with current corpus')) { if (debug) message.channel.send(`\n\`\`\`\nERROR${err}\n\`\`\``);
console.log('Not enough chat data for a response.'); if (err.message.includes('Cannot build sentence with current corpus')) {
} console.log('Not enough chat data for a response.');
}); }
});
} }
client.on('ready', () => { client.on('ready', () => {
console.log('Markbot by Charlie Laabs'); console.log('Markbot by Charlie Laabs');
client.user.setActivity(GAME); client.user.setActivity(GAME);
regenMarkov(); regenMarkov();
}); });
client.on('error', (err) => { client.on('error', err => {
const errText = `ERROR: ${err.name} - ${err.message}`; const errText = `ERROR: ${err.name} - ${err.message}`;
console.log(errText); console.log(errText);
errors.push(errText); errors.push(errText);
fs.writeFile('./config/error.json', JSON.stringify(errors), (fsErr) => { fs.writeFile('./config/error.json', JSON.stringify(errors), fsErr => {
if (fsErr) { if (fsErr) {
console.log(`error writing to error file: ${fsErr.message}`); console.log(`error writing to error file: ${fsErr.message}`);
} }
}); });
}); });
client.on('message', (message) => { client.on('message', message => {
if (message.guild) { if (message.guild) {
const command = validateMessage(message); const command = validateMessage(message);
if (command === 'help') { if (command === 'help') {
@@ -275,14 +316,26 @@ client.on('message', (message) => {
.setAuthor(client.user.username, client.user.avatarURL) .setAuthor(client.user.username, client.user.avatarURL)
.setThumbnail(client.user.avatarURL) .setThumbnail(client.user.avatarURL)
.setDescription('A Markov chain chatbot that speaks based on previous chat input.') .setDescription('A Markov chain chatbot that speaks based on previous chat input.')
.addField('!mark', 'Generates a sentence to say based on the chat database. Send your ' .addField(
+ 'message as TTS to recieve it as TTS.') '!mark',
.addField('!mark train', 'Fetches the maximum amount of previous messages in the current ' 'Generates a sentence to say based on the chat database. Send your ' +
+ 'text channel, adds it to the database, and regenerates the corpus. Takes some time.') 'message as TTS to recieve it as TTS.'
.addField('!mark regen', 'Manually regenerates the corpus to add recent chat info. Run ' )
+ 'this before shutting down to avoid any data loss. This automatically runs at midnight.') .addField(
.addField('!mark invite', 'Don\'t invite this bot to other servers. The database is shared ' '!mark train',
+ 'between all servers and text channels.') 'Fetches the maximum amount of previous messages in the current ' +
'text channel, adds it to the database, and regenerates the corpus. Takes some time.'
)
.addField(
'!mark regen',
'Manually regenerates the corpus to add recent chat info. Run ' +
'this before shutting down to avoid any data loss. This automatically runs at midnight.'
)
.addField(
'!mark invite',
"Don't invite this bot to other servers. The database is shared " +
'between all servers and text channels.'
)
.addField('!mark debug', 'Runs the !mark command and follows it up with debug info.') .addField('!mark debug', 'Runs the !mark command and follows it up with debug info.')
.setFooter(`Markov Discord v${version} by Charlie Laabs`); .setFooter(`Markov Discord v${version} by Charlie Laabs`);
message.channel.send(richem).catch(() => { message.channel.send(richem).catch(() => {
@@ -314,7 +367,7 @@ client.on('message', (message) => {
if (command === null) { if (command === null) {
console.log('Listening...'); console.log('Listening...');
if (!message.author.bot) { if (!message.author.bot) {
const dbObj = { const dbObj: MessageRecord = {
string: message.content, string: message.content,
id: message.id, id: message.id,
}; };
@@ -331,17 +384,19 @@ client.on('message', (message) => {
const richem = new Discord.RichEmbed() const richem = new Discord.RichEmbed()
.setAuthor(`Invite ${client.user.username}`, client.user.avatarURL) .setAuthor(`Invite ${client.user.username}`, client.user.avatarURL)
.setThumbnail(client.user.avatarURL) .setThumbnail(client.user.avatarURL)
.addField('Invite', `[Invite ${client.user.username} to your server](https://discordapp.com/oauth2/authorize?client_id=${client.user.id}&scope=bot)`); .addField(
'Invite',
`[Invite ${client.user.username} to your server](https://discordapp.com/oauth2/authorize?client_id=${client.user.id}&scope=bot)`
);
message.channel.send(richem) message.channel.send(richem).catch(() => {
.catch(() => { message.author.send(richem);
message.author.send(richem); });
});
} }
} }
}); });
client.on('messageDelete', (message) => { client.on('messageDelete', message => {
// console.log('Adding message ' + message.id + ' to deletion cache.') // console.log('Adding message ' + message.id + ' to deletion cache.')
deletionCache.push(message.id); deletionCache.push(message.id);
console.log('deletionCache:', deletionCache); console.log('deletionCache:', deletionCache);

1025
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,38 +23,29 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bufferutil": "^4.0.1", "bufferutil": "^4.0.1",
"discord.js": "^11.4.2", "discord.js": "^11.5.1",
"erlpack": "github:discordapp/erlpack", "erlpack": "github:discordapp/erlpack",
"markov-strings": "^1.5.2", "markov-strings": "^2.1.0",
"node-schedule": "^1.3.2", "node-schedule": "^1.3.2",
"zlib-sync": "^0.1.4" "zlib-sync": "^0.1.6"
}, },
"engines": { "engines": {
"node": ">=8.0.0" "node": ">=8.0.0"
}, },
"devDependencies": { "devDependencies": {
"eslint": "^5.16.0", "@types/node": "^12.12.21",
"eslint-config-airbnb-base": "^13.1.0", "@types/node-schedule": "^1.3.0",
"eslint-plugin-import": "^2.17.2" "@typescript-eslint/eslint-plugin": "^2.12.0",
"@typescript-eslint/parser": "^2.12.0",
"eslint": "^6.8.0",
"eslint-config-airbnb-base": "^14.0.0",
"eslint-config-prettier": "^6.7.0",
"eslint-plugin-import": "^2.19.1",
"eslint-plugin-prettier": "^3.1.2",
"prettier": "^1.19.1",
"typescript": "^3.7.4"
}, },
"eslintConfig": { "eslintIgnore": [
"parserOptions": { "**/*.js"
"ecmaVersion": 2017 ]
}, }
"env": {
"node": true
},
"extends": [
"airbnb-base"
],
"rules": {
"no-console": 0,
"no-plusplus": [
"error",
{
"allowForLoopAfterthoughts": true
}
]
}
}
}

22
tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2019", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
"outDir": "./dist", /* Redirect output structure to the directory. */
"removeComments": true, /* Do not emit comments to output. */
"strict": true, /* Enable all strict type-checking options. */
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
"typeRoots": [
"node_modules/@types"
], /* List of folders to include type definitions from. */
"inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
},
"include": [
"**/*.ts"
],
"exclude": [
"node_modules",
"dist"
]
}