// @ts-check const Ty = require("../../types") const DiscordTypes = require("discord-api-types/v10") const {Readable} = require("stream") const chunk = require("chunk-text") const TurndownService = require("turndown") const domino = require("domino") const assert = require("assert").strict const entities = require("entities") const passthrough = require("../../passthrough") const {sync, db, discord, select, from} = passthrough /** @type {import("../converters/utils")} */ const mxUtils = sync.require("../converters/utils") /** @type {import("../../discord/utils")} */ const dUtils = sync.require("../../discord/utils") /** @type {import("./emoji-sheet")} */ const emojiSheet = sync.require("./emoji-sheet") /** @type {[RegExp, string][]} */ const markdownEscapes = [ [/\\/g, '\\\\'], [/\*/g, '\\*'], [/^-/g, '\\-'], [/^\+ /g, '\\+ '], [/^(=+)/g, '\\$1'], [/^(#{1,6}) /g, '\\$1 '], [/`/g, '\\`'], [/^~~~/g, '\\~~~'], [/\[/g, '\\['], [/\]/g, '\\]'], [/^>/g, '\\>'], [/_/g, '\\_'], [/^(\d+)\. /g, '$1\\. '] /* Strikethrough is deliberately not escaped. Usually when Matrix users type ~~ it's not because they wanted to send ~~, it's because they wanted strikethrough and it didn't work because their client doesn't support it. As bridge developers, we can choose between "messages should look as similar as possible" vs "it was most likely intended to be strikethrough". I went with the latter. Even though the appearance doesn't match, I'd rather it displayed as originally intended for 80% of the readers than for 0%. */ ] const turndownService = new TurndownService({ hr: "----", headingStyle: "atx", preformattedCode: true, codeBlockStyle: "fenced" }) /** * Markdown characters in the HTML content need to be escaped, though take care not to escape the middle of bare links * @param {string} string */ // @ts-ignore bad type from turndown turndownService.escape = function (string) { const escapedWords = string.split(" ").map(word => { if (word.match(/^https?:\/\//)) { return word } else { return markdownEscapes.reduce(function (accumulator, escape) { return accumulator.replace(escape[0], escape[1]) }, word) } }) return escapedWords.join(" ") } turndownService.remove("mx-reply") turndownService.addRule("strikethrough", { filter: ["del", "s"], replacement: function (content) { return "~~" + content + "~~" } }) turndownService.addRule("underline", { filter: ["u"], replacement: function (content) { return "__" + content + "__" } }) turndownService.addRule("blockquote", { filter: "blockquote", replacement: function (content) { content = content.replace(/^\n+|\n+$/g, "") content = content.replace(/^/gm, "> ") return content } }) turndownService.addRule("spoiler", { filter: function (node, options) { return node.tagName === "SPAN" && node.hasAttribute("data-mx-spoiler") }, replacement: function (content, node) { if (node.getAttribute("data-mx-spoiler")) { // escape parentheses so it can't become a link return `\\(${node.getAttribute("data-mx-spoiler")}\\) ||${content}||` } return `||${content}||` } }) turndownService.addRule("inlineLink", { filter: function (node, options) { return ( node.nodeName === "A" && node.getAttribute("href") ) }, replacement: function (content, node) { if (node.getAttribute("data-user-id")) { const user_id = node.getAttribute("data-user-id") const row = select("sim_proxy", ["displayname", "proxy_owner_id"], {user_id}).get() if (row) { return `**@${row.displayname}** (<@${row.proxy_owner_id}>)` } else { return `<@${user_id}>` } } if (node.getAttribute("data-message-id")) return `https://discord.com/channels/${node.getAttribute("data-guild-id")}/${node.getAttribute("data-channel-id")}/${node.getAttribute("data-message-id")}` if (node.getAttribute("data-channel-id")) return `<#${node.getAttribute("data-channel-id")}>` const href = node.getAttribute("href") content = content.replace(/ @.*/, "") if (href === content) return href if (href.startsWith("https://matrix.to/#/@") && content[0] !== "@") content = "@" + content return "[" + content + "](" + href + ")" } }) turndownService.addRule("listItem", { filter: "li", replacement: function (content, node, options) { content = content .replace(/^\n+/, "") // remove leading newlines .replace(/\n+$/, "\n") // replace trailing newlines with just a single one .replace(/\n/gm, "\n ") // indent var prefix = options.bulletListMarker + " " var parent = node.parentNode if (parent.nodeName === "OL") { var start = parent.getAttribute("start") var index = Array.prototype.indexOf.call(parent.children, node) prefix = (start ? Number(start) + index : index + 1) + ". " } return prefix + content + (node.nextSibling && !/\n$/.test(content) ? "\n" : "") } }) /** @type {string[]} SPRITE SHEET EMOJIS FEATURE: mxc urls for the currently processing message */ let endOfMessageEmojis = [] turndownService.addRule("emoji", { filter: function (node, options) { if (node.nodeName !== "IMG" || !node.hasAttribute("data-mx-emoticon") || !node.getAttribute("src") || !node.getAttribute("title")) return false return true }, replacement: function (content, node) { const mxcUrl = node.getAttribute("src") const guessedName = node.getAttribute("title").replace(/^:|:$/g, "") return convertEmoji(mxcUrl, guessedName, true, true) } }) turndownService.addRule("fencedCodeBlock", { filter: function (node, options) { return ( options.codeBlockStyle === "fenced" && node.nodeName === "PRE" && node.firstChild && node.firstChild.nodeName === "CODE" ) }, replacement: function (content, node, options) { const className = node.firstChild.getAttribute("class") || "" const language = (className.match(/language-(\S+)/) || [null, ""])[1] const code = node.firstChild const visibleCode = getCodeContent(code) var fence = "```" return ( fence + language + "\n" + visibleCode + "\n" + fence ) } }) /** @param {{ childNodes: Node[]; }} preCode the directly inside the
 */
