const databaseManager = require("../databaseManager.js");
let databaseWrapper = require("./databaseWrapper.js");
const fileManager = require("../fileManager.js");
const Discord = require("discord.js");
/**
* Slack's HTTP client for making requests to Slack’s Web API
* @external WebClient
* @see https://slack.dev/node-slack-sdk/web-api
*/
/**
* Handles all things related to the Discord end of things. Most functions take in a specific subtype of SyntaxTree<br>
* Meant to be a static class (Do not initialize members of this class)
*/
class DiscordManager {
/**
* Array containing a list of formats that Discord embeds consider attachable.<br>
* Files with these extensions are sent in embeds instead of by themselves.<br>
* Audio extensions removed from attachable formats because they automatically embed themselves (Wow flac files are huge!)<br>
* Video formats removed from attachable formats because they automatically embed themselves then fail to load (Still available via "Open Original" download link though)<br>
* Type gif was removed because they don't work in embeds for some reason. Also, gifv technically is not real.
* @type {string[]}
*/
static attachableFormats = ["png", "jpg", "jpeg"];
/**
* The Discord bot client being used by the manager. Remember to call start() before using
* @type {Discord.Client}
*/
static client = new Discord.Client({ intents: require("./Intents.js") });
/**
* Discord Server/Guild to log messages into
* @type {Discord.Guild}
*/
static LoggingGuild;
/**
* Starts up the Discord Bot responsible for logging messages, locates the logging guild, and loads the sqlite3 tables
* @async
* @memberOf module:discordManager.DiscordManager
*/
static async start() {
// Start up Discord Bot
await DiscordManager.client.login(process.env.DISCORD_TOKEN);
DiscordManager.client.once("disconnect", () => {
console.log("======= Disconnecting. Goodbye! =======");
process.exit(1);
});
DiscordManager.client.user.setPresence({
activities: [{
type: "LISTENING",
name: "Slack Messages"
}],
status: "online",
afk: false
});
console.log(`===== Logged in as ${DiscordManager.client.user.tag} ====`);
try {
console.log("======= Locating Logging Server =======");
DiscordManager.LoggingGuild = await DiscordManager.client.guilds.fetch(process.env.DISCORD_GUILD_ID);
} catch(locateError) {
console.warn("⚠⚠ Failed to Locate Logging Server ⚠⚠");
console.error(locateError);
process.exit(1);
}
console.log("======= Creating SQLite3 Tables =======");
databaseWrapper = await databaseWrapper.startup(DiscordManager.LoggingGuild);
console.log("======= SQLite3 Tables Created ========");
console.log("========== Discord Bot Ready ==========");
}
/**
* Does not handle attachments. Only top level data (depth of 1) for a syntax tree AKA the main embed
* @param {SyntaxTreeBase} syntaxTree A SyntaxTreeBase or anything extending it that can be used to create a blank embed (Sets colors, author, timestamp, and text)
*/
static embedFromSyntaxTree(syntaxTree) {
const parsedEmbed = new Discord.MessageEmbed();
parsedEmbed.setColor(syntaxTree.color);
parsedEmbed.setAuthor(syntaxTree.name, syntaxTree.profilePic);
parsedEmbed.setDescription(DiscordManager.syntaxTreeParseText(syntaxTree));
parsedEmbed.setTimestamp(syntaxTree.timestamp * 1000);
return { embeds: [parsedEmbed] };
}
/**
* Converts a file object from a Syntax Tree into an embed
* @param {FileData} file Data about the file to convert into an embed
* @param {Discord.MessageEmbed} templateEmbed Blank embed that the file will be added onto. Set colors, timestamps, and more before passing it into this function
* @return {Object} Final Discord message payload. Contains two arrays: embeds which should be sent first and files which should be sent after
*/
static embedFromFile(file, templateEmbed) {
if(!templateEmbed instanceof Discord.MessageEmbed) {
throw new TypeError("Template Embed Required");
}
const result = {
embeds: [],
files: []
};
const fileEmbed = new Discord.MessageEmbed()
.setColor(templateEmbed.color)
.setTimestamp(templateEmbed.timestamp * 1000);
if(file.size < 8) {
if(DiscordManager.attachableFormats.includes(file.extension.toLowerCase().trim()))
result.embeds.push(fileEmbed.setImage(`attachment://${file.name}`));
result.files.push({
attachment: file.path,
name: file.name
});
} else {
fileEmbed.setTitle(file.name);
let serverURLText = `[Copy Saved on Server as: /${file.storedAs}]`;
if(process.env.SERVER_URL) {
serverURLText += `\n(${process.env.SERVER_URL}/${encodeURIComponent(file.storedAs)})`;
}
result.embeds.push(fileEmbed.setDescription(`[File Too Large to Send](${file.original.url_private})${serverURLText}`));
}
return result;
}
/**
* Converts text from Slack Markdown to Discord Markdown
* @param {SyntaxTreeBase} syntaxTree Syntax tree to fetch message from
* @return {string} Parsed message. Returns a default string if there is no message
*/
static syntaxTreeParseText(syntaxTree) {
if(syntaxTree.unparsedText.length === 0) {
return "[No Message Content]";
}
let parsedText = syntaxTree.unparsedText;
// URL Slack to Discord Markdown Translation
// Known Bugs:
// * Including the character '>' in any part of the link's text will make the translation cut off early
// * Certain non-urls will not parse correctly for some odd reason. For example, Slack will try to auto-encode text into a URL if it is entered as one and that won't sit well with Discord
let urls = parsedText.match(/(?<=<)https?:\/\/[\w@:%.\/+~#=]+(|.+?)?(?=>)/g);
if(urls) {
urls.map(link => {
let split = link.split("|");
parsedText = parsedText.replace(`<${link}>`, `[${split[1] && split[1].trim().length > 0 ? split[1].trim() : split[0]}](${split[0]})`);
});
}
// Simple Slack to Discord Markdown Translation
// Known Bugs:
// * Using Ctrl + Z on Slack to undo any markdown results in that undo being ignored on Discord. Escaped markdown is parsed as if it was never escaped
// * Formatting markdown using the format buttons instead of actual markdown means that results may not reflect what is seen on Slack
// Strikethrough Translation
parsedText = parsedText.replace(/~~/g, "\\~\\~").replace(/(?<=^|\s)(~(?=[^\s])[^~]+(?<=[^\s])~)(?=$|\s)/g, "~$1~");
// Italic Translation (Untested)
parsedText = parsedText.replace(/(?<=^|\s)_((?=[^\s])[^_]+(?<=[^\s]))_(?=$|\s)/g, "*$1*");
// Bold Translation (Untested)
parsedText = parsedText.replace(/(?<=^|\s)(\*(?=[^\s])[^_]+(?<=[^\s])\*)(?=$|\s)/g, "*$1*");
// Unescaping HTML Escapes created by Slack's API
// Known Bugs:
// * Literally typing any of the following HTML escape codes normally will result in them being converted over to their unescaped form on Discord
// - Typing > on Slack translates to > on Discord
// - Typing < on Slack translates to < on Discord
// - Typing & on Slack translates to & on Discord
parsedText = parsedText.replace(/>/g, '>').replace(/</g, '<').replace(/&/g, '&');
// Replace all mentions and channels
for(const user of syntaxTree.parseData.users) {
parsedText = parsedText.replace(new RegExp(user.mention, "g"), user.plainText);
}
// TODO: Map to actual channel on Discord and use a Discord channel mention
for(const channel of syntaxTree.parseData.channels) {
parsedText = parsedText.replace(new RegExp(channel.channelReference, "g"), channel.plainText);
}
// Additional Known Bugs:
// * When using code blocks, the first word is invisible when sent to Discord if it is the only word on the line with the opening ``` since it is parsed as a programming language instead of text by Discord
// console.log(parsedText);
return parsedText;
}
/**
* Handles sending messages to Discord
* @param {MessageSyntaxTree} syntaxTree Syntax tree to parse and send
* @return {Promise} Resolves when messages have been successfully sent
*/
static async handleMessages(syntaxTree) {
const mainEmbed = DiscordManager.embedFromSyntaxTree(syntaxTree);
const parsedMessage = {
mainEmbed,
// Format in {embed, files}
additionalEmbeds: syntaxTree.attachments.files
.map(file => DiscordManager.embedFromFile(file, mainEmbed))
};
syntaxTree.attachments.embeds
.map(DiscordManager.embedFromSyntaxTree)
.forEach(parsedMessage.additionalEmbeds.push);
const targetData = await DiscordManager.locateChannel(syntaxTree);
if(parsedMessage.additionalEmbeds.length !== 0) {
mainEmbed.embeds[0].setFooter(`${mainEmbed.embeds[0].footer ? `${mainEmbed.embeds[0].footer}\n` : ""}↓ Message Includes ${parsedMessage.additionalEmbeds.length} Additional Attachment${parsedMessage.additionalEmbeds.length === 1 ? "" : "s"} Below ↓`);
}
const sentMessage = await targetData.target.send(mainEmbed);
const messageIDs = [];
await databaseWrapper.messageMap({
SlackMessageID: syntaxTree.timestamp,
DiscordMessageID: sentMessage.id,
SlackThreadID: syntaxTree.parseData.thread.id,
DiscordThreadID: targetData.thread?.id,
SlackChannelID: syntaxTree.parseData.channel.id,
DiscordChannelID: targetData.channel.id,
textOnly: true
});
for(const additionalData of parsedMessage.additionalEmbeds)
messageIDs.push(await targetData.target.send(additionalData)
.then(message => message.id)
);
await Promise.all(messageIDs.map(id =>
databaseWrapper.messageMap({
SlackMessageID: syntaxTree.timestamp,
DiscordMessageID: id,
SlackThreadID: syntaxTree.parseData.thread.id,
DiscordThreadID: targetData.thread?.id,
SlackChannelID: syntaxTree.parseData.channel.id,
DiscordChannelID: targetData.channel.id,
})
));
// Clean-up downloaded files after sending
await Promise.all(
syntaxTree.attachments.files
.filter(file => file.size < 8)
.map(file => fileManager.fileDelete(file.path))
);
}
/**
* Handles reflecting edits/changes on Discord
* @param {ChangeSyntaxTree} syntaxTree Syntax tree with edits to parse and send. Mostly identical to MessageSyntaxTree
* @return {Promise} Resolves when messages have been successfully sent/edited
*/
static async handleChanges(syntaxTree) {
const timestamp = syntaxTree.timestamp.toString();
const mainEmbed = DiscordManager.embedFromSyntaxTree(syntaxTree);
const originalMessageMaps = await databaseWrapper.locateMessageMaps(timestamp, true);
if(originalMessageMaps.length === 0) {
console.warn(`Unable to edit message: Original Message Not Found For ${timestamp}`);
} else if(originalMessageMaps.length >= 1) {
console.warn(`Odd Behavior Warning: Multiple Messages Will Be Overwritten For ${timestamp}`);
}
await Promise.all(originalMessageMaps.map(message => message.edit(mainEmbed)));
}
/**
* Handles reflecting message deletions on Discord. Safely ignores messages that cannot be found
* @param {DeleteSyntaxTree} syntaxTree Syntax tree with information about what messages to delete
* @return {Promise} Resolves when messages have been successfully deleted
*/
static async handleDeletes(syntaxTree) {
// TODO: Delete only what is necessary. Currently deletes all parts of a message even if only a portion is deleted from Slack
// (Example: Deleting 1 of 3 files on Slack deletes all 3 + the message on Discord)
const messages = await databaseWrapper.locateMessageMaps(syntaxTree.timestamp);
console.log(`Deleting ${messages.length} Messages`);
await Promise.all(messages
.map(message => message.delete()
.catch(err => {
console.warn(`Unable to Delete Message ${message.id} (${syntaxTree.timestamp}): [${err.name}] ${err.message}`);
}))
);
}
/**
* Handles reflecting channel metadata updates on Discord
* @param {ChannelSyntaxTree} syntaxTree Syntax tree with channel metadata. It's literally the same as the base syntax tree class
* @return {Promise} Resolves when channel data has been updated
*/
static async handleChannelUpdates(syntaxTree) {
const targetChannel = await DiscordManager.locateChannel(syntaxTree);
const channelData = syntaxTree.parseData.channel;
const discordTopic = `${channelData.topic} | ${channelData.purpose || "Archive Channel"}`;
if(targetChannel.name !== channelData.name) {
await targetChannel.setName(channelData.name, "Channel name changed from Slack");
}
if(targetChannel.topic !== discordTopic) {
await targetChannel.setTopic(discordTopic, "Channel Topic changed from Slack");
}
}
/**
* Locates a channel given a Slack Channel ID. Will grab associated channel from the serverMap.json or search by name. If it does not exist, the bot will create a channel with a matching name and serverMap it
* @async
* @memberOf module:discordManager.DiscordManager
* @param {SyntaxTreeBase} A syntax tree with data on what channel to look up
* @return {Promise<{channel: Discord.TextChannel, id: string, thread: undefined | Discord.ThreadChannel, target: Discord.TextChannel | Discord.ThreadChannel}>} Resolves to an object containing what channel can/should be sent to
*/
static async locateChannel(syntaxTree) {
const channelData = syntaxTree.parseData.channel;
const targetData = {
id: undefined,
channel: undefined,
thread: undefined,
target: undefined
};
const targetChannelID = await databaseManager.locateChannelMap(channelData.id);
if(targetChannelID) {
targetData.channel = await DiscordManager.LoggingGuild.channels.fetch(targetChannelID);
} else {
// Quirk: If two channels on Discord have a matching name, only the first one found will be used
const allChannels = (await DiscordManager.LoggingGuild.channels.fetch());
targetData.channel = allChannels
.filter(channel => channel.type === "GUILD_TEXT")
.find(channel => channel.name === channelData.name);
if(!(targetData.channel instanceof Discord.TextChannel)) {
try {
targetData.channel = await DiscordManager.LoggingGuild.channels.create(channelData.name, { reason: `#${channelData.name} created for new Slack Messages` });
} catch(channelMakeErr) {
throw `Channel #${channelData.name} could not be found or created.\n${channelMakeErr}`;
}
}
await databaseManager.tableMap(databaseManager.Tables.CHANNEL_MAP, channelData.id, targetData.channel.id);
}
if(syntaxTree.parseData.thread.id) {
targetData.thread = await this.locateThread(syntaxTree, targetData.channel);
}
targetData.id = targetData.channel.id;
targetData.target = targetData.thread || targetData.channel;
return targetData;
}
/**
* Creates and sends out a fake message and links it to a Slack Message. Used when creating threads without an existing starting point on the Discord end
* @async
* @param {string} threadID ID for the thread that the fake message is for
* @param {MessageSyntaxTree} syntaxTreeBase Syntax tree for the next message in the thread
* @param {MessageSyntaxTree} syntaxTreeMessageSkeleton An empty syntax tree to use for the fake message
* @return {string} Returns the DiscordMessageID of the fake message (Now the starting point of a thread)
*/
static async fakeMessage(threadID, syntaxTreeBase, syntaxTreeMessageSkeleton) {
// Share properties via shallow copy
Object.assign(syntaxTreeMessageSkeleton.parseData.channel, syntaxTreeBase.parseData.channel);
console.log(`Thread ${threadID} is missing. Creating fake main message...`);
syntaxTreeMessageSkeleton.name = "Unknown Thread Handler";
syntaxTreeMessageSkeleton.unparsedText = "[Thread Not Found: Some Messages May Be Missing]";
syntaxTreeMessageSkeleton.color = "#DD2020";
syntaxTreeMessageSkeleton.timestamp = threadID;
await DiscordManager.handleMessages(syntaxTreeMessageSkeleton);
console.log(`Missing Main Message Created For Thread ${threadID}. Proceeding...`);
return (await databaseManager.locateMessageMaps(threadID))[0].DiscordMessageID;
}
/**
* Finds a specific thread channel given information about it and the main channel it should be in. Creates one if it does not exist
* @param {SyntaxTreeBase} syntaxTree Syntax tree containing the thread id to look up
* @param {Discord.TextChannel} channel Discord channel to find or make thread channel in
* @return {Discord.ThreadChannel} Returns the located thread channel
*/
static async locateThread(syntaxTree, channel) {
// Note: Does NOT look things up by name unlike locateChannel.
const storedThreadID = await databaseManager.locateThreadMap(syntaxTree.parseData.thread.id);
let targetThread;
if(storedThreadID) {
targetThread = await channel.threads.fetch(storedThreadID);
} else {
const threadID = syntaxTree.parseData.thread.id;
const boundMessageIDs = await databaseManager.locateMessageMaps(threadID);
const originalMessageID = boundMessageIDs
.find(messageMap => messageMap.PurelyText)
?.DiscordMessageID || await DiscordManager.fakeMessage(threadID, syntaxTree, syntaxTree.parseData.thread.skeleton);
const originalMessage = await channel.messages.fetch(originalMessageID);
const originalContent = originalMessage.embeds[0]?.description || "No Text Content";
if(originalMessage.hasThread) {
targetThread = originalMessage.thread;
} else {
targetThread = await originalMessage.startThread({
// 1-Day
name: originalContent.length > 50 ? `${originalContent.slice(0, 49)}…` : originalContent,
autoArchiveDuration: 1440,
reason: "Mirroring thread started on Slack"
});
}
await databaseManager.tableMap(databaseManager.Tables.THREAD_MAP, threadID, targetThread.id);
}
return targetThread;
}
/**
* Use on a Slack event to generate an id for messages that SHOULD be unique (No official documentation found)
* @memberOf module:discordManager.DiscordManager
* @param {string} channel The Slack Channel id. Can be obtained through event.channel
* @param {number} ts The timestamp of the Slack message
*/
static identify(channel, ts) {
return `${channel}/${ts}`;
}
}
/**
* A module for converting Slack messages to Discord messages and sending them out to channels
* @module discordManager
*/
module.exports = DiscordManager;