Update 0.4.0: Major refactor and debug command

This commit is contained in:
Charlie Laabs
2018-08-24 18:06:14 -05:00
parent 6576bab911
commit 455c3e0029
6 changed files with 1821 additions and 206 deletions

View File

@@ -1,7 +1,27 @@
# MarkBot for Discord # MarkBot for Discord
This is a super rough prototype. A Markov chain bot using markov-strings. Just uploading here so it can run and generate training data. This is a super rough prototype. A Markov chain bot using markov-strings. Just uploading here so it can run and generate training data.
# Setup
## Configuration
Create a file called `config.json` in the project directory with the contents:
```
{
"prefix":"!mark",
"game":"\"!mark help\" for help",
"token":"k5NzE2NDg1MTIwMjc0ODQ0Nj.DSnXwg.ttNotARealToken5p3WfDoUxhiH"
}
```
## Changelog ## Changelog
### 0.4.0
Huge refactor.
Added `!mark debug` which sends debug info alongside the message.
Converted the fetchMessages function to async/await (updating the requirement to Node.js 8).
Updated module versions.
Added faster unique-array-by-property function
Added linting and linted the project.
### 0.3.0 ### 0.3.0
Added TTS support and random message attachments. Added TTS support and random message attachments.
Deleted messages no longer persist in the database longer than 24 hours. Deleted messages no longer persist in the database longer than 24 hours.

View File

@@ -1 +0,0 @@
npm start

View File

@@ -1,2 +0,0 @@
#!/bin/bash
npm start

398
index.js
View File