function getCodeContent(preCode) {
	return preCode.childNodes.map(c => c.nodeName === "BR" ? "\n" : c.textContent).join("").replace(/\n*$/g, "")
}

/**
 * @param {string | null} mxcUrl
 * @param {string | null} nameForGuess without colons
 * @param {boolean} allowSpriteSheetIndicator
 * @param {boolean} allowLink
 * @returns {string} discord markdown that represents the custom emoji in some form
 */
function convertEmoji(mxcUrl, nameForGuess, allowSpriteSheetIndicator, allowLink) {
	// Get the known emoji from the database.
	let row
	if (mxcUrl) row = select("emoji", ["emoji_id", "name", "animated"], {mxc_url: mxcUrl}).get()
	// Now we have to search all servers to see if we're able to send this emoji.
	if (row) {
		const found = [...discord.guilds.values()].find(g => g.emojis.find(e => e.id === row.id))
		if (!found) row = null
	}
	// Or, if we don't have an emoji right now, we search for the name instead.
	if (!row && nameForGuess) {
		const nameForGuessLower = nameForGuess.toLowerCase()
		for (const guild of discord.guilds.values()) {
			/** @type {{name: string, id: string, animated: number}[]} */
			// @ts-ignore
			const emojis = guild.emojis
			const found = emojis.find(e => e.id === row?.id || e.name?.toLowerCase() === nameForGuessLower)
			if (found) {
				row = {
					animated: found.animated,
					emoji_id: found.id,
					name: found.name
				}
				break
			}
		}
	}
	if (row) {
		// We know an emoji, and we can use it
		const animatedChar = row.animated ? "a" : ""
		return `<${animatedChar}:${row.name}:${row.emoji_id}>`
	} else if (allowSpriteSheetIndicator && mxcUrl && endOfMessageEmojis.includes(mxcUrl)) {
		// We can't locate or use a suitable emoji. After control returns, it will rewind over this, delete this section, and upload the emojis as a sprite sheet.
		return `<::>`
	} else if (allowLink && mxcUrl && nameForGuess) {
		// We prefer not to upload this as a sprite sheet because the emoji is not at the end of the message, it is in the middle.
		return `[:${nameForGuess}:](${mxUtils.getPublicUrlForMxc(mxcUrl)})`
	} else if (nameForGuess) {
		return `:${nameForGuess}:`
	} else {
		return ""
	}
}

/**
 * @param {string} roomID
 * @param {string} mxid
 * @returns {Promise<{displayname?: string?, avatar_url?: string?}>}
 */
