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

440
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
inviteCmd = cfg.invitecmd
// regenMarkov() // regenMarkov()
client.login(cfg.token) client.login(cfg.token);
} } else {
else { console.log(`Oh no!!! ${cfgfile} could not be found!`);
console.log('Oh no!!! ' + cfgfile + ' could not be found!')
} }
} }
client.on('ready', () => { /**
console.log('Markbot by Charlie Laabs') * Reads a new message and checks if and which command it is.
client.user.setActivity(GAME) * @param {Message} message Message to be interpreted as a command
}) * @return {String} Command string
*/
client.on('error', (err) => {
let errText = 'ERROR: ' + err.name + ' - ' + err.message
console.log(errText)
errors.push(errText)
fs.writeFile('error.json', JSON.stringify(errors), function (err) {
if (err)
console.log('error writing to error file: ' + err.message)
})
})
client.on('message', message => {
if (message.guild) {
let command = validateMessage(message)
if (command === 'help') {
let richem = new Discord.RichEmbed()
.setAuthor(client.user.username, client.user.avatarURL)
.setThumbnail(client.user.avatarURL)
.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 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.')
.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.')
message.channel.send(richem)
.catch(reason => {
message.author.send(richem)
})
}
if (command === 'train') {
console.log("Training...")
fileObj = {
messages: []
}
fs.writeFileSync('markovDB.json', JSON.stringify(fileObj), 'utf-8')
fetchMessageChunk(message, null, [])
}
if (command === 'respond') {
console.log("Responding...")
markov.generateSentence().then(result => {
console.log('Generated Result:', result)
let messageOpts = {
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') {
console.log("Regenerating...")
regenMarkov()
}
if (command === null) {
console.log("Listening...")
if (!message.author.bot) {
let dbObj = {
string: message.content,
id: message.id
}
if (message.attachments.size > 0) {
dbObj.attachment = message.attachments.values().next().value.url
}
messageCache.push(dbObj)
}
}
if (command === inviteCmd) {
let richem = new Discord.RichEmbed()
.setAuthor('Invite ' + client.user.username, 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)")
message.channel.send(richem)
.catch(reason => {
message.author.send(richem)
})
}
}
})
client.on('messageDelete', message => {
// console.log('Adding message ' + message.id + ' to deletion cache.')
deletionCache.push(message.id)
console.log('deletionCache:', deletionCache)
})
function validateMessage(message) { function validateMessage(message) {
let messageText = message.content.toLowerCase() const messageText = message.content.toLowerCase();
let command = null; let command = null;
let thisPrefix = messageText.substring(0, PREFIX.length) const thisPrefix = messageText.substring(0, PREFIX.length);
if (thisPrefix == PREFIX) { if (thisPrefix === PREFIX) {
let split = messageText.split(" ") const split = messageText.split(' ');
if (split[0] == PREFIX && split.length == 1) if (split[0] === PREFIX && split.length === 1) {
command = 'respond' command = 'respond';
else if (split[1] == 'train') } else if (split[1] === 'train') {
command = 'train' command = 'train';
else if (split[1] == 'help') } else if (split[1] === 'help') {
command = 'help' command = 'help';
else if (split[1] == 'regen') } else if (split[1] === 'regen') {
command = 'regen' command = 'regen';
else if (split[1] == 'invite') } else if (split[1] === 'invite') {
command = 'invite' command = 'invite';
} else if (split[1] === 'debug') {
command = 'debug';
} }
return command }
return command;
} }
function fetchMessageChunk(message, oldestMessageID, historyCache) { /**
message.channel.fetchMessages({ before: oldestMessageID, limit: 100 }) * Function to recursively get all messages in a text channel's history. Ends
.then(messages => { * by regnerating the corpus.
historyCache = historyCache.concat(messages.filter(elem => !elem.author.bot).map(elem => { * @param {Message} message Message initiating the command, used for getting
let dbObj = { * 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, string: elem.content,
id: elem.id id: elem.id,
} };
if (elem.attachments.size > 0) { if (elem.attachments.size > 0) {
dbObj.attachment = elem.attachments.values().next().value.url 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.');
} }
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()); client.on('ready', () => {
console.log('Markbot by Charlie Laabs');
client.user.setActivity(GAME);
});
client.on('error', (err) => {
const errText = `ERROR: ${err.name} - ${err.message}`;
console.log(errText);
errors.push(errText);
fs.writeFile('error.json', JSON.stringify(errors), (fsErr) => {
if (fsErr) {
console.log(`error writing to error file: ${fsErr.message}`);
}
});
});
client.on('message', (message) => {
if (message.guild) {
const command = validateMessage(message);
if (command === 'help') {
console.log(message.channel);
const richem = new Discord.RichEmbed()
.setAuthor(client.user.username, client.user.avatarURL)
.setThumbnail(client.user.avatarURL)
.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 train', '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.')
.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') {
console.log('Training...');
fileObj = {
messages: [],
};
fs.writeFileSync('markovDB.json', JSON.stringify(fileObj), 'utf-8');
fetchMessages(message);
}
if (command === 'respond') {
generateResponse(message);
}
if (command === 'debug') {
generateResponse(message, true);
}
if (command === 'regen') {
console.log('Regenerating...');
regenMarkov();
}
if (command === null) {
console.log('Listening...');
if (!message.author.bot) {
const dbObj = {
string: message.content,
id: message.id,
};
if (message.attachments.size > 0) {
dbObj.attachment = message.attachments.values().next().value.url;
}
messageCache.push(dbObj);
}
}
if (command === inviteCmd) {
const richem = new Discord.RichEmbed()
.setAuthor(`Invite ${client.user.username}`, 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)`);
message.channel.send(richem)
.catch(() => {
message.author.send(richem);
});
}
}
});
client.on('messageDelete', (message) => {
// console.log('Adding message ' + message.id + ' to deletion cache.')
deletionCache.push(message.id);
console.log('deletionCache:', deletionCache);
});
loadConfig();
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
}
]
}
} }
} }