diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..92609732 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM node:12-alpine + +WORKDIR /opt/TediCross/ + +COPY . . + +RUN npm install --production + +# The node user (from node:12-alpine) has UID 1000, meaning most people with single-user systems will not have to change UID +USER node + +VOLUME /opt/TediCross/data/ + +ENTRYPOINT /usr/local/bin/npm start -- -c data/settings.yaml diff --git a/README-Docker.md b/README-Docker.md new file mode 100644 index 00000000..93079a75 --- /dev/null +++ b/README-Docker.md @@ -0,0 +1,31 @@ +TediCross with Docker +===================== + +**This document assumes you know how to use [Docker](https://en.wikipedia.org/wiki/Docker_(software)). If you are completely clueless, please disregard Docker and follow the ordinary [install instructions](README.md#step-by-step-installation)** + +TediCross is available as a Docker image, through [DockerHub](https://cloud.docker.com/u/tedicross/repository/docker/tedicross/tedicross) + +It requires the `data/` directory to be mounted as a volume. + +Unlike the non-docker version, the `settings.yaml` file must be in the `data/` directory instead of in the root of the project. + +Using the official Docker image +------------------------------- + +The official docker image is used like this: + +``` +docker run \ + -v /path/to/data/:/opt/TediCross/data \ + -e TELEGRAM_BOT_TOKEN="Your Telegram token" \ + -e DISCORD_BOT_TOKEN="Your Discord token" \ + tedicross/tedicross +``` + +Of course, you can add `-d` or `--rm` or a name or whatever else you want to that command + +If you have the tokens in the settings file instead of reading them from the environment, you can of course drop the `-e` lines + +### Permissions + +The dockerfile says the container should start as the user with UID 1000. This should be fine for most single user Linux systems, but may cause problems for multi user systems. If you get a permission error when starting the container, try changing the user the container is using. Find your user's UID with the command `id -u $USER`, then add the argument `-u ` to the `docker run` command. For example `docker run -u 1001 -v ...` diff --git a/README.md b/README.md index 6e7fe994..bcd41749 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Setting up the bot requires basic knowledge of the command line, which is bash o 1. Install [nodejs](https://nodejs.org) 2. Clone this git repo, or download it as a zip or whatever 3. Open a terminal and enter the repo with the [`cd`](https://en.wikipedia.org/wiki/Cd_(command)) command. Something like `cd Downloads/TediCross-master`. Your exact command may differ - 4. Run the command `npm install` + 4. Run the command `npm install --production` 5. Make a copy of the file `example.settings.yaml` and name it `settings.yaml` 6. Aquire a bot token for Telegram ([How to create a Telegram bot](https://core.telegram.org/bots#3-how-do-i-create-a-bot)) and put it in the settings file - The Telegram bot must be able to access all messages. Talk to [@BotFather](https://t.me/BotFather) to disable privacy mode for the bot @@ -39,7 +39,7 @@ Setting up the bot requires basic knowledge of the command line, which is bash o 10. Start TediCross: `npm start` 11. Ask the bots for the remaining details. In the Telegram chat and the Discord channel, write `/chatinfo`. Put the info you get in the settings file. - If you want to bridge a Telegram group or channel, remember that the ID is negative. Include the `-` when entering it into the settings file - - It is important that the Discord IDs are wrapped with single quotes when entered into the settings file. `'244791815503347712'`, not `244791815503347712` + - It is important that the Discord channel ID is wrapped with single quotes when entered into the settings file. `'244791815503347712'`, not `244791815503347712` 12. Restart TediCross. You stop it by pressing CTRL + C in the terminal it is running in Done! You now have a nice bridge between a Telegram chat and a Discord channel @@ -55,11 +55,14 @@ As mentioned in the step by step installation guide, there is a settings file. H * `useFirstNameInsteadOfUsername`: **EXPERIMENTAL** If set to `false`, the messages sent to Discord will be tagged with the sender's username. If set to `true`, the messages sent to Discord will be tagged with the sender's first name (or nickname). Note that Discord users can't @-mention Telegram users by their first name. Defaults to `false` * `colonAfterSenderName`: Whether or not to put a colon after the name of the sender in messages from Discord to Telegram. If true, the name is displayed `Name:`. If false, it is displayed `Name`. Defaults to false * `skipOldMessages`: Whether or not to skip through all previous messages cached from the telegram-side and start processing new messages ONLY. Defaults to true. Note that there is no guarantee the old messages will arrive at Discord in order - * `sendEmojisWithStickers`: Whether or not to send the corresponding emoji when relaying stickers to Discord + * `sendEmojiWithStickers`: Whether or not to send the corresponding emoji when relaying stickers to Discord * `discord`: Object authorizing and defining the Discord bot's behaviour * `token`: The Discord bot's token. It is needed for the bot to authenticate to the Discord servers and be able to send and receive messages. If set to `"env"`, TediCross will read the token from the environment variable `DISCORD_BOT_TOKEN` * `skipOldMessages`: Whether or not to skip through all previous messages sent since the bot was last turned off and start processing new messages ONLY. Defaults to true. Note that there is no guarantee the old messages will arrive at Telegram in order. **NOTE:** [Telegram has a limit](https://core.telegram.org/bots/faq#my-bot-is-hitting-limits-how-do-i-avoid-this) on how quickly a bot can send messages. If there is a big backlog, this will cause problems * `useNickname`: Uses the sending user's nickname instead of username when relaying messages to Telegram + * `displayTelegramReplies`: How to display Telegram replies. Either the string `blockqoute`, `inline` or `embed` + * `replyLength`: How many characters of the original message to display on replies + * `maxReplyLines`: How many lines of the original message to display on replies * `debug`: If set to `true`, activates debugging output from the bot. Defaults to `false` * `bridges`: An array containing all your chats and channels. For each object in this array, you should have the following properties: * `name`: A internal name of the chat. Appears in the log @@ -68,8 +71,8 @@ As mentioned in the step by step installation guide, there is a settings file. H * `telegram.relayJoinMessages`: Whether or not to relay messages to Discord about people joining the Telegram chat * `telegram.relayLeaveMessages`: Whether or not to relay messages to Discord about people leaving the Telegram chat * `telegram.sendUsernames`: Whether or not to send the sender's name with the messages to Discord - * `discord.guild`: ID of the server the Discord end of the bridge is in. If a message to the bot originates from within this server, but not the correct channel, it is ignored, instead of triggering a reply telling the sender to get their own bot. See step 11 on how to aquire it - * `discord.channel`: ID of the channel the Discord end of the bridge is in. See step 11 on how to aquire it + * `telegram.relayCommands`: If set to `false`, messages starting with a `/` are not relayed to Discord + * `discord.channelId`: ID of the channel the Discord end of the bridge is in. See step 11 on how to aquire it * `discord.relayJoinMessages`: Whether or not to relay messages to Telegram about people joining the Discord chat * `discord.relayLeaveMessages`: Whether or not to relay messages to Telegram about people leaving the Discord chat * `discord.sendUsernames`: Whether or not to send the sender's name with the messages to Telegram @@ -114,6 +117,12 @@ The Telegram team unfortunately decided that bots cannot interact with each othe See https://core.telegram.org/bots/faq#why-doesn-39t-my-bot-see-messages-from-other-bots +### Deleting a message in Telegram does not delete it in Discord + +Telegram bots are unfortunately completely unable to detect when a message is deleted. There is no way to implement T2D cross-deletion until Telegram implements this. + +Deleting messages D2T works. + ### When running `npm install`, it complains about missing dependencies? The [Discord library](https://discord.js.org/#/) TediCross is using has support for audio channels and voice chat. For this, it needs some additional libraries, like [node-opus](https://www.npmjs.com/package/node-opus), [libsodium](https://www.npmjs.com/package/libsodium) and others. TediCross does not do audio, so these warnings can safely be ignored diff --git a/example.settings.yaml b/example.settings.yaml index fbf1ac81..2d77af0e 100644 --- a/example.settings.yaml +++ b/example.settings.yaml @@ -8,18 +8,22 @@ discord: useNickname: false token: DISCORD_BOT_TOKEN_HERE # Discord bot tokens look like this: MjI3MDA1NzIvOBQ2MzAzMiMz.DRf-aw.N0MVYtDxXYPSQew4g2TPqvQve2c skipOldMessages: true + displayTelegramReplies: embed + replyLength: 100 + maxReplyLines: 2 bridges: - - name: Default bridge + - name: First bridge direction: both telegram: chatId: TELEGRAM_CHAT_ID # Remember that Telegram group and channel IDs are negative. Include the `-` if you are bridging a group or a channel relayJoinMessages: true relayLeaveMessages: true sendUsernames: true + relayCommands: true discord: - serverId: 'DISCORD_SERVER_ID' # This ID must be wrapped in single quotes. Example: '244791815503347712' channelId: 'DISCORD_CHANNEL_ID' # This ID must be wrapped in single quotes. Example: '244791815503347712' relayJoinMessages: true relayLeaveMessages: true sendUsernames: true + crossDeleteOnTelegram: true debug: false diff --git a/lib/telegram2discord/handleUpdates.js b/lib/telegram2discord/handleUpdates.js deleted file mode 100644 index 8cb11379..00000000 --- a/lib/telegram2discord/handleUpdates.js +++ /dev/null @@ -1,78 +0,0 @@ -"use strict"; - -/************************** - * Import important stuff * - **************************/ - -// Nothing - -/***************************** - * The handleUpdate function * - *****************************/ - -/** - * Handles update objects - * - * @param {Update[]} updates The updates to handle - * @param {EventEmitter} emitter The event emitter to use for emitting the updates - * - * @returns {Integer} ID of the last processed update - */ -function handleUpdates(updates, emitter) { - return updates.reduce((newOffset, update) => handleUpdate(update, emitter), 0); -} - -/** - * Finds out what type of update an update object is, and emits it as an event - * - * @param {Update} update The update object to handle - * @param {EventEmitter} emitter The event emitter to use - * - * @returns {Integer} ID of the update - */ -function handleUpdate(update, emitter) { - // Check what type of update this is - if (update.message !== undefined || update.channel_post !== undefined) { - // Extract the message. Treat ordinary messages and channel posts the same - const message = update.message || update.channel_post; - - // Determine type - if (message.text !== undefined) { - emitter.emit("text", message); - } else if (message.photo !== undefined) { - emitter.emit("photo", message); - } else if (message.document !== undefined) { - emitter.emit("document", message); - } else if (message.voice !== undefined) { - emitter.emit("voice", message); - } else if (message.audio !== undefined) { - emitter.emit("audio", message); - } else if (message.video !== undefined) { - emitter.emit("video", message); - } else if (message.sticker !== undefined) { - emitter.emit("sticker", message); - } else if (message.new_chat_members !== undefined) { - emitter.emit("newParticipants", message); - } else if (message.left_chat_member !== undefined) { - emitter.emit("participantLeft", message); - } - } else if (update.edited_message !== undefined) { - // Extract the message - const message = update.edited_message; - - // This is an update to a message - emitter.emit("messageEdit", message); - } - - // Return the new offset - return update.update_id; -} - -/************* - * Export it * - *************/ - -module.exports = { - handleUpdates, - handleUpdate -}; diff --git a/lib/telegram2discord/makeUpdateEmitter.js b/lib/telegram2discord/makeUpdateEmitter.js deleted file mode 100644 index 72d83717..00000000 --- a/lib/telegram2discord/makeUpdateEmitter.js +++ /dev/null @@ -1,106 +0,0 @@ -"use strict"; - -/************************** - * Import important stuff * - **************************/ - -const moment = require("moment"); -const { EventEmitter } = require("events"); -const { handleUpdates } = require("./handleUpdates"); - -const DEFAULT_TIMEOUT = moment.duration(1, "minute").asSeconds(); - -/***************************** - * The UpdateGetter function * - *****************************/ - -/** - * Runs getUpdates until there are no more updates to get. This is meant to run - * at the startup of the bot to remove initial cached messages if the bot has - * been down for a while - * - * @param {teleapiwrapper.BotAPI} bot The bot to get updates for - * - * @returns {Promise} A promise which resolves when all updates have been cleared - * - * @private - */ -async function clearInitialUpdates(bot) { - // Get updates for the bot. -1 means only the latest update will be fetched - const updates = await bot.getUpdates({offset: -1, timeout: 0}); - - // If an update was fetched, confirm it by sending a new request with its ID +1 - if (updates.length > 0) { - const offset = updates[updates.length-1].update_id + 1; - await bot.getUpdates({offset, timeout: 0}); - } - - // All updates are now cleared -} - -/** - * Fetches Telegram updates - * - * @param {Logger} logger The Logger instance to log messages to - * @param {BotAPI} bot The telegram bot to use - * @param {Integer} offset The update offset to use - * @param {EventEmitter} emitter The emitter to emit the updates from - * - * @private - */ -async function fetchUpdates(logger, bot, offset, emitter) { - // Log the event if debugging is on - logger.debug("Fetching Telegram updates"); - - try { - // Do the fetching - const updates = await bot.getUpdates({offset, timeout: DEFAULT_TIMEOUT}); - offset = handleUpdates(updates, emitter) + 1; - } catch (err) { - logger.error("Couldn't fetch Telegram messages. Reason:", `${err.name}: ${err.message}`); - logger.debug(err.stack); - } finally { - // Do it again, no matter what happened. XXX There is currently no way to stop it - fetchUpdates(logger, bot, offset, emitter); - } -} - -/** - * Creates an event emitter emitting update events for a Telegram bot - * - * @param {Logger} logger The Logger instance to log messages to - * @param {teleapiwrapper.BotAPI} bot The bot to get updates for - * @param {Settings} settings The settings to use - * - * @returns {EventEmitter} The event emitter - */ -function makeUpdateEmitter(logger, bot, settings) { - // Create an event emitter - const emitter = new EventEmitter(); - - // Clear old messages, if wanted - let p = Promise.resolve(); - if (settings.telegram.skipOldMessages) { - // Log the start of the clearing if debugging is on - logger.debug("Clearing old Telegram messages"); - - // Start clearing messages - p = clearInitialUpdates(bot) - .then(() => { - // Log that the clearing has ended if debugging is on - logger.debug("Old Telegram messages cleared"); - }); - } - - // Start the fetching - p.then(() => fetchUpdates(logger, bot, 0, emitter)); - - // Return the event emitter - return emitter; -} - -/*********************** - * Export the function * - ***********************/ - -module.exports = makeUpdateEmitter; diff --git a/lib/telegram2discord/processMessage.js b/lib/telegram2discord/processMessage.js deleted file mode 100644 index 371d5536..00000000 --- a/lib/telegram2discord/processMessage.js +++ /dev/null @@ -1,114 +0,0 @@ -"use strict"; - -/************************** - * Import important stuff * - **************************/ - -const handleEntities = require("./handleEntities"); - -/********************* - * Make some helpers * - *********************/ - -/** - * Creates a 'from' object from a channel - * - * @param {Object} chat The chat object of the channel - * - * @returns {Object} An object on the form {type, title} where 'type' is "channel" - * - * @private - */ -function channelFrom(chat) { - return { - type: "channel", - title: chat.title - }; -} - -/** - * Creates a 'from' object from a user - * - * @param {Object} user The user's user object - * - * @returns {Object} An object on the form {type, firstName, lastName, username} where 'type' is "user" - * - * @private - */ -function userFrom(user) { - return { - type: "user", - firstName: user.first_name, - lastName: user.last_name, - username: user.username - }; -} - -/** - * Processes a reply - * - * @param {Object} reply The reply to process - * - * @returns {Object} The processed reply - * - * @private - */ -function processReply(reply, tgBot) { - const processed = processMessage(reply); - - // If the reply was to the bot, it was really to the Discord user - if (processed.from.username === tgBot.me.username) { - // Extract the Discord user's name from the message and update the 'from' object - const usernameMatch = /^\*\*([^*]+)\*\*/.exec(processed.text); - if (usernameMatch !== undefined) { - processed.from = userFrom({ - first_name: usernameMatch[1], - last_name: undefined, - username: usernameMatch[1] - }); - } - } - return processed; -} - -/************************ - * The processor itself * - ************************/ - -/** - * Processes a message into a more handleable internal TediCross format - * - * @param {Object} message A raw message object from Telegram. Either from 'update.message', 'update.channel_post', 'update.edited_message' or 'message.edited_channel_post' - * @param {Bridge} bridge The bridge this message is on - * @param {Discord} dcBot The discord bot - * @param {BotAPI} tgBot The telegram bot - * - * @returns {ProcessedMessage} The processed message - */ -function processMessage(message, bridge, dcBot, tgBot) { - return { - from: message.chat.type === "channel" - ? channelFrom(message.chat) - : userFrom(message.from) - , - reply: message.reply_to_message !== undefined - ? processReply(message.reply_to_message, tgBot) - : null - , - forward: message.forward_from !== undefined - ? userFrom(message.forward_from) - : message.forward_from_chat !== undefined - ? channelFrom(message.forward_from_chat) - : null - , - text: message.text !== undefined - ? handleEntities(message.text, message.entities, dcBot, bridge) - : undefined - }; -} - -/************* - * Export it * - *************/ - -module.exports = processMessage; diff --git a/lib/telegram2discord/setup.js b/lib/telegram2discord/setup.js deleted file mode 100644 index c7b4e2a6..00000000 --- a/lib/telegram2discord/setup.js +++ /dev/null @@ -1,398 +0,0 @@ -"use strict"; - -/************************** - * Import important stuff * - **************************/ - -const makeUpdateEmitter = require("./makeUpdateEmitter"); -const messageConverter = require("./messageConverter"); -const MessageMap = require("../MessageMap"); -const R = require("ramda"); -const mime = require("mime/lite"); -const Bridge = require("../bridgestuff/Bridge"); -const Discord = require("discord.js"); - -/** - * Creates a function which sends files from Telegram to discord - * - * @param {BotAPI} tgBot The Telegram bot - * @param {Discord.Client} dcBot The Discord bot - * @param {MessageMap} messageMap Map between IDs of messages - * @param {Settings} settings The settings to use - * - * @returns {Function} A function which can be used to send files from Telegram to Discord - * - * @private - */ -function makeFileSender(tgBot, dcBot, messageMap, settings) { - /** - * Sends a file to Discord - * - * @param {String} arg.discordChannel Discord channel ID - * @param {Message} arg.message The message the file comes from - * @param {String} arg.fileId ID of the file to download from Telegram's servers - * @param {String} arg.fileName Name of the file to send - * @param {String} [arg.caption] Additional text to send with the file - * @param {Boolean} [arg.resolveExtension] Set to true if the bot should try to find the file extension itself, in which case it will be appended to the file name. Defaults to false - */ - return async function (bridge, {message, fileId, fileName, caption = "", resolveExtension = false}) { - // Make the text to send - const messageObj = messageConverter(message, tgBot, settings, dcBot, bridge); - const textToSend = bridge.telegram.sendUsernames - ? `**${messageObj.from}**:\n${caption}` - : caption - ; - - // Wait for the Discord bot to become ready - await dcBot.ready; - - // Start getting the file - const file = await tgBot.getFile({file_id: fileId}); - const fileStream = await tgBot.helperGetFileStream(file); - - // Get the extension, if necessary - const extension = resolveExtension - ? "." + file.file_path.split(".").pop() - : "" - ; - - // Send it to Discord - const dcMessage = await dcBot.channels.get(bridge.discord.channelId).send( - textToSend, - new Discord.Attachment(fileStream, fileName + extension) - ); - - messageMap.insert(MessageMap.TELEGRAM_TO_DISCORD, bridge, message.message_id, dcMessage.id); - }; -} - -/** - * Makes a name object (for lack of better term) of a user object. It contains the user's full name, and the username or the text `No username` - * - * @param {User} user The user object to make the name object of - * - * @returns {Object} The name object, with `name` and `username` as properties - */ -function makeNameObject(user) { - // Make the user's full name - const name = user.first_name - + (user.last_name !== undefined - ? " " + user.last_name - : "" - ); - - // Make the user's username - const username = user.username !== undefined - ? "@" + user.username - : "No username"; - - return { - name, - username - }; -} - -/** - * Curryed function creating handlers handling messages which should not be relayed, and passing through those which should - * - * @param {Logger} logger The Logger instance to log messages to - * @param {BotAPI} tgBot The Telegram bot - * @param {BridgeMap} bridgeMap Map of the bridges to use - * @param {Function} func The message handler to wrap - * @param {Message} message The Telegram message triggering the wrapped function - * - * @private - */ -const createMessageHandler = R.curry((logger, tgBot, bridgeMap, func, message) => { - if (message.text === "/chatinfo") { - // This is a request for chat info. Give it, no matter which chat this is from - tgBot.sendMessage({ - chat_id: message.chat.id, - text: "chatID: " + message.chat.id - }); - } else { - // Get the bridge - const bridges = bridgeMap.fromTelegramChatId(message.chat.id); - - // Check if the message came from the correct chat - if (bridges === undefined) { - // Tell the sender that this is a private bot - tgBot.sendMessage({ - chat_id: message.chat.id, - text: "This is an instance of a [TediCross](https://github.com/TediCross/TediCross) bot, " - + "bridging a chat in Telegram with one in Discord. " - + "If you wish to use TediCross yourself, please download and create an instance. " - + "Join our [Telegram group](https://t.me/TediCrossSupport) or [Discord server](https://discord.gg/MfzGMzy) for help" - , - parse_mode: "markdown" - }) - .catch((err) => { - // Hmm... Could not send the message for some reason - logger.error("Could not tell user to get their own TediCross instance:", err, message); - }); - } else { - bridges.forEach((bridge) => { - // Do the thing, if this is not a discord-to-telegram bridge - if (bridge.direction !== Bridge.DIRECTION_DISCORD_TO_TELEGRAM) { - func(message, bridge); - } - }); - } - } -}); - -/********************** - * The setup function * - **********************/ - -/** - * Sets up the receiving of Telegram messages, and relaying them to Discord - * - * @param {Logger} logger The Logger instance to log messages to - * @param {BotAPI} tgBot The Telegram bot - * @param {Discord.Client} dcBot The Discord bot - * @param {MessageMap} messageMap Map between IDs of messages - * @param {BridgeMap} bridgeMap Map of the bridges to use - * @param {Settings} settings The settings to use - */ -function setup(logger, tgBot, dcBot, messageMap, bridgeMap, settings) { - // Start longpolling - const updateEmitter = makeUpdateEmitter(logger, tgBot, settings); - - // Make the file sender - const sendFile = makeFileSender(tgBot, dcBot, messageMap, settings); - - // Create the message handler wrapper - const wrapFunction = createMessageHandler(logger, tgBot, bridgeMap); - - // Set up event listener for text messages from Telegram - updateEmitter.on("text", wrapFunction(async (message, bridge) => { - - // Turn the text discord friendly - const messageObj = messageConverter(message, tgBot, settings, dcBot, bridge); - - try { - // Pass it on to Discord when the dcBot is ready - await dcBot.ready; - - // Get the channel to send to - const channel = dcBot.channels.get(bridge.discord.channelId); - - // Handle replies - if (messageObj.reply !== null) { - // Make a Discord embed and send it first - const embed = new Discord.RichEmbed({ - description: `Reply to **${messageObj.reply.author}**\n${messageObj.reply.text}` - }); - - const textToSend = bridge.telegram.sendUsernames - ? `**${messageObj.from}**` - : undefined - ; - - await channel.send(textToSend, {embed}); - } - - // Discord doesn't handle messages longer than 2000 characters. Split it up into chunks that big - const messageText = (!bridge.telegram.sendUsernames || messageObj.reply !== null) - ? messageObj.text - : `**${messageObj.from}**\n${messageObj.text}` - ; - const chunks = R.splitEvery(2000, messageText); - - // Send them in serial - let dcMessage = null; - for (const chunk of chunks) { - dcMessage = await channel.send(chunk); - } - - // Make the mapping so future edits can work XXX Only the last chunk is considered - messageMap.insert(MessageMap.TELEGRAM_TO_DISCORD, bridge, message.message_id, dcMessage.id); - } catch (err) { - logger.error(`[${bridge.name}] Discord did not accept a text message:`, err); - logger.error(`[${bridge.name}] Failed message:`, message.text); - } - })); - - // Set up event listener for photo messages from Telegram - updateEmitter.on("photo", wrapFunction(async (message, bridge) => { - try { - await sendFile(bridge, { - message, - fileId: message.photo[message.photo.length-1].file_id, - fileName: "photo.jpg", // Telegram will convert it to jpg no matter what filetype is actually sent - caption: message.caption - }); - } catch (err) { - logger.error(`[${bridge.name}] Could not send photo`, err); - } - })); - - // Set up event listener for stickers from Telegram - updateEmitter.on("sticker", wrapFunction(async (message, bridge) => { - try { - await sendFile(bridge, { - message, - fileId: message.sticker.thumb.file_id, - fileName: "sticker.webp", // Telegram will insist that it is a jpg, but it really is a webp - caption: settings.telegram.sendEmojiWithStickers ? message.sticker.emoji : undefined - }); - } catch (err) { - logger.error(`[${bridge.name}] Could not send sticker`, err); - } - })); - - // Set up event listener for filetypes not caught by the other filetype handlers - updateEmitter.on("document", wrapFunction(async (message, bridge) => { - // message.file_name can for some reason be undefined some times. Default to "file.ext" - let fileName = message.document.file_name; - if (fileName === undefined) { - fileName = "file." + mime.getExtension(message.document.mime_type); - } - - try { - // Pass it on to Discord - await sendFile(bridge, { - message, - fileId: message.document.file_id, - fileName: fileName, - resolveExtension: false - }); - } catch (err) { - logger.error(`[${bridge.name}] Could not send document`, err); - } - })); - - // Set up event listener for voice messages - updateEmitter.on("voice", wrapFunction(async (message, bridge) => { - try { - await sendFile(bridge, { - message, - fileId: message.voice.file_id, - fileName: "voice" + "." + mime.getExtension(message.voice.mime_type), - resolveExtension: false - }); - } catch (err) { - logger.error(`[${bridge.name}] Could not send voice`, err); - } - })); - - // Set up event listener for audio messages - updateEmitter.on("audio", wrapFunction(async (message, bridge) => { - try { - await sendFile(bridge, { - message, - fileId: message.audio.file_id, - fileName: message.audio.title, - resolveExtension: true - }); - } catch (err) { - logger.error(`[${bridge.name}] Could not send audio`, err); - } - })); - - // Set up event listener for video messages - updateEmitter.on("video", wrapFunction(async (message, bridge) => { - try { - await sendFile(bridge, { - message, - caption: message.caption, - fileId: message.video.file_id, - fileName: "video" + "." + mime.getExtension(message.video.mime_type), - resolveExtension: false - }); - } catch (err) { - logger.error(`[${bridge.name}] Could not send video`, err); - } - })); - - // Listen for users joining the chat - updateEmitter.on("newParticipants", wrapFunction(({new_chat_members}, bridge) => { - // Ignore it if the settings say no - if (!bridge.telegram.relayJoinMessages) { - return; - } - - // Notify Discord about each user - new_chat_members.forEach((user) => { - // Make the text to send - const nameObj = makeNameObject(user); - const text = `**${nameObj.name} (${nameObj.username})** joined the Telegram side of the chat`; - - // Pass it on - dcBot.ready.then(() => { - return dcBot.channels.get(bridge.discord.channelId).send(text); - }) - .catch((err) => logger.error(`[${bridge.name}] Could not notify Discord about a user that joined Telegram`, err)); - }); - })); - - // Listen for users leaving the chat - updateEmitter.on("participantLeft", wrapFunction(async ({left_chat_member}, bridge) => { - // Ignore it if the settings say no - if (!bridge.telegram.relayLeaveMessages) { - return; - } - - // Make the text to send - const nameObj = makeNameObject(left_chat_member); - const text = `**${nameObj.name} (${nameObj.username})** left the Telegram side of the chat`; - - try { - // Pass it on when Discord is ready - await dcBot.ready; - await dcBot.channels.get(bridge.discord.channelId).send(text); - } catch (err) { - logger.error(`[${bridge.name}] Could not notify Discord about a user that left Telegram`, err); - } - })); - - // Set up event listener for message edits - updateEmitter.on("messageEdit", wrapFunction(async (tgMessage, bridge) => { - try { - // Wait for the Discord bot to become ready - await dcBot.ready; - - // Find the ID of this message on Discord - const [dcMessageId] = messageMap.getCorresponding(MessageMap.TELEGRAM_TO_DISCORD, bridge, tgMessage.message_id); - - // Get the messages from Discord - const dcMessage = await dcBot.channels.get(bridge.discord.channelId).fetchMessage(dcMessageId); - - const messageObj = messageConverter(tgMessage, tgBot, settings, dcBot, bridge); - - // Try to edit the message - const textToSend = bridge.telegram.sendUsernames - ? `**${messageObj.from}**\n${messageObj.text}` - : messageObj.text - ; - await dcMessage.edit(textToSend); - } catch (err) { - // Log it - logger.error(`[${bridge.name}] Could not edit Discord message:`, err); - } - })); - - // Make a promise which resolves when the dcBot is ready - tgBot.ready = tgBot.getMe() - .then((bot) => { - // Log the bot's info - logger.info(`Telegram: ${bot.username} (${bot.id})`); - - // Put the data on the bot - tgBot.me = bot; - }) - .catch((err) => { - // Log the error( - logger.error("Failed at getting the Telegram bot's me-object:", err); - - // Pass it on - throw err; - }); -} - -/***************************** - * Export the setup function * - *****************************/ - -module.exports = setup; diff --git a/main.js b/main.js index ae20c637..0ecccac7 100644 --- a/main.js +++ b/main.js @@ -5,42 +5,88 @@ **************************/ // General stuff +const yargs = require("yargs"); const path = require("path"); -const Logger = require("./lib/Logger"); -const MessageMap = require("./lib/MessageMap"); -const Bridge = require("./lib/bridgestuff/Bridge"); -const BridgeMap = require("./lib/bridgestuff/BridgeMap"); -const Settings = require("./lib/settings/Settings"); -const migrateSettingsToYAML = require("./lib/migrateSettingsToYAML"); +const Logger = require("./src/Logger"); +const MessageMap = require("./src/MessageMap"); +const Bridge = require("./src/bridgestuff/Bridge"); +const BridgeMap = require("./src/bridgestuff/BridgeMap"); +const Settings = require("./src/settings/Settings"); +const migrateSettingsToYAML = require("./src/migrateSettingsToYAML"); +const jsYaml = require("js-yaml"); +const fs = require("fs"); +const R = require("ramda"); +const os = require("os"); // Telegram stuff -const { BotAPI } = require("teleapiwrapper"); -const telegramSetup = require("./lib/telegram2discord/setup"); +const Telegraf = require("telegraf"); +const telegramSetup = require("./src/telegram2discord/setup"); // Discord stuff const Discord = require("discord.js"); -const discordSetup = require("./lib/discord2telegram/setup"); +const discordSetup = require("./src/discord2telegram/setup"); /************* * TediCross * *************/ +// Get command line arguments if any +const args = yargs + .alias("v", "version") + .alias("h", "help") + .option("config", { + alias: "c", + default: path.join(__dirname, "settings.yaml"), + describe: "Specify path to settings file", + type: "string" + }) + .option("data-dir", { + alias: "d", + default: path.join(__dirname, "data"), + describe: "Specify the path to the directory to store data in", + type: "string" + }).argv; + // Migrate the settings from JSON to YAML const settingsPathJSON = path.join(__dirname, "settings.json"); -const settingsPathYAML = path.join(__dirname, "settings.yaml"); +const settingsPathYAML = args.config; migrateSettingsToYAML(settingsPathJSON, settingsPathYAML); // Get the settings -const settings = Settings.fromFile(settingsPathYAML); +const rawSettingsObj = jsYaml.safeLoad(fs.readFileSync(settingsPathYAML)); +const settings = Settings.fromObj(rawSettingsObj); // Initialize logger const logger = new Logger(settings.debug); -// Save the settings, as they might have changed -settings.toFile(settingsPathYAML); +// Write the settings back to the settings file if they have been modified +const newRawSettingsObj = settings.toObj(); +if (R.not(R.equals(rawSettingsObj, newRawSettingsObj))) { + // Turn it into notepad friendly YAML + const yaml = jsYaml.safeDump(newRawSettingsObj).replace(/\n/g, "\r\n"); + + try { + fs.writeFileSync(settingsPathYAML, yaml); + } catch (err) { + if (err.code === "EACCES") { + // The settings file is not writable. Give a warning + logger.warn("Changes to TediCross' settings have been introduced. Your settings file it not writable, so it could not be automatically updated. TediCross will still work, with the modified settings, but you will see this warning until you update your settings file"); + + // Write the settings to temp instead + const tmpPath = path.join(os.tmpdir(), "tedicross-settings.yaml"); + try { + fs.writeFileSync(tmpPath, yaml); + logger.info(`The new settings file has instead been written to '${tmpPath}'. Copy it to its proper location to get rid of the warning`); + } catch (err) { + logger.warn(`An attempt was made to put the modified settings file at '${tmpPath}', but it could not be done. See the following error message`); + logger.warn(err); + } + } + } +} // Create a Telegram bot -const tgBot = new BotAPI(settings.telegram.token); +const tgBot = new Telegraf(settings.telegram.token, { channelMode: true }); // Create a Discord bot const dcBot = new Discord.Client(); @@ -55,5 +101,5 @@ const bridgeMap = new BridgeMap(settings.bridges.map((bridgeSettings) => new Bri * Set up the bridge * *********************/ -discordSetup(logger, dcBot, tgBot, messageMap, bridgeMap, settings); -telegramSetup(logger, tgBot, dcBot, messageMap, bridgeMap, settings); +discordSetup(logger, dcBot, tgBot, messageMap, bridgeMap, settings, args.dataDir); +telegramSetup(logger, tgBot, dcBot, messageMap, bridgeMap, settings, args.dataDir); diff --git a/package.json b/package.json index 35edbc7f..1e280816 100644 --- a/package.json +++ b/package.json @@ -1,31 +1,35 @@ { - "name": "tedicross", - "version": "0.1.0", - "description": "Better DiteCross", + "name": "GoatBot", + "version": "0.9.500", + "description": "Almost Better TediCross", "license": "MIT", "repository": { "type": "git", - "url": "git+https://github.com/TediCross/TediCross.git" - }, + "url": "git+https://github.com/Myke500/GoatBot.git" + }, "scripts": { "start": "node main.js", + "start-dev": "nodemon main.js -i data/", "lint": "eslint ." }, "dependencies": { - "discord.js": "^11.3.0", - "js-yaml": "^3.11.0", - "mime": "^2.2.0", - "moment": "^2.21.0", - "ramda": "^0.25.0", - "simple-markdown": "^0.3.2", - "teleapiwrapper": "^2.3.2" + "discord.js": "^11.5.1", + "js-yaml": "^3.13.1", + "mime": "^2.4.4", + "moment": "^2.24.0", + "ramda": "^0.26.1", + "request": "^2.88.0", + "simple-markdown": "^0.4.4", + "telegraf": "^3.32.0", + "yargs": "^13.3.0" }, "devDependencies": { - "eslint": "^4.19.1" + "eslint": "^5.16.0", + "nodemon": "^2.0.2" }, "eslintConfig": { "parserOptions": { - "ecmaVersion": 2017 + "ecmaVersion": 2018 }, "env": { "es6": true, diff --git a/lib/Logger.js b/src/Logger.js similarity index 100% rename from lib/Logger.js rename to src/Logger.js diff --git a/lib/MessageMap.js b/src/MessageMap.js similarity index 100% rename from lib/MessageMap.js rename to src/MessageMap.js diff --git a/lib/bridgestuff/Bridge.js b/src/bridgestuff/Bridge.js similarity index 100% rename from lib/bridgestuff/Bridge.js rename to src/bridgestuff/Bridge.js diff --git a/lib/bridgestuff/BridgeMap.js b/src/bridgestuff/BridgeMap.js similarity index 71% rename from lib/bridgestuff/BridgeMap.js rename to src/bridgestuff/BridgeMap.js index 93959227..4c95164b 100644 --- a/lib/bridgestuff/BridgeMap.js +++ b/src/bridgestuff/BridgeMap.js @@ -4,8 +4,7 @@ * Import important stuff * **************************/ -// eslint-disable-next-line no-unused-vars -const Bridge = require("./Bridge"); +const R = require("ramda"); /*********************** * The BridgeMap class * @@ -46,22 +45,12 @@ class BridgeMap { */ this._telegramToBridge = new Map(); - /** - * Set of Discord servers which are bridged - * - * @type {Set} - * - * @private - */ - this._discordServers = new Set(); - // Populate the maps and set bridges.forEach((bridge) => { const d = this._discordToBridge.get(bridge.discord.channelId) || []; const t = this._telegramToBridge.get(bridge.telegram.chatId) || []; this._discordToBridge.set(bridge.discord.channelId, [...d, bridge]); this._telegramToBridge.set(bridge.telegram.chatId, [...t, bridge]); - this._discordServers.add(bridge.discord.serverId); }); } @@ -73,7 +62,7 @@ class BridgeMap { * @returns {Bridge[]} The bridges corresponding to the chat ID */ fromTelegramChatId(telegramChatId) { - return this._telegramToBridge.get(telegramChatId); + return R.defaultTo([], this._telegramToBridge.get(telegramChatId)); } /** @@ -84,18 +73,7 @@ class BridgeMap { * @returns {Bridges[]} The bridges corresponding to the channel ID */ fromDiscordChannelId(discordChannelId) { - return this._discordToBridge.get(discordChannelId); - } - - /** - * Checks if a Discord server ID is known - * - * @param {String} discordServerId Discord server ID to check - * - * @returns {Boolean} True if the server is known, false otherwise - */ - knownDiscordServer(discordServerId) { - return this._discordServers.has(discordServerId); + return R.defaultTo([], this._discordToBridge.get(discordChannelId)); } } diff --git a/lib/bridgestuff/BridgeSettingsDiscord.js b/src/bridgestuff/BridgeSettingsDiscord.js similarity index 87% rename from lib/bridgestuff/BridgeSettingsDiscord.js rename to src/bridgestuff/BridgeSettingsDiscord.js index 3d7c7398..c4113ac5 100644 --- a/lib/bridgestuff/BridgeSettingsDiscord.js +++ b/src/bridgestuff/BridgeSettingsDiscord.js @@ -18,7 +18,6 @@ class BridgeSettingsDiscord { * Creates a new BridgeSettingsDiscord object * * @param {Object} settings Settings for the Discord side of the bridge - * @param {String} settings.serverId ID of the Discord server this bridge is part of * @param {String} settings.channelId ID of the Discord channel this bridge is part of * @param {Boolean} settings.relayJoinMessages Whether or not to relay join messages from Discord to Telegram * @param {Boolean} settings.relayLeaveMessages Whether or not to relay leave messages from Discord to Telegram @@ -28,13 +27,6 @@ class BridgeSettingsDiscord { constructor(settings) { BridgeSettingsDiscord.validate(settings); - /** - * ID of the Discord server this bridge is part of - * - * @type {String} - */ - this.serverId = settings.serverId; - /** * ID of the Discord channel this bridge is part of * @@ -62,6 +54,13 @@ class BridgeSettingsDiscord { * @type {Boolean} */ this.sendUsernames = settings.sendUsernames; + + /** + * Whether or not to delete messages on Telegram when a message is deleted on Discord + * + * @type {Boolean} + */ + this.crossDeleteOnTelegram = settings.crossDeleteOnTelegram; } /** @@ -91,6 +90,11 @@ class BridgeSettingsDiscord { if (Boolean(settings.sendUsernames) !== settings.sendUsernames) { throw new Error("`settings.sendUsernames` must be a boolean"); } + + // Check that crossDeleteOnTelegram is a boolean + if (Boolean(settings.crossDeleteOnTelegram) !== settings.crossDeleteOnTelegram) { + throw new Error("`settings.crossDeleteOnTelegram` must be a boolean"); + } } } diff --git a/lib/bridgestuff/BridgeSettingsTelegram.js b/src/bridgestuff/BridgeSettingsTelegram.js similarity index 88% rename from lib/bridgestuff/BridgeSettingsTelegram.js rename to src/bridgestuff/BridgeSettingsTelegram.js index aaf613ec..e8340b03 100644 --- a/lib/bridgestuff/BridgeSettingsTelegram.js +++ b/src/bridgestuff/BridgeSettingsTelegram.js @@ -53,6 +53,13 @@ class BridgeSettingsTelegram { * @type {Boolean} */ this.sendUsernames = settings.sendUsernames; + + /** + * Whether or not to relay messages starting with "/" (commands) + * + * @type {Boolean} + */ + this.relayCommands = settings.relayCommands; } /** @@ -82,6 +89,11 @@ class BridgeSettingsTelegram { if (Boolean(settings.sendUsernames) !== settings.sendUsernames) { throw new Error("`settings.sendUsernames` must be a boolean"); } + + // Check that relayCommands is a boolean + if (Boolean(settings.relayCommands) !== settings.relayCommands) { + throw new Error("`settings.relayCommands` must be a boolean"); + } } } diff --git a/lib/discord2telegram/LatestDiscordMessageIds.js b/src/discord2telegram/LatestDiscordMessageIds.js similarity index 79% rename from lib/discord2telegram/LatestDiscordMessageIds.js rename to src/discord2telegram/LatestDiscordMessageIds.js index 2f19ccc6..a024727c 100644 --- a/lib/discord2telegram/LatestDiscordMessageIds.js +++ b/src/discord2telegram/LatestDiscordMessageIds.js @@ -5,7 +5,7 @@ **************************/ const fs = require("fs"); -const path = require("path"); +const promisify = require("util").promisify; /************************************* * The LatestDiscordMessageIds class * @@ -19,9 +19,9 @@ class LatestDiscordMessageIds { * Creates a new instance which keeps track of messages and bridges * * @param {Logger} logger The Logger instance to log messages to - * @param {String} filename Name of the file to persistently store the map in. Will be put in the `data/` directory + * @param {String} filepath Path to the file to persistently store the map in */ - constructor(logger, filename) { + constructor(logger, filepath) { /** * The Logger instance to log messages to * @@ -36,7 +36,7 @@ class LatestDiscordMessageIds { * * @private */ - this._filename = path.join(__dirname, "..", "..", "data", filename); + this._filepath = filepath; /** * The actual map @@ -49,19 +49,19 @@ class LatestDiscordMessageIds { try { // Check if the file exists. This throws if it doesn't - fs.accessSync(this._filename, fs.constants.F_OK); + fs.accessSync(this._filepath, fs.constants.F_OK); } catch (e) { // Nope, it doesn't. Create it - fs.writeFileSync(this._filename, JSON.stringify({})); + fs.writeFileSync(this._filepath, JSON.stringify({})); } // Read the file let data = null; try { - data = fs.readFileSync(this._filename); + data = fs.readFileSync(this._filepath); } catch (err) { // Well, the file has been confirmed to exist, so there must be no read access - this._logger.error(`Cannot read the file ${this._filename}:`, err); + this._logger.error(`Cannot read the file ${this._filepath}:`, err); data = JSON.stringify({}); } @@ -70,7 +70,7 @@ class LatestDiscordMessageIds { this._map = JSON.parse(data); } catch (err) { // Invalid JSON - this._logger.error(`Could not read or parse the file ${this._filename}:`, err); + this._logger.error(`Could not read or parse the file ${this._filepath}:`, err); this._map = {}; } @@ -100,9 +100,7 @@ class LatestDiscordMessageIds { // Write it to file when previous writes have completed this._finishedWriting = this._finishedWriting - .then(() => new Promise((resolve, reject) => { - fs.writeFile(this._filename, JSON.stringify(this._map, null, "\t"), (err) => err ? reject(err) : resolve()); - })) + .then(() => promisify(fs.writeFile)(this._filepath, JSON.stringify(this._map, null, "\t"))) .catch((err) => this._logger.error("Writing last Discord message ID to file failed!", err)); } diff --git a/lib/discord2telegram/handleEmbed.js b/src/discord2telegram/handleEmbed.js similarity index 100% rename from lib/discord2telegram/handleEmbed.js rename to src/discord2telegram/handleEmbed.js diff --git a/src/discord2telegram/helpers.js b/src/discord2telegram/helpers.js new file mode 100644 index 00000000..e9138f79 --- /dev/null +++ b/src/discord2telegram/helpers.js @@ -0,0 +1,48 @@ +"use strict"; + +/************************** + * Import important stuff * + **************************/ + +const R = require("ramda"); + +/******************** + * Make the helpers * + ********************/ + +/** + * Ignores errors arising from trying to delete an already deleted message. Rethrows other errors + * + * @param {Error} err The error to check + * + * @returns {undefined} + * + * @throws {Error} The error, if it is another type + */ +const ignoreAlreadyDeletedError = R.ifElse( + R.propEq("message", "Unknown Message"), + R.always(undefined), + err => {throw err;} +); + +/** + * Converts characters '&', '<' and '>' in strings into HTML safe strings + * + * @param {String} text The text to escape the characters in + * + * @returns {String} The escaped string + */ +const escapeHTMLSpecialChars = R.compose( + R.replace(/>/g, ">"), + R.replace(//g, ">"); + const processedText = escapeHTMLSpecialChars(text); // Parse the markdown and build HTML out of it const html = mdParse(processedText) diff --git a/lib/discord2telegram/relayOldMessages.js b/src/discord2telegram/relayOldMessages.js similarity index 79% rename from lib/discord2telegram/relayOldMessages.js rename to src/discord2telegram/relayOldMessages.js index c23d3e59..e42aa723 100644 --- a/lib/discord2telegram/relayOldMessages.js +++ b/src/discord2telegram/relayOldMessages.js @@ -39,10 +39,18 @@ async function relayOldMessages(logger, dcBot, latestDiscordMessageIds, bridgeMa // Get messages which have arrived on each bridge since the bot was last shut down .map(async ({bridge, messageId}) => { try { + // Check if the message exists. This will throw if it does not + // XXX If the message does not exist, the following `fetchMessages` call will not throw, but instead return *everything* it can get its hands on, spamming Telegram + await dcBot.channels.get(bridge.discord.channelId).fetchMessage(messageId); + + // Get messages since that one const messages = await dcBot.channels.get(bridge.discord.channelId).fetchMessages({limit: 100, after: messageId}); + + // Relay them sortAndRelay(messages.array()); } catch (err) { logger.error(err); + logger.log("The previous error is probably nothing to worry about"); } }); } diff --git a/lib/discord2telegram/setup.js b/src/discord2telegram/setup.js similarity index 71% rename from lib/discord2telegram/setup.js rename to src/discord2telegram/setup.js index cd53b7b8..35cbea9d 100644 --- a/lib/discord2telegram/setup.js +++ b/src/discord2telegram/setup.js @@ -11,6 +11,10 @@ const LatestDiscordMessageIds = require("./LatestDiscordMessageIds"); const handleEmbed = require("./handleEmbed"); const relayOldMessages = require("./relayOldMessages"); const Bridge = require("../bridgestuff/Bridge"); +const path = require("path"); +const R = require("ramda"); +const { sleepOneMinute } = require("../sleep"); +const helpers = require("./helpers"); /*********** * Helpers * @@ -22,7 +26,7 @@ const Bridge = require("../bridgestuff/Bridge"); * @param {Logger} logger The Logger instance to log messages to * @param {String} verb Either "joined" or "left" * @param {BridgeMap} bridgeMap Map of existing bridges - * @param {BotAPI} tgBot The Telegram bot to send the messages to + * @param {Telegraf} tgBot The Telegram bot to send the messages to * * @returns {Function} Function which can be given to the 'guildMemberAdd' or 'guildMemberRemove' events of a Discord bot * @@ -54,11 +58,13 @@ function makeJoinLeaveFunc(logger, verb, bridgeMap, tgBot) { try { // Send it - await tgBot.sendMessage({ + await tgBot.telegram.sendMessage( + bridge.telegram.chatId, text, - chat_id: bridge.telegram.chatId, - parse_mode: "HTML" - }); + { + parse_mode: "HTML" + } + ); } catch (err) { logger.error(`[${bridge.name}] Could not notify Telegram about a user that ${verb} Discord`, err); } @@ -75,16 +81,23 @@ function makeJoinLeaveFunc(logger, verb, bridgeMap, tgBot) { * * @param {Logger} logger The Logger instance to log messages to * @param {Discord.Client} dcBot The Discord bot - * @param {BotAPI} tgBot The Telegram bot + * @param {Telegraf} tgBot The Telegram bot * @param {MessageMap} messageMap Map between IDs of messages * @param {BridgeMap} bridgeMap Map of the bridges to use * @param {Settings} settings Settings to use + * @param {String} datadirPath Path to the directory to put data files in */ -function setup(logger, dcBot, tgBot, messageMap, bridgeMap, settings) { +function setup(logger, dcBot, tgBot, messageMap, bridgeMap, settings, datadirPath) { // Create the map of latest message IDs and bridges - const latestDiscordMessageIds = new LatestDiscordMessageIds(logger, "latestDiscordMessageIds.json"); + const latestDiscordMessageIds = new LatestDiscordMessageIds(logger, path.join(datadirPath, "latestDiscordMessageIds.json")); const useNickname = settings.discord.useNickname; + // Make a set to keep track of where the "This is an instance of TediCross..." message has been sent the last minute + const antiInfoSpamSet = new Set(); + + // Set of server IDs. Will be filled when the bot is ready + const knownServerIds = new Set(); + // Listen for users joining the server dcBot.on("guildMemberAdd", makeJoinLeaveFunc(logger, "joined", bridgeMap, tgBot)); @@ -103,20 +116,39 @@ function setup(logger, dcBot, tgBot, messageMap, bridgeMap, settings) { if (message.channel.type === "text" && message.cleanContent === "/chatinfo") { // It is. Give it message.reply( - "serverId: '" + message.guild.id + "'\n" + - "channelId: '" + message.channel.id + "'\n" - ); + "\nchannelId: '" + message.channel.id + "'" + ) + .then(sleepOneMinute) + .then(info => Promise.all([ + info.delete(), + message.delete() + ])) + .catch(helpers.ignoreAlreadyDeletedError); // Don't process the message any further return; } // Get info about the sender - const senderName = (useNickname && message.member ? message.member.displayName : message.author.username) + (settings.telegram.colonAfterSenderName ? ":" : ""); + const senderName = R.compose( + // Make it HTML safe + helpers.escapeHTMLSpecialChars, + // Add a colon if wanted + R.when( + R.always(settings.telegram.colonAfterSenderName), + senderName => senderName + ":" + ), + // Figure out what name to use + R.ifElse( + message => useNickname && !R.isNil(message.member), + R.path(["member", "displayName"]), + R.path(["author", "username"]) + ) + )(message); // Check if the message is from the correct chat const bridges = bridgeMap.fromDiscordChannelId(message.channel.id); - if (bridges !== undefined) { + if (!R.isEmpty(bridges)) { bridges.forEach(async (bridge) => { // Ignore it if this is a telegram-to-discord bridge if (bridge.direction === Bridge.DIRECTION_TELEGRAM_TO_DISCORD) { @@ -133,11 +165,13 @@ function setup(logger, dcBot, tgBot, messageMap, bridgeMap, settings) { ? `${senderName}\n${url}` : `${url}` ; - const tgMessage = await tgBot.sendMessage({ - chat_id: bridge.telegram.chatId, - text: textToSend, - parse_mode: "HTML" - }); + const tgMessage = await tgBot.telegram.sendMessage( + bridge.telegram.chatId, + textToSend, + { + parse_mode: "HTML" + } + ); messageMap.insert(MessageMap.DISCORD_TO_TELEGRAM, bridge, message.id, tgMessage.message_id); } catch (err) { logger.error(`[${bridge.name}] Telegram did not accept an attachment:`, err); @@ -156,12 +190,14 @@ function setup(logger, dcBot, tgBot, messageMap, bridgeMap, settings) { try { // Send it - tgBot.sendMessage({ + tgBot.telegram.sendMessage( + bridge.telegram.chatId, text, - chat_id: bridge.telegram.chatId, - parse_mode: "HTML", - disable_web_page_preview: true - }); + { + parse_mode: "HTML", + disable_web_page_preview: true + } + ); } catch (err) { logger.error(`[${bridge.name}] Telegram did not accept an embed:`, err); } @@ -179,11 +215,13 @@ function setup(logger, dcBot, tgBot, messageMap, bridgeMap, settings) { ? `${senderName}\n${processedMessage}` : processedMessage ; - const tgMessage = await tgBot.sendMessage({ - chat_id: bridge.telegram.chatId, - text: textToSend, - parse_mode: "HTML" - }); + const tgMessage = await tgBot.telegram.sendMessage( + bridge.telegram.chatId, + textToSend, + { + parse_mode: "HTML" + } + ); // Make the mapping so future edits can work messageMap.insert(MessageMap.DISCORD_TO_TELEGRAM, bridge, message.id, tgMessage.message_id); @@ -193,15 +231,22 @@ function setup(logger, dcBot, tgBot, messageMap, bridgeMap, settings) { } } }); - } else if (message.channel.guild === undefined || !bridgeMap.knownDiscordServer(message.channel.guild.id)) { // Check if it is the correct server - // The message is from the wrong chat. Inform the sender that this is a private bot - message.reply( - "This is an instance of a TediCross bot, bridging a chat in Telegram with one in Discord. " - + "If you wish to use TediCross yourself, please download and create an instance. " - + "You may join our Discord server (https://discord.gg/MfzGMzy) " - + "or Telegram group (https://t.me/TediCrossSupport) for help. " - + "See also https://github.com/TediCross/TediCross" - ); + } else if (R.isNil(message.channel.guild) || !knownServerIds.has(message.channel.guild.id)) { // Check if it is the correct server + // The message is from the wrong chat. Inform the sender that this is a private bot, if they have not been informed the last minute + if (!antiInfoSpamSet.has(message.channel.id)) { + antiInfoSpamSet.add(message.channel.id); + + message.reply( + "This is an instance of a TediCross bot, bridging a chat in Telegram with one in Discord. " + + "If you wish to use TediCross yourself, please download and create an instance. " + + "See https://github.com/TediCross/TediCross" + ) + // Delete it again after some time + .then(sleepOneMinute) + .then(message => message.delete()) + .catch(helpers.ignoreAlreadyDeletedError) + .then(() => antiInfoSpamSet.delete(message.channel.id)); + } } }); @@ -212,13 +257,8 @@ function setup(logger, dcBot, tgBot, messageMap, bridgeMap, settings) { return; } - // Ignore it if it's not from a known bridge - const bridges = bridgeMap.fromDiscordChannelId(newMessage.channel.id); - if (bridges === undefined) { - return; - } - - bridges.forEach(async (bridge) => { + // Pass it on to the bridges + bridgeMap.fromDiscordChannelId(newMessage.channel.id).forEach(async (bridge) => { try { // Get the corresponding Telegram message ID const [tgMessageId] = messageMap.getCorresponding(MessageMap.DISCORD_TO_TELEGRAM, bridge, newMessage.id); @@ -234,12 +274,15 @@ function setup(logger, dcBot, tgBot, messageMap, bridgeMap, settings) { ? `${senderName}\n${processedMessage}` : processedMessage ; - await tgBot.editMessageText({ - chat_id: bridge.telegram.chatId, - message_id: tgMessageId, - text: textToSend, - parse_mode: "HTML" - }); + await tgBot.telegram.editMessageText( + bridge.telegram.chatId, + tgMessageId, + null, + textToSend, + { + parse_mode: "HTML" + } + ); } catch (err) { logger.error(`[${bridge.name}] Could not edit Telegram message:`, err); } @@ -248,16 +291,16 @@ function setup(logger, dcBot, tgBot, messageMap, bridgeMap, settings) { // Listen for deleted messages function onMessageDelete(message) { - // Ignore it if it's not from a known bridge - const bridges = bridgeMap.fromDiscordChannelId(message.channel.id); - if (bridges === undefined) { - return; - } - // Check if it is a relayed message const isFromTelegram = message.author.id === dcBot.user.id; - bridges.forEach(async (bridge) => { + // Hand it on to the bridges + bridgeMap.fromDiscordChannelId(message.channel.id).forEach(async (bridge) => { + // Ignore it if cross deletion is disabled + if (!bridge.discord.crossDeleteOnTelegram) { + return; + } + try { // Get the corresponding Telegram message IDs const tgMessageIds = isFromTelegram @@ -267,7 +310,7 @@ function setup(logger, dcBot, tgBot, messageMap, bridgeMap, settings) { // Try to delete them await Promise.all( - tgMessageIds.map((tgMessageId) => tgBot.deleteMessage({chat_id: bridge.telegram.chatId, message_id: tgMessageId})) + tgMessageIds.map((tgMessageId) => tgBot.telegram.deleteMessage(bridge.telegram.chatId, tgMessageId)) ); } catch (err) { logger.error(`[${bridge.name}] Could not delete Telegram message:`, err); @@ -288,10 +331,10 @@ function setup(logger, dcBot, tgBot, messageMap, bridgeMap, settings) { bridgeMap.bridges.forEach(async (bridge) => { try { - await tgBot.sendMessage({ - chat_id: bridge.telegram.chatId, - text: "**TEDICROSS**\nThe discord side of the bot disconnected! Please check the log" - }); + await tgBot.telegram.sendMessage( + bridge.telegram.chatId, + "**TEDICROSS**\nThe discord side of the bot disconnected! Please check the log" + ); } catch (err) { logger.error(`[${bridge.name}] Could not send message to Telegram:`, err); } @@ -352,6 +395,22 @@ function setup(logger, dcBot, tgBot, messageMap, bridgeMap, settings) { // Log the event logger.info(`Discord: ${dcBot.user.username} (${dcBot.user.id})`); + // Get the server IDs from the channels + R.compose( + // Add them to the known server ID set + R.reduce((knownServerIds, serverId) => knownServerIds.add(serverId), knownServerIds), + // Remove the invalid channels + R.filter(R.complement(R.isNil)), + // Extract the server IDs from the channels + R.map(R.path(["guild", "id"])), + // Get the channels + R.map(channelId => dcBot.channels.get(channelId)), + // Get the channel IDs + R.map(R.path(["discord", "channelId"])), + // Get the bridges + R.prop("bridges") + )(bridgeMap); + // Mark the bot as ready resolve(); }); diff --git a/lib/migrateSettingsToYAML.js b/src/migrateSettingsToYAML.js similarity index 100% rename from lib/migrateSettingsToYAML.js rename to src/migrateSettingsToYAML.js diff --git a/lib/settings/DiscordSettings.js b/src/settings/DiscordSettings.js similarity index 81% rename from lib/settings/DiscordSettings.js rename to src/settings/DiscordSettings.js index 66123881..b515a526 100644 --- a/lib/settings/DiscordSettings.js +++ b/src/settings/DiscordSettings.js @@ -49,6 +49,20 @@ class DiscordSettings { * @type {Boolean} */ this.useNickname = settings.useNickname; + + /** + * How much of the original message to show in replies from Telegram + * + * @type {Integer} + */ + this.replyLength = settings.replyLength; + + /** + * How many lines of the original message to show in replies from Telegram + * + * @type {Integer} + */ + this.maxReplyLines = settings.maxReplyLines; } /** @@ -105,9 +119,20 @@ class DiscordSettings { throw new Error("`settings.skipOldMessages` must be a boolean"); } + // Check that `useNickname` is a boolean if (Boolean(settings.useNickname) !== settings.useNickname) { throw new Error("`settings.useNickname` must be a boolean"); } + + // Check that `replyLength` is an integer + if (!Number.isInteger(settings.replyLength) || settings.replyLength <= 0) { + throw new ("`settings.replyLength` must be an integer greater than 0"); + } + + // Check that `maxReplyLines` is an integer + if (!Number.isInteger(settings.maxReplyLines) || settings.maxReplyLines <= 0) { + throw new ("`settings.maxReplyLines` must be an integer greater than 0"); + } } /** diff --git a/lib/settings/Settings.js b/src/settings/Settings.js similarity index 51% rename from lib/settings/Settings.js rename to src/settings/Settings.js index dc504503..c0f2f025 100644 --- a/lib/settings/Settings.js +++ b/src/settings/Settings.js @@ -78,6 +78,16 @@ class Settings { fs.writeFileSync(filepath, notepadFriendlyYaml); } + /** + * Makes a raw settings object from this object + * + * @returns {Object} A plain object with the settings + */ + toObj() { + // Hacky way to turn this into a plain object... + return JSON.parse(JSON.stringify(this)); + } + /** * Validates a raw settings object, checking if it is usable for creating a Settings object * @@ -127,111 +137,51 @@ class Settings { // Make a clone, to not operate directly on the provided object const settings = R.clone(rawSettings); - // Check if the bridge map exists - if (settings.bridgeMap === undefined || settings.bridgeMap.length === 0) { - - // Check if a bridge on the old format should be migrated - const migrate = ( - settings.telegram.chatID !== undefined || - settings.discord.serverID !== undefined || - settings.discord.channelID !== undefined - ); - - if (migrate) { - // Migrate the old settings to the bridge map - settings.bridgeMap = [ - { - name: "Migrated bridge", - telegram: settings.telegram.chatID, - discord: { - guild: settings.discord.serverID, - channel: settings.discord.channelID - } - } - ]; - - // Delete the old properties - delete settings.telegram.chatID; - delete settings.discord.serverID; - delete settings.discord.channelID; + // 2019-02-16: Add the `crossDeleteOnTelegram` option to Discord + for (const bridge of settings.bridges) { + if (bridge.discord.crossDeleteOnTelegram === undefined) { + bridge.discord.crossDeleteOnTelegram = true; } } - // ...and convert `bridgeMap` to just `bridges` - if (settings.bridgeMap !== undefined) { - - // Move it - settings.bridges = settings.bridgeMap; + // 2019-04-21: Add the `displayTelegramReplies` option to Discord + if (R.isNil(settings.discord.displayTelegramReplies)) { + settings.discord.displayTelegramReplies = "embed"; + } - // Delete the old property - delete settings.bridgeMap; + // 2019-04-21: Add the `replyLength` option to Discord + if (R.isNil(settings.discord.replyLength)) { + settings.discord.replyLength = 100; } - // Convert the bridge objects if necessary + // 2019-04-22: Add the `ignoreCommands` option to Telegram for (const bridge of settings.bridges) { - if (!(bridge.telegram instanceof Object)) { - bridge.telegram = { - chatId: bridge.telegram, - relayJoinLeaveMessages: true - }; - bridge.discord = { - serverId: bridge.discord.guild, - channelId: bridge.discord.channel, - relayJoinLeaveMessages: true - }; + if (R.isNil(bridge.telegram.ignoreCommands) && R.isNil(bridge.telegram.relayCommands)) { + bridge.telegram.ignoreCommands = false; } - - // Default to bidirectional bridges - if (bridge.direction === undefined) { - bridge.direction = Bridge.DIRECTION_BOTH; - } - } - - // Get rid of the `telegram.auth` object - if (settings.telegram.auth !== undefined) { - settings.telegram.token = settings.telegram.auth.token; - delete settings.telegram.auth; } - // Get rid of the `discord.auth` object - if (settings.discord.auth !== undefined) { - settings.discord.token = settings.discord.auth.token; - delete settings.discord.auth; + // 2019-05-31: Add the `maxReplyLines` option to Discord + if (R.isNil(settings.discord.maxReplyLines)) { + settings.discord.maxReplyLines = 2; } - // Get rid of the `telegram.commaAfterSenderName` property - if (settings.telegram.commaAfterSenderName !== undefined) { - delete settings.telegram.commaAfterSenderName; - } - - // Split the `relayJoinLeaveMessages` + // 2019-11-08: Turn `ignoreCommands` into `relayCommands`, as `ignoreCommands` accidently did the opposite of what it was supposed to do for (const bridge of settings.bridges) { - // Do the Telegram part - if (bridge.telegram.relayJoinLeaveMessages !== undefined) { - bridge.telegram.relayJoinMessages = bridge.telegram.relayJoinLeaveMessages; - bridge.telegram.relayLeaveMessages = bridge.telegram.relayJoinLeaveMessages; - delete bridge.telegram.relayJoinLeaveMessages; - } - - // Do the Discord part - if (bridge.discord.relayJoinLeaveMessages !== undefined) { - bridge.discord.relayJoinMessages = bridge.discord.relayJoinLeaveMessages; - bridge.discord.relayLeaveMessages = bridge.discord.relayJoinLeaveMessages; - delete bridge.discord.relayJoinLeaveMessages; + if (R.isNil(bridge.telegram.relayCommands)) { + bridge.telegram.relayCommands = bridge.telegram.ignoreCommands; } + delete bridge.telegram.ignoreCommands; } - // Add the `sendUsername` option to the bridges 2018-11-25 + // 2019-11-08: Remove the `serverId` setting from the discord part of the bridges for (const bridge of settings.bridges) { - // Do the Telegram part - if (bridge.telegram.sendUsernames === undefined) { - bridge.telegram.sendUsernames = true; - } + delete bridge.discord.serverId; + } - // Do the Discord part - if (bridge.discord.sendUsernames === undefined) { - bridge.discord.sendUsernames = true; - } + // 2020-02-09: Removed the `displayTelegramReplies` option from Discord + if (!R.isNil(settings.discord.displayTelegramReplies)) { + delete settings.discord.displayTelegramReplies; } // All done! @@ -239,42 +189,18 @@ class Settings { } /** - * Creates a new settings object from file + * Creates a new settings object from a plain object * - * @param {String} filepath Path to the settings file to use. Absolute path is recommended + * @param {Object} obj The object to create a settings object from * - * @returns {Settings} A settings object - * - * @throws If the file does not contain a YAML object, or it cannot be read/written + * @returns {Settings} The settings object */ - static fromFile(filepath) { - // Read the file - let contents = null; - try { - contents = fs.readFileSync(filepath); - } catch (err) { - // Could not read it. Check if it exists - if (err.code === "ENOENT") { - // It didn't. Claim it contained an empty YAML object - contents = jsYaml.safeDump({}); - - // ...and make it so that it actually does - fs.writeFileSync(filepath, contents); - } else { - // Pass the error on - throw err; - } - } - - // Parse the contents as YAML - let settings = jsYaml.safeLoad(contents); - - // Assign defaults and migrate to newest format - settings = Settings.applyDefaults(settings); - settings = Settings.migrate(settings); - - // Create and return the settings object - return new Settings(settings); + static fromObj(obj) { + return R.compose( + R.construct(Settings), + Settings.migrate, + Settings.applyDefaults + )(obj); } /** diff --git a/lib/settings/TelegramSettings.js b/src/settings/TelegramSettings.js similarity index 100% rename from lib/settings/TelegramSettings.js rename to src/settings/TelegramSettings.js diff --git a/src/sleep.js b/src/sleep.js new file mode 100644 index 00000000..ddc185d9 --- /dev/null +++ b/src/sleep.js @@ -0,0 +1,29 @@ +"use strict"; + +const util = require("util"); +const R = require("ramda"); +const moment = require("moment"); + +/** + * Makes a promise which resolves after a set number of milliseconds + * + * @param {Integer} ms Number of milliseconds to slieep + * @param {Any} [arg] Optional argument to resolve the promise to + * + * @returns {Promise} Promise resolving after the given number of ms + */ +const sleep = util.promisify(setTimeout); + +/** + * Makes a promise which resolves after one minute + * + * @param {Any} [arg] Optional argument to resolve the promise to + * + * @returns {Promise} Promise resolving after one minute + */ +const sleepOneMinute = R.partial(sleep, [moment.duration(1, "minute").asMilliseconds()]); + +module.exports = { + sleep, + sleepOneMinute +}; diff --git a/src/telegram2discord/From.js b/src/telegram2discord/From.js new file mode 100644 index 00000000..195a4da4 --- /dev/null +++ b/src/telegram2discord/From.js @@ -0,0 +1,112 @@ +"use strict"; + +/************************** + * Import important stuff * + **************************/ + +const R = require("ramda"); + +/********************** + * The From functions * + **********************/ + +/** + * Information about the sender of a Telegram message + * + * @typedef {Object} From + * @prop {String} firstName First name of the sender + * @prop {String} lastName Last name of the sender + * @param {String} username Username of the sender + */ + +/** + * Creates a new From object + * + * @param {String} firstName First name of the sender + * @param {String} [lastName] Last name of the sender + * @param {String} [username] Username of the sender + * + * @returns {From} The From object + * + * @memberof From + */ +function createFromObj(firstName, lastName, username) { + return { + firstName, + lastName, + username + }; +} + +/** + * Creates a new From object from a Telegram message + * + * @param {Object} message The Telegram message to create the from object from + * + * @returns {From} The from object + */ +function createFromObjFromMessage(message) { + return R.ifElse( + // Check if the `from` object exists + R.compose(R.isNil, R.prop("from")), + // This message is from a channel + message => createFromObj(message.chat.title), + // This message is from a user + R.compose( + createFromObjFromUser, + R.prop("from") + ) + )(message); +} + +/** + * Creates a new From object from a Telegram User object + * + * @param {Object} user The Telegram user object to create the from object from + * + * @returns {From} The From object created from the user + */ +function createFromObjFromUser(user) { + return createFromObj(user.first_name, user.last_name, user.username); +} + +/** + * Creates a From object from a Telegram chat object + * + * @param {Object} chat The Telegram chat object to create the from object from + * + * @returns {From} The From object created from the chat + */ +function createFromObjFromChat(chat) { + return createFromObj(chat.title); +} + +/** + * Makes a display name out of a from object + * + * @param {Boolean} useFirstNameInsteadOfUsername Whether or not to always use the first name instead of the username + * @param {From} from The from object + * + * @returns {String} The display name + * + * @memberof From + */ +function makeDisplayName(useFirstNameInsteadOfUsername, from) { + return R.ifElse( + from => useFirstNameInsteadOfUsername || R.isNil(from.username), + R.prop("firstName"), + R.prop("username") + )(from); +} + +/*************** + * Export them * + ***************/ + +module.exports = { + createFromObj, + createFromObjFromMessage, + createFromObjFromUser, + createFromObjFromChat, + makeDisplayName +}; diff --git a/src/telegram2discord/endwares.js b/src/telegram2discord/endwares.js new file mode 100644 index 00000000..461efdcd --- /dev/null +++ b/src/telegram2discord/endwares.js @@ -0,0 +1,178 @@ +"use strict"; + +/************************** + * Import important stuff * + **************************/ + +const R = require("ramda"); +const From = require("./From"); +const MessageMap = require("../MessageMap"); +const { sleepOneMinute } = require("../sleep"); +const helpers = require("./helpers"); + +/*********** + * Helpers * + ***********/ + +/** + * Makes an endware function be handled by all bridges it applies to. Curried + * + * @param {Function} func The message handler to wrap + * @param {Context} ctx The Telegraf context + * + * @returns {undefined} + * + * @private + */ +const createMessageHandler = R.curry((func, ctx) => { + // Wait for the Discord bot to become ready + ctx.TediCross.dcBot.ready.then(() => + R.forEach(bridge => func(ctx, bridge))(ctx.tediCross.bridges) + ); +}); + +/************************* + * The endware functions * + *************************/ + +/** + * Replies to a message with info about the chat + * + * @param {Object} ctx The Telegraf context + * @param {Object} ctx.tediCross The TediCross object on the context + * @param {Object} ctx.tediCross.message The message to reply to + * @param {Object} ctx.tediCross.message.chat The object of the chat the message is from + * @param {Integer} ctx.tediCross.message.chat.id ID of the chat the message is from + * + * @returns {undefined} + */ +const chatinfo = ctx => { + // Reply with the info + ctx.reply(`chatID: ${ctx.tediCross.message.chat.id}`) + // Wait some time + .then(sleepOneMinute) + // Delete the info and the command + .then(message => Promise.all([ + // Delete the info + helpers.deleteMessage(ctx, message), + // Delete the command + ctx.deleteMessage() + ])) + .catch(helpers.ignoreAlreadyDeletedError); +}; + +/** + * Handles users joining chats + * + * @param {Object} ctx The Telegraf context + * @param {Object} ctx.tediCross.message The Telegram message received + * @param {Object} ctx.tediCross.message The Telegram message received + * @param {Object} ctx.tediCross.message.new_chat_members List of the users who joined the chat + * @param {Object} ctx.TediCross The global TediCross context of the message + * + * @returns {undefined} + */ +const newChatMembers = createMessageHandler((ctx, bridge) => + // Notify Discord about each user + R.forEach(user => { + // Make the text to send + const from = From.createFromObjFromUser(user); + const text = `**${from.firstName} (${R.defaultTo("No username", from.username)})** joined the Telegram side of the chat`; + + // Pass it on + helpers.getDiscordChannel(ctx, bridge) + .send(text); + })(ctx.tediCross.message.new_chat_members) +); + +/** + * Handles users leaving chats + * + * @param {Object} ctx The Telegraf context + * @param {Object} ctx.tediCross The TediCross context of the message + * @param {Object} ctx.tediCross.message The Telegram message received + * @param {Object} ctx.tediCross.message.left_chat_member The user object of the user who left + * @param {Object} ctx.TediCross The global TediCross context of the message + * + * @returns {undefined} + */ +const leftChatMember = createMessageHandler((ctx, bridge) => { + // Make the text to send + const from = From.createFromObjFromUser(ctx.tediCross.message.left_chat_member); + const text = `**${from.firstName} (${R.defaultTo("No username", from.username)})** left the Telegram side of the chat`; + + // Pass it on + helpers.getDiscordChannel(ctx, bridge) + .send(text); +}); + +/** + * Relays a message from Telegram to Discord + * + * @param {Object} ctx The Telegraf context + * @param {Object} ctx.tediCross The TediCross context of the message + * @param {Object} ctx.TediCross The global TediCross context of the message + * + * @returns {undefined} + */ +const relayMessage = ctx => + R.forEach(async prepared => { + // Get the channel to send to + const channel = helpers.getDiscordChannel(ctx, prepared.bridge); + + // Discord doesn't handle messages longer than 2000 characters. Split it up into chunks that big + const messageText = prepared.header + "\n" + prepared.text; + const chunks = R.splitEvery(2000, messageText); + + // Send them in serial, with the attachment first, if there is one + let dcMessage = await channel.send(R.head(chunks), { file: prepared.file }); + if (R.length(chunks) > 1) { + dcMessage = await R.reduce((p, chunk) => p.then(() => channel.send(chunk)), Promise.resolve(), R.tail(chunks)); + } + + // Make the mapping so future edits can work XXX Only the last chunk is considered + ctx.TediCross.messageMap.insert(MessageMap.TELEGRAM_TO_DISCORD, prepared.bridge, ctx.tediCross.messageId, dcMessage.id); + })(ctx.tediCross.prepared); + +/** + * Handles message edits + * + * @param {Object} ctx The Telegraf context + * + * @returns {undefined} + */ +const handleEdits = createMessageHandler(async (ctx, bridge) => { + try { + const tgMessage = ctx.tediCross.message; + + // Find the ID of this message on Discord + const [dcMessageId] = ctx.TediCross.messageMap.getCorresponding(MessageMap.TELEGRAM_TO_DISCORD, bridge, tgMessage.message_id); + + // Get the messages from Discord + const dcMessage = await helpers.getDiscordChannel(ctx, bridge) + .fetchMessage(dcMessageId); + + R.forEach(async prepared => { + // Discord doesn't handle messages longer than 2000 characters. Take only the first 2000 + const messageText = R.slice(0, 2000, prepared.header + "\n" + prepared.text); + + // Send them in serial, with the attachment first, if there is one + await dcMessage.edit(messageText, { attachment: prepared.attachment }); + })(ctx.tediCross.prepared); + } catch (err) { + // Log it + ctx.TediCross.logger.error(`[${bridge.name}] Could not edit Discord message:`, err); + } +}); + +/*************** + * Export them * + ***************/ + +module.exports = { + chatinfo, + newChatMembers, + leftChatMember, + relayMessage, + handleEdits +}; diff --git a/lib/telegram2discord/handleEntities.js b/src/telegram2discord/handleEntities.js similarity index 84% rename from lib/telegram2discord/handleEntities.js rename to src/telegram2discord/handleEntities.js index 3e60d0b1..6b4dcf18 100644 --- a/lib/telegram2discord/handleEntities.js +++ b/src/telegram2discord/handleEntities.js @@ -6,6 +6,19 @@ const R = require("ramda"); +/********************* + * Make some helpers * + *********************/ + +// XXX This is also present in `messageConverter`. Merge somehow +const findFn = (prop, regexp) => + R.compose( + R.not, + R.isEmpty, + R.match(regexp), + R.prop(prop) + ); + /***************************** * Define the entity handler * *****************************/ @@ -51,9 +64,10 @@ function handleEntities(text, entities, dcBot, bridge) { // A mention. Substitute the Discord user ID or Discord role ID if one exists // XXX Telegram considers it a mention if it is a valid Telegram username, not necessarily taken. This means the mention matches the regexp /^@[a-zA-Z0-9_]{5,}$/ // In turn, this means short usernames and roles in Discord, like '@devs', will not be possible to mention - const mentionable = part.substring(1); - const dcUser = dcBot.channels.get(bridge.discord.channelId).members.find("displayName", mentionable); - const dcRole = dcBot.guilds.get(bridge.discord.serverId).roles.find("name", mentionable); + const channel = dcBot.channels.get(bridge.discord.channelId); + const mentionable = new RegExp(`^${part.substring(1)}$`, "i"); + const dcUser = channel.members.find(findFn("displayName", mentionable)); + const dcRole = channel.guild.roles.find(findFn("name", mentionable)); if (!R.isNil(dcUser)) { substitute = `<@${dcUser.id}>`; } else if (!R.isNil(dcRole)) { @@ -94,10 +108,10 @@ function handleEntities(text, entities, dcBot, bridge) { } case "hashtag": { // Possible name of a Discord channel on the same Discord server - const channelName = part.substring(1); + const channelName = new RegExp(`^${part.substring(1)}$`); // Find out if this is a channel on the bridged Discord server - const channel = dcBot.guilds.get(bridge.discord.serverId).channels.find("name", channelName); + const channel = dcBot.channels.get(bridge.discord.channelId).guild.channels.find(findFn("name", channelName)); // Make Discord recognize it as a channel mention if (channel !== null) { @@ -121,7 +135,7 @@ function handleEntities(text, entities, dcBot, bridge) { } // Put the markdown links on the end, if there are any - if (markdownLinks.length > 0) { + if (!R.isEmpty(markdownLinks)) { substitutedText.push("\n\n"); for (let i = 0; i < markdownLinks.length; i++) { // Find out where the corresponding text is diff --git a/src/telegram2discord/helpers.js b/src/telegram2discord/helpers.js new file mode 100644 index 00000000..ad950aa6 --- /dev/null +++ b/src/telegram2discord/helpers.js @@ -0,0 +1,74 @@ +"use strict"; + +/************************** + * Import important stuff * + **************************/ + +const R = require("ramda"); + +/******************** + * Make the helpers * + ********************/ + +/** + * Ignores errors arising from trying to delete an already deleted message. Rethrows other errors + * + * @param {Error} err The error to check + * + * @returns {undefined} + * + * @throws {Error} The error, if it is another type + */ +const ignoreAlreadyDeletedError = R.ifElse( + R.propEq("description", "Bad Request: message to delete not found"), + R.always(undefined), + err => {throw err;} +); + +/** + * Deletes a Telegram message + * + * @param {Context} ctx The Telegraf context to use + * @param {Object} message The message to delete + * + * @returns {Promise} Promise resolving when the message is deleted + */ +const deleteMessage = R.curry((ctx, { chat, message_id }) => + ctx.telegram.deleteMessage( + chat.id, + message_id + )); + +/** + * Gets a Discord channel by ID from a Discord bot + * + * @param {Context} ctx The Telegraf context to use + * @param {Bridge} bridge The bridge to get the channel for + * + * @returns {Discord.Channel} The channel + * + * @throws {Error} If the channel was not found + */ +const getDiscordChannel = R.curry((ctx, { name, discord: { channelId } }) => { + // Get the channel + const channel = ctx.TediCross.dcBot.channels.get(channelId); + + // Verify it exists + if (R.isNil(channel)) { + ctx.TediCross.logger.error(`[${name}] Could not get Discord channel with ID '${channelId}'. Please verify it is correct. Remember the quotes!`); + throw new Error(`Could not find channel with ID '${channelId}'`); + } + + // Return it + return channel; +}); + +/*************** + * Export them * + ***************/ + +module.exports = { + ignoreAlreadyDeletedError, + deleteMessage, + getDiscordChannel +}; diff --git a/lib/telegram2discord/messageConverter.js b/src/telegram2discord/messageConverter.js similarity index 76% rename from lib/telegram2discord/messageConverter.js rename to src/telegram2discord/messageConverter.js index df5374e0..a35d7e24 100644 --- a/lib/telegram2discord/messageConverter.js +++ b/src/telegram2discord/messageConverter.js @@ -10,6 +10,15 @@ const R = require("ramda"); /*********** * Helpers * ***********/ + +// XXX This is also present in `handleEntities`. Merge somehow +const findFn = (prop, regexp) => + R.compose( + R.not, + R.isEmpty, + R.match(regexp), + R.prop(prop) + ); /** * Gets the display name of a user @@ -65,36 +74,34 @@ function getDisplayNameFromUser(user, useFirstNameInsteadOfUsername) { /** * Converts Telegram messages to appropriate from and text * - * @param {Message} message The Telegram message to convert - * @param {BotAPI} tgBot The Telegram bot - * @param {Settings} settings The settings to use - * @param {Discord.Client} dcBot The Discord bot + * @param {Object} ctx The Telegraf context * @param {Bridge} bridge The bridge the message is crossing * * @return {Object} A object containing message information as from, text etc */ -function messageConverter(message, tgBot, settings, dcBot, bridge) { +function messageConverter(ctx, bridge) { + const message = ctx.tediCross.message; + // Convert the text to Discord format - const text = handleEntities(message.text, message.entities, dcBot, bridge); + const text = handleEntities(message.text, message.entities, ctx.TediCross.dcBot, bridge); // Handle for the reply object let reply = null; // Find out who the message is from - let fromName = getDisplayName(message.from, message.chat, settings.telegram.useFirstNameInsteadOfUsername); + let fromName = getDisplayName(message.from, message.chat, ctx.TediCross.settings.telegram.useFirstNameInsteadOfUsername); // Check if it is a reply - if (message.reply_to_message !== undefined) { - + if (!R.isNil(ctx.tediCross.replyTo)) { // Get the name of the user this is a reply to - let originalAuthor = getDisplayName(message.reply_to_message.from, message.chat, settings.telegram.useFirstNameInsteadOfUsername); + let originalAuthor = getDisplayName(message.reply_to_message.from, message.chat, ctx.TediCross.settings.telegram.useFirstNameInsteadOfUsername); let originalText = ""; // Is this a reply to the bot, i.e. to a Discord user? - if (message.reply_to_message.from !== undefined && message.reply_to_message.from.id === tgBot.me.id) { + if (message.reply_to_message.from !== undefined && message.reply_to_message.from.id === ctx.TediCross.me.id) { // Get the name of the Discord user this is a reply to const dcUsername = message.reply_to_message.text.split("\n")[0]; - const dcUser = dcBot.channels.get(bridge.discord.channelId).members.find("displayName", dcUsername); + const dcUser = ctx.TediCross.dcBot.channels.get(bridge.discord.channelId).members.find(findFn("displayName", new RegExp(dcUsername))); originalAuthor = !R.isNil(dcUser) ? `<@${dcUser.id}>` : dcUsername; } @@ -103,14 +110,14 @@ function messageConverter(message, tgBot, settings, dcBot, bridge) { originalText = message.reply_to_message.text; // Is this a reply to the bot, i.e. to a Discord user? - if (message.reply_to_message.from !== undefined && message.reply_to_message.from.id === tgBot.me.id) { + if (message.reply_to_message.from !== undefined && message.reply_to_message.from.id === ctx.TediCross.me.id) { [ , ...originalText] = message.reply_to_message.text.split("\n"); originalText = originalText.join("\n"); } - // Take only the first 100 characters, or up to second newline - originalText = originalText.length > 100 - ? originalText.slice(0, 100) + "…" + // Take only the first few characters, or up to second newline + originalText = originalText.length > ctx.TediCross.settings.discord.replyLength + ? originalText.slice(0, ctx.TediCross.settings.discord.replyLength) + "…" : originalText ; const newlineIndices = [...originalText].reduce((indices, c, i) => { @@ -133,7 +140,7 @@ function messageConverter(message, tgBot, settings, dcBot, bridge) { const forward_from = message.forward_from || message.forward_from_chat; if (forward_from !== undefined) { // Find the name of the user this was forwarded from - const forwardFrom = getDisplayName(forward_from, forward_from, settings.telegram.useFirstNameInsteadOfUsername); + const forwardFrom = getDisplayName(forward_from, forward_from, ctx.TediCross.settings.telegram.useFirstNameInsteadOfUsername); // Add it to the 'from' text fromName = `${forwardFrom} (forwarded by ${fromName})`; diff --git a/src/telegram2discord/middlewares.js b/src/telegram2discord/middlewares.js new file mode 100644 index 00000000..334c9536 --- /dev/null +++ b/src/telegram2discord/middlewares.js @@ -0,0 +1,638 @@ +"use strict"; + +/************************** + * Import important stuff * + **************************/ + +const R = require("ramda"); +const Bridge = require("../bridgestuff/Bridge"); +const From = require("./From"); +const mime = require("mime/lite"); +const request = require("request"); +const handleEntities = require("./handleEntities"); +const Discord = require("discord.js"); +const { sleepOneMinute } = require("../sleep"); +const helpers = require("./helpers"); + +/*********** + * Helpers * + ***********/ + +/** + * Creates a text object from a Telegram message + * + * @param {Object} message The message object + * + * @returns {Object} The text object, or undefined if no text was found + */ +function createTextObjFromMessage(ctx, message) { + return R.cond([ + // Text + [R.has("text"), ({ text, entities }) => ({ + raw: text, + entities: R.defaultTo([], entities) + })], + // Animation, audio, document, photo, video or voice + [R.has("caption"), ({ caption, caption_entities }) => ({ + raw: caption, + entities: R.defaultTo([], caption_entities) + })], + // Stickers have an emoji instead of text + [R.has("sticker"), message => ({ + raw: R.ifElse( + () => ctx.TediCross.settings.telegram.sendEmojiWithStickers, + R.path(["sticker", "emoji"]), + R.always("") + )(message), + entities: [] + })], + // Locations must be turned into an URL + [R.has("location"), ({ location }) => ({ + raw: `https://maps.google.com/maps?q=${location.latitude},${location.longitude}&ll=${location.latitude},${location.longitude}&z=16`, + entities: [] + })], + // Default to undefined + [R.T, R.always({ raw: "", entities: [] })] + ])(message); +} + +/** + * Makes the reply text to show on Discord + * + * @param {Object} replyTo The replyTo object from the tediCross context + * @param {Integer} replyLength How many characters to take from the original + * @param {Integer} maxReplyLines How many lines to cut the reply text after + * + * @returns {String} The reply text to display + */ +const makeReplyText = (replyTo, replyLength, maxReplyLines) => { + const countDoublePipes = R.tryCatch( + str => str.match(/\|\|/g).length, + R.always(0) + ); + + // Make the reply string + return R.compose( + // Add ellipsis if the text was cut + R.ifElse( + R.compose( + R.equals(R.length(replyTo.text.raw)), + R.length + ), + R.identity, + R.concat(R.__, "…") + ), + // Handle spoilers (pairs of "||" in Discord) + R.ifElse( + // If one of a pair of "||" has been removed + quote => R.and( + countDoublePipes(quote, "||") % 2 === 1, + countDoublePipes(replyTo.text.raw) % 2 === 0 + ), + // Add one to the end + R.concat(R.__, "||"), + // Otherwise do nothing + R.identity + ), + // Take only a number of lines + R.join("\n"), + R.slice(0, maxReplyLines), + R.split("\n"), + // Take only a portion of the text + R.slice(0, replyLength), + )(replyTo.text.raw); +}; + +/** + * Makes a discord mention out of a username + * + * @param {String} username The username to make the mention from + * @param {Discord.Client} dcBot The Discord bot to look up the user's ID with + * @param {String} channelId ID of the Discord channel to look up the username in + * + * @returns {String} A Discord mention of the user + */ +function makeDiscordMention(username, dcBot, channelId) { + // Get the name of the Discord user this is a reply to + const dcUser = dcBot.channels.get(channelId).members.find(R.propEq("displayName", username)); + + return R.ifElse( + R.isNil, + R.always(username), + dcUser => `<@${dcUser.id}>` + )(dcUser); +} + +/**************************** + * The middleware functions * + ****************************/ + +/** + * Adds a `tediCross` property to the context + * + * @param {Object} ctx The context to add the property to + * @param {Function} next Function to pass control to next middleware + * + * @returns {undefined} + */ +function addTediCrossObj(ctx, next) { + ctx.tediCross = {}; + next(); +} + +/** + * Adds a message object to the tediCross context. One of the four optional arguments must be present. Requires the tediCross context to work + * + * @param {Object} ctx The Telegraf context + * @param {Object} ctx.tediCross The TediCross object on the context + * @param {Object} [ctx.channelPost] + * @param {Object} [ctx.editedChannelPost] + * @param {Object} [ctx.message] + * @param {Object} [ctx.editedChannelPost] + * @param {Function} next Function to pass control to next middleware + * + * @returns {undefined} + */ +function addMessageObj(ctx, next) { + // Put it on the context + ctx.tediCross.message = R.cond([ + // XXX I tried both R.has and R.hasIn as conditions. Neither worked for some reason + [ctx => !R.isNil(ctx.channelPost), R.prop("channelPost")], + [ctx => !R.isNil(ctx.editedChannelPost), R.prop("editedChannelPost")], + [ctx => !R.isNil(ctx.message), R.prop("message")], + [ctx => !R.isNil(ctx.editedMessage), R.prop("editedMessage")] + ])(ctx); + + next(); +} + +/** + * Adds the message ID as a prop to the tedicross context + * + * @param {Object} ctx The Telegraf context + * @param {Object} ctx.tediCross The Tedicross object on the context + * @param {Object} ctx.tediCross.message The message object being handled + * @param {Function} next Function to pass control to next middleware + * + * @returns {undefined} + */ +function addMessageId(ctx, next) { + ctx.tediCross.messageId = ctx.tediCross.message.message_id; + + next(); +} + +/** + * Adds the bridges to the tediCross object on the context. Requires the tediCross context to work + * + * @param {Object} ctx The context to add the property to + * @param {Object} ctx.tediCross The TediCross object on the context + * @param {Object} ctx.TediCross The global TediCross context + * @param {Object} ctx.TediCross.bridgeMap The bridge map of the application + * @param {Function} next Function to pass control to next middleware + * + * @returns {undefined} + */ +function addBridgesToContext(ctx, next) { + ctx.tediCross.bridges = ctx.TediCross.bridgeMap.fromTelegramChatId(ctx.tediCross.message.chat.id); + next(); +} + +/** + * Removes d2t bridges from the bridge list + * + * @param {Object} ctx The Telegraf context to use + * @param {Object} ctx.tediCross The TediCross object on the context + * @param {Bridge[]} ctx.tediCross.bridges The bridges the message could use + * @param {Function} next Function to pass control to next middleware + * + * @returns {undefined} + */ +function removeD2TBridges(ctx, next) { + ctx.tediCross.bridges = R.reject( + R.propEq("direction", Bridge.DIRECTION_DISCORD_TO_TELEGRAM) + )(ctx.tediCross.bridges); + + next(); +} + +/** + * Removes bridges with the `relayCommands` flag set to false from the bridge list + * + * @param {Object} ctx The Telegraf context to use + * @param {Object} ctx.tediCross The TediCross object on the context + * @param {Bridge[]} ctx.tediCross.bridges The bridges the message could use + * @param {Function} next Function to pass control to next middleware + * + * @returns {undefined} + */ +function removeBridgesIgnoringCommands(ctx, next) { + ctx.tediCross.bridges = R.filter(R.path(["telegram", "relayCommands"]), ctx.tediCross.bridges); + next(); +} + +/** + * Removes bridges with `telegram.relayJoinMessages === false` + * + * @param {Object} ctx The Telegraf context to use + * @param {Object} ctx.tediCross The TediCross object on the context + * @param {Bridge[]} ctx.tediCross.bridges The bridges the message could use + * @param {Function} next Function to pass control to next middleware + * + * @returns {undefined} + */ +function removeBridgesIgnoringJoinMessages(ctx, next) { + ctx.tediCross.bridges = R.filter(R.path(["telegram", "relayJoinMessages"]), ctx.tediCross.bridges); + next(); +} + +/** + * Removes bridges with `telegram.relayLeaveMessages === false` + * + * @param {Object} ctx The Telegraf context to use + * @param {Object} ctx.tediCross The TediCross object on the context + * @param {Bridge[]} ctx.tediCross.bridges The bridges the message could use + * @param {Function} next Function to pass control to next middleware + * + * @returns {undefined} + */ +function removeBridgesIgnoringLeaveMessages(ctx, next) { + ctx.tediCross.bridges = R.filter(R.path(["telegram", "relayLeaveMessages"]), ctx.tediCross.bridges); + next(); +} + +/** + * Replies to the message telling the user this is a private bot if there are no bridges on the tediCross context + * + * @param {Object} ctx The Telegraf context + * @param {Function} ctx.reply The context's reply function + * @param {Function} next Function to pass control to next middleware + * + * @returns {undefined} + */ +function informThisIsPrivateBot(ctx, next) { + R.ifElse( + // If there are no bridges + R.compose( + R.isEmpty, + R.path(["tediCross", "bridges"]) + ), + // Inform the user, if enough time has passed since last time + R.when( + // When there is no timer for the chat in the anti spam map + ctx => R.not(ctx.TediCross.antiInfoSpamSet.has(ctx.message.chat.id)), + // Inform the chat this is an instance of TediCross + ctx => { + // Update the anti spam set + ctx.TediCross.antiInfoSpamSet.add(ctx.message.chat.id); + + // Send the reply + ctx.reply( + "This is an instance of a [TediCross](https://github.com/TediCross/TediCross) bot, " + + "bridging a chat in Telegram with one in Discord. " + + "If you wish to use TediCross yourself, please download and create an instance.", + { + parse_mode: "markdown" + } + ) + .then(msg => + // Delete it again after a while + sleepOneMinute() + .then(() => helpers.deleteMessage(ctx, msg)) + .catch(helpers.ignoreAlreadyDeletedError) + // Remove it from the anti spam set again + .then(() => ctx.TediCross.antiInfoSpamSet.delete(ctx.message.chat.id)) + ); + } + ), + // Otherwise go to next middleware + next + )(ctx); +} + +/** + * Adds a `from` object to the tediCross context + * + * @param {Object} ctx The context to add the property to + * @param {Object} ctx.tediCross The tediCross on the context + * @param {Object} ctx.tediCross.message The message object to create the `from` object from + * @param {Function} next Function to pass control to next middleware + * + * @returns {undefined} + */ +function addFromObj(ctx, next) { + ctx.tediCross.from = From.createFromObjFromMessage(ctx.tediCross.message); + next(); +} + +/** + * Adds a `reply` object to the tediCross context, if the message is a reply + * + * @param {Object} ctx The context to add the property to + * @param {Object} ctx.tediCross The tediCross on the context + * @param {Object} ctx.tediCross.message The message object to create the `reply` object from + * @param {Function} next Function to pass control to next middleware + * + * @returns {undefined} + */ +function addReplyObj(ctx, next) { + const repliedToMessage = ctx.tediCross.message.reply_to_message; + + if (!R.isNil(repliedToMessage)) { + // This is a reply + const isReplyToTediCross = !R.isNil(repliedToMessage.from) && R.equals(repliedToMessage.from.id, ctx.TediCross.me.id); + ctx.tediCross.replyTo = { + isReplyToTediCross, + message: repliedToMessage, + originalFrom: From.createFromObjFromMessage(repliedToMessage), + text: createTextObjFromMessage(ctx, repliedToMessage), + }; + + // Handle replies to TediCross + if (isReplyToTediCross) { + // Get the username of the Discord user who sent this and remove it from the text + const split = R.split("\n", ctx.tediCross.replyTo.text.raw); + ctx.tediCross.replyTo.dcUsername = R.head(split); + ctx.tediCross.replyTo.text.raw = R.join("\n", R.tail(split)); + + // Cut off the first entity (the bold text on the username) and reduce the offset of the rest by the length of the username and the newline + ctx.tediCross.replyTo.text.entities = R.compose( + R.map(entity => R.mergeRight(entity, { offset: entity.offset - ctx.tediCross.replyTo.dcUsername.length - 1 })), + R.tail + )(ctx.tediCross.replyTo.text.entities); + } + + // Turn the original text into "" if there is no text + if (R.isEmpty(ctx.tediCross.replyTo.text.raw)) { + ctx.tediCross.replyTo.text.raw = ""; + } + } + + next(); +} + +/** + * Adds a `forward` object to the tediCross context, if the message is a forward + * + * @param {Object} ctx The context to add the property to + * @param {Object} ctx.tediCross The tediCross on the context + * @param {Object} ctx.tediCross.message The message object to create the `forward` object from + * @param {Function} next Function to pass control to next middleware + * + * @returns {undefined} + */ +function addForwardFrom(ctx, next) { + const msg = ctx.tediCross.message; + + if (!R.isNil(msg.forward_from) || !R.isNil(msg.forward_from_chat)) { + ctx.tediCross.forwardFrom = R.ifElse( + // If there is no `forward_from` prop + R.compose(R.isNil, R.prop("forward_from")), + // Then this is a forward from a chat (channel) + R.compose(From.createFromObjFromChat, R.prop("forward_from_chat")), + // Else it is from a user + R.compose(From.createFromObjFromUser, R.prop("forward_from")) + )(msg); + } + + next(); +} + +/** + * Adds a text object to the tediCross property on the context, if there is text in the message + * + * @param {Object} ctx The context to add the property to + * @param {Object} ctx.tediCross The tediCross on the context + * @param {Object} ctx.tediCross.message The message object to get the text data from + * @param {Function} next Function to pass control to next middleware + * + * @returns {undefined} + */ +function addTextObj(ctx, next) { + const text = createTextObjFromMessage(ctx, ctx.tediCross.message); + + if (!R.isNil(text)) { + ctx.tediCross.text = text; + } + + next(); +} + +/** + * Adds a file object to the tediCross property on the context + * + * @param {Object} ctx The context to add the property to + * @param {Object} ctx.tediCross The tediCross on the context + * @param {Object} ctx.tediCross.message The message object to get the file data from + * @param {Function} next Function to pass control to next middleware + * + * @returns {undefined} + */ +function addFileObj(ctx, next) { + const message = ctx.tediCross.message; + + // Figure out if a file is present + if (!R.isNil(message.audio)) { + // Audio + ctx.tediCross.file = { + type: "audio", + id: message.audio.file_id, + name: message.audio.title + "." + mime.getExtension(message.audio.mime_type) + }; + } else if (!R.isNil(message.document)) { + // Generic file + ctx.tediCross.file = { + type: "document", + id: message.document.file_id, + name: message.document.file_name + }; + } else if (!R.isNil(message.photo)) { + // Photo. It has an array of photos of different sizes. Use the last and biggest + const photo = R.last(message.photo); + ctx.tediCross.file = { + type: "photo", + id: photo.file_id, + name: "photo.jpg" // Telegram will convert it to a jpg no matter which format is orignally sent + }; + } else if (!R.isNil(message.sticker)) { + // Sticker + ctx.tediCross.file = { + type: "sticker", + id: R.ifElse( + R.propEq("is_animated", true), + R.path(["thumb", "file_id"]), + R.prop("file_id") + )(message.sticker), + name: "sticker.webp" + }; + } else if (!R.isNil(message.video)) { + // Video + ctx.tediCross.file = { + type: "video", + id: message.video.file_id, + name: "video" + "." + mime.getExtension(message.video.mime_type), + }; + } else if (!R.isNil(message.voice)) { + // Voice + ctx.tediCross.file = { + type: "voice", + id: message.voice.file_id, + name: "voice" + "." + mime.getExtension(message.voice.mime_type), + }; + } + + next(); +} + +/** + * Adds a file stream to the file object on the tedicross context, if there is one + * + * @param {Object} ctx The context to add the property to + * @param {Object} ctx.tediCross The tediCross on the context + * @param {Function} next Function to pass control to next middleware + * + * @returns {Promise} Promise resolving to nothing when the operation is complete + */ +function addFileStream(ctx, next) { + return Promise.resolve() + .then(() => { + // Get a stream to the file, if one was found + if (!R.isNil(ctx.tediCross.file)) { + return ctx.telegram.getFileLink(ctx.tediCross.file.id) + .then(fileLink => { + ctx.tediCross.file.stream = request(fileLink); + }); + } + }) + .then(next) + .then(R.always(undefined)); +} + +function addPreparedObj(ctx, next) { + // Shorthand for the tediCross context + const tc = ctx.tediCross; + + ctx.tediCross.prepared = R.map( + bridge => { + // Make the header + // WARNING! Butt-ugly code! If you see a nice way to clean this up, please do it + const header = (() => { + // Get the name of the sender of this message + const senderName = From.makeDisplayName(ctx.TediCross.settings.telegram.useFirstNameInsteadOfUsername, tc.from); + + // Get the name of the original sender, if this is a forward + const originalSender = R.isNil(tc.forwardFrom) + ? null + : From.makeDisplayName(ctx.TediCross.settings.telegram.useFirstNameInsteadOfUsername, tc.forwardFrom) + ; + + // Get the name of the replied-to user, if this is a reply + const repliedToName = R.isNil(tc.replyTo) + ? null + : R.ifElse( + R.prop("isReplyToTediCross"), + R.compose( + username => makeDiscordMention(username, ctx.TediCross.dcBot, bridge.discord.channelId), + R.prop("dcUsername") + ), + R.compose( + R.partial(From.makeDisplayName, [ctx.TediCross.settings.telegram.useFirstNameInsteadOfUsername]), + R.prop("originalFrom") + ) + )(tc.replyTo) + ; + + // Build the header + let header = ""; + if (bridge.telegram.sendUsernames) { + if (!R.isNil(tc.forwardFrom)) { + // Forward + header = `**${originalSender}** (forwarded by **${senderName}**)`; + } else if (!R.isNil(tc.replyTo)) { + // Reply + header = `**${senderName}** (in reply to **${repliedToName}**)`; + } else { + // Ordinary message + header = `**${senderName}**`; + } + } else { + if (!R.isNil(tc.forwardFrom)) { + // Forward + header = `(forward from **${originalSender}**)`; + } else if (!R.isNil(tc.replyTo)) { + // Reply + header = `(in reply to **${repliedToName}**)`; + } else { + // Ordinary message + header = ""; + } + } + + return header; + })(); + + // Handle blockquote replies + const replyQuote = R.ifElse( + tc => !R.isNil(tc.replyTo), + R.compose( + R.replace(/^/gm, "> "), + tc => makeReplyText(tc.replyTo, ctx.TediCross.settings.discord.replyLength, ctx.TediCross.settings.discord.maxReplyLines), + ), + R.always(undefined) + )(tc); + + // Handle file + const file = R.ifElse( + R.compose( + R.isNil, + R.prop("file") + ), + R.always(undefined), + tc => new Discord.Attachment(tc.file.stream, tc.file.name) + )(tc); + + // Make the text to send + const text = (() => { + let text = handleEntities(tc.text.raw, tc.text.entities, ctx.TediCross.dcBot, bridge); + + if (!R.isNil(replyQuote)) { + text = replyQuote + "\n" + text; + } + + return text; + })(); + + return { + bridge, + header, + file, + text + }; + } + )(tc.bridges); + + next(); +} + +/*************** + * Export them * + ***************/ + +module.exports = { + addTediCrossObj, + addMessageObj, + addMessageId, + addBridgesToContext, + removeD2TBridges, + removeBridgesIgnoringCommands, + removeBridgesIgnoringJoinMessages, + removeBridgesIgnoringLeaveMessages, + informThisIsPrivateBot, + addFromObj, + addReplyObj, + addForwardFrom, + addTextObj, + addFileObj, + addFileStream, + addPreparedObj +}; diff --git a/src/telegram2discord/setup.js b/src/telegram2discord/setup.js new file mode 100644 index 00000000..2d164a48 --- /dev/null +++ b/src/telegram2discord/setup.js @@ -0,0 +1,110 @@ +"use strict"; + +/************************** + * Import important stuff * + **************************/ + +const R = require("ramda"); +const middlewares = require("./middlewares"); +const endwares = require("./endwares"); + +/*********** + * Helpers * + ***********/ + +/** + * Clears old messages on a tgBot, making sure there are no updates in the queue + * + * @param {Telegraf} tgBot The Telegram bot to clear messages on + * + * @returns {Promise} Promise resolving to nothing when the clearing is done + */ +function clearOldMessages(tgBot, offset = -1) { + const timeout = 0; + const limit = 100; + return tgBot.telegram.getUpdates(timeout, limit, offset) + .then(R.ifElse( + R.isEmpty, + R.always(undefined), + R.compose( + newOffset => clearOldMessages(tgBot, newOffset), + R.add(1), + R.prop("update_id"), + R.last + ) + )); +} + +/********************** + * The setup function * + **********************/ + +/** + * Sets up the receiving of Telegram messages, and relaying them to Discord + * + * @param {Logger} logger The Logger instance to log messages to + * @param {Telegraf} tgBot The Telegram bot + * @param {Discord.Client} dcBot The Discord bot + * @param {MessageMap} messageMap Map between IDs of messages + * @param {BridgeMap} bridgeMap Map of the bridges to use + * @param {Settings} settings The settings to use + */ +function setup(logger, tgBot, dcBot, messageMap, bridgeMap, settings) { + tgBot.ready = Promise.all([ + // Get info about the bot + tgBot.telegram.getMe(), + // Clear old messages, if wanted + settings.telegram.skipOldMessages ? clearOldMessages(tgBot) : Promise.resolve() + ]) + .then(([me]) => { + // Log the bot's info + logger.info(`Telegram: ${me.username} (${me.id})`); + + // Set keeping track of where the "This is an instance of TediCross..." has been sent the last minute + const antiInfoSpamSet = new Set(); + + // Add some global context + tgBot.context.TediCross = { + me, + bridgeMap, + dcBot, + settings, + messageMap, + logger, + antiInfoSpamSet + }; + + // Apply middlewares and endwares + tgBot.use(middlewares.addTediCrossObj); + tgBot.use(middlewares.addMessageObj); + tgBot.use(middlewares.addMessageId); + tgBot.command("chatinfo", endwares.chatinfo); + tgBot.use(middlewares.addBridgesToContext); + tgBot.use(middlewares.informThisIsPrivateBot); + tgBot.use(middlewares.removeD2TBridges); + tgBot.command(middlewares.removeBridgesIgnoringCommands); + tgBot.on("new_chat_members", middlewares.removeBridgesIgnoringJoinMessages); + tgBot.on("left_chat_member", middlewares.removeBridgesIgnoringLeaveMessages); + tgBot.on("new_chat_members", endwares.newChatMembers); + tgBot.on("left_chat_member", endwares.leftChatMember); + tgBot.use(middlewares.addFromObj); + tgBot.use(middlewares.addReplyObj); + tgBot.use(middlewares.addForwardFrom); + tgBot.use(middlewares.addTextObj); + tgBot.use(middlewares.addFileObj); + tgBot.use(middlewares.addFileStream); + tgBot.use(middlewares.addPreparedObj); + + // Apply endwares + tgBot.on(["edited_message", "edited_channel_post"], endwares.handleEdits); + tgBot.use(endwares.relayMessage); + }) + // Start getting updates + .then(() => tgBot.startPolling()); +} + +/***************************** + * Export the setup function * + *****************************/ + +module.exports = setup;