async function getMemberFromCacheOrHomeserver(roomID, mxid, api) {
	const row = select("member_cache", ["displayname", "avatar_url"], {room_id: roomID, mxid}).get()
	if (row) return row
	return api.getStateEvent(roomID, "m.room.member", mxid).then(event => {
		db.prepare("REPLACE INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?)").run(roomID, mxid, event?.displayname || null, event?.avatar_url || null)
		return event
	}).catch(() => {
		return {displayname: null, avatar_url: null}
	})
}

/**
 * Splits a display name into one chunk containing <=80 characters (80 being how many characters Discord allows for the name of a webhook),
 * and another chunk containing the rest of the characters. Splits on whitespace if possible.
 * These chunks, respectively, go in the display name, and at the top of the message.
 * If the second part isn't empty, it'll also contain boldening markdown and a line break at the end, so that regardless of its value it
 * can be prepended to the message content as-is.
 * @summary Splits too-long Matrix names into a display name chunk and a message content chunk.
 * @param  {string} displayName - The Matrix side display name to chop up.
 * @returns {[string, string]} [shortened display name, display name runoff]
 */
function splitDisplayName(displayName) {
	/** @type {string[]} */
	let displayNameChunks = chunk(displayName, 80)

	if (displayNameChunks.length === 1) {
		return [displayName, ""]
	} else {
		const displayNamePreRunoff = displayNameChunks[0]
		// displayNameRunoff is a slice of the original rather than a concatenation of the rest of the chunks in order to preserve whatever whitespace it was broken on.
		const displayNameRunoff = `**${displayName.slice(displayNamePreRunoff.length + 1)}**\n`

		return [displayNamePreRunoff, displayNameRunoff]
	}
}

/**
 * Convert a Matrix user ID into a Discord user ID for mentioning, where if the user is a PK proxy, it will mention the proxy owner.
 * @param {string} mxid
 */
function getUserOrProxyOwnerID(mxid) {
	const row = from("sim").join("sim_proxy", "user_id", "left").select("user_id", "proxy_owner_id").where({mxid}).get()
	if (!row) return null
	return row.proxy_owner_id || row.user_id
}

/**
 * At the time of this executing, we know what the end of message emojis are, and we know that at least one of them is unknown.
 * This function will strip them from the content and generate the correct pending file of the sprite sheet.
 * @param {string} content
 * @param {{id: string, name: string}[]} attachments
 * @param {({name: string, url: string} | {name: string, url: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} pendingFiles
 * @param {(mxc: string) => Promise} mxcDownloader function that will download the mxc URLs and convert to uncompressed PNG data. use `getAndConvertEmoji` or a mock.
 */
async function uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles, mxcDownloader) {
	if (!content.includes("<::>")) return content // No unknown emojis, nothing to do
	// Remove known and unknown emojis from the end of the message
	const r = /\s*$/
	while (content.match(r)) {
		content = content.replace(r, "")
	}
	// Create a sprite sheet of known and unknown emojis from the end of the message
	const buffer = await emojiSheet.compositeMatrixEmojis(endOfMessageEmojis, mxcDownloader)
	// Attach it
	const name = "emojis.png"
	attachments.push({id: String(attachments.length), name})
	pendingFiles.push({name, buffer})
	return content
}

/**
 * @param {string} input
 * @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API
 */
