Relay small attachments as attachments. Auto-close threads if the channel no longer exists when receiving a reply.
parent
ad7aa66c99
commit
32c22f4d46
|
@ -3,6 +3,7 @@
|
||||||
## v2.0.0
|
## v2.0.0
|
||||||
* Rewrote large parts of the code to be more modular and maintainable. There may be some new bugs because of this - please report them through GitHub issues if you encounter any!
|
* Rewrote large parts of the code to be more modular and maintainable. There may be some new bugs because of this - please report them through GitHub issues if you encounter any!
|
||||||
* Threads, logs, and snippets are now stored in an SQLite database. The bot will migrate old data on the first run.
|
* Threads, logs, and snippets are now stored in an SQLite database. The bot will migrate old data on the first run.
|
||||||
|
* Small attachments (<2MB) from users are now relayed as Discord attachments in the modmail thread. Logs will have the link as usual.
|
||||||
* Fixed system messages like pins in DMs being relayed to the thread
|
* Fixed system messages like pins in DMs being relayed to the thread
|
||||||
* Fixed channels sometimes being created without a category
|
* Fixed channels sometimes being created without a category
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,7 @@ exports.up = async function(knex, Promise) {
|
||||||
table.string('user_name', 128).notNullable();
|
table.string('user_name', 128).notNullable();
|
||||||
table.text('body').notNullable();
|
table.text('body').notNullable();
|
||||||
table.integer('is_anonymous').unsigned().notNullable();
|
table.integer('is_anonymous').unsigned().notNullable();
|
||||||
table.string('original_message_id', 20).nullable().unique();
|
table.string('dm_message_id', 20).nullable().unique();
|
||||||
table.dateTime('created_at').notNullable().index();
|
table.dateTime('created_at').notNullable().index();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "modmailbot",
|
"name": "modmailbot",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
|
const fs = require("fs");
|
||||||
const moment = require('moment');
|
const moment = require('moment');
|
||||||
|
const {promisify} = require('util');
|
||||||
|
|
||||||
const bot = require('../bot');
|
const bot = require('../bot');
|
||||||
const knex = require('../knex');
|
const knex = require('../knex');
|
||||||
|
@ -26,21 +28,11 @@ class Thread {
|
||||||
/**
|
/**
|
||||||
* @param {Eris~Member} moderator
|
* @param {Eris~Member} moderator
|
||||||
* @param {String} text
|
* @param {String} text
|
||||||
* @param {Eris~Attachment[]} replyAttachments
|
* @param {Eris~MessageFile[]} replyAttachments
|
||||||
* @param {Boolean} isAnonymous
|
* @param {Boolean} isAnonymous
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async replyToUser(moderator, text, replyAttachments = [], isAnonymous = false) {
|
async replyToUser(moderator, text, replyAttachments = [], isAnonymous = false) {
|
||||||
// Try to open a DM channel with the user
|
|
||||||
const dmChannel = await bot.getDMChannel(this.user_id);
|
|
||||||
if (! dmChannel) {
|
|
||||||
const channel = bot.getChannel(this.channel_id);
|
|
||||||
if (channel) {
|
|
||||||
channel.createMessage('Could not send reply: couldn\'t open DM channel with user');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Username to reply with
|
// Username to reply with
|
||||||
let modUsername, logModUsername;
|
let modUsername, logModUsername;
|
||||||
const mainRole = utils.getMainRole(moderator);
|
const mainRole = utils.getMainRole(moderator);
|
||||||
|
@ -55,29 +47,34 @@ class Thread {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the reply message
|
// Build the reply message
|
||||||
|
const timestamp = utils.getTimestamp();
|
||||||
let dmContent = `**${modUsername}:** ${text}`;
|
let dmContent = `**${modUsername}:** ${text}`;
|
||||||
let threadContent = `**${logModUsername}:** ${text}`;
|
let threadContent = `**${logModUsername}:** ${text}`;
|
||||||
let logContent = text;
|
let logContent = text;
|
||||||
|
|
||||||
let attachmentFile = null;
|
let files = [];
|
||||||
let attachmentUrl = null;
|
|
||||||
|
|
||||||
// Prepare attachments, if any
|
// Prepare attachments, if any
|
||||||
if (replyAttachments.length > 0) {
|
if (replyAttachments.length > 0) {
|
||||||
fs.readFile(attachments.getPath(replyAttachments[0].id), async (err, data) => {
|
for (const attachment of replyAttachments) {
|
||||||
attachmentFile = {file: data, name: replyAttachments[0].filename};
|
files.push(await attachments.attachmentToFile(attachment));
|
||||||
attachmentUrl = await attachments.getUrl(replyAttachments[0].id, replyAttachments[0].filename);
|
const url = await attachments.getUrl(attachment.id, attachment.filename);
|
||||||
|
|
||||||
threadContent += `\n\n**Attachment:** ${attachmentUrl}`;
|
logContent += `\n\n**Attachment:** ${url}`;
|
||||||
logContent += `\n\n**Attachment:** ${attachmentUrl}`;
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the reply DM
|
// Send the reply DM
|
||||||
dmChannel.createMessage(dmContent, attachmentFile);
|
let dmMessage;
|
||||||
|
try {
|
||||||
|
dmMessage = await this.postToUser(dmContent, files);
|
||||||
|
} catch (e) {
|
||||||
|
await this.postSystemMessage(`Error while replying to user: ${e.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Send the reply to the modmail thread
|
// Send the reply to the modmail thread
|
||||||
const originalMessage = await this.postToThreadChannel(threadContent);
|
await this.postToThreadChannel(threadContent, files);
|
||||||
|
|
||||||
// Add the message to the database
|
// Add the message to the database
|
||||||
await this.addThreadMessageToDB({
|
await this.addThreadMessageToDB({
|
||||||
|
@ -86,60 +83,84 @@ class Thread {
|
||||||
user_name: logModUsername,
|
user_name: logModUsername,
|
||||||
body: logContent,
|
body: logContent,
|
||||||
is_anonymous: (isAnonymous ? 1 : 0),
|
is_anonymous: (isAnonymous ? 1 : 0),
|
||||||
original_message_id: originalMessage.id
|
dm_message_id: dmMessage.id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Eris.Message} msg
|
* @param {Eris~Message} msg
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async receiveUserReply(msg) {
|
async receiveUserReply(msg) {
|
||||||
const timestamp = utils.getTimestamp();
|
|
||||||
|
|
||||||
let content = msg.content;
|
let content = msg.content;
|
||||||
if (msg.content.trim() === '' && msg.embeds.length) {
|
if (msg.content.trim() === '' && msg.embeds.length) {
|
||||||
content = '<message contains embeds>';
|
content = '<message contains embeds>';
|
||||||
}
|
}
|
||||||
|
|
||||||
let threadContent = `[${timestamp}] « **${msg.author.username}#${msg.author.discriminator}:** ${content}`;
|
let threadContent = `**${msg.author.username}#${msg.author.discriminator}:** ${content}`;
|
||||||
let logContent = msg.content;
|
let logContent = msg.content;
|
||||||
let finalThreadContent;
|
let attachmentFiles = [];
|
||||||
let attachmentSavePromise;
|
|
||||||
|
|
||||||
if (msg.attachments.length) {
|
for (const attachment of msg.attachments) {
|
||||||
attachmentSavePromise = attachments.saveAttachmentsInMessage(msg);
|
await attachments.saveAttachment(attachment);
|
||||||
const formattedAttachments = await Promise.all(msg.attachments.map(utils.formatAttachment));
|
|
||||||
const attachmentMsg = `\n\n` + formattedAttachments.reduce((str, formatted) => str + `\n\n${formatted}`);
|
|
||||||
|
|
||||||
finalThreadContent = threadContent + attachmentMsg;
|
// Forward small attachments (<2MB) as attachments, just link to larger ones
|
||||||
threadContent += '\n\n*Attachments pending...*';
|
const formatted = '\n\n' + await utils.formatAttachment(attachment);
|
||||||
logContent += attachmentMsg;
|
logContent += formatted; // Logs always contain the link
|
||||||
|
|
||||||
|
if (attachment.size > 1024 * 1024 * 2) {
|
||||||
|
threadContent += formatted;
|
||||||
|
} else {
|
||||||
|
const file = await attachments.attachmentToFile(attachment);
|
||||||
|
attachmentFiles.push(file);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdMessage = await this.postToThreadChannel(threadContent);
|
await this.postToThreadChannel(threadContent, attachmentFiles);
|
||||||
await this.addThreadMessageToDB({
|
await this.addThreadMessageToDB({
|
||||||
message_type: THREAD_MESSAGE_TYPE.FROM_USER,
|
message_type: THREAD_MESSAGE_TYPE.FROM_USER,
|
||||||
user_id: this.user_id,
|
user_id: this.user_id,
|
||||||
user_name: `${msg.author.username}#${msg.author.discriminator}`,
|
user_name: `${msg.author.username}#${msg.author.discriminator}`,
|
||||||
body: logContent,
|
body: logContent,
|
||||||
is_anonymous: 0,
|
is_anonymous: 0,
|
||||||
original_message_id: msg.id
|
dm_message_id: msg.id
|
||||||
});
|
});
|
||||||
|
|
||||||
if (msg.attachments.length) {
|
|
||||||
await attachmentSavePromise;
|
|
||||||
await createdMessage.edit(finalThreadContent);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {String} text
|
* @param {String} text
|
||||||
* @param {Eris.MessageFile} file
|
* @param {Eris~MessageFile|Eris~MessageFile[]} file
|
||||||
* @returns {Promise<Eris.Message>}
|
* @returns {Promise<Eris~Message>}
|
||||||
|
* @throws Error
|
||||||
|
*/
|
||||||
|
async postToUser(text, file = null) {
|
||||||
|
// Try to open a DM channel with the user
|
||||||
|
const dmChannel = await bot.getDMChannel(this.user_id);
|
||||||
|
if (! dmChannel) {
|
||||||
|
throw new Error('Could not open DMs with the user. They may have blocked the bot or set their privacy settings higher.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the DM
|
||||||
|
return dmChannel.createMessage(text, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {String} text
|
||||||
|
* @param {Eris~MessageFile|Eris~MessageFile[]} file
|
||||||
|
* @returns {Promise<Eris~Message>}
|
||||||
*/
|
*/
|
||||||
async postToThreadChannel(text, file = null) {
|
async postToThreadChannel(text, file = null) {
|
||||||
return bot.createMessage(this.channel_id, text, file);
|
try {
|
||||||
|
return await bot.createMessage(this.channel_id, text, file);
|
||||||
|
} catch (e) {
|
||||||
|
// Channel not found
|
||||||
|
if (e.code === 10003) {
|
||||||
|
console.log(`[INFO] Auto-closing thread with ${this.user_name} because the channel no longer exists`);
|
||||||
|
this.close(true);
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -154,7 +175,7 @@ class Thread {
|
||||||
user_name: '',
|
user_name: '',
|
||||||
body: text,
|
body: text,
|
||||||
is_anonymous: 0,
|
is_anonymous: 0,
|
||||||
original_message_id: msg.id
|
dm_message_id: msg.id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -177,7 +198,7 @@ class Thread {
|
||||||
user_name: `${msg.author.username}#${msg.author.discriminator}`,
|
user_name: `${msg.author.username}#${msg.author.discriminator}`,
|
||||||
body: msg.content,
|
body: msg.content,
|
||||||
is_anonymous: 0,
|
is_anonymous: 0,
|
||||||
original_message_id: msg.id
|
dm_message_id: msg.id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -188,7 +209,7 @@ class Thread {
|
||||||
async updateChatMessage(msg) {
|
async updateChatMessage(msg) {
|
||||||
await knex('thread_messages')
|
await knex('thread_messages')
|
||||||
.where('thread_id', this.id)
|
.where('thread_id', this.id)
|
||||||
.where('original_message_id', msg.id)
|
.where('dm_message_id', msg.id)
|
||||||
.update({
|
.update({
|
||||||
content: msg.content
|
content: msg.content
|
||||||
});
|
});
|
||||||
|
@ -201,7 +222,7 @@ class Thread {
|
||||||
async deleteChatMessage(messageId) {
|
async deleteChatMessage(messageId) {
|
||||||
await knex('thread_messages')
|
await knex('thread_messages')
|
||||||
.where('thread_id', this.id)
|
.where('thread_id', this.id)
|
||||||
.where('original_message_id', messageId)
|
.where('dm_message_id', messageId)
|
||||||
.delete();
|
.delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -234,9 +255,11 @@ class Thread {
|
||||||
/**
|
/**
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async close() {
|
async close(silent = false) {
|
||||||
|
if (! silent) {
|
||||||
console.log(`Closing thread ${this.id}`);
|
console.log(`Closing thread ${this.id}`);
|
||||||
await this.postToThreadChannel('Closing thread...');
|
await this.postToThreadChannel('Closing thread...');
|
||||||
|
}
|
||||||
|
|
||||||
// Update DB status
|
// Update DB status
|
||||||
await knex('threads')
|
await knex('threads')
|
||||||
|
@ -246,9 +269,9 @@ class Thread {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete channel
|
// Delete channel
|
||||||
console.log(`Deleting channel ${this.channel_id}`);
|
|
||||||
const channel = bot.getChannel(this.channel_id);
|
const channel = bot.getChannel(this.channel_id);
|
||||||
if (channel) {
|
if (channel) {
|
||||||
|
console.log(`Deleting channel ${this.channel_id}`);
|
||||||
await channel.delete('Thread closed');
|
await channel.delete('Thread closed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
* @property {String} user_name
|
* @property {String} user_name
|
||||||
* @property {String} body
|
* @property {String} body
|
||||||
* @property {Number} is_anonymous
|
* @property {Number} is_anonymous
|
||||||
* @property {Number} original_message_id
|
* @property {Number} dm_message_id
|
||||||
* @property {String} created_at
|
* @property {String} created_at
|
||||||
*/
|
*/
|
||||||
class ThreadMessage {
|
class ThreadMessage {
|
||||||
|
|
|
@ -2,11 +2,17 @@ const Eris = require('eris');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
|
const {promisify} = require('util');
|
||||||
|
|
||||||
const getUtils = () => require('../utils');
|
const getUtils = () => require('../utils');
|
||||||
|
|
||||||
|
const access = promisify(fs.access);
|
||||||
|
const readFile = promisify(fs.readFile);
|
||||||
|
|
||||||
const attachmentDir = config.attachmentDir || `${__dirname}/../../attachments`;
|
const attachmentDir = config.attachmentDir || `${__dirname}/../../attachments`;
|
||||||
|
|
||||||
|
const attachmentSavePromises = {};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the filesystem path for the given attachment id
|
* Returns the filesystem path for the given attachment id
|
||||||
* @param {String} attachmentId
|
* @param {String} attachmentId
|
||||||
|
@ -22,7 +28,30 @@ function getPath(attachmentId) {
|
||||||
* @param {Number=0} tries
|
* @param {Number=0} tries
|
||||||
* @returns {Promise}
|
* @returns {Promise}
|
||||||
*/
|
*/
|
||||||
function saveAttachment(attachment, tries = 0) {
|
async function saveAttachment(attachment) {
|
||||||
|
if (attachmentSavePromises[attachment.id]) {
|
||||||
|
return attachmentSavePromises[attachment.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
const filepath = getPath(attachment.id);
|
||||||
|
try {
|
||||||
|
// If the file already exists, resolve immediately
|
||||||
|
await access(filepath);
|
||||||
|
return;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
attachmentSavePromises[attachment.id] = saveAttachmentInner(attachment);
|
||||||
|
attachmentSavePromises[attachment.id]
|
||||||
|
.then(() => {
|
||||||
|
delete attachmentSavePromises[attachment.id];
|
||||||
|
}, () => {
|
||||||
|
delete attachmentSavePromises[attachment.id];
|
||||||
|
});
|
||||||
|
|
||||||
|
return attachmentSavePromises[attachment.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAttachmentInner(attachment, tries = 0) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (tries > 3) {
|
if (tries > 3) {
|
||||||
console.error('Attachment download failed after 3 tries:', attachment);
|
console.error('Attachment download failed after 3 tries:', attachment);
|
||||||
|
@ -42,7 +71,7 @@ function saveAttachment(attachment, tries = 0) {
|
||||||
}).on('error', (err) => {
|
}).on('error', (err) => {
|
||||||
fs.unlink(filepath);
|
fs.unlink(filepath);
|
||||||
console.error('Error downloading attachment, retrying');
|
console.error('Error downloading attachment, retrying');
|
||||||
resolve(saveAttachment(attachment));
|
resolve(saveAttachmentInner(attachment, tries++));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -68,9 +97,16 @@ function getUrl(attachmentId, desiredName = null) {
|
||||||
return getUtils().getSelfUrl(`attachments/${attachmentId}/${desiredName}`);
|
return getUtils().getSelfUrl(`attachments/${attachmentId}/${desiredName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function attachmentToFile(attachment) {
|
||||||
|
await saveAttachment(attachment);
|
||||||
|
const data = await readFile(getPath(attachment.id));
|
||||||
|
return {file: data, name: attachment.filename};
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getPath,
|
getPath,
|
||||||
saveAttachment,
|
saveAttachment,
|
||||||
saveAttachmentsInMessage,
|
saveAttachmentsInMessage,
|
||||||
getUrl,
|
getUrl,
|
||||||
|
attachmentToFile
|
||||||
};
|
};
|
||||||
|
|
|
@ -12,6 +12,10 @@ const utils = require('../utils');
|
||||||
const Thread = require('./Thread');
|
const Thread = require('./Thread');
|
||||||
const {THREAD_STATUS} = require('./constants');
|
const {THREAD_STATUS} = require('./constants');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {String} id
|
||||||
|
* @returns {Promise<Thread>}
|
||||||
|
*/
|
||||||
async function findById(id) {
|
async function findById(id) {
|
||||||
const thread = await knex('threads')
|
const thread = await knex('threads')
|
||||||
.where('id', id)
|
.where('id', id)
|
||||||
|
@ -79,6 +83,11 @@ async function createNewThreadForUser(user) {
|
||||||
const logUrl = await newThread.getLogUrl();
|
const logUrl = await newThread.getLogUrl();
|
||||||
await newThread.postNonLogMessage(`Log URL: <${logUrl}>`);
|
await newThread.postNonLogMessage(`Log URL: <${logUrl}>`);
|
||||||
|
|
||||||
|
// Send auto-reply to the user
|
||||||
|
if (config.responseMessage) {
|
||||||
|
newThread.postToUser(config.responseMessage);
|
||||||
|
}
|
||||||
|
|
||||||
// Post some info to the beginning of the new thread
|
// Post some info to the beginning of the new thread
|
||||||
const mainGuild = utils.getMainGuild();
|
const mainGuild = utils.getMainGuild();
|
||||||
const member = (mainGuild ? mainGuild.members.get(user.id) : null);
|
const member = (mainGuild ? mainGuild.members.get(user.id) : null);
|
||||||
|
|
|
@ -12,6 +12,7 @@ const threads = require('./data/threads');
|
||||||
const snippets = require('./plugins/snippets');
|
const snippets = require('./plugins/snippets');
|
||||||
const webserver = require('./plugins/webserver');
|
const webserver = require('./plugins/webserver');
|
||||||
const greeting = require('./plugins/greeting');
|
const greeting = require('./plugins/greeting');
|
||||||
|
const attachments = require("./data/attachments");
|
||||||
|
|
||||||
const messageQueue = new Queue();
|
const messageQueue = new Queue();
|
||||||
|
|
||||||
|
@ -38,6 +39,7 @@ bot.on('messageCreate', async msg => {
|
||||||
|
|
||||||
if (config.alwaysReply) {
|
if (config.alwaysReply) {
|
||||||
// AUTO-REPLY: If config.alwaysReply is enabled, send all chat messages in thread channels as replies
|
// AUTO-REPLY: If config.alwaysReply is enabled, send all chat messages in thread channels as replies
|
||||||
|
if (msg.attachments.length) await attachments.saveAttachmentsInMessage(msg);
|
||||||
await thread.replyToUser(msg.member, msg.content.trim(), msg.attachments, config.alwaysReplyAnon || false);
|
await thread.replyToUser(msg.member, msg.content.trim(), msg.attachments, config.alwaysReplyAnon || false);
|
||||||
msg.delete();
|
msg.delete();
|
||||||
} else {
|
} else {
|
||||||
|
@ -139,6 +141,7 @@ addInboxServerCommand('reply', async (msg, args, thread) => {
|
||||||
if (! thread) return;
|
if (! thread) return;
|
||||||
|
|
||||||
const text = args.join(' ').trim();
|
const text = args.join(' ').trim();
|
||||||
|
if (msg.attachments.length) await attachments.saveAttachmentsInMessage(msg);
|
||||||
await thread.replyToUser(msg.member, text, msg.attachments, false);
|
await thread.replyToUser(msg.member, text, msg.attachments, false);
|
||||||
msg.delete();
|
msg.delete();
|
||||||
});
|
});
|
||||||
|
@ -150,6 +153,7 @@ addInboxServerCommand('anonreply', async (msg, args, thread) => {
|
||||||
if (! thread) return;
|
if (! thread) return;
|
||||||
|
|
||||||
const text = args.join(' ').trim();
|
const text = args.join(' ').trim();
|
||||||
|
if (msg.attachments.length) await attachments.saveAttachmentsInMessage(msg);
|
||||||
await thread.replyToUser(msg.member, text, msg.attachments, true);
|
await thread.replyToUser(msg.member, text, msg.attachments, true);
|
||||||
msg.delete();
|
msg.delete();
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in New Issue