Files
markov-discord/index.js
2019-05-25 15:05:37 -05:00

351 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const Discord = require('discord.js'); // https://discord.js.org/#/docs/main/stable/general/welcome
const fs = require('fs');
const Markov = require('markov-strings');
const schedule = require('node-schedule');
const { version } = require('./package.json');
const client = new Discord.Client();
// const ZEROWIDTH_SPACE = String.fromCharCode(parseInt('200B', 16));
// const MAXMESSAGELENGTH = 2000;
const PAGE_SIZE = 100;
// let guilds = [];
// let connected = -1;
let GAME = 'GAME';
let PREFIX = '! ';
const inviteCmd = 'invite';
const errors = [];
let fileObj = {
messages: [],
};
let markovDB = [];
let messageCache = [];
let deletionCache = [];
const markovOpts = {
stateSize: 2,
maxLength: 2000,
minWords: 3,
maxWords: 0,
minScore: 10,
minScorePerWord: 0,
maxTries: 10000,
};
let markov;
// let markov = new Markov(markovDB, markovOpts);
function uniqueBy(arr, propertyName) {
const unique = [];
const found = {};
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('config/markovDB.json', 'utf8'));
} catch (err) {
console.log('No markovDB.json, starting with initial values');
fileObj = {
messages: [
{
id: '0',
string: '',
},
],
};
}
// console.log("MessageCache", messageCache)
markovDB = fileObj.messages;
markovDB = uniqueBy(markovDB.concat(messageCache), 'id');
deletionCache.forEach((id) => {
const removeIndex = markovDB.map(item => item.id).indexOf(id);
// console.log('Remove Index:', removeIndex)
markovDB.splice(removeIndex, 1);
});
deletionCache = [];
markov = new Markov(markovDB, markovOpts);
markov.buildCorpusSync();
fileObj.messages = markovDB;
// console.log("WRITING THE FOLLOWING DATA:")
// console.log(fileObj)
fs.writeFileSync('config/markovDB.json', JSON.stringify(fileObj), 'utf-8');
fileObj = null;
messageCache = [];
console.log('Done regenerating Markov corpus.');
}
/**
* Loads the config settings from disk
*/
function loadConfig() {
// Move config if in legacy location
if (fs.existsSync('./config.json')) {
console.log('Copying config.json to new location in ./config');
fs.renameSync('./config.json', './config/config.json');
}
if (fs.existsSync('./markovDB.json')) {
console.log('Copying markovDB.json to new location in ./config');
fs.renameSync('./markovDB.json', './config/markovDB.json');
}
try {
// eslint-disable-next-line global-require
const cfg = require('./config/config.json');
PREFIX = cfg.prefix;
GAME = cfg.game;
client.login(cfg.token);
} catch (e) {
console.warn('Failed to use config.json. using default configuration with token environment variable');
PREFIX = '!mark';
GAME = '"!mark help" for help';
client.login(process.env.TOKEN);
}
}
/**
* Checks if the author of a message as moderator-like permissions.
* @param {Message} message Message object to get the sender of the message.
* @return {Boolean} True if the sender is a moderator.
*/
function isModerator(message) {
const { member } = message;
return member.hasPermission('ADMINISTRATOR')
|| member.hasPermission('MANAGE_CHANNELS')
|| member.hasPermission('KICK_MEMBERS')
|| member.hasPermission('MOVE_MEMBERS');
}
/**
* 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';
} else if (split[1] === 'tts') {
command = 'tts';
}
}
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.
* @param {Boolean} tts If the message should be sent as TTS. Defaults to the TTS setting of the
* invoking message.
* @param {Array<String>} filterWords Array of words that the message generated will be filtered on.
*/
function generateResponse(message, debug = false, tts = message.tts, filterWords) {
console.log('Responding...');
const options = {};
if (filterWords) {
options.filter = (result) => {
for (let i = 0; i < filterWords.length; i++) {
if (result.string.includes(filterWords[i])) {
return true;
}
}
return false;
};
options.maxTries = 5000;
}
markov.generateSentence(options).then((result) => {
console.log('Generated Result:', result);
const messageOpts = { tts };
const attachmentRefs = result.refs.filter(ref => Object.prototype.hasOwnProperty.call(ref, 'attachment'));
if (attachmentRefs.length > 0) {
const randomRef = attachmentRefs[Math.floor(Math.random() * attachmentRefs.length)];
messageOpts.files = [{ attachment: randomRef.attachment }];
} else {
const randomMessage = markovDB[Math.floor(Math.random() * markovDB.length)];
if (Object.prototype.hasOwnProperty.call(randomMessage, 'attachment')) {
messageOpts.files = [{ attachment: randomMessage.attachment }];
}
}
result.string.replace(/@everyone/g, '@everyοne'); // Replace @everyone with a homoglyph 'o'
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', () => {
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('./config/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') {
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.')
.addField('!mark debug', 'Runs the !mark command and follows it up with debug info.')
.setFooter(`Markov Discord v${version} by Charlie Laabs`);
message.channel.send(richem).catch(() => {
message.author.send(richem);
});
}
if (command === 'train') {
if (isModerator(message)) {
console.log('Training...');
fileObj = {
messages: [],
};
fs.writeFileSync('config/markovDB.json', JSON.stringify(fileObj), 'utf-8');
fetchMessages(message);
}
}
if (command === 'respond') {
generateResponse(message);
}
if (command === 'tts') {
generateResponse(message, false, true);
}
if (command === 'debug') {
generateResponse(message, true);
}
if (command === 'regen') {
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 (message.isMentioned(client.user)) {
generateResponse(message);
}
}
}
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 4 * * *', regenMarkov());