1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
|
const {User, Member} = require('eris');
const transliterate = require('transliteration');
const moment = require('moment');
const uuid = require('uuid');
const humanizeDuration = require('humanize-duration');
const bot = require('../bot');
const knex = require('../knex');
const config = require('../config');
const utils = require('../utils');
const updates = require('./updates');
const Thread = require('./Thread');
const {THREAD_STATUS} = require('./constants');
const MINUTES = 60 * 1000;
const HOURS = 60 * MINUTES;
/**
* @param {String} id
* @returns {Promise<Thread>}
*/
async function findById(id) {
const thread = await knex('threads')
.where('id', id)
.first();
return (thread ? new Thread(thread) : null);
}
/**
* @param {String} userId
* @returns {Promise<Thread>}
*/
async function findOpenThreadByUserId(userId) {
const thread = await knex('threads')
.where('user_id', userId)
.where('status', THREAD_STATUS.OPEN)
.first();
return (thread ? new Thread(thread) : null);
}
function getHeaderGuildInfo(member) {
return {
nickname: member.nick || member.user.username,
joinDate: humanizeDuration(Date.now() - member.joinedAt, {largest: 2, round: true})
};
}
/**
* Creates a new modmail thread for the specified user
* @param {User} user
* @param {Member} member
* @param {Boolean} quiet If true, doesn't ping mentionRole or reply with responseMessage
* @returns {Promise<Thread|undefined>}
* @throws {Error}
*/
async function createNewThreadForUser(user, quiet = false, ignoreRequirements = false) {
const existingThread = await findOpenThreadByUserId(user.id);
if (existingThread) {
throw new Error('Attempted to create a new thread for a user with an existing open thread!');
}
// If set in config, check that the user's account is old enough (time since they registered on Discord)
// If the account is too new, don't start a new thread and optionally reply to them with a message
if (config.requiredAccountAge && ! ignoreRequirements) {
if (user.createdAt > moment() - config.requiredAccountAge * HOURS){
if (config.accountAgeDeniedMessage) {
const accountAgeDeniedMessage = utils.readMultilineConfigValue(config.accountAgeDeniedMessage);
const privateChannel = await user.getDMChannel();
await privateChannel.createMessage(accountAgeDeniedMessage);
}
return;
}
}
// Find which main guilds this user is part of
const mainGuilds = utils.getMainGuilds();
const userGuildData = new Map();
for (const guild of mainGuilds) {
let member = guild.members.get(user.id);
if (! member) {
try {
member = await bot.getRESTGuildMember(guild.id, user.id);
} catch (e) {
continue;
}
}
if (member) {
userGuildData.set(guild.id, { guild, member });
}
}
// If set in config, check that the user has been a member of one of the main guilds long enough
// If they haven't, don't start a new thread and optionally reply to them with a message
if (config.requiredTimeOnServer && ! ignoreRequirements) {
// Check if the user joined any of the main servers a long enough time ago
// If we don't see this user on any of the main guilds (the size check below), assume we're just missing some data and give the user the benefit of the doubt
const isAllowed = userGuildData.size === 0 || Array.from(userGuildData.values()).some(({guild, member}) => {
return member.joinedAt < moment() - config.requiredTimeOnServer * MINUTES;
});
if (! isAllowed) {
if (config.timeOnServerDeniedMessage) {
const timeOnServerDeniedMessage = utils.readMultilineConfigValue(config.timeOnServerDeniedMessage);
const privateChannel = await user.getDMChannel();
await privateChannel.createMessage(timeOnServerDeniedMessage);
}
return;
}
}
// 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}`);
// Figure out which category we should place the thread channel in
let newThreadCategoryId;
if (config.categoryAutomation.newThreadFromGuild) {
// Categories for specific source guilds (in case of multiple main guilds)
for (const [guildId, categoryId] of Object.entries(config.categoryAutomation.newThreadFromGuild)) {
if (userGuildData.has(guildId)) {
newThreadCategoryId = categoryId;
break;
}
}
}
if (! newThreadCategoryId && config.categoryAutomation.newThread) {
// Blanket category id for all new threads (also functions as a fallback for the above)
newThreadCategoryId = config.categoryAutomation.newThread;
}
// Attempt to create the inbox channel for this thread
let createdChannel;
try {
createdChannel = await utils.getInboxGuild().createChannel(channelName, null, 'New Modmail thread', 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 createThreadInDB({
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 newThread = await findById(newThreadId);
let responseMessageError = null;
if (! quiet) {
// Ping moderators of the new thread
if (config.mentionRole) {
await newThread.postNonLogMessage({
content: `${utils.getInboxMention()}New modmail thread (${newThread.user_name})`,
disableEveryone: false
});
}
// Send auto-reply to the user
if (config.responseMessage) {
const responseMessage = utils.readMultilineConfigValue(config.responseMessage);
try {
await newThread.postToUser(responseMessage);
} catch (err) {
responseMessageError = err;
}
}
}
// Post some info to the beginning of the new thread
const infoHeaderItems = [];
// Account age
const accountAge = humanizeDuration(Date.now() - user.createdAt, {largest: 2, round: true});
infoHeaderItems.push(`ACCOUNT AGE **${accountAge}**`);
// User id (and mention, if enabled)
if (config.mentionUserInThreadHeader) {
infoHeaderItems.push(`ID **${user.id}** (<@!${user.id}>)`);
} else {
infoHeaderItems.push(`ID **${user.id}**`);
}
let infoHeader = infoHeaderItems.join(', ');
// Guild member info
for (const [guildId, guildData] of userGuildData.entries()) {
const {nickname, joinDate} = getHeaderGuildInfo(guildData.member);
const headerItems = [
`NICKNAME **${utils.escapeMarkdown(nickname)}**`,
`JOINED **${joinDate}** ago`
];
if (guildData.member.voiceState.channelID) {
const voiceChannel = guildData.guild.channels.get(guildData.member.voiceState.channelID);
if (voiceChannel) {
headerItems.push(`VOICE CHANNEL **${utils.escapeMarkdown(voiceChannel.name)}**`);
}
}
if (config.rolesInThreadHeader && guildData.member.roles.length) {
const roles = guildData.member.roles.map(roleId => guildData.guild.roles.get(roleId)).filter(Boolean);
headerItems.push(`ROLES **${roles.map(r => r.name).join(', ')}**`);
}
const headerStr = headerItems.join(', ');
if (mainGuilds.length === 1) {
infoHeader += `\n${headerStr}`;
} else {
infoHeader += `\n**[${utils.escapeMarkdown(guildData.guild.name)}]** ${headerStr}`;
}
}
// Modmail history / previous logs
const userLogCount = await getClosedThreadCountByUserId(user.id);
if (userLogCount > 0) {
infoHeader += `\n\nThis user has **${userLogCount}** previous modmail threads. Use \`${config.prefix}logs\` to see them.`;
}
infoHeader += '\n────────────────';
await newThread.postSystemMessage(infoHeader);
if (config.updateNotifications) {
const availableUpdate = await updates.getAvailableUpdate();
if (availableUpdate) {
await newThread.postNonLogMessage(`📣 New bot version available (${availableUpdate})`);
}
}
// If there were errors sending a response to the user, note that
if (responseMessageError) {
await newThread.postSystemMessage(`**NOTE:** Could not send auto-response to the user. The error given was: \`${responseMessageError.message}\``);
}
// Return the thread
return newThread;
}
/**
* Creates a new thread row in the database
* @param {Object} data
* @returns {Promise<String>} The ID of the created thread
*/
async function createThreadInDB(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(finalData);
return threadId;
}
/**
* @param {String} channelId
* @returns {Promise<Thread>}
*/
async function findByChannelId(channelId) {
const thread = await knex('threads')
.where('channel_id', channelId)
.first();
return (thread ? new Thread(thread) : null);
}
/**
* @param {String} channelId
* @returns {Promise<Thread>}
*/
async function findOpenThreadByChannelId(channelId) {
const thread = await knex('threads')
.where('channel_id', channelId)
.where('status', THREAD_STATUS.OPEN)
.first();
return (thread ? new Thread(thread) : null);
}
/**
* @param {String} channelId
* @returns {Promise<Thread>}
*/
async function findSuspendedThreadByChannelId(channelId) {
const thread = await knex('threads')
.where('channel_id', channelId)
.where('status', THREAD_STATUS.SUSPENDED)
.first();
return (thread ? new Thread(thread) : null);
}
/**
* @param {String} userId
* @returns {Promise<Thread[]>}
*/
async function getClosedThreadsByUserId(userId) {
const threads = await knex('threads')
.where('status', THREAD_STATUS.CLOSED)
.where('user_id', userId)
.select();
return threads.map(thread => new Thread(thread));
}
/**
* @param {String} userId
* @returns {Promise<number>}
*/
async function getClosedThreadCountByUserId(userId) {
const row = await knex('threads')
.where('status', THREAD_STATUS.CLOSED)
.where('user_id', userId)
.first(knex.raw('COUNT(id) AS thread_count'));
return parseInt(row.thread_count, 10);
}
async function findOrCreateThreadForUser(user) {
const existingThread = await findOpenThreadByUserId(user.id);
if (existingThread) return existingThread;
return createNewThreadForUser(user);
}
async function getThreadsThatShouldBeClosed() {
const now = moment.utc().format('YYYY-MM-DD HH:mm:ss');
const threads = await knex('threads')
.where('status', THREAD_STATUS.OPEN)
.whereNotNull('scheduled_close_at')
.where('scheduled_close_at', '<=', now)
.whereNotNull('scheduled_close_at')
.select();
return threads.map(thread => new Thread(thread));
}
async function getThreadsThatShouldBeSuspended() {
const now = moment.utc().format('YYYY-MM-DD HH:mm:ss');
const threads = await knex('threads')
.where('status', THREAD_STATUS.OPEN)
.whereNotNull('scheduled_suspend_at')
.where('scheduled_suspend_at', '<=', now)
.whereNotNull('scheduled_suspend_at')
.select();
return threads.map(thread => new Thread(thread));
}
module.exports = {
findById,
findOpenThreadByUserId,
findByChannelId,
findOpenThreadByChannelId,
findSuspendedThreadByChannelId,
createNewThreadForUser,
getClosedThreadsByUserId,
findOrCreateThreadForUser,
getThreadsThatShouldBeClosed,
getThreadsThatShouldBeSuspended,
createThreadInDB
};
|