Add thread numbers

cshd
Dragory 2020-11-01 21:41:03 +02:00
parent f6825376c0
commit 280fad36f7
No known key found for this signature in database
GPG Key ID: 5F387BA66DF8AAC1
5 changed files with 226 additions and 176 deletions

View File

@ -0,0 +1,12 @@
exports.up = async function(knex) {
await knex.schema.table("threads", table => {
table.integer("thread_number");
table.unique("thread_number");
});
};
exports.down = async function(knex) {
await knex.schema.table("threads", table => {
table.dropColumn("thread_number");
});
};

View File

@ -0,0 +1,16 @@
exports.up = async function(knex) {
const threads = await knex.table("threads")
.orderBy("created_at", "ASC")
.select(["id"]);
let threadNumber = 0;
for (const { id } of threads) {
await knex.table("threads")
.where("id", id)
.update({ thread_number: ++threadNumber });
}
};
exports.down = async function(knex) {
// Nothing
};

View File

@ -19,6 +19,18 @@ const {THREAD_STATUS, DISOCRD_CHANNEL_TYPES} = require("./constants");
const MINUTES = 60 * 1000; const MINUTES = 60 * 1000;
const HOURS = 60 * MINUTES; const HOURS = 60 * MINUTES;
let threadCreationQueue = Promise.resolve();
function _addToThreadCreationQueue(fn) {
threadCreationQueue = threadCreationQueue
.then(fn)
.catch(err => {
console.error(`Error while creating thread: ${err.message}`);
});
return threadCreationQueue;
}
/** /**
* @param {String} id * @param {String} id
* @returns {Promise<Thread>} * @returns {Promise<Thread>}
@ -69,204 +81,206 @@ function getHeaderGuildInfo(member) {
* @throws {Error} * @throws {Error}
*/ */
async function createNewThreadForUser(user, opts = {}) { async function createNewThreadForUser(user, opts = {}) {
const quiet = opts.quiet != null ? opts.quiet : false; return _addToThreadCreationQueue(async () => {
const ignoreRequirements = opts.ignoreRequirements != null ? opts.ignoreRequirements : false; const quiet = opts.quiet != null ? opts.quiet : false;
const ignoreHooks = opts.ignoreHooks != null ? opts.ignoreHooks : false; const ignoreRequirements = opts.ignoreRequirements != null ? opts.ignoreRequirements : false;
const ignoreHooks = opts.ignoreHooks != null ? opts.ignoreHooks : false;
const existingThread = await findOpenThreadByUserId(user.id); const existingThread = await findOpenThreadByUserId(user.id);
if (existingThread) { if (existingThread) {
throw new Error("Attempted to create a new thread for a user with an existing open thread!"); 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 // If set in config, check that the user's account is old enough (time since they registered on Discord)
const mainGuilds = utils.getMainGuilds(); // If the account is too new, don't start a new thread and optionally reply to them with a message
const userGuildData = new Map(); if (config.requiredAccountAge && ! ignoreRequirements) {
if (user.createdAt > moment() - config.requiredAccountAge * HOURS){
for (const guild of mainGuilds) { if (config.accountAgeDeniedMessage) {
let member = guild.members.get(user.id); const accountAgeDeniedMessage = utils.readMultilineConfigValue(config.accountAgeDeniedMessage);
const privateChannel = await user.getDMChannel();
if (! member) { await privateChannel.createMessage(accountAgeDeniedMessage);
try { }
member = await bot.getRESTGuildMember(guild.id, user.id); return;
} catch (e) {
continue;
} }
} }
if (member) { // Find which main guilds this user is part of
userGuildData.set(guild.id, { guild, member }); const mainGuilds = utils.getMainGuilds();
} const userGuildData = new Map();
}
// If set in config, check that the user has been a member of one of the main guilds long enough for (const guild of mainGuilds) {
// If they haven't, don't start a new thread and optionally reply to them with a message let member = guild.members.get(user.id);
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 (! member) {
if (config.timeOnServerDeniedMessage) { try {
const timeOnServerDeniedMessage = utils.readMultilineConfigValue(config.timeOnServerDeniedMessage); member = await bot.getRESTGuildMember(guild.id, user.id);
const privateChannel = await user.getDMChannel(); } catch (e) {
await privateChannel.createMessage(timeOnServerDeniedMessage); continue;
}
} }
return;
}
}
// Call any registered beforeNewThreadHooks if (member) {
const hookResult = await callBeforeNewThreadHooks({ user, opts, message: opts.message }); userGuildData.set(guild.id, { guild, member });
if (hookResult.cancelled) 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
let channelName = `${cleanName}-${user.discriminator}`;
if (config.anonymizeChannelName) {
channelName = crypto.createHash("md5").update(channelName + Date.now()).digest("hex").slice(0, 12);
}
console.log(`[NOTE] Creating new thread channel ${channelName}`);
// Figure out which category we should place the thread channel in
let newThreadCategoryId = hookResult.categoryId || opts.categoryId || null;
if (! newThreadCategoryId && config.categoryAutomation.newThreadFromServer) {
// Categories for specific source guilds (in case of multiple main guilds)
for (const [guildId, categoryId] of Object.entries(config.categoryAutomation.newThreadFromServer)) {
if (userGuildData.has(guildId)) {
newThreadCategoryId = categoryId;
break;
} }
} }
}
if (! newThreadCategoryId && config.categoryAutomation.newThread) { // If set in config, check that the user has been a member of one of the main guilds long enough
// Blanket category id for all new threads (also functions as a fallback for the above) // If they haven't, don't start a new thread and optionally reply to them with a message
newThreadCategoryId = config.categoryAutomation.newThread; 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
// Attempt to create the inbox channel for this thread const isAllowed = userGuildData.size === 0 || Array.from(userGuildData.values()).some(({guild, member}) => {
let createdChannel; return member.joinedAt < moment() - config.requiredTimeOnServer * MINUTES;
try {
createdChannel = await utils.getInboxGuild().createChannel(channelName, DISOCRD_CHANNEL_TYPES.GUILD_TEXT, {
reason: "New Modmail thread",
parentID: 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
const staffMention = utils.getInboxMention();
if (staffMention.trim() !== "") {
await newThread.postNonLogMessage({
content: `${staffMention}New modmail thread (${newThread.user_name})`,
allowedMentions: utils.getInboxMentionAllowedMentions(),
}); });
}
}
// Post some info to the beginning of the new thread if (! isAllowed) {
const infoHeaderItems = []; if (config.timeOnServerDeniedMessage) {
const timeOnServerDeniedMessage = utils.readMultilineConfigValue(config.timeOnServerDeniedMessage);
// Account age const privateChannel = await user.getDMChannel();
const accountAge = humanizeDuration(Date.now() - user.createdAt, {largest: 2, round: true}); await privateChannel.createMessage(timeOnServerDeniedMessage);
infoHeaderItems.push(`ACCOUNT AGE **${accountAge}**`); }
return;
// 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) { // Call any registered beforeNewThreadHooks
const roles = guildData.member.roles.map(roleId => guildData.guild.roles.get(roleId)).filter(Boolean); const hookResult = await callBeforeNewThreadHooks({ user, opts, message: opts.message });
headerItems.push(`ROLES **${roles.map(r => r.name).join(", ")}**`); if (hookResult.cancelled) 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
let channelName = `${cleanName}-${user.discriminator}`;
if (config.anonymizeChannelName) {
channelName = crypto.createHash("md5").update(channelName + Date.now()).digest("hex").slice(0, 12);
} }
const headerStr = headerItems.join(", "); console.log(`[NOTE] Creating new thread channel ${channelName}`);
if (mainGuilds.length === 1) { // Figure out which category we should place the thread channel in
infoHeader += `\n${headerStr}`; let newThreadCategoryId = hookResult.categoryId || opts.categoryId || null;
if (! newThreadCategoryId && config.categoryAutomation.newThreadFromServer) {
// Categories for specific source guilds (in case of multiple main guilds)
for (const [guildId, categoryId] of Object.entries(config.categoryAutomation.newThreadFromServer)) {
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, DISOCRD_CHANNEL_TYPES.GUILD_TEXT, {
reason: "New Modmail thread",
parentID: 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
const staffMention = utils.getInboxMention();
if (staffMention.trim() !== "") {
await newThread.postNonLogMessage({
content: `${staffMention}New modmail thread (${newThread.user_name})`,
allowedMentions: utils.getInboxMentionAllowedMentions(),
});
}
}
// 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 { } else {
infoHeader += `\n**[${utils.escapeMarkdown(guildData.guild.name)}]** ${headerStr}`; infoHeaderItems.push(`ID **${user.id}**`);
} }
}
// Modmail history / previous logs let infoHeader = infoHeaderItems.join(", ");
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────────────────"; // 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`
];
await newThread.postSystemMessage(infoHeader, { if (guildData.member.voiceState.channelID) {
allowedMentions: config.mentionUserInThreadHeader ? { users: [user.id] } : undefined, 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, {
allowedMentions: config.mentionUserInThreadHeader ? { users: [user.id] } : undefined,
});
if (config.updateNotifications) {
const availableUpdate = await updates.getAvailableUpdate();
if (availableUpdate) {
await newThread.postNonLogMessage(`📣 New bot version available (${availableUpdate})`);
}
}
// Return the thread
return newThread;
}); });
if (config.updateNotifications) {
const availableUpdate = await updates.getAvailableUpdate();
if (availableUpdate) {
await newThread.postNonLogMessage(`📣 New bot version available (${availableUpdate})`);
}
}
// Return the thread
return newThread;
} }
/** /**
@ -277,7 +291,15 @@ async function createNewThreadForUser(user, opts = {}) {
async function createThreadInDB(data) { async function createThreadInDB(data) {
const threadId = uuid.v4(); const threadId = uuid.v4();
const now = moment.utc().format("YYYY-MM-DD HH:mm:ss"); const now = moment.utc().format("YYYY-MM-DD HH:mm:ss");
const finalData = Object.assign({created_at: now, is_legacy: 0}, data, {id: threadId}); const latestThreadNumberRow = await knex("threads")
.orderBy("thread_number", "DESC")
.first();
const latestThreadNumber = latestThreadNumberRow ? latestThreadNumberRow.thread_number : 0;
const finalData = Object.assign(
{created_at: now, is_legacy: 0},
data,
{id: threadId, thread_number: latestThreadNumber + 1}
);
await knex("threads").insert(finalData); await knex("threads").insert(finalData);

View File

@ -44,7 +44,7 @@ module.exports = ({ bot, knex, config, commands }) => {
await thread.close(false, thread.scheduled_close_silent); await thread.close(false, thread.scheduled_close_silent);
await sendCloseNotification(thread, `Modmail thread with ${thread.user_name} (${thread.user_id}) was closed as scheduled by ${thread.scheduled_close_name}`); await sendCloseNotification(thread, `Modmail thread #${thread.thread_number} with ${thread.user_name} (${thread.user_id}) was closed as scheduled by ${thread.scheduled_close_name}`);
} }
} }
@ -145,7 +145,7 @@ module.exports = ({ bot, knex, config, commands }) => {
await thread.close(suppressSystemMessages, silentClose); await thread.close(suppressSystemMessages, silentClose);
await sendCloseNotification(thread, `Modmail thread with ${thread.user_name} (${thread.user_id}) was closed by ${closedBy}`); await sendCloseNotification(thread, `Modmail thread #${thread.thread_number} with ${thread.user_name} (${thread.user_id}) was closed by ${closedBy}`);
}); });
// Auto-close threads if their channel is deleted // Auto-close threads if their channel is deleted
@ -164,6 +164,6 @@ module.exports = ({ bot, knex, config, commands }) => {
await thread.close(true); await thread.close(true);
await sendCloseNotification(thread, `Modmail thread with ${thread.user_name} (${thread.user_id}) was closed automatically because the channel was deleted`); await sendCloseNotification(thread, `Modmail thread #${thread.thread_number} with ${thread.user_name} (${thread.user_id}) was closed automatically because the channel was deleted`);
}); });
}; };

View File

@ -48,7 +48,7 @@ module.exports = ({ bot, knex, config, commands, hooks }) => {
? `<${addOptQueryStringToUrl(logUrl, args)}>` ? `<${addOptQueryStringToUrl(logUrl, args)}>`
: `View log with \`${config.prefix}log ${thread.id}\`` : `View log with \`${config.prefix}log ${thread.id}\``
const formattedDate = moment.utc(thread.created_at).format("MMM Do [at] HH:mm [UTC]"); const formattedDate = moment.utc(thread.created_at).format("MMM Do [at] HH:mm [UTC]");
return `\`${formattedDate}\`: ${formattedLogUrl}`; return `\`#${thread.thread_number}\` \`${formattedDate}\`: ${formattedLogUrl}`;
})); }));
let message = isPaginated let message = isPaginated