Start work on moving data to an SQLite database. Add a migrator for legacy data.

master
Dragory 2017-12-24 22:04:08 +02:00
parent e034b514f1
commit 58f35c87da
21 changed files with 2460 additions and 873 deletions

View File

@ -1,7 +1,8 @@
{ {
"token": "your bot token", "token": "your bot token",
"mailGuildId": "id of the modmail inbox guild", "mailGuildId": "id of the modmail inbox server",
"mainGuildId": "id of the main server where users will DM the bot", "mainGuildId": "id of the main server where users will DM the bot",
"logChannelId": "id of the channel on the inbox server where notifications of new logs etc. will be posted",
"status": "Message me for help!", "status": "Message me for help!",
"responseMessage": "Thank you for your message! Our mod team will reply to you here as soon as possible." "responseMessage": "Thank you for your message! Our mod team will reply to you here as soon as possible."

1
db/.gitignore vendored
View File

@ -1,2 +1,3 @@
* *
!/.gitignore !/.gitignore
!/migrations

2
knexfile.js Normal file
View File

@ -0,0 +1,2 @@
const config = require('./src/config');
module.exports = config.knex;

1463
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,10 +13,13 @@
"dependencies": { "dependencies": {
"eris": "^0.7.2", "eris": "^0.7.2",
"humanize-duration": "^3.10.0", "humanize-duration": "^3.10.0",
"knex": "^0.14.2",
"mime": "^1.3.4", "mime": "^1.3.4",
"moment": "^2.17.1", "moment": "^2.17.1",
"public-ip": "^2.0.1", "public-ip": "^2.0.1",
"transliteration": "^1.6.2" "sqlite3": "^3.1.13",
"transliteration": "^1.6.2",
"uuid": "^3.1.0"
}, },
"devDependencies": { "devDependencies": {
"eslint": "^3.9.1" "eslint": "^3.9.1"

View File

@ -1,42 +0,0 @@
const jsonDb = require('./jsonDb');
/**
* Checks whether userId is blocked
* @param {String} userId
* @returns {Promise<Boolean>}
*/
function isBlocked(userId) {
return jsonDb.get('blocked', []).then(blocked => {
return blocked.indexOf(userId) !== -1;
});
}
/**
* Blocks the given userId
* @param {String} userId
* @returns {Promise}
*/
function block(userId) {
return jsonDb.get('blocked', []).then(blocked => {
blocked.push(userId);
return jsonDb.save('blocked', blocked);
});
}
/**
* Unblocks the given userId
* @param {String} userId
* @returns {Promise}
*/
function unblock(userId) {
return jsonDb.get('blocked', []).then(blocked => {
blocked.splice(blocked.indexOf(userId), 1);
return jsonDb.save('blocked', blocked);
});
}
module.exports = {
isBlocked,
block,
unblock,
};

View File

@ -1,3 +1,5 @@
const path = require('path');
let userConfig; let userConfig;
try { try {
@ -31,7 +33,12 @@ const defaultConfig = {
"greetingAttachment": null, "greetingAttachment": null,
"port": 8890, "port": 8890,
"url": null "url": null,
"dbDir": path.join(__dirname, '..', 'db'),
"knex": null,
"logDir": path.join(__dirname, '..', 'logs'),
}; };
const finalConfig = Object.assign({}, defaultConfig); const finalConfig = Object.assign({}, defaultConfig);
@ -44,8 +51,28 @@ for (const [prop, value] of Object.entries(userConfig)) {
finalConfig[prop] = value; finalConfig[prop] = value;
} }
if (! finalConfig.token) throw new Error('Missing token!'); if (! finalConfig['knex']) {
if (! finalConfig.mailGuildId) throw new Error('Missing mailGuildId (inbox server id)!'); finalConfig['knex'] = {
if (! finalConfig.mainGuildId) throw new Error('Missing mainGuildId!'); client: 'sqlite',
connection: {
filename: path.join(finalConfig.dbDir, 'data.sqlite')
},
useNullAsDefault: true
};
}
Object.assign(finalConfig['knex'], {
migrations: {
directory: path.join(finalConfig.dbDir, 'migrations')
}
});
const required = ['token', 'mailGuildId', 'mainGuildId', 'logChannelId'];
for (const opt of required) {
if (! finalConfig[opt]) {
console.error(`Missing required config.json value: ${opt}`);
process.exit(1);
}
}
module.exports = finalConfig; module.exports = finalConfig;

View File

@ -1,9 +1,9 @@
const Eris = require('eris'); const Eris = require('eris');
const fs = require('fs'); const fs = require('fs');
const https = require('https'); const https = require('https');
const config = require('./config'); const config = require('../config');
const getUtils = () => require('./utils'); const getUtils = () => require('../utils');
const attachmentDir = config.attachmentDir || `${__dirname}/../attachments`; const attachmentDir = config.attachmentDir || `${__dirname}/../attachments`;
@ -36,7 +36,7 @@ function saveAttachment(attachment, tries = 0) {
https.get(attachment.url, (res) => { https.get(attachment.url, (res) => {
res.pipe(writeStream); res.pipe(writeStream);
writeStream.on('finish', () => { writeStream.on('finish', () => {
writeStream.close() writeStream.closeByChannelId()
resolve(); resolve();
}); });
}).on('error', (err) => { }).on('error', (err) => {

49
src/data/blocked.js Normal file
View File

@ -0,0 +1,49 @@
const moment = require('moment');
const knex = require('../knex');
/**
* Checks whether userId is blocked
* @param {String} userId
* @returns {Promise<Boolean>}
*/
async function isBlocked(userId) {
const row = await knex('blocked_users')
.where('user_id', userId)
.first();
return !!row;
}
/**
* Blocks the given userId
* @param {String} userId
* @returns {Promise}
*/
async function block(userId, userName = '', blockedBy = 0) {
if (await isBlocked(userId)) return;
return knex('blocked_users')
.insert({
user_id: userId,
user_name: userName,
blocked_by: blockedBy,
blocked_at: moment.utc().format('YYYY-MM-DD HH:mm:ss')
});
}
/**
* Unblocks the given userId
* @param {String} userId
* @returns {Promise}
*/
async function unblock(userId) {
return knex('blocked_users')
.where('user_id', userId)
.delete();
}
module.exports = {
isBlocked,
block,
unblock,
};

69
src/data/snippets.js Normal file
View File

@ -0,0 +1,69 @@
const moment = require('moment');
const knex = require('../knex');
/**
* @property {String} trigger
* @property {String} body
* @property {Number} is_anonymous
* @property {String} created_by
* @property {String} created_at
*/
class Snippet {
constructor(props) {
Object.assign(this, props);
}
}
/**
* @param {String} trigger
* @returns {Promise<Snippet>}
*/
async function getSnippet(trigger) {
const snippet = await knex('snippets')
.where('trigger', trigger)
.first();
return (snippet ? new Snippet(snippet) : null);
}
/**
* @param {String} trigger
* @param {String} body
* @param {Boolean} isAnonymous
* @returns {Promise<void>}
*/
async function addSnippet(trigger, body, isAnonymous = false, createdBy = 0) {
if (await getSnippet(trigger)) return;
return knex('snippets').insert({
trigger,
body,
is_anonymous: isAnonymous ? 1 : 0,
created_by: createdBy,
created_at: moment.utc().format('YYYY-MM-DD HH:mm:ss')
});
}
/**
* @param {String} trigger
* @returns {Promise}
*/
async function deleteSnippet(trigger) {
return knex('snippets')
.where('trigger', trigger)
.delete();
}
async function getAllSnippets() {
const snippets = await knex('snippets')
.select();
return snippets.map(s => new Snippet(s));
}
module.exports = {
get: getSnippet,
add: addSnippet,
del: deleteSnippet,
all: getAllSnippets,
};

213
src/data/threads.js Normal file
View File

@ -0,0 +1,213 @@
const Eris = require('eris');
const transliterate = require('transliteration');
const moment = require('moment');
const uuid = require('uuid');
const bot = require('../bot');
const knex = require('../knex');
const config = require('../config');
const getUtils = () => require('../utils');
// If the following messages would be used to start a thread, ignore it instead
// This is to prevent accidental threads from e.g. irrelevant replies after the thread was already closed
// or replies to the greeting message
const accidentalThreadMessages = [
'ok',
'okay',
'thanks',
'ty',
'k',
'thank you',
'thanx',
'thnx',
'thx',
'tnx',
'ok thank you',
'ok thanks',
'ok ty',
'ok thanx',
'ok thnx',
'ok thx',
'ok no problem',
'ok np',
'okay thank you',
'okay thanks',
'okay ty',
'okay thanx',
'okay thnx',
'okay thx',
'okay no problem',
'okay np',
'okey thank you',
'okey thanks',
'okey ty',
'okey thanx',
'okey thnx',
'okey thx',
'okey no problem',
'okey np',
'cheers'
];
const THREAD_STATUS = {
OPEN: 1,
CLOSED: 2
};
const THREAD_MESSAGE_TYPE = {
SYSTEM: 1,
CHAT: 2,
FROM_USER: 3,
TO_USER: 4,
LEGACY: 5
};
/**
* @property {Number} id
* @property {Number} status
* @property {String} user_id
* @property {String} user_name
* @property {String} channel_id
* @property {String} created_at
* @property {Boolean} _wasCreated
*/
class Thread {
constructor(props) {
Object.assign(this, {_wasCreated: false}, props);
}
}
/**
* Returns information about the modmail thread channel for the given user. We can't return channel objects
* directly since they're not always available immediately after creation.
* @param {Eris.User} user
* @param {Boolean} allowCreate
* @returns {Promise<Thread>}
*/
async function getOpenThreadForUser(user, allowCreate = true, originalMessage = null) {
// Attempt to find an open thread for this user
const thread = await knex('threads')
.where('user_id', user.id)
.where('status', THREAD_STATUS.OPEN)
.select();
if (thread) {
return new Thread(thread);
}
// If no open thread was found, and we're not allowed to create one, just return null
if (! allowCreate) {
return null;
}
// No open thread was found, and we *are* allowed to create a new one, so let's do that
// If the message's content matches any of the values in accidentalThreadMessages,
// and config.ignoreAccidentalThreads is enabled, ignore this thread
if (config.ignoreAccidentalThreads && originalMessage && originalMessage.cleanContent) {
const cleaned = originalMessage.cleanContent.replace(/[^a-z\s]/gi, '').toLowerCase().trim();
if (accidentalThreadMessages.includes(cleaned)) {
console.log('[NOTE] Skipping thread creation for message:', originalMessage.cleanContent);
return null;
}
}
// Use the user's name+discrim for the thread channel's name
// Channel names are particularly picky about what characters they allow, so we gotta do some clean-up
let cleanName = transliterate.slugify(user.username);
if (cleanName === '') cleanName = 'unknown';
cleanName = cleanName.slice(0, 95); // Make sure the discrim fits
const channelName = `${cleanName}-${user.discriminator}`;
console.log(`[NOTE] Creating new thread channel ${channelName}`);
// Attempt to create the inbox channel for this thread
let createdChannel;
try {
createdChannel = await getUtils().getInboxGuild().createChannel(channelName);
if (config.newThreadCategoryId) {
// If a category id for new threads is specified, move the newly created channel there
bot.editChannel(createdChannel.id, {parentID: config.newThreadCategoryId});
}
} catch (err) {
console.error(`Error creating modmail channel for ${user.username}#${user.discriminator}!`);
throw err;
}
// Save the new thread in the database
const newThreadId = await create({
status: THREAD_STATUS.OPEN,
user_id: user.id,
user_name: `${user.username}#${user.discriminator}`,
channel_id: createdChannel.id,
created_at: moment.utc().format('YYYY-MM-DD HH:mm:ss')
});
const newThreadObj = new Thread(newThread);
newThreadObj._wasCreated = true;
return newThreadObj;
}
/**
* Creates a new thread row in the database
* @param {Object} data
* @returns {Promise<String>} The ID of the created thread
*/
async function create(data) {
const threadId = uuid.v4();
const now = moment.utc().format('YYYY-MM-DD HH:mm:ss');
const finalData = Object.assign({created_at: now, is_legacy: 0}, data, {id: threadId});
await knex('threads').insert(newThread);
return threadId;
}
async function addThreadMessage(threadId, messageType, user, body) {
return knex('thread_messages').insert({
thread_id: threadId,
message_type: messageType,
user_id: (user ? user.id : 0),
user_name: (user ? `${user.username}#${user.discriminator}` : ''),
body,
created_at: moment.utc().format('YYYY-MM-DD HH:mm:ss')
});
}
/**
* @param {String} channelId
* @returns {Promise<Thread>}
*/
async function getByChannelId(channelId) {
const thread = await knex('threads')
.where('channel_id', channelId)
.first();
return (thread ? new Thread(thread) : null);
}
/**
* Deletes the modmail thread for the given channel id
* @param {String} channelId
* @returns {Promise<void>}
*/
async function closeByChannelId(channelId) {
await knex('threads')
.where('channel_id', channelId)
.update({
status: THREAD_STATUS.CLOSED
});
}
module.exports = {
getOpenThreadForUser,
getByChannelId,
closeByChannelId,
create,
THREAD_STATUS,
THREAD_MESSAGE_TYPE,
};

View File

@ -1,21 +1,9 @@
const fs = require('fs'); const path = require('path');
const Eris = require('eris');
const moment = require('moment');
const humanizeDuration = require('humanize-duration');
const config = require('./config'); const config = require('./config');
const bot = require('./bot');
const Queue = require('./queue');
const utils = require('./utils'); const utils = require('./utils');
const blocked = require('./blocked'); const main = require('./main');
const threads = require('./threads'); const knex = require('./knex');
const logs = require('./logs'); const legacyMigrator = require('./legacy/legacyMigrator');
const attachments = require('./attachments');
const snippets = require('./snippets');
const webserver = require('./webserver');
const greeting = require('./greeting');
const messageQueue = new Queue();
// Force crash on unhandled rejections (use something like forever/pm2 to restart) // Force crash on unhandled rejections (use something like forever/pm2 to restart)
process.on('unhandledRejection', err => { process.on('unhandledRejection', err => {
@ -29,412 +17,35 @@ process.on('unhandledRejection', err => {
process.exit(1); process.exit(1);
}); });
// Once the bot has connected, set the status/"playing" message (async function() {
bot.on('ready', () => { // Make sure the database is up to date
bot.editStatus(null, {name: config.status || 'Message me for help'}); await knex.migrate.latest();
console.log('Bot started, listening to DMs');
});
// If the alwaysReply option is set to true, send all messages in modmail threads as replies, unless they start with a command prefix // Migrate legacy data if we need to
if (config.alwaysReply) { if (await legacyMigrator.shouldMigrate()) {
bot.on('messageCreate', msg => { console.log('=== MIGRATING LEGACY DATA ===');
if (! utils.messageIsOnInboxServer(msg)) return; console.log('Do not close the bot!');
if (! utils.isStaff(msg)) return; console.log('');
if (msg.author.bot) return;
if (msg.content.startsWith(config.prefix) || msg.content.startsWith(config.snippetPrefix)) return;
reply(msg, msg.content.trim(), config.alwaysReplyAnon || false); await legacyMigrator.migrate();
});
}
// "Bot was mentioned in #general-discussion" const relativeDbDir = (path.isAbsolute(config.dbDir) ? config.dbDir : path.resolve(process.cwd(), config.dbDir));
bot.on('messageCreate', async msg => { const relativeLogDir = (path.isAbsolute(config.logDir) ? config.logDir : path.resolve(process.cwd(), config.logDir));
if (! utils.messageIsOnMainServer(msg)) return;
if (! msg.mentions.some(user => user.id === bot.user.id)) return;
// If the person who mentioned the modmail bot is also on the modmail server, ignore them console.log('');
if (utils.getInboxGuild().members.get(msg.author.id)) return; console.log('=== LEGACY DATA MIGRATION FINISHED ===');
console.log('');
// If the person who mentioned the bot is blocked, ignore them console.log('IMPORTANT: After the bot starts, please verify that all logs, threads, blocked users, and snippets are still working correctly.');
if (await blocked.isBlocked(msg.author.id)) return; console.log(`Once you've done that, feel free to delete the following legacy files/directories:`);
console.log('');
bot.createMessage(utils.getLogChannel(bot).id, { console.log('FILE: ' + path.resolve(relativeDbDir, 'threads.json'));
content: `@here Bot mentioned in ${msg.channel.mention} by **${msg.author.username}#${msg.author.discriminator}**: "${msg.cleanContent}"`, console.log('FILE: ' + path.resolve(relativeDbDir, 'blocked.json'));
disableEveryone: false, console.log('FILE: ' + path.resolve(relativeDbDir, 'snippets.json'));
}); console.log('DIRECTORY: ' + relativeLogDir);
}); console.log('');
console.log('Starting the bot...');
// When we get a private message, forward the contents to the corresponding modmail thread
bot.on('messageCreate', async msg => {
if (! (msg.channel instanceof Eris.PrivateChannel)) return;
if (msg.author.id === bot.user.id) return;
if (await blocked.isBlocked(msg.author.id)) return;
// Download and save copies of attachments in the background
const attachmentSavePromise = attachments.saveAttachmentsInMessage(msg);
let threadCreationFailed = false;
// Private message handling is queued so e.g. multiple message in quick succession don't result in multiple channels being created
messageQueue.add(async () => {
let thread;
// Find the corresponding modmail thread
try {
thread = await threads.getForUser(msg.author, true, msg);
} catch (e) {
console.error(e);
utils.postError(`
Modmail thread for ${msg.author.username}#${msg.author.discriminator} (${msg.author.id}) could not be created:
\`\`\`${e.message}\`\`\`
Here's what their message contained:
\`\`\`${msg.cleanContent}\`\`\``);
return;
} }
if (! thread) { // Start the bot
// If there's no thread returned, this message was probably ignored (e.g. due to a common word) // main.start();
// TODO: Move that logic here instead? })();
return;
}
if (thread._wasCreated) {
const mainGuild = utils.getMainGuild();
const member = (mainGuild ? mainGuild.members.get(msg.author.id) : null);
if (! member) console.log(`[INFO] Member ${msg.author.id} not found in main guild ${config.mainGuildId}`);
let mainGuildNickname = null;
if (member && member.nick) mainGuildNickname = member.nick;
else if (member && member.user) mainGuildNickname = member.user.username;
else if (member == null) mainGuildNickname = 'NOT ON SERVER';
if (mainGuildNickname == null) mainGuildNickname = 'UNKNOWN';
const userLogs = await logs.getLogsByUserId(msg.author.id);
const accountAge = humanizeDuration(Date.now() - msg.author.createdAt, {largest: 2});
const infoHeader = `ACCOUNT AGE **${accountAge}**, ID **${msg.author.id}**, NICKNAME **${mainGuildNickname}**, LOGS **${userLogs.length}**\n-------------------------------`;
await bot.createMessage(thread.channelId, infoHeader);
// Ping mods of the new thread
await bot.createMessage(thread.channelId, {
content: `@here New modmail thread (${msg.author.username}#${msg.author.discriminator})`,
disableEveryone: false,
});
// Send an automatic reply to the user informing them of the successfully created modmail thread
msg.channel.createMessage(config.responseMessage).catch(err => {
utils.postError(`There is an issue sending messages to ${msg.author.username}#${msg.author.discriminator} (${msg.author.id}); consider messaging manually`);
});
}
const timestamp = utils.getTimestamp();
const attachmentsPendingStr = '\n\n*Attachments pending...*';
let content = msg.content;
if (msg.attachments.length > 0) content += attachmentsPendingStr;
const createdMsg = await bot.createMessage(thread.channelId, `[${timestamp}] « **${msg.author.username}#${msg.author.discriminator}:** ${content}`);
if (msg.attachments.length > 0) {
await attachmentSavePromise;
const formattedAttachments = await Promise.all(msg.attachments.map(utils.formatAttachment));
const attachmentMsg = `\n\n` + formattedAttachments.reduce((str, formatted) => str + `\n\n${formatted}`);
createdMsg.edit(createdMsg.content.replace(attachmentsPendingStr, attachmentMsg));
}
});
});
// Edits in DMs
bot.on('messageUpdate', async (msg, oldMessage) => {
if (! (msg.channel instanceof Eris.PrivateChannel)) return;
if (msg.author.id === bot.user.id) return;
if (await blocked.isBlocked(msg.author.id)) return;
let oldContent = oldMessage.content;
const newContent = msg.content;
// Old message content doesn't persist between bot restarts
if (oldContent == null) oldContent = '*Unavailable due to bot restart*';
// Ignore bogus edit events with no changes
if (newContent.trim() === oldContent.trim()) return;
const thread = await threads.getForUser(msg.author);
if (! thread) return;
const editMessage = utils.disableLinkPreviews(`**The user edited their message:**\n\`B:\` ${oldContent}\n\`A:\` ${newContent}`);
bot.createMessage(thread.channelId, editMessage);
});
/**
* Sends a reply to the modmail thread where `msg` was posted.
* @param {Eris.Message} msg
* @param {string} text
* @param {bool} anonymous
* @returns {Promise<void>}
*/
async function reply(msg, text, anonymous = false) {
const thread = await threads.getByChannelId(msg.channel.id);
if (! thread) return;
await attachments.saveAttachmentsInMessage(msg);
const dmChannel = await bot.getDMChannel(thread.userId);
let modUsername, logModUsername;
const mainRole = utils.getMainRole(msg.member);
if (anonymous) {
modUsername = (mainRole ? mainRole.name : 'Moderator');
logModUsername = `(Anonymous) (${msg.author.username}) ${mainRole ? mainRole.name : 'Moderator'}`;
} else {
const name = (config.useNicknames ? msg.member.nick || msg.author.username : msg.author.username);
modUsername = (mainRole ? `(${mainRole.name}) ${name}` : name);
logModUsername = modUsername;
}
let content = `**${modUsername}:** ${text}`;
let logContent = `**${logModUsername}:** ${text}`;
async function sendMessage(file, attachmentUrl) {
try {
await dmChannel.createMessage(content, file);
} catch (e) {
if (e.resp && e.resp.statusCode === 403) {
msg.channel.createMessage(`Could not send reply; the user has likely left the server or blocked the bot`);
} else if (e.resp) {
msg.channel.createMessage(`Could not send reply; error code ${e.resp.statusCode}`);
} else {
msg.channel.createMessage(`Could not send reply: ${e.toString()}`);
}
}
if (attachmentUrl) {
content += `\n\n**Attachment:** ${attachmentUrl}`;
logContent += `\n\n**Attachment:** ${attachmentUrl}`;
}
// Show the message in the modmail thread as well
msg.channel.createMessage(`[${utils.getTimestamp()}] » ${logContent}`);
msg.delete();
};
if (msg.attachments.length > 0) {
// If the reply has an attachment, relay it as is
fs.readFile(attachments.getPath(msg.attachments[0].id), async (err, data) => {
const file = {file: data, name: msg.attachments[0].filename};
const attachmentUrl = await attachments.getUrl(msg.attachments[0].id, msg.attachments[0].filename);
sendMessage(file, attachmentUrl);
});
} else {
// Otherwise just send the message regularly
sendMessage();
}
}
// Mods can reply to modmail threads using !r or !reply
// These messages get relayed back to the DM thread between the bot and the user
utils.addInboxCommand('reply', (msg, args) => {
const text = args.join(' ').trim();
reply(msg, text, false);
});
bot.registerCommandAlias('r', 'reply');
// Anonymous replies only show the role, not the username
utils.addInboxCommand('anonreply', (msg, args) => {
const text = args.join(' ').trim();
reply(msg, text, true);
});
bot.registerCommandAlias('ar', 'anonreply');
// Close a thread. Closing a thread saves a log of the channel's contents and then deletes the channel.
utils.addInboxCommand('close', async (msg, args, thread) => {
if (! thread) return;
await msg.channel.createMessage('Saving logs and closing channel...');
const logMessages = await msg.channel.getMessages(10000);
const log = logMessages.reverse().map(msg => {
const date = moment.utc(msg.timestamp, 'x').format('YYYY-MM-DD HH:mm:ss');
return `[${date}] ${msg.author.username}#${msg.author.discriminator}: ${msg.content}`;
}).join('\n') + '\n';
const logFilename = await logs.getNewLogFile(thread.userId);
await logs.saveLogFile(logFilename, log);
const logUrl = await logs.getLogFileUrl(logFilename);
const closeMessage = `Modmail thread with ${thread.username} (${thread.userId}) was closed by ${msg.author.username}
Logs: <${logUrl}>`;
bot.createMessage(utils.getLogChannel(bot).id, closeMessage);
await threads.close(thread.channelId);
msg.channel.delete();
});
utils.addInboxCommand('block', (msg, args, thread) => {
async function block(userId) {
await blocked.block(userId);
msg.channel.createMessage(`Blocked <@${userId}> (id ${userId}) from modmail`);
}
if (args.length > 0) {
// User mention/id as argument
const userId = utils.getUserMention(args.join(' '));
if (! userId) return;
block(userId);
} else if (thread) {
// Calling !block without args in a modmail thread blocks the user of that thread
block(thread.userId);
}
});
utils.addInboxCommand('unblock', (msg, args, thread) => {
async function unblock(userId) {
await blocked.unblock(userId);
msg.channel.createMessage(`Unblocked <@${userId}> (id ${userId}) from modmail`);
}
if (args.length > 0) {
// User mention/id as argument
const userId = utils.getUserMention(args.join(' '));
if (! userId) return;
unblock(userId);
} else if (thread) {
// Calling !unblock without args in a modmail thread unblocks the user of that thread
unblock(thread.userId);
}
});
utils.addInboxCommand('logs', (msg, args, thread) => {
async function getLogs(userId) {
const infos = await logs.getLogsWithUrlByUserId(userId);
let message = `**Log files for <@${userId}>:**\n`;
message += infos.map(info => {
const formattedDate = moment.utc(info.date, 'YYYY-MM-DD HH:mm:ss').format('MMM Do [at] HH:mm [UTC]');
return `\`${formattedDate}\`: <${info.url}>`;
}).join('\n');
// Send the list of logs in chunks of 15 lines per message
const lines = message.split('\n');
const chunks = utils.chunk(lines, 15);
let root = Promise.resolve();
chunks.forEach(lines => {
root = root.then(() => msg.channel.createMessage(lines.join('\n')));
});
}
if (args.length > 0) {
// User mention/id as argument
const userId = utils.getUserMention(args.join(' '));
if (! userId) return;
getLogs(userId);
} else if (thread) {
// Calling !logs without args in a modmail thread returns the logs of the user of that thread
getLogs(thread.userId);
}
});
// Snippets
bot.on('messageCreate', async msg => {
if (! utils.messageIsOnInboxServer(msg)) return;
if (! utils.isStaff(msg.member)) return;
if (msg.author.bot) return;
if (! msg.content) return;
if (! msg.content.startsWith(config.snippetPrefix)) return;
const shortcut = msg.content.replace(config.snippetPrefix, '').toLowerCase();
const snippet = await snippets.get(shortcut);
if (! snippet) return;
reply(msg, snippet.text, snippet.isAnonymous);
});
// Show or add a snippet
utils.addInboxCommand('snippet', async (msg, args) => {
const shortcut = args[0];
if (! shortcut) return
const text = args.slice(1).join(' ').trim();
const snippet = await snippets.get(shortcut);
if (snippet) {
if (text) {
// If the snippet exists and we're trying to create a new one, inform the user the snippet already exists
msg.channel.createMessage(`Snippet "${shortcut}" already exists! You can edit or delete it with ${prefix}edit_snippet and ${prefix}delete_snippet respectively.`);
} else {
// If the snippet exists and we're NOT trying to create a new one, show info about the existing snippet
msg.channel.createMessage(`\`${config.snippetPrefix}${shortcut}\` replies ${snippet.isAnonymous ? 'anonymously ' : ''}with:\n${snippet.text}`);
}
} else {
if (text) {
// If the snippet doesn't exist and the user wants to create it, create it
await snippets.add(shortcut, text, false);
msg.channel.createMessage(`Snippet "${shortcut}" created!`);
} else {
// If the snippet doesn't exist and the user isn't trying to create it, inform them how to create it
msg.channel.createMessage(`Snippet "${shortcut}" doesn't exist! You can create it with \`${prefix}snippet ${shortcut} text\``);
}
}
});
bot.registerCommandAlias('s', 'snippet');
utils.addInboxCommand('delete_snippet', async (msg, args) => {
const shortcut = args[0];
if (! shortcut) return;
const snippet = await snippets.get(shortcut);
if (! snippet) {
msg.channel.createMessage(`Snippet "${shortcut}" doesn't exist!`);
return;
}
await snippets.del(shortcut);
msg.channel.createMessage(`Snippet "${shortcut}" deleted!`);
});
bot.registerCommandAlias('ds', 'delete_snippet');
utils.addInboxCommand('edit_snippet', async (msg, args) => {
const shortcut = args[0];
if (! shortcut) return;
const text = args.slice(1).join(' ').trim();
if (! text) return;
const snippet = await snippets.get(shortcut);
if (! snippet) {
msg.channel.createMessage(`Snippet "${shortcut}" doesn't exist!`);
return;
}
await snippets.del(shortcut);
await snippets.add(shortcut, text, snippet.isAnonymous);
msg.channel.createMessage(`Snippet "${shortcut}" edited!`);
});
bot.registerCommandAlias('es', 'edit_snippet');
utils.addInboxCommand('snippets', async msg => {
const allSnippets = await snippets.all();
const shortcuts = Object.keys(allSnippets);
shortcuts.sort();
msg.channel.createMessage(`Available snippets (prefix ${config.snippetPrefix}):\n${shortcuts.join(', ')}`);
});
// Start the bot!
bot.connect();
webserver.run();
greeting.init(bot);

2
src/knex.js Normal file
View File

@ -0,0 +1,2 @@
const config = require('./config');
module.exports = require('knex')(config.knex);

View File

@ -1,11 +1,14 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const config = require('./config'); const config = require('../config');
const dbDir = config.dbDir || `${__dirname}/../db`; const dbDir = config.dbDir;
const databases = {}; const databases = {};
/**
* @deprecated Only used for migrating legacy data
*/
class JSONDB { class JSONDB {
constructor(path, def = {}, useCloneByDefault = false) { constructor(path, def = {}, useCloneByDefault = false) {
this.path = path; this.path = path;

View File

@ -0,0 +1,184 @@
const fs = require('fs');
const path = require('path');
const promisify = require('util').promisify;
const moment = require('moment');
const uuid = require('uuid');
const knex = require('../knex');
const config = require('../config');
const jsonDb = require('./jsonDb');
const threads = require('../data/threads');
const readDir = promisify(fs.readdir);
const readFile = promisify(fs.readFile);
const access = promisify(fs.access);
const writeFile = promisify(fs.writeFile);
async function migrate() {
console.log('Migrating open threads...');
await migrateOpenThreads();
console.log('Migrating logs...');
await migrateLogs();
console.log('Migrating blocked users...');
await migrateBlockedUsers();
console.log('Migrating snippets...');
await migrateSnippets();
await writeFile(path.join(config.dbDir, '.migrated_legacy'), '');
}
async function shouldMigrate() {
// If there is a file marking a finished migration, assume we don't need to migrate
const migrationFile = path.join(config.dbDir, '.migrated_legacy');
try {
await access(migrationFile);
return false;
} catch (e) {}
// If there are any old threads, we need to migrate
const oldThreads = await jsonDb.get('threads', []);
if (oldThreads.length) {
return true;
}
// If there are any old blocked users, we need to migrate
const blockedUsers = await jsonDb.get('blocked', []);
if (blockedUsers.length) {
return true;
}
// If there are any old snippets, we need to migrate
const snippets = await jsonDb.get('snippets', {});
if (Object.keys(snippets).length) {
return true;
}
// If the log file dir exists, we need to migrate
try {
await access(config.logDir);
return true;
} catch(e) {}
return false;
}
async function migrateOpenThreads() {
const oldThreads = await jsonDb.get('threads', []);
const promises = oldThreads.map(async oldThread => {
const existingOpenThread = await knex('threads')
.where('channel_id', oldThread.channelId)
.first();
if (existingOpenThread) return;
const newThread = {
status: threads.THREAD_STATUS.OPEN,
user_id: oldThread.userId,
user_name: oldThread.username,
channel_id: oldThread.channelId,
is_legacy: 1
};
return threads.create(newThread);
});
return Promise.all(promises);
}
async function migrateLogs() {
const logDir = config.logDir || `${__dirname}/../../logs`;
const logFiles = await readDir(logDir);
const promises = logFiles.map(async logFile => {
if (! logFile.endsWith('.txt')) return;
const [rawDate, userId, threadId] = logFile.slice(0, -4).split('__');
const date = `${rawDate.slice(0, 10)} ${rawDate.slice(11).replace('-', ':')}`;
const fullPath = path.join(logDir, logFile);
const contents = await readFile(fullPath, {encoding: 'utf8'});
const newThread = {
id: threadId,
status: threads.THREAD_STATUS.CLOSED,
user_id: userId,
user_name: '',
channel_id: null,
is_legacy: 1,
created_at: date
};
return knex.transaction(async trx => {
const existingThread = await trx('threads')
.where('id', newThread.id)
.first();
if (existingThread) return;
await trx('threads').insert(newThread);
await trx('thread_messages').insert({
thread_id: newThread.id,
message_type: threads.THREAD_MESSAGE_TYPE.LEGACY,
user_id: userId,
user_name: '',
body: contents,
created_at: date
});
});
});
return Promise.all(promises);
}
async function migrateBlockedUsers() {
const now = moment.utc().format('YYYY-MM-DD HH:mm:ss');
const blockedUsers = await jsonDb.get('blocked', []);
const promises = blockedUsers.map(async userId => {
const existingBlockedUser = await knex('blocked_users')
.where('user_id', userId)
.first();
if (existingBlockedUser) return;
return knex('blocked_users').insert({
user_id: userId,
user_name: '',
blocked_by: 0,
blocked_at: now
});
});
return Promise.all(promises);
}
async function migrateSnippets() {
const now = moment.utc().format('YYYY-MM-DD HH:mm:ss');
const snippets = await jsonDb.get('snippets', {});
const promises = Object.entries(snippets).map(async ([trigger, data]) => {
const existingSnippet = await knex('snippets')
.where('trigger', trigger)
.first();
if (existingSnippet) return;
return knex('snippets').insert({
trigger,
body: data.text,
is_anonymous: data.isAnonymous ? 1 : 0,
created_by: null,
created_at: now
});
});
return Promise.all(promises);
}
module.exports = {
migrate,
shouldMigrate,
};

View File

@ -1,163 +0,0 @@
const fs = require('fs');
const crypto = require('crypto');
const moment = require('moment');
const config = require('./config');
const getUtils = () => require('./utils');
const logDir = config.logDir || `${__dirname}/../logs`;
const logFileFormatRegex = /^([0-9\-]+?)__([0-9]+?)__([0-9a-f]+?)\.txt$/;
/**
* @typedef {Object} LogFileInfo
* @property {String} filename
* @property {String} date
* @property {String} userId
* @property {String} token
* @property {String=} url
*/
/**
* Returns information about the given logfile
* @param {String} logFilename
* @returns {LogFileInfo}
*/
function getLogFileInfo(logFilename) {
const match = logFilename.match(logFileFormatRegex);
if (! match) return null;
const date = moment.utc(match[1], 'YYYY-MM-DD-HH-mm-ss').format('YYYY-MM-DD HH:mm:ss');
return {
filename: logFilename,
date: date,
userId: match[2],
token: match[3],
};
}
/**
* Returns the filesystem path to the given logfile
* @param {String} logFilename
* @returns {String}
*/
function getLogFilePath(logFilename) {
return `${logDir}/${logFilename}`;
}
/**
* Returns the self-hosted URL to the given logfile
* @param {String} logFilename
* @returns {String}
*/
function getLogFileUrl(logFilename) {
const info = getLogFileInfo(logFilename);
return getUtils().getSelfUrl(`logs/${info.token}`);
}
/**
* Returns a new, unique log file name for the given userId
* @param {String} userId
* @returns {Promise<String>}
*/
function getNewLogFile(userId) {
return new Promise(resolve => {
crypto.randomBytes(16, (err, buf) => {
const token = buf.toString('hex');
const date = moment.utc().format('YYYY-MM-DD-HH-mm-ss');
resolve(`${date}__${userId}__${token}.txt`);
});
});
}
/**
* Finds a log file name by its token
* @param {String} token
* @returns {Promise<String>}
*/
function findLogFile(token) {
return new Promise(resolve => {
fs.readdir(logDir, (err, files) => {
for (const file of files) {
if (file.endsWith(`__${token}.txt`)) {
resolve(file);
return;
}
}
resolve(null);
});
});
}
/**
* Returns all log file infos for the given userId
* @param {String} userId
* @returns {Promise<LogFileInfo[]>}
*/
function getLogsByUserId(userId) {
return new Promise((resolve, reject) => {
fs.readdir(logDir, (err, files) => {
if (err) return reject(err);
const logfileInfos = files
.map(file => getLogFileInfo(file))
.filter(info => info && info.userId === userId);
resolve(logfileInfos);
});
});
}
/**
* Returns all log file infos with URLs for the given userId
* @param {String} userId
* @returns {Promise<LogFileInfo[]>}
*/
function getLogsWithUrlByUserId(userId) {
return getLogsByUserId(userId).then(infos => {
const urlPromises = infos.map(info => {
return getLogFileUrl(info.filename).then(url => {
info.url = url;
return info;
});
});
return Promise.all(urlPromises).then(infos => {
// Sort logs by date, in descending order
infos.sort((a, b) => {
if (a.date > b.date) return -1;
if (a.date < b.date) return 1;
return 0;
});
return infos;
});
});
}
/**
* @param {String} logFilename
* @param {String} content
* @returns {Promise}
*/
function saveLogFile(logFilename, content) {
return new Promise((resolve, reject) => {
fs.writeFile(getLogFilePath(logFilename), content, {encoding: 'utf8'}, err => {
if (err) return reject(err);
resolve();
});
});
}
module.exports = {
getLogFileInfo,
getLogFilePath,
getNewLogFile,
findLogFile,
getLogsByUserId,
getLogsWithUrlByUserId,
saveLogFile,
getLogFileUrl,
};

430
src/main.js Normal file
View File

@ -0,0 +1,430 @@
const fs = require('fs');
const Eris = require('eris');
const moment = require('moment');
const humanizeDuration = require('humanize-duration');
const config = require('./config');
const bot = require('./bot');
const Queue = require('./queue');
const utils = require('./utils');
const blocked = require('./data/blocked');
const threads = require('./data/threads');
const attachments = require('./data/attachments');
const snippets = require('./data/snippets');
const webserver = require('./webserver');
const greeting = require('./greeting');
const messageQueue = new Queue();
// Once the bot has connected, set the status/"playing" message
bot.on('ready', () => {
bot.editStatus(null, {name: config.status});
console.log('Bot started, listening to DMs');
});
// If the alwaysReply option is set to true, send all messages in modmail threads as replies, unless they start with a command prefix
if (config.alwaysReply) {
bot.on('messageCreate', msg => {
if (! utils.messageIsOnInboxServer(msg)) return;
if (! utils.isStaff(msg)) return;
if (msg.author.bot) return;
if (msg.content.startsWith(config.prefix) || msg.content.startsWith(config.snippetPrefix)) return;
reply(msg, msg.content.trim(), config.alwaysReplyAnon || false);
});
}
// "Bot was mentioned in #general-discussion"
bot.on('messageCreate', async msg => {
if (! utils.messageIsOnMainServer(msg)) return;
if (! msg.mentions.some(user => user.id === bot.user.id)) return;
// If the person who mentioned the modmail bot is also on the modmail server, ignore them
if (utils.getInboxGuild().members.get(msg.author.id)) return;
// If the person who mentioned the bot is blocked, ignore them
if (await blocked.isBlocked(msg.author.id)) return;
bot.createMessage(utils.getLogChannel(bot).id, {
content: `@here Bot mentioned in ${msg.channel.mention} by **${msg.author.username}#${msg.author.discriminator}**: "${msg.cleanContent}"`,
disableEveryone: false,
});
});
// When we get a private message, forward the contents to the corresponding modmail thread
bot.on('messageCreate', async msg => {
if (! (msg.channel instanceof Eris.PrivateChannel)) return;
if (msg.author.id === bot.user.id) return;
if (await blocked.isBlocked(msg.author.id)) return;
// Download and save copies of attachments in the background
const attachmentSavePromise = attachments.saveAttachmentsInMessage(msg);
let threadCreationFailed = false;
// Private message handling is queued so e.g. multiple message in quick succession don't result in multiple channels being created
messageQueue.add(async () => {
let thread;
// Find the corresponding modmail thread
try {
thread = await threads.getOpenThreadForUser(msg.author, true, msg);
} catch (e) {
console.error(e);
utils.postError(`
Modmail thread for ${msg.author.username}#${msg.author.discriminator} (${msg.author.id}) could not be created:
\`\`\`${e.message}\`\`\`
Here's what their message contained:
\`\`\`${msg.cleanContent}\`\`\``);
return;
}
if (! thread) {
// If there's no thread returned, this message was probably ignored (e.g. due to a common word)
// TODO: Move that logic here instead?
return;
}
if (thread._wasCreated) {
const mainGuild = utils.getMainGuild();
const member = (mainGuild ? mainGuild.members.get(msg.author.id) : null);
if (! member) console.log(`[INFO] Member ${msg.author.id} not found in main guild ${config.mainGuildId}`);
let mainGuildNickname = null;
if (member && member.nick) mainGuildNickname = member.nick;
else if (member && member.user) mainGuildNickname = member.user.username;
else if (member == null) mainGuildNickname = 'NOT ON SERVER';
if (mainGuildNickname == null) mainGuildNickname = 'UNKNOWN';
const userLogs = await logs.getLogsByUserId(msg.author.id);
const accountAge = humanizeDuration(Date.now() - msg.author.createdAt, {largest: 2});
const infoHeader = `ACCOUNT AGE **${accountAge}**, ID **${msg.author.id}**, NICKNAME **${mainGuildNickname}**, LOGS **${userLogs.length}**\n-------------------------------`;
await bot.createMessage(thread.channelId, infoHeader);
// Ping mods of the new thread
await bot.createMessage(thread.channelId, {
content: `@here New modmail thread (${msg.author.username}#${msg.author.discriminator})`,
disableEveryone: false,
});
// Send an automatic reply to the user informing them of the successfully created modmail thread
msg.channel.createMessage(config.responseMessage).catch(err => {
utils.postError(`There is an issue sending messages to ${msg.author.username}#${msg.author.discriminator} (${msg.author.id}); consider messaging manually`);
});
}
const timestamp = utils.getTimestamp();
const attachmentsPendingStr = '\n\n*Attachments pending...*';
let content = msg.content;
if (msg.attachments.length > 0) content += attachmentsPendingStr;
const createdMsg = await bot.createMessage(thread.channelId, `[${timestamp}] « **${msg.author.username}#${msg.author.discriminator}:** ${content}`);
if (msg.attachments.length > 0) {
await attachmentSavePromise;
const formattedAttachments = await Promise.all(msg.attachments.map(utils.formatAttachment));
const attachmentMsg = `\n\n` + formattedAttachments.reduce((str, formatted) => str + `\n\n${formatted}`);
createdMsg.edit(createdMsg.content.replace(attachmentsPendingStr, attachmentMsg));
}
});
});
// Edits in DMs
bot.on('messageUpdate', async (msg, oldMessage) => {
if (! (msg.channel instanceof Eris.PrivateChannel)) return;
if (msg.author.id === bot.user.id) return;
if (await blocked.isBlocked(msg.author.id)) return;
let oldContent = oldMessage.content;
const newContent = msg.content;
// Old message content doesn't persist between bot restarts
if (oldContent == null) oldContent = '*Unavailable due to bot restart*';
// Ignore bogus edit events with no changes
if (newContent.trim() === oldContent.trim()) return;
const thread = await threads.getOpenThreadForUser(msg.author);
if (! thread) return;
const editMessage = utils.disableLinkPreviews(`**The user edited their message:**\n\`B:\` ${oldContent}\n\`A:\` ${newContent}`);
bot.createMessage(thread.channelId, editMessage);
});
/**
* Sends a reply to the modmail thread where `msg` was posted.
* @param {Eris.Message} msg
* @param {string} text
* @param {bool} anonymous
* @returns {Promise<void>}
*/
async function reply(msg, text, anonymous = false) {
const thread = await threads.getByChannelId(msg.channel.id);
if (! thread) return;
await attachments.saveAttachmentsInMessage(msg);
const dmChannel = await bot.getDMChannel(thread.userId);
let modUsername, logModUsername;
const mainRole = utils.getMainRole(msg.member);
if (anonymous) {
modUsername = (mainRole ? mainRole.name : 'Moderator');
logModUsername = `(Anonymous) (${msg.author.username}) ${mainRole ? mainRole.name : 'Moderator'}`;
} else {
const name = (config.useNicknames ? msg.member.nick || msg.author.username : msg.author.username);
modUsername = (mainRole ? `(${mainRole.name}) ${name}` : name);
logModUsername = modUsername;
}
let content = `**${modUsername}:** ${text}`;
let logContent = `**${logModUsername}:** ${text}`;
async function sendMessage(file, attachmentUrl) {
try {
await dmChannel.createMessage(content, file);
} catch (e) {
if (e.resp && e.resp.statusCode === 403) {
msg.channel.createMessage(`Could not send reply; the user has likely left the server or blocked the bot`);
} else if (e.resp) {
msg.channel.createMessage(`Could not send reply; error code ${e.resp.statusCode}`);
} else {
msg.channel.createMessage(`Could not send reply: ${e.toString()}`);
}
}
if (attachmentUrl) {
content += `\n\n**Attachment:** ${attachmentUrl}`;
logContent += `\n\n**Attachment:** ${attachmentUrl}`;
}
// Show the message in the modmail thread as well
msg.channel.createMessage(`[${utils.getTimestamp()}] » ${logContent}`);
msg.delete();
};
if (msg.attachments.length > 0) {
// If the reply has an attachment, relay it as is
fs.readFile(attachments.getPath(msg.attachments[0].id), async (err, data) => {
const file = {file: data, name: msg.attachments[0].filename};
const attachmentUrl = await attachments.getUrl(msg.attachments[0].id, msg.attachments[0].filename);
sendMessage(file, attachmentUrl);
});
} else {
// Otherwise just send the message regularly
sendMessage();
}
}
// Mods can reply to modmail threads using !r or !reply
// These messages get relayed back to the DM thread between the bot and the user
utils.addInboxCommand('reply', (msg, args) => {
const text = args.join(' ').trim();
reply(msg, text, false);
});
bot.registerCommandAlias('r', 'reply');
// Anonymous replies only show the role, not the username
utils.addInboxCommand('anonreply', (msg, args) => {
const text = args.join(' ').trim();
reply(msg, text, true);
});
bot.registerCommandAlias('ar', 'anonreply');
// Close a thread. Closing a thread saves a log of the channel's contents and then deletes the channel.
utils.addInboxCommand('close', async (msg, args, thread) => {
if (! thread) return;
await msg.channel.createMessage('Saving logs and closing channel...');
const logMessages = await msg.channel.getMessages(10000);
const log = logMessages.reverse().map(msg => {
const date = moment.utc(msg.timestamp, 'x').format('YYYY-MM-DD HH:mm:ss');
return `[${date}] ${msg.author.username}#${msg.author.discriminator}: ${msg.content}`;
}).join('\n') + '\n';
const logFilename = await logs.getNewLogFile(thread.userId);
await logs.saveLogFile(logFilename, log);
const logUrl = await logs.getLogFileUrl(logFilename);
const closeMessage = `Modmail thread with ${thread.username} (${thread.userId}) was closed by ${msg.author.username}
Logs: <${logUrl}>`;
bot.createMessage(utils.getLogChannel(bot).id, closeMessage);
await threads.closeByChannelId(thread.channelId);
msg.channel.delete();
});
utils.addInboxCommand('block', (msg, args, thread) => {
async function block(userId) {
await blocked.block(userId);
msg.channel.createMessage(`Blocked <@${userId}> (id ${userId}) from modmail`);
}
if (args.length > 0) {
// User mention/id as argument
const userId = utils.getUserMention(args.join(' '));
if (! userId) return;
block(userId);
} else if (thread) {
// Calling !block without args in a modmail thread blocks the user of that thread
block(thread.userId);
}
});
utils.addInboxCommand('unblock', (msg, args, thread) => {
async function unblock(userId) {
await blocked.unblock(userId);
msg.channel.createMessage(`Unblocked <@${userId}> (id ${userId}) from modmail`);
}
if (args.length > 0) {
// User mention/id as argument
const userId = utils.getUserMention(args.join(' '));
if (! userId) return;
unblock(userId);
} else if (thread) {
// Calling !unblock without args in a modmail thread unblocks the user of that thread
unblock(thread.userId);
}
});
utils.addInboxCommand('logs', (msg, args, thread) => {
async function getLogs(userId) {
const infos = await logs.getLogsWithUrlByUserId(userId);
let message = `**Log files for <@${userId}>:**\n`;
message += infos.map(info => {
const formattedDate = moment.utc(info.date, 'YYYY-MM-DD HH:mm:ss').format('MMM Do [at] HH:mm [UTC]');
return `\`${formattedDate}\`: <${info.url}>`;
}).join('\n');
// Send the list of logs in chunks of 15 lines per message
const lines = message.split('\n');
const chunks = utils.chunk(lines, 15);
let root = Promise.resolve();
chunks.forEach(lines => {
root = root.then(() => msg.channel.createMessage(lines.join('\n')));
});
}
if (args.length > 0) {
// User mention/id as argument
const userId = utils.getUserMention(args.join(' '));
if (! userId) return;
getLogs(userId);
} else if (thread) {
// Calling !logs without args in a modmail thread returns the logs of the user of that thread
getLogs(thread.userId);
}
});
// Snippets
bot.on('messageCreate', async msg => {
if (! utils.messageIsOnInboxServer(msg)) return;
if (! utils.isStaff(msg.member)) return;
if (msg.author.bot) return;
if (! msg.content) return;
if (! msg.content.startsWith(config.snippetPrefix)) return;
const shortcut = msg.content.replace(config.snippetPrefix, '').toLowerCase();
const snippet = await snippets.get(shortcut);
if (! snippet) return;
reply(msg, snippet.text, snippet.isAnonymous);
});
// Show or add a snippet
utils.addInboxCommand('snippet', async (msg, args) => {
const shortcut = args[0];
if (! shortcut) return
const text = args.slice(1).join(' ').trim();
const snippet = await snippets.get(shortcut);
if (snippet) {
if (text) {
// If the snippet exists and we're trying to create a new one, inform the user the snippet already exists
msg.channel.createMessage(`Snippet "${shortcut}" already exists! You can edit or delete it with ${prefix}edit_snippet and ${prefix}delete_snippet respectively.`);
} else {
// If the snippet exists and we're NOT trying to create a new one, show info about the existing snippet
msg.channel.createMessage(`\`${config.snippetPrefix}${shortcut}\` replies ${snippet.isAnonymous ? 'anonymously ' : ''}with:\n${snippet.text}`);
}
} else {
if (text) {
// If the snippet doesn't exist and the user wants to create it, create it
await snippets.add(shortcut, text, false);
msg.channel.createMessage(`Snippet "${shortcut}" created!`);
} else {
// If the snippet doesn't exist and the user isn't trying to create it, inform them how to create it
msg.channel.createMessage(`Snippet "${shortcut}" doesn't exist! You can create it with \`${prefix}snippet ${shortcut} text\``);
}
}
});
bot.registerCommandAlias('s', 'snippet');
utils.addInboxCommand('delete_snippet', async (msg, args) => {
const shortcut = args[0];
if (! shortcut) return;
const snippet = await snippets.get(shortcut);
if (! snippet) {
msg.channel.createMessage(`Snippet "${shortcut}" doesn't exist!`);
return;
}
await snippets.del(shortcut);
msg.channel.createMessage(`Snippet "${shortcut}" deleted!`);
});
bot.registerCommandAlias('ds', 'delete_snippet');
utils.addInboxCommand('edit_snippet', async (msg, args) => {
const shortcut = args[0];
if (! shortcut) return;
const text = args.slice(1).join(' ').trim();
if (! text) return;
const snippet = await snippets.get(shortcut);
if (! snippet) {
msg.channel.createMessage(`Snippet "${shortcut}" doesn't exist!`);
return;
}
await snippets.del(shortcut);
await snippets.add(shortcut, text, snippet.isAnonymous);
msg.channel.createMessage(`Snippet "${shortcut}" edited!`);
});
bot.registerCommandAlias('es', 'edit_snippet');
utils.addInboxCommand('snippets', async msg => {
const allSnippets = await snippets.all();
const shortcuts = Object.keys(allSnippets);
shortcuts.sort();
msg.channel.createMessage(`Available snippets (prefix ${config.snippetPrefix}):\n${shortcuts.join(', ')}`);
});
module.exports = {
start() {
bot.connect();
webserver.run();
greeting.init(bot);
}
};

View File

@ -1,59 +0,0 @@
const jsonDb = require('./jsonDb');
/**
* @typedef {Object} Snippet
* @property {String} text
* @property {Boolean} isAnonymous
*/
/**
* Returns the expanded text for the given snippet shortcut
* @param {String} shortcut
* @returns {Promise<Snippet|null>}
*/
function getSnippet(shortcut) {
return jsonDb.get('snippets', {}).then(snippets => {
return snippets[shortcut] || null;
});
}
/**
* Adds a snippet
* @param {String} shortcut
* @param {String} text
* @param {Boolean} isAnonymous
* @returns {Promise}
*/
function addSnippet(shortcut, text, isAnonymous = false) {
return jsonDb.get('snippets', {}).then(snippets => {
snippets[shortcut] = {
text,
isAnonymous,
};
jsonDb.save('snippets', snippets);
});
}
/**
* Deletes a snippet
* @param {String} shortcut
* @returns {Promise}
*/
function deleteSnippet(shortcut) {
return jsonDb.get('snippets', {}).then(snippets => {
delete snippets[shortcut];
jsonDb.save('snippets', snippets);
});
}
function getAllSnippets() {
return jsonDb.get('snippets', {});
}
module.exports = {
get: getSnippet,
add: addSnippet,
del: deleteSnippet,
all: getAllSnippets,
};

View File

@ -1,144 +0,0 @@
const Eris = require('eris');
const bot = require('./bot');
const transliterate = require('transliteration');
const jsonDb = require('./jsonDb');
const config = require('./config');
const getUtils = () => require('./utils');
// If the following messages would be used to start a thread, ignore it instead
// This is to prevent accidental threads from e.g. irrelevant replies after the thread was already closed
// or replies to the greeting message
const accidentalThreadMessages = [
'ok',
'okay',
'thanks',
'ty',
'k',
'thank you',
'thanx',
'thnx',
'thx',
'tnx',
'ok thank you',
'ok thanks',
'ok ty',
'ok thanx',
'ok thnx',
'ok thx',
'ok no problem',
'ok np',
'okay thank you',
'okay thanks',
'okay ty',
'okay thanx',
'okay thnx',
'okay thx',
'okay no problem',
'okay np',
'okey thank you',
'okey thanks',
'okey ty',
'okey thanx',
'okey thnx',
'okey thx',
'okey no problem',
'okey np',
'cheers'
];
/**
* @typedef {Object} ModMailThread
* @property {String} channelId
* @property {String} userId
* @property {String} username
* @property {Boolean} _wasCreated
*/
/**
* Returns information about the modmail thread channel for the given user. We can't return channel objects
* directly since they're not always available immediately after creation.
* @param {Eris.User} user
* @param {Boolean} allowCreate
* @returns {Promise<ModMailThread>}
*/
function getForUser(user, allowCreate = true, originalMessage = null) {
return jsonDb.get('threads', []).then(threads => {
const thread = threads.find(t => t.userId === user.id);
if (thread) return thread;
// If we didn't find an existing modmail thread, attempt creating one
if (! allowCreate) return null;
// Channel names are particularly picky about what characters they allow...
let cleanName = transliterate.slugify(user.username);
if (cleanName === '') cleanName = 'unknown';
cleanName = cleanName.slice(0, 95); // Make sure the discrim fits
const channelName = `${cleanName}-${user.discriminator}`;
if (originalMessage && originalMessage.cleanContent && config.ignoreAccidentalThreads) {
const cleaned = originalMessage.cleanContent.replace(/[^a-z\s]/gi, '').toLowerCase().trim();
if (accidentalThreadMessages.includes(cleaned)) {
console.log('[NOTE] Skipping thread creation for message:', originalMessage.cleanContent);
return null;
}
}
console.log(`[NOTE] Creating new thread channel ${channelName}`);
return getUtils().getInboxGuild().createChannel(`${channelName}`)
.then(channel => {
const thread = {
channelId: channel.id,
userId: user.id,
username: `${user.username}#${user.discriminator}`,
};
if (config.newThreadCategoryId) {
// If a category id is specified, move the newly created channel there
bot.editChannel(channel.id, {parentID: config.newThreadCategoryId});
}
return jsonDb.get('threads', []).then(threads => {
threads.push(thread);
jsonDb.save('threads', threads);
return Object.assign({}, thread, {_wasCreated: true});
});
}, err => {
console.error(`Error creating modmail channel for ${user.username}#${user.discriminator}!`);
throw err;
});
});
}
/**
* @param {String} channelId
* @returns {Promise<ModMailThread>}
*/
function getByChannelId(channelId) {
return jsonDb.get('threads', []).then(threads => {
return threads.find(t => t.channelId === channelId);
});
}
/**
* Deletes the modmail thread for the given channel id
* @param {String} channelId
* @returns {Promise}
*/
function close(channelId) {
return jsonDb.get('threads', []).then(threads => {
const thread = threads.find(t => t.channelId === channelId);
if (! thread) return;
threads.splice(threads.indexOf(thread), 1);
return jsonDb.save('threads', threads);
});
}
module.exports = {
getForUser,
getByChannelId,
close,
};

View File

@ -2,8 +2,8 @@ const Eris = require('eris');
const bot = require('./bot'); const bot = require('./bot');
const moment = require('moment'); const moment = require('moment');
const publicIp = require('public-ip'); const publicIp = require('public-ip');
const threads = require('./threads'); const threads = require('./data/threads');
const attachments = require('./attachments'); const attachments = require('./data/attachments');
const config = require('./config'); const config = require('./config');
class BotError extends Error {} class BotError extends Error {}

View File

@ -3,8 +3,7 @@ const mime = require('mime');
const url = require('url'); const url = require('url');
const fs = require('fs'); const fs = require('fs');
const config = require('./config'); const config = require('./config');
const logs = require('./logs'); const attachments = require('./data/attachments');
const attachments = require('./attachments');
const port = config.port || 8890; const port = config.port || 8890;