async function handleRoomOrMessageLinks(input, di) {
	let offset = 0
	for (const match of [...input.matchAll(/("?https:\/\/matrix.to\/#\/(![^"/, ?)]+)(?:\/(\$[^"/ ?)]+))?(?:\?[^",:!? )]*)?)(">|[, )]|$)/g)]) {
		assert(typeof match.index === "number")
		const [_, attributeValue, roomID, eventID, endMarker] = match
		let result

		const resultType = endMarker === '">' ? "html" : "plain"
		const MAKE_RESULT = {
			ROOM_LINK: {
				html: channelID => `${attributeValue}" data-channel-id="${channelID}">`,
				plain: channelID => `<#${channelID}>${endMarker}`
			},
			MESSAGE_LINK: {
				html: (guildID, channelID, messageID) => `${attributeValue}" data-channel-id="${channelID}" data-guild-id="${guildID}" data-message-id="${messageID}">`,
				plain: (guildID, channelID, messageID) => `https://discord.com/channels/${guildID}/${channelID}/${messageID}${endMarker}`
			}
		}

		// Don't process links that are part of the reply fallback, they'll be removed entirely by turndown
		if (input.slice(match.index + match[0].length + offset).startsWith("In reply to")) continue

		const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get()
		if (!channelID) continue
		if (!eventID) {
			// 1: It's a room link, so <#link> to the channel
			result = MAKE_RESULT.ROOM_LINK[resultType](channelID)
		} else {
			// Linking to a particular event with a discord.com/channels/guildID/channelID/messageID link
			// Need to know the guildID and messageID
			const guildID = discord.channels.get(channelID)?.["guild_id"]
			if (!guildID) continue
			const messageID = select("event_message", "message_id", {event_id: eventID}).pluck().get()
			if (messageID) {
				// 2: Linking to a known event
				result = MAKE_RESULT.MESSAGE_LINK[resultType](guildID, channelID, messageID)
			} else {
				// 3: Linking to an unknown event that OOYE didn't originally bridge - we can guess messageID from the timestamp
				let originalEvent
				try {
					originalEvent = await di.api.getEvent(roomID, eventID)
				} catch (e) {
					continue // Our homeserver doesn't know about the event, so can't resolve it to a Discord link
				}
				const guessedMessageID = dUtils.timestampToSnowflakeInexact(originalEvent.origin_server_ts)
				result = MAKE_RESULT.MESSAGE_LINK[resultType](guildID, channelID, guessedMessageID)
			}
		}

		input = input.slice(0, match.index + offset) + result + input.slice(match.index + match[0].length + offset)
		offset += result.length - match[0].length
	}

	return input
}

/**
 * @param {string} content
 * @param {DiscordTypes.APIGuild} guild
 * @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, fetch: import("node-fetch")["default"]}} di
 */
