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",
|
||||
"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",
|
||||
"logChannelId": "id of the channel on the inbox server where notifications of new logs etc. will be posted",
|
||||
|
||||
"status": "Message me for help!",
|
||||
"responseMessage": "Thank you for your message! Our mod team will reply to you here as soon as possible."
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
*
|
||||
!/.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": {
|
||||
"eris": "^0.7.2",
|
||||
"humanize-duration": "^3.10.0",
|
||||
"knex": "^0.14.2",
|
||||
"mime": "^1.3.4",
|
||||
"moment": "^2.17.1",
|
||||
"public-ip": "^2.0.1",
|
||||
"transliteration": "^1.6.2"
|
||||
"sqlite3": "^3.1.13",
|
||||
"transliteration": "^1.6.2",
|
||||
"uuid": "^3.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"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;
|
||||
|
||||
try {
|
||||
|
@ -31,7 +33,12 @@ const defaultConfig = {
|
|||
"greetingAttachment": null,
|
||||
|
||||
"port": 8890,
|
||||
"url": null
|
||||
"url": null,
|
||||
|
||||
"dbDir": path.join(__dirname, '..', 'db'),
|
||||
"knex": null,
|
||||
|
||||
"logDir": path.join(__dirname, '..', 'logs'),
|
||||
};
|
||||
|
||||
const finalConfig = Object.assign({}, defaultConfig);
|
||||
|
@ -44,8 +51,28 @@ for (const [prop, value] of Object.entries(userConfig)) {
|
|||
finalConfig[prop] = value;
|
||||
}
|
||||
|
||||
if (! finalConfig.token) throw new Error('Missing token!');
|
||||
if (! finalConfig.mailGuildId) throw new Error('Missing mailGuildId (inbox server id)!');
|
||||
if (! finalConfig.mainGuildId) throw new Error('Missing mainGuildId!');
|
||||
if (! finalConfig['knex']) {
|
||||
finalConfig['knex'] = {
|
||||
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;
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
const Eris = require('eris');
|
||||
const fs = require('fs');
|
||||
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`;
|
||||
|
||||
|
@ -36,7 +36,7 @@ function saveAttachment(attachment, tries = 0) {
|
|||
https.get(attachment.url, (res) => {
|
||||
res.pipe(writeStream);
|
||||
writeStream.on('finish', () => {
|
||||
writeStream.close()
|
||||
writeStream.closeByChannelId()
|
||||
resolve();
|
||||
});
|
||||
}).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 Eris = require('eris');
|
||||
const moment = require('moment');
|
||||
const humanizeDuration = require('humanize-duration');
|
||||
|
||||
const path = require('path');
|
||||
const config = require('./config');
|
||||
const bot = require('./bot');
|
||||
const Queue = require('./queue');
|
||||
const utils = require('./utils');
|
||||
const blocked = require('./blocked');
|
||||
const threads = require('./threads');
|
||||
const logs = require('./logs');
|
||||
const attachments = require('./attachments');
|
||||
const snippets = require('./snippets');
|
||||
const webserver = require('./webserver');
|
||||
const greeting = require('./greeting');
|
||||
|
||||
const messageQueue = new Queue();
|
||||
const main = require('./main');
|
||||
const knex = require('./knex');
|
||||
const legacyMigrator = require('./legacy/legacyMigrator');
|
||||
|
||||
// Force crash on unhandled rejections (use something like forever/pm2 to restart)
|
||||
process.on('unhandledRejection', err => {
|
||||
|
@ -29,412 +17,35 @@ process.on('unhandledRejection', err => {
|
|||
process.exit(1);
|
||||
});
|
||||
|
||||
// Once the bot has connected, set the status/"playing" message
|
||||
bot.on('ready', () => {
|
||||
bot.editStatus(null, {name: config.status || 'Message me for help'});
|
||||
console.log('Bot started, listening to DMs');
|
||||
});
|
||||
(async function() {
|
||||
// Make sure the database is up to date
|
||||
await knex.migrate.latest();
|
||||
|
||||
// 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;
|
||||
// Migrate legacy data if we need to
|
||||
if (await legacyMigrator.shouldMigrate()) {
|
||||
console.log('=== MIGRATING LEGACY DATA ===');
|
||||
console.log('Do not close the bot!');
|
||||
console.log('');
|
||||
|
||||
reply(msg, msg.content.trim(), config.alwaysReplyAnon || false);
|
||||
});
|
||||
}
|
||||
await legacyMigrator.migrate();
|
||||
|
||||
// "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;
|
||||
const relativeDbDir = (path.isAbsolute(config.dbDir) ? config.dbDir : path.resolve(process.cwd(), config.dbDir));
|
||||
const relativeLogDir = (path.isAbsolute(config.logDir) ? config.logDir : path.resolve(process.cwd(), config.logDir));
|
||||
|
||||
// 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.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;
|
||||
console.log('');
|
||||
console.log('=== LEGACY DATA MIGRATION FINISHED ===');
|
||||
console.log('');
|
||||
console.log('IMPORTANT: After the bot starts, please verify that all logs, threads, blocked users, and snippets are still working correctly.');
|
||||
console.log(`Once you've done that, feel free to delete the following legacy files/directories:`);
|
||||
console.log('');
|
||||
console.log('FILE: ' + path.resolve(relativeDbDir, 'threads.json'));
|
||||
console.log('FILE: ' + path.resolve(relativeDbDir, 'blocked.json'));
|
||||
console.log('FILE: ' + path.resolve(relativeDbDir, 'snippets.json'));
|
||||
console.log('DIRECTORY: ' + relativeLogDir);
|
||||
console.log('');
|
||||
console.log('Starting the bot...');
|
||||
}
|
||||
|
||||
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);
|
||||
// Start the bot
|
||||
// main.start();
|
||||
})();
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
const config = require('./config');
|
||||
module.exports = require('knex')(config.knex);
|
|
@ -1,11 +1,14 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const config = require('./config');
|
||||
const config = require('../config');
|
||||
|
||||
const dbDir = config.dbDir || `${__dirname}/../db`;
|
||||
const dbDir = config.dbDir;
|
||||
|
||||
const databases = {};
|
||||
|
||||
/**
|
||||
* @deprecated Only used for migrating legacy data
|
||||
*/
|
||||
class JSONDB {
|
||||
constructor(path, def = {}, useCloneByDefault = false) {
|
||||
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 moment = require('moment');
|
||||
const publicIp = require('public-ip');
|
||||
const threads = require('./threads');
|
||||
const attachments = require('./attachments');
|
||||
const threads = require('./data/threads');
|
||||
const attachments = require('./data/attachments');
|
||||
const config = require('./config');
|
||||
|
||||
class BotError extends Error {}
|
||||
|
|
|
@ -3,8 +3,7 @@ const mime = require('mime');
|
|||
const url = require('url');
|
||||
const fs = require('fs');
|
||||
const config = require('./config');
|
||||
const logs = require('./logs');
|
||||
const attachments = require('./attachments');
|
||||
const attachments = require('./data/attachments');
|
||||
|
||||
const port = config.port || 8890;
|
||||
|
||||
|
|
Loading…
Reference in New Issue