@@ -1,226 +1,286 @@
const Discord = require('discord.js') //https://discord.js.org/#/docs/main/stable/general/welcome const Discord = require('discord.js'); // https://discord.js.org/#/docs/main/stable/general/welcome
const fs = require('fs') const fs = require('fs');
const Markov = require('markov-strings') const Markov = require('markov-strings');
const uniqueBy = require('unique-by');
const schedule = require('node-schedule'); const schedule = require('node-schedule');
const client = new Discord.Client()
const ZEROWIDTH_SPACE = String.fromCharCode(parseInt('200B', 16))
const MAXMESSAGELENGTH = 2000
const client = new Discord.Client();
// const ZEROWIDTH_SPACE = String.fromCharCode(parseInt('200B', 16));
// const MAXMESSAGELENGTH = 2000;
const PAGE_SIZE = 100;
let guilds = [] // let guilds = [];
let connected = -1 // let connected = -1;
let GAME = 'GAME' let GAME = 'GAME';
let BOTDESC = 'Amazing.' let PREFIX = '! ';
let PREFIX = '! ' const inviteCmd = 'invite';
let VOLUME const errors = [];
let inviteCmd = 'invite'
let commands = {}
let aliases = {}
let errors = []
let fileObj = { let fileObj = {
messages: [] messages: [],
} };
let markovDB = [] let markovDB = [];
let messageCache = [] let messageCache = [];
let deletionCache = [] let deletionCache = [];
const markovOpts = { const markovOpts = {
maxLength: 400, stateSize: 2,
maxLength: 2000,
minWords: 3, minWords: 3,
minScore: 10 maxWords: 0,
} minScore: 10,
let markov minScorePerWord: 0,
maxTries: 10000,
};
let markov;
// let markov = new Markov(markovDB, markovOpts); // let markov = new Markov(markovDB, markovOpts);
function regenMarkov() { function uniqueBy(arr, propertyName) {
console.log("Regenerating Markov corpus...") const unique = [];
try { const found = {};
fileObj = JSON.parse(fs.readFileSync('markovDB.json', 'utf8'))
for (let i = 0; i < arr.length; i++) {
const value = arr[i][propertyName];
if (!found[value]) {
found[value] = true;
unique.push(arr[i]);
}
}
return unique;
}
/**
* Regenerates the corpus and saves all cached changes to disk
*/
function regenMarkov() {
console.log('Regenerating Markov corpus...');
try {
fileObj = JSON.parse(fs.readFileSync('markovDB.json', 'utf8'));
} catch (err) {
console.log(err);
} }
catch (err) { console.log(err) }
// console.log("MessageCache", messageCache) // console.log("MessageCache", messageCache)
markovDB = fileObj.messages markovDB = fileObj.messages;
markovDB = uniqueBy(markovDB.concat(messageCache), 'id') markovDB = uniqueBy(markovDB.concat(messageCache), 'id');
deletionCache.forEach(id => { deletionCache.forEach((id) => {
let removeIndex = markovDB.map(function (item) { return 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 = [];
if (markovDB.length == 0) if (markovDB.length === 0) {
markovDB.push({ string: 'hello', id: null }) markovDB.push({ string: 'hello', id: null });
}
markov = new Markov(markovDB, markovOpts); markov = new Markov(markovDB, markovOpts);
markov.buildCorpusSync() markov.buildCorpusSync();
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('markovDB.json', JSON.stringify(fileObj), 'utf-8') fs.writeFileSync('markovDB.json', JSON.stringify(fileObj), 'utf-8');
fileObj = null; fileObj = null;
// markovDB = [] messageCache = [];
messageCache = [] console.log('Done regenerating Markov corpus.');
console.log("Done regenerating Markov corpus.")
} }
/**
* Loads the config settings from disk
*/
function loadConfig() { function loadConfig() {
let cfgfile = 'config.json' const cfgfile = 'config.json';
if (fs.existsSync(cfgfile)) { if (fs.existsSync(cfgfile)) {
let cfg = JSON.parse(fs.readFileSync(cfgfile, 'utf8')) const cfg = JSON.parse(fs.readFileSync(cfgfile, 'utf8'));
PREFIX = cfg.prefix PREFIX = cfg.prefix;
GAME = cfg.game GAME = cfg.game;
BOTDESC = cfg.description // regenMarkov()
inviteCmd = cfg.invitecmd client.login(cfg.token);
//regenMarkov() } else {
client.login(cfg.token) console.log(`Oh no!!! ${cfgfile} could not be found!`);
}
else {
console.log('Oh no!!! ' + cfgfile + ' could not be found!')
} }
} }
/**
* Reads a new message and checks if and which command it is.
* @param {Message} message Message to be interpreted as a command
* @return {String} Command string
*/
function validateMessage(message) {
const messageText = message.content.toLowerCase();
let command = null;
const thisPrefix = messageText.substring(0, PREFIX.length);
if (thisPrefix === PREFIX) {
const split = messageText.split(' ');
if (split[0] === PREFIX && split.length === 1) {
command = 'respond';
} else if (split[1] === 'train') {
command = 'train';
} else if (split[1] === 'help') {
command = 'help';
} else if (split[1] === 'regen') {
command = 'regen';
} else if (split[1] === 'invite') {
command = 'invite';
} else if (split[1] === 'debug') {
command = 'debug';
}
}
return command;
}
/**
* Function to recursively get all messages in a text channel's history. Ends
* by regnerating the corpus.
* @param {Message} message Message initiating the command, used for getting
* channel data
*/
async function fetchMessages(message) {
let historyCache = [];
let keepGoing = true;
let oldestMessageID = null;
while (keepGoing) {
// eslint-disable-next-line no-await-in-loop
const messages = await message.channel.fetchMessages({
before: oldestMessageID,
limit: PAGE_SIZE,
});
const nonBotMessageFormatted = messages
.filter(elem => !elem.author.bot).map((elem) => {
const dbObj = {
string: elem.content,
id: elem.id,
};
if (elem.attachments.size > 0) {
dbObj.attachment = elem.attachments.values().next().value.url;
}
return dbObj;
});
historyCache = historyCache.concat(nonBotMessageFormatted);
oldestMessageID = messages.last().id;
if (messages.size < PAGE_SIZE) {
keepGoing = false;
}
}
console.log(`Trained from ${historyCache.length} past human authored messages.`);
messageCache = messageCache.concat(historyCache);
regenMarkov();
message.reply(`Finished training from past ${historyCache.length} messages.`);
}
/**
* General Markov-chain response function
* @param {Message} message The message that invoked the action, used for channel info.
* @param {Boolean} debug Sends debug info as a message if true.
*/
function generateResponse(message, debug = false) {
console.log('Responding...');
markov.generateSentence().then((result) => {
console.log('Generated Result:', result);
const messageOpts = { tts: message.tts };
const randomMessage = markovDB[Math.floor(Math.random() * markovDB.length)];
console.log('Random Message:', randomMessage);
if (Object.prototype.hasOwnProperty.call(randomMessage, 'attachment')) {
messageOpts.files = [{ attachment: randomMessage.attachment }];
}
message.channel.send(result.string, messageOpts);
if (debug) message.channel.send(`\`\`\`\n${JSON.stringify(result, null, 2)}\n\`\`\``);
}).catch((err) => {
console.log(err);
if (debug) message.channel.send(`\n\`\`\`\nERROR${err}\n\`\`\``);
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);
}) });
client.on('error', (err) => { client.on('error', (err) => {
let 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('error.json', JSON.stringify(errors), function (err) { fs.writeFile('error.json', JSON.stringify(errors), (fsErr) => {
if (err) if (fsErr) {
console.log('error writing to error file: ' + err.message) console.log(`error writing to error file: ${fsErr.message}`);
}) }
}) });
});
client.on('message', message => { client.on('message', (message) => {
if (message.guild) { if (message.guild) {
let command = validateMessage(message) const command = validateMessage(message);
if (command === 'help') { if (command === 'help') {
let richem = new Discord.RichEmbed() console.log(message.channel);
const richem = new Discord.RichEmbed()
.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 message as TTS to recieve it as TTS.') .addField('!mark', 'Generates a sentence to say based on the chat database. Send your '
.addField('!mark train', 'Fetches the maximum amount of previous messages in the current text channel, adds it to the database, and regenerates the corpus. Takes about 2 minutes.') + '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('!mark train', 'Fetches the maximum amount of previous messages in the current '
.addField('!mark invite', 'Don\'t invite this bot to other servers. The database is shared between all servers and text channels.') + 'text channel, adds it to the database, and regenerates the corpus. Takes some time.')
message.channel.send(richem) .addField('!mark regen', 'Manually regenerates the corpus to add recent chat info. Run '
.catch(reason => { + 'this before shutting down to avoid any data loss. This automatically runs at midnight.')
message.author.send(richem) .addField('!mark invite', 'Don\'t invite this bot to other servers. The database is shared '
}) + 'between all servers and text channels.')
.addBlankField('!mark debug', 'Runs the !mark command and follows it up with debug info.');
message.channel.send(richem).catch(() => {
message.author.send(richem);
});
} }
if (command === 'train') { if (command === 'train') {
console.log("Training...") console.log('Training...');
fileObj = { fileObj = {
messages: [] messages: [],
} };
fs.writeFileSync('markovDB.json', JSON.stringify(fileObj), 'utf-8') fs.writeFileSync('markovDB.json', JSON.stringify(fileObj), 'utf-8');
fetchMessageChunk(message, null, []) fetchMessages(message);
} }
if (command === 'respond') { if (command === 'respond') {
console.log("Responding...") generateResponse(message);
markov.generateSentence().then(result => { }
console.log('Generated Result:', result) if (command === 'debug') {
let messageOpts = { generateResponse(message, true);
tts: message.tts
}
let randomMessage = markovDB[Math.floor(Math.random() * markovDB.length)]
console.log('Random Message:', randomMessage)
if (randomMessage.hasOwnProperty('attachment')) {
messageOpts.files = [{
attachment: randomMessage.attachment
}]
}
message.channel.send(result.string, messageOpts)
}).catch(err => {
console.log(err)
if (err.message == 'Cannot build sentence with current corpus and options')
// message.reply('Not enough chat data for a response. Run `!mark train` to process past messages.')
console.log('Not enough chat data for a response.')
})
} }
if (command === 'regen') { if (command === 'regen') {
console.log("Regenerating...") console.log('Regenerating...');
regenMarkov() regenMarkov();
} }
if (command === null) { if (command === null) {
console.log("Listening...") console.log('Listening...');
if (!message.author.bot) { if (!message.author.bot) {
let dbObj = { const dbObj = {
string: message.content, string: message.content,
id: message.id id: message.id,
} };
if (message.attachments.size > 0) { if (message.attachments.size > 0) {
dbObj.attachment = message.attachments.values().next().value.url dbObj.attachment = message.attachments.values().next().value.url;
} }
messageCache.push(dbObj) messageCache.push(dbObj);
} }
} }
if (command === inviteCmd) { if (command === inviteCmd) {
let 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(reason => { .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);
}) });
function validateMessage(message) { loadConfig();
let messageText = message.content.toLowerCase() schedule.scheduleJob('0 0 * * *', regenMarkov());
let command = null;
let thisPrefix = messageText.substring(0, PREFIX.length)
if (thisPrefix == PREFIX) {
let split = messageText.split(" ")
if (split[0] == PREFIX && split.length == 1)
command = 'respond'
else if (split[1] == 'train')
command = 'train'
else if (split[1] == 'help')
command = 'help'
else if (split[1] == 'regen')
command = 'regen'
else if (split[1] == 'invite')
command = 'invite'
}
return command
}
function fetchMessageChunk(message, oldestMessageID, historyCache) {
message.channel.fetchMessages({ before: oldestMessageID, limit: 100 })
.then(messages => {
historyCache = historyCache.concat(messages.filter(elem => !elem.author.bot).map(elem => {
let dbObj = {
string: elem.content,
id: elem.id
}
if (elem.attachments.size > 0) {
dbObj.attachment = elem.attachments.values().next().value.url
}
return dbObj
}));
oldestMessageID = messages.last().id
return historyCache.concat(fetchMessageChunk(message, oldestMessageID, historyCache))
}).catch(err => {
console.log("Trained from " + historyCache.length + " past messages.")
messageCache = messageCache.concat(historyCache)
regenMarkov()
message.reply('Finished training from past ' + historyCache.length + ' messages.')
});
}
loadConfig()
const daily = schedule.scheduleJob('0 0 * * *', regenMarkov());

1570
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "markbot", "name": "markbot",
"version": "0.3.0", "version": "0.4.0",
"description": "A conversational Markov chain bot for Discord", "description": "A conversational Markov chain bot for Discord",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -16,11 +16,39 @@
"author": "Charlie Laabs <charlielaabs@gmail.com>", "author": "Charlie Laabs <charlielaabs@gmail.com>",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"discord.js": "^11.3.2", "bufferutil": "^4.0.0",
"discord.js": "^11.4.2",
"erlpack": "github:discordapp/erlpack", "erlpack": "github:discordapp/erlpack",
"markov-strings": "^1.3.5", "markov-strings": "^1.5.0",
"node-schedule": "^1.3.0", "node-schedule": "^1.3.0",
"unique-by": "^1.0.0",
"zlib-sync": "^0.1.4" "zlib-sync": "^0.1.4"
},
"engines": {
"node": ">=8.0.0"
},
"devDependencies": {
"eslint": "^5.4.0",
"eslint-config-airbnb-base": "^13.1.0",
"eslint-plugin-import": "^2.14.0"
},
"eslintConfig": {
"parserOptions": {
"ecmaVersion": 2017
},
"env": {
"node": true
},
"extends": [
"airbnb-base"
],
"rules": {
"no-console": 0,
"no-plusplus": [
"error",
{
"allowForLoopAfterthoughts": true
}
]
}
} }
} }