async function checkWrittenMentions(content, guild, di) {
	let writtenMentionMatch = content.match(/(?:^|[^"[<>/A-Za-z0-9])@([A-Za-z][A-Za-z0-9._\[\]\(\)-]+):?/d) // /d flag for indices requires node.js 16+
	if (writtenMentionMatch) {
		const results = await di.snow.guild.searchGuildMembers(guild.id, {query: writtenMentionMatch[1]})
		if (results[0]) {
			assert(results[0].user)
			return {
				// @ts-ignore - typescript doesn't know about indices yet
				content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `<@${results[0].user.id}>` + content.slice(writtenMentionMatch.indices[1][1]),
				ensureJoined: results[0].user
			}
		}
	}
}

/**
 * @param {Element} node
 * @param {string[]} tagNames allcaps tag names
 * @returns {any | undefined} the node you were checking for, or undefined
 */
function nodeIsChildOf(node, tagNames) {
	// @ts-ignore
	for (; node; node = node.parentNode) if (tagNames.includes(node.tagName)) return node
}

const attachmentEmojis = new Map([
	["m.image", "🖼️"],
	["m.video", "🎞️"],
	["m.audio", "🎶"],
	["m.file", "📄"]
])

/**
 * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker | Ty.Event.Outer_M_Room_Message_Encrypted_File} event
 * @param {import("discord-api-types/v10").APIGuild} guild
 * @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, fetch: import("node-fetch")["default"], mxcDownloader: (mxc: string) => Promise}} di simple-as-nails dependency injection for the matrix API
 */
async function eventToMessage(event, guild, di) {
	/** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]})[]} */
	let messages = []

	let displayName = event.sender
	let avatarURL = undefined
	/** @type {string[]} */
	let messageIDsToEdit = []
	let replyLine = ""
	// Extract a basic display name from the sender
	const match = event.sender.match(/^@(.*?):/)
	if (match) displayName = match[1]
	// Try to extract an accurate display name and avatar URL from the member event
	const member = await getMemberFromCacheOrHomeserver(event.room_id, event.sender, di?.api)
	if (member.displayname) displayName = member.displayname
	if (member.avatar_url) avatarURL = mxUtils.getPublicUrlForMxc(member.avatar_url) || undefined
	// If the display name is too long to be put into the webhook (80 characters is the maximum),
	// put the excess characters into displayNameRunoff, later to be put at the top of the message
	let [displayNameShortened, displayNameRunoff] = splitDisplayName(displayName)
	// If the message type is m.emote, the full name is already included at the start of the message, so remove any runoff
	if (event.type === "m.room.message" && event.content.msgtype === "m.emote") {
		displayNameRunoff = ""
	}

	let content = event.content.body // ultimate fallback
	const attachments = []
	/** @type {({name: string, url: string} | {name: string, url: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} */
	const pendingFiles = []
	/** @type {DiscordTypes.APIUser[]} */
	const ensureJoined = []

	// Convert content depending on what the message is
	if (event.type === "m.room.message" && (event.content.msgtype === "m.text" || event.content.msgtype === "m.emote")) {
		// Handling edits. If the edit was an edit of a reply, edits do not include the reply reference, so we need to fetch up to 2 more events.
		// this event ---is an edit of--> original event ---is a reply to--> past event
		await (async () => {
			// Check if there is an edit
			const relatesTo = event.content["m.relates_to"]
			if (!event.content["m.new_content"] || !relatesTo || relatesTo.rel_type !== "m.replace") return
			// Check if we have a pointer to what was edited
			const originalEventId = relatesTo.event_id
			if (!originalEventId) return
			messageIDsToEdit = select("event_message", "message_id", {event_id: originalEventId}, "ORDER BY part").pluck().all()
			if (!messageIDsToEdit.length) return

			// Ok, it's an edit.
			event.content = event.content["m.new_content"]

			// Is it editing a reply? We need special handling if it is.
			// Get the original event, then check if it was a reply
			const originalEvent = await di.api.getEvent(event.room_id, originalEventId)
			if (!originalEvent) return
			const repliedToEventId = originalEvent.content["m.relates_to"]?.["m.in_reply_to"]?.event_id
			if (!repliedToEventId) return

			// After all that, it's an edit of a reply.
			// We'll be sneaky and prepare the message data so that the next steps can handle it just like original messages.
			Object.assign(event.content, {
				"m.relates_to": {
					"m.in_reply_to": {
						event_id: repliedToEventId
					}
				}
			})
		})()

		// Handling replies. We'll look up the data of the replied-to event from the Matrix homeserver.
		// Note that an  element is not guaranteed because this might be m.new_content.
		await (async () => {
			const repliedToEventId = event.content["m.relates_to"]?.["m.in_reply_to"]?.event_id
			if (!repliedToEventId) return
			let repliedToEvent
			try {
				repliedToEvent = await di.api.getEvent(event.room_id, repliedToEventId)
			} catch (e) {
				// Original event isn't on our homeserver, so we'll *partially* trust the client's reply fallback.
				// We'll trust the fallback's quoted content and put it in the reply preview, but we won't trust the authorship info on it.

				// (But if the fallback's quoted content doesn't exist, we give up. There's nothing for us to quote.)
				if (event.content["format"] !== "org.matrix.custom.html" || typeof event.content["formatted_body"] !== "string") {
					const lines = event.content.body.split("\n")
					let stage = 0
					for (let i = 0; i < lines.length; i++) {
						if (stage >= 0 && lines[i][0] === ">") stage = 1
						if (stage >= 1 && lines[i].trim() === "") stage = 2
						if (stage === 2 && lines[i].trim() !== "") {
							event.content.body = lines.slice(i).join("\n")
							break
						}
					}
					return
				}
				const mxReply = event.content["formatted_body"]
				const quoted = mxReply.match(/^
.*?In reply to.*?
(.*)<\/blockquote><\/mx-reply>/)?.[1] if (!quoted) return const contentPreviewChunks = chunk( entities.decodeHTML5Strict( // Remove entities like & " quoted.replace(/^\s*
.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards .replace(/(?:\n|
)+/g, " ") // Should all be on one line .replace(/]*data-mx-spoiler\b[^>]*>.*?<\/span>/g, "[spoiler]") // Good enough method of removing spoiler content. (I don't want to break out the HTML parser unless I have to.) .replace(/<[^>]+>/g, "") // Completely strip all HTML tags and formatting. ), 50) replyLine = "> " + contentPreviewChunks[0] if (contentPreviewChunks.length > 1) replyLine = replyLine.replace(/[,.']$/, "") + "..." replyLine += "\n" return } // @ts-ignore const autoEmoji = new Map(select("auto_emoji", ["name", "emoji_id"], {}, "WHERE name = 'L1' OR name = 'L2'").raw().all()) replyLine = `<:L1:${autoEmoji.get("L1")}><:L2:${autoEmoji.get("L2")}>` const row = from("event_message").join("message_channel", "message_id").select("channel_id", "message_id").where({event_id: repliedToEventId}).and("ORDER BY part").get() if (row) { replyLine += `https://discord.com/channels/${guild.id}/${row.channel_id}/${row.message_id} ` } const sender = repliedToEvent.sender const authorID = getUserOrProxyOwnerID(sender) if (authorID) { replyLine += `<@${authorID}>` } else { let senderName = select("member_cache", "displayname", {mxid: sender}).pluck().get() if (!senderName) { const match = sender.match(/@([^:]*)/) assert(match) senderName = match[1] } replyLine += `Ⓜ️**${senderName}**` } // If the event has been edited, the homeserver will include the relation in `unsigned`. if (repliedToEvent.unsigned?.["m.relations"]?.["m.replace"]?.content?.["m.new_content"]) { repliedToEvent = repliedToEvent.unsigned["m.relations"]["m.replace"] // Note: this changes which event_id is in repliedToEvent. repliedToEvent.content = repliedToEvent.content["m.new_content"] } let contentPreview const fileReplyContentAlternative = attachmentEmojis.get(repliedToEvent.content.msgtype) if (fileReplyContentAlternative) { contentPreview = " " + fileReplyContentAlternative } else if (repliedToEvent.unsigned?.redacted_because) { contentPreview = " (in reply to a deleted message)" } else { // Generate a reply preview for a standard message /** @type {string} */ let repliedToContent = repliedToEvent.content.formatted_body || repliedToEvent.content.body repliedToContent = repliedToContent.replace(/.*<\/mx-reply>/s, "") // Remove everything before replies, so just use the actual message body repliedToContent = repliedToContent.replace(/^\s*
.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards repliedToContent = repliedToContent.replace(/(?:\n|
)+/g, " ") // Should all be on one line repliedToContent = repliedToContent.replace(/]*data-mx-spoiler\b[^>]*>.*?<\/span>/g, "[spoiler]") // Good enough method of removing spoiler content. (I don't want to break out the HTML parser unless I have to.) repliedToContent = repliedToContent.replace(/]*)>/g, (_, att) => { // Convert Matrix emoji images into Discord emoji markdown const mxcUrlMatch = att.match(/\bsrc="(mxc:\/\/[^"]+)"/) const titleTextMatch = att.match(/\btitle=":?([^:"]+)/) return convertEmoji(mxcUrlMatch?.[1], titleTextMatch?.[1], false, false) }) repliedToContent = repliedToContent.replace(/<[^:>][^>]*>/g, "") // Completely strip all HTML tags and formatting. repliedToContent = entities.decodeHTML5Strict(repliedToContent) // Remove entities like & " const contentPreviewChunks = chunk(repliedToContent, 50) if (contentPreviewChunks.length) { contentPreview = ":\n> " + contentPreviewChunks[0] if (contentPreviewChunks.length > 1) contentPreview = contentPreview.replace(/[,.']$/, "") + "..." } else { console.log("Unable to generate reply preview for this replied-to event because we stripped all of it:", repliedToEvent) contentPreview = "" } } replyLine = `> ${replyLine}${contentPreview}\n` })() if (event.content.format === "org.matrix.custom.html" && event.content.formatted_body) { let input = event.content.formatted_body if (event.content.msgtype === "m.emote") { input = `* ${displayName} ${input}` } // Handling mentions of Discord users input = input.replace(/("https:\/\/matrix.to\/#\/((?:@|%40)[^"]+)")>/g, (whole, attributeValue, mxid) => { mxid = decodeURIComponent(mxid) if (mxUtils.eventSenderIsFromDiscord(mxid)) { // Handle mention of an OOYE sim user by their mxid const id = select("sim", "user_id", {mxid}).pluck().get() if (!id) return whole return `${attributeValue} data-user-id="${id}">` } else { // Handle mention of a Matrix user by their mxid // Check if this Matrix user is actually the sim user from another old bridge in the room? const match = mxid.match(/[^:]*discord[^:]*_([0-9]{6,}):/) // try to match @_discord_123456, @_discordpuppet_123456, etc. if (match) return `${attributeValue} data-user-id="${match[1]}">` // Nope, just a real Matrix user. return whole } }) // Handling mentions of rooms and room-messages input = await handleRoomOrMessageLinks(input, di) // Stripping colons after mentions input = input.replace(/( data-user-id.*?<\/a>):?/g, "$1") input = input.replace(/("https:\/\/matrix.to.*?<\/a>):?/g, "$1") // Element adds a bunch of
before
but doesn't render them. I can't figure out how this even works in the browser, so let's just delete those. input = input.replace(/(?:\n|
\s*)*<\/blockquote>/g, "
") // The matrix spec hasn't decided whether \n counts as a newline or not, but I'm going to count it, because if it's in the data it's there for a reason. // But I should not count it if it's between block elements. input = input.replace(/(<\/?([^ >]+)[^>]*>)?\n(<\/?([^ >]+)[^>]*>)?/g, (whole, beforeContext, beforeTag, afterContext, afterTag) => { // console.error(beforeContext, beforeTag, afterContext, afterTag) if (typeof beforeTag !== "string" && typeof afterTag !== "string") { return "
" } beforeContext = beforeContext || "" beforeTag = beforeTag || "" afterContext = afterContext || "" afterTag = afterTag || "" if (!mxUtils.BLOCK_ELEMENTS.includes(beforeTag.toUpperCase()) && !mxUtils.BLOCK_ELEMENTS.includes(afterTag.toUpperCase())) { return beforeContext + "
" + afterContext } else { return whole } }) // Note: Element's renderers on Web and Android currently collapse whitespace, like the browser does. Turndown also collapses whitespace which is good for me. // If later I'm using a client that doesn't collapse whitespace and I want turndown to follow suit, uncomment the following line of code, and it Just Works: // input = input.replace(/ /g, " ") // There is also a corresponding test to uncomment, named "event2message: whitespace is retained" // Handling written @mentions: we need to look for candidate Discord members to join to the room // This shouldn't apply to code blocks, links, or inside attributes. So editing the HTML tree instead of regular expressions is a sensible choice here. // We're using the domino parser because Turndown uses the same and can reuse this tree. const doc = domino.createDocument( // DOM parsers arrange elements in the and . Wrapping in a custom element ensures elements are reliably arranged in a single element. '' + input + '' ); const root = doc.getElementById("turndown-root"); async function forEachNode(node) { for (; node; node = node.nextSibling) { // Check written mentions if (node.nodeType === 3 && node.nodeValue.includes("@") && !nodeIsChildOf(node, ["A", "CODE", "PRE"])) { const result = await checkWrittenMentions(node.nodeValue, guild, di) if (result) { node.nodeValue = result.content ensureJoined.push(result.ensureJoined) } } // Check for incompatible backticks in code blocks let preNode if (node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"]))) { if (preNode.firstChild?.nodeName === "CODE") { const ext = (preNode.firstChild.className.match(/language-(\S+)/) || [null, "txt"])[1] const filename = `inline_code.${ext}` // Build the replacement node const replacementCode = doc.createElement("code") replacementCode.textContent = `[${filename}]` // Build its containing node const replacement = doc.createElement("span") replacement.appendChild(doc.createTextNode(" ")) replacement.appendChild(replacementCode) replacement.appendChild(doc.createTextNode(" ")) // Replace the code block with the preNode.replaceWith(replacement) // Upload the code as an attachment const content = getCodeContent(preNode.firstChild) attachments.push({id: String(attachments.length), filename}) pendingFiles.push({name: filename, buffer: Buffer.from(content, "utf8")}) } } await forEachNode(node.firstChild) } } await forEachNode(root) // SPRITE SHEET EMOJIS FEATURE: Emojis at the end of the message that we don't know about will be reuploaded as a sprite sheet. // First we need to determine which emojis are at the end. endOfMessageEmojis = [] let match let last = input.length while ((match = input.slice(0, last).match(/]*>\s*$/))) { if (!match[0].includes("data-mx-emoticon")) break const mxcUrl = match[0].match(/\bsrc="(mxc:\/\/[^"]+)"/) if (mxcUrl) endOfMessageEmojis.unshift(mxcUrl[1]) assert(typeof match.index === "number", "Your JavaScript implementation does not comply with TC39: https://tc39.es/ecma262/multipage/text-processing.html#sec-regexpbuiltinexec") last = match.index } // @ts-ignore bad type from turndown content = turndownService.turndown(root) // Put < > around any surviving matrix.to links to hide the URL previews content = content.replace(/\bhttps?:\/\/matrix\.to\/[^ )]*/, "<$&>") // It's designed for commonmark, we need to replace the space-space-newline with just newline content = content.replace(/ \n/g, "\n") // If there's a blockquote at the start of the message body and this message is a reply, they should be visually separated if (replyLine && content.startsWith("> ")) content = "\n" + content // SPRITE SHEET EMOJIS FEATURE: content = await uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles, di?.mxcDownloader) } else { // Looks like we're using the plaintext body! content = event.content.body if (event.content.msgtype === "m.emote") { content = `* ${displayName} ${content}` } content = await handleRoomOrMessageLinks(content, di) // Replace matrix.to links with discord.com equivalents where possible content = content.replace(/\bhttps?:\/\/matrix\.to\/[^ )]*/, "<$&>") // Put < > around any surviving matrix.to links to hide the URL previews const result = await checkWrittenMentions(content, guild, di) if (result) { content = result.content ensureJoined.push(result.ensureJoined) } // Markdown needs to be escaped, though take care not to escape the middle of links // @ts-ignore bad type from turndown content = turndownService.escape(content) } } else if (event.type === "m.room.message" && (event.content.msgtype === "m.file" || event.content.msgtype === "m.video" || event.content.msgtype === "m.audio" || event.content.msgtype === "m.image")) { content = "" const filename = event.content.filename || event.content.body // A written `event.content.body` will be bridged to Discord's image `description` which is like alt text. // Bridging as description rather than message content in order to match Matrix clients (Element, Neochat) which treat this as alt text or title text. const description = (event.content.body !== event.content.filename && event.content.filename && event.content.body) || undefined if ("url" in event.content) { // Unencrypted const url = mxUtils.getPublicUrlForMxc(event.content.url) assert(url) attachments.push({id: "0", description, filename}) pendingFiles.push({name: filename, url}) } else { // Encrypted const url = mxUtils.getPublicUrlForMxc(event.content.file.url) assert(url) assert.equal(event.content.file.key.alg, "A256CTR") attachments.push({id: "0", description, filename}) pendingFiles.push({name: filename, url, key: event.content.file.key.k, iv: event.content.file.iv}) } } else if (event.type === "m.sticker") { content = "" const url = mxUtils.getPublicUrlForMxc(event.content.url) assert(url) let filename = event.content.body if (event.type === "m.sticker") { let mimetype if (event.content.info?.mimetype?.includes("/")) { mimetype = event.content.info.mimetype } else { const res = await di.fetch(url, {method: "HEAD"}) if (res.status === 200) { mimetype = res.headers.get("content-type") } if (!mimetype) throw new Error(`Server error ${res.status} or missing content-type while detecting sticker mimetype`) } filename += "." + mimetype.split("/")[1] } attachments.push({id: "0", filename}) pendingFiles.push({name: filename, url}) } content = displayNameRunoff + replyLine + content // Split into 2000 character chunks const chunks = chunk(content, 2000) messages = messages.concat(chunks.map(content => ({ content, username: displayNameShortened, avatar_url: avatarURL }))) if (attachments.length) { // If content is empty (should be the case when uploading a file) then chunk-text will create 0 messages. // There needs to be a message to add attachments to. if (!messages.length) messages.push({ content, username: displayNameShortened, avatar_url: avatarURL }) messages[0].attachments = attachments // @ts-ignore these will be converted to real files when the message is about to be sent messages[0].pendingFiles = pendingFiles } const messagesToEdit = [] const messagesToSend = [] for (let i = 0; i < messages.length; i++) { const next = messageIDsToEdit[0] if (next) { messagesToEdit.push({id: next, message: messages[i]}) messageIDsToEdit.shift() } else { messagesToSend.push(messages[i]) } } // Ensure there is code coverage for adding, editing, and deleting if (messagesToSend.length) void 0 if (messagesToEdit.length) void 0 if (messageIDsToEdit.length) void 0 return { messagesToEdit, messagesToSend, messagesToDelete: messageIDsToEdit, ensureJoined } } module.exports.eventToMessage = eventToMessage