Start work on moving data to an SQLite database. Add a migrator for legacy data.
parent
e034b514f1
commit
58f35c87da
|
@ -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,2 +1,3 @@
|
||||||
*
|
*
|
||||||
!/.gitignore
|
!/.gitignore
|
||||||
|
!/migrations
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
const config = require('./src/config');
|
||||||
|
module.exports = config.knex;
|
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
||||||
|
|
|
@ -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,
|
|
||||||
};
|
|
|
@ -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;
|
||||||
|
|
|
@ -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) => {
|
|
@ -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,
|
||||||
|
};
|
|
@ -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,
|
||||||
|
};
|
|
@ -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,
|
||||||
|
};
|
449
src/index.js
449
src/index.js
|
@ -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) {
|
|
||||||
// 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.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}`;
|
// Start the bot
|
||||||
let logContent = `**${logModUsername}:** ${text}`;
|
// main.start();
|
||||||
|
})();
|
||||||
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);
|
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
const config = require('./config');
|
||||||
|
module.exports = require('knex')(config.knex);
|
|
@ -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;
|
|
@ -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,
|
||||||
|
};
|
163
src/logs.js
163
src/logs.js
|
@ -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,
|
|
||||||
};
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
};
|
|
@ -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,
|
|
||||||
};
|
|
144
src/threads.js
144
src/threads.js
|
@ -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,
|
|
||||||
};
|
|
|
@ -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 {}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue