support rich replies, support basic m.mentions
This commit is contained in:
		
							parent
							
								
									5326b7d6be
								
							
						
					
					
						commit
						328ae74b61
					
				
					 7 changed files with 689 additions and 16 deletions
				
			
		| 
						 | 
				
			
			@ -2,6 +2,7 @@
 | 
			
		|||
 | 
			
		||||
const assert = require("assert").strict
 | 
			
		||||
const markdown = require("discord-markdown")
 | 
			
		||||
const DiscordTypes = require("discord-api-types/v10")
 | 
			
		||||
 | 
			
		||||
const passthrough = require("../../passthrough")
 | 
			
		||||
const { sync, db, discord } = passthrough
 | 
			
		||||
| 
						 | 
				
			
			@ -39,10 +40,56 @@ function getDiscordParseCallbacks(message, useHTML) {
 | 
			
		|||
/**
 | 
			
		||||
 * @param {import("discord-api-types/v10").APIMessage} message
 | 
			
		||||
 * @param {import("discord-api-types/v10").APIGuild} guild
 | 
			
		||||
 * @param {import("../../matrix/api")} api simple-as-nails dependency injection for the matrix API
 | 
			
		||||
 */
 | 
			
		||||
async function messageToEvent(message, guild) {
 | 
			
		||||
async function messageToEvent(message, guild, api) {
 | 
			
		||||
	const events = []
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	   @type {{room?: boolean, user_ids?: string[]}}
 | 
			
		||||
		We should consider the following scenarios for mentions:
 | 
			
		||||
		1. TODO A discord user rich-replies to a matrix user with a text post
 | 
			
		||||
			+ The matrix user needs to be m.mentioned in the text event
 | 
			
		||||
			+ The matrix user needs to have their name/mxid/link in the text event (notification fallback)
 | 
			
		||||
				- So prepend their `@name:` to the start of the plaintext body
 | 
			
		||||
		2. TODO A discord user rich-replies to a matrix user with an image event only
 | 
			
		||||
			+ The matrix user needs to be m.mentioned in the image event
 | 
			
		||||
			+ The matrix user needs to have their name/mxid in the image event's body field, alongside the filename (notification fallback)
 | 
			
		||||
				- So append their name to the filename body, I guess!!!
 | 
			
		||||
		3. TODO A discord user `@`s a matrix user in the text body of their text box
 | 
			
		||||
			+ The matrix user needs to be m.mentioned in the text event
 | 
			
		||||
			+ No change needed to the text event content: it already has their name
 | 
			
		||||
				- So make sure we don't do anything in this case.
 | 
			
		||||
	*/
 | 
			
		||||
	const mentions = {}
 | 
			
		||||
	let repliedToEventId = null
 | 
			
		||||
	let repliedToEventRoomId = null
 | 
			
		||||
	let repliedToEventSenderMxid = null
 | 
			
		||||
	let repliedToEventOriginallyFromMatrix = false
 | 
			
		||||
 | 
			
		||||
	function addMention(mxid) {
 | 
			
		||||
		if (!mentions.user_ids) mentions.user_ids = []
 | 
			
		||||
		mentions.user_ids.push(mxid)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Mentions scenarios 1 and 2, part A. i.e. translate relevant message.mentions to m.mentions
 | 
			
		||||
	// (Still need to do scenarios 1 and 2 part B, and scenario 3.)
 | 
			
		||||
	if (message.type === DiscordTypes.MessageType.Reply && message.message_reference?.message_id) {
 | 
			
		||||
		const row = db.prepare("SELECT event_id, room_id, source FROM event_message INNER JOIN channel_room USING (channel_id) WHERE message_id = ? AND part = 0").get(message.message_reference.message_id)
 | 
			
		||||
		if (row) {
 | 
			
		||||
			repliedToEventId = row.event_id
 | 
			
		||||
			repliedToEventRoomId = row.room_id
 | 
			
		||||
			repliedToEventOriginallyFromMatrix = row.source === 0 // source 0 = matrix
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	if (repliedToEventOriginallyFromMatrix) {
 | 
			
		||||
		// Need to figure out who sent that event...
 | 
			
		||||
		const event = await api.getEvent(repliedToEventRoomId, repliedToEventId)
 | 
			
		||||
		repliedToEventSenderMxid = event.sender
 | 
			
		||||
		// Need to add the sender to m.mentions
 | 
			
		||||
		addMention(repliedToEventSenderMxid)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Text content appears first
 | 
			
		||||
	if (message.content) {
 | 
			
		||||
		let content = message.content
 | 
			
		||||
| 
						 | 
				
			
			@ -55,33 +102,63 @@ async function messageToEvent(message, guild) {
 | 
			
		|||
			}
 | 
			
		||||
		})
 | 
			
		||||
 | 
			
		||||
		const html = markdown.toHTML(content, {
 | 
			
		||||
		let html = markdown.toHTML(content, {
 | 
			
		||||
			discordCallback: getDiscordParseCallbacks(message, true)
 | 
			
		||||
		}, null, null)
 | 
			
		||||
 | 
			
		||||
		const body = markdown.toHTML(content, {
 | 
			
		||||
		let body = markdown.toHTML(content, {
 | 
			
		||||
			discordCallback: getDiscordParseCallbacks(message, false),
 | 
			
		||||
			discordOnly: true,
 | 
			
		||||
			escapeHTML: false,
 | 
			
		||||
		}, null, null)
 | 
			
		||||
 | 
			
		||||
		// Fallback body/formatted_body for replies
 | 
			
		||||
		if (repliedToEventId) {
 | 
			
		||||
			let repliedToDisplayName
 | 
			
		||||
			let repliedToUserHtml
 | 
			
		||||
			if (repliedToEventOriginallyFromMatrix && repliedToEventSenderMxid) {
 | 
			
		||||
				const match = repliedToEventSenderMxid.match(/^@([^:]*)/)
 | 
			
		||||
				assert(match)
 | 
			
		||||
				repliedToDisplayName = match[1] || "a Matrix user" // grab the localpart as the display name, whatever
 | 
			
		||||
				repliedToUserHtml = `<a href="https://matrix.to/#/${repliedToEventSenderMxid}">${repliedToDisplayName}</a>`
 | 
			
		||||
			} else {
 | 
			
		||||
				repliedToDisplayName = message.referenced_message?.author.global_name || message.referenced_message?.author.username || "a Discord user"
 | 
			
		||||
				repliedToUserHtml = repliedToDisplayName
 | 
			
		||||
			}
 | 
			
		||||
			const repliedToContent = message.referenced_message?.content || "[Replied-to message content wasn't provided by Discord]"
 | 
			
		||||
			const repliedToHtml = markdown.toHTML(repliedToContent, {
 | 
			
		||||
				discordCallback: getDiscordParseCallbacks(message, true)
 | 
			
		||||
			}, null, null)
 | 
			
		||||
			const repliedToBody = markdown.toHTML(repliedToContent, {
 | 
			
		||||
				discordCallback: getDiscordParseCallbacks(message, false),
 | 
			
		||||
				discordOnly: true,
 | 
			
		||||
				escapeHTML: false,
 | 
			
		||||
			}, null, null)
 | 
			
		||||
			html = `<mx-reply><blockquote><a href="https://matrix.to/#/${repliedToEventRoomId}/${repliedToEventId}">In reply to</a> ${repliedToUserHtml}`
 | 
			
		||||
				+ `<br>${repliedToHtml}</blockquote></mx-reply>`
 | 
			
		||||
				+ html
 | 
			
		||||
			body = (`${repliedToDisplayName}: ` // scenario 1 part B for mentions
 | 
			
		||||
				+ repliedToBody).split("\n").map(line => "> " + line).join("\n")
 | 
			
		||||
				+ "\n\n" + body
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		const newTextMessageEvent = {
 | 
			
		||||
			$type: "m.room.message",
 | 
			
		||||
			"m.mentions": mentions,
 | 
			
		||||
			msgtype: "m.text",
 | 
			
		||||
			body: body
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		const isPlaintext = body === html
 | 
			
		||||
 | 
			
		||||
		if (isPlaintext) {
 | 
			
		||||
			events.push({
 | 
			
		||||
				$type: "m.room.message",
 | 
			
		||||
				msgtype: "m.text",
 | 
			
		||||
				body: body
 | 
			
		||||
			})
 | 
			
		||||
		} else {
 | 
			
		||||
			events.push({
 | 
			
		||||
				$type: "m.room.message",
 | 
			
		||||
				msgtype: "m.text",
 | 
			
		||||
				body: body,
 | 
			
		||||
		if (!isPlaintext) {
 | 
			
		||||
			Object.assign(newTextMessageEvent, {
 | 
			
		||||
				format: "org.matrix.custom.html",
 | 
			
		||||
				formatted_body: html
 | 
			
		||||
			})
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		events.push(newTextMessageEvent)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Then attachments
 | 
			
		||||
| 
						 | 
				
			
			@ -90,6 +167,7 @@ async function messageToEvent(message, guild) {
 | 
			
		|||
		if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) {
 | 
			
		||||
			return {
 | 
			
		||||
				$type: "m.room.message",
 | 
			
		||||
				"m.mentions": mentions,
 | 
			
		||||
				msgtype: "m.image",
 | 
			
		||||
				url: await file.uploadDiscordFileToMxc(attachment.url),
 | 
			
		||||
				external_url: attachment.url,
 | 
			
		||||
| 
						 | 
				
			
			@ -105,6 +183,7 @@ async function messageToEvent(message, guild) {
 | 
			
		|||
		} else {
 | 
			
		||||
			return {
 | 
			
		||||
				$type: "m.room.message",
 | 
			
		||||
				"m.mentions": mentions,
 | 
			
		||||
				msgtype: "m.text",
 | 
			
		||||
				body: "Unsupported attachment:\n" + JSON.stringify(attachment, null, 2)
 | 
			
		||||
			}
 | 
			
		||||
| 
						 | 
				
			
			@ -122,6 +201,7 @@ async function messageToEvent(message, guild) {
 | 
			
		|||
				if (sticker && sticker.description) body += ` - ${sticker.description}`
 | 
			
		||||
				return {
 | 
			
		||||
					$type: "m.sticker",
 | 
			
		||||
					"m.mentions": mentions,
 | 
			
		||||
					body,
 | 
			
		||||
					info: {
 | 
			
		||||
						mimetype: format.mime
 | 
			
		||||
| 
						 | 
				
			
			@ -131,6 +211,7 @@ async function messageToEvent(message, guild) {
 | 
			
		|||
			} else {
 | 
			
		||||
				return {
 | 
			
		||||
					$type: "m.room.message",
 | 
			
		||||
					"m.mentions": mentions,
 | 
			
		||||
					msgtype: "m.text",
 | 
			
		||||
					body: "Unsupported sticker format. Name: " + stickerItem.name
 | 
			
		||||
				}
 | 
			
		||||
| 
						 | 
				
			
			@ -139,6 +220,17 @@ async function messageToEvent(message, guild) {
 | 
			
		|||
		events.push(...stickerEvents)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Rich replies
 | 
			
		||||
	if (repliedToEventId) {
 | 
			
		||||
		Object.assign(events[0], {
 | 
			
		||||
			"m.relates_to": {
 | 
			
		||||
				"m.in_reply_to": {
 | 
			
		||||
					event_id: repliedToEventId
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return events
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,11 +1,39 @@
 | 
			
		|||
const {test} = require("supertape")
 | 
			
		||||
const {messageToEvent} = require("./message-to-event")
 | 
			
		||||
const data = require("../../test/data")
 | 
			
		||||
const Ty = require("../../types")
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @param {string} roomID
 | 
			
		||||
 * @param {string} eventID
 | 
			
		||||
 * @returns {(roomID: string, eventID: string) => Promise<Ty.Event.Outer<Ty.Event.M_Room_Message>>}
 | 
			
		||||
 */
 | 
			
		||||
function mockGetEvent(t, roomID_in, eventID_in, outer) {
 | 
			
		||||
	return async function(roomID, eventID) {
 | 
			
		||||
		t.equal(roomID, roomID_in)
 | 
			
		||||
		t.equal(eventID, eventID_in)
 | 
			
		||||
		return new Promise(resolve => {
 | 
			
		||||
			setTimeout(() => {
 | 
			
		||||
				resolve({
 | 
			
		||||
					event_id: eventID_in,
 | 
			
		||||
					room_id: roomID_in,
 | 
			
		||||
					origin_server_ts: 1680000000000,
 | 
			
		||||
					unsigned: {
 | 
			
		||||
						age: 2245,
 | 
			
		||||
						transaction_id: "$local.whatever"
 | 
			
		||||
					},
 | 
			
		||||
					...outer
 | 
			
		||||
				})
 | 
			
		||||
			})
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
test("message2event: simple plaintext", async t => {
 | 
			
		||||
	const events = await messageToEvent(data.message.simple_plaintext, data.guild.general)
 | 
			
		||||
	t.deepEqual(events, [{
 | 
			
		||||
		$type: "m.room.message",
 | 
			
		||||
		"m.mentions": {},
 | 
			
		||||
		msgtype: "m.text",
 | 
			
		||||
		body: "ayy lmao"
 | 
			
		||||
	}])
 | 
			
		||||
| 
						 | 
				
			
			@ -15,6 +43,7 @@ test("message2event: simple user mention", async t => {
 | 
			
		|||
	const events = await messageToEvent(data.message.simple_user_mention, data.guild.general)
 | 
			
		||||
	t.deepEqual(events, [{
 | 
			
		||||
		$type: "m.room.message",
 | 
			
		||||
		"m.mentions": {},
 | 
			
		||||
		msgtype: "m.text",
 | 
			
		||||
		body: "@crunch god: Tell me about Phil, renowned martial arts master and creator of the Chin Trick",
 | 
			
		||||
		format: "org.matrix.custom.html",
 | 
			
		||||
| 
						 | 
				
			
			@ -26,6 +55,7 @@ test("message2event: simple room mention", async t => {
 | 
			
		|||
	const events = await messageToEvent(data.message.simple_room_mention, data.guild.general)
 | 
			
		||||
	t.deepEqual(events, [{
 | 
			
		||||
		$type: "m.room.message",
 | 
			
		||||
		"m.mentions": {},
 | 
			
		||||
		msgtype: "m.text",
 | 
			
		||||
		body: "#main",
 | 
			
		||||
		format: "org.matrix.custom.html",
 | 
			
		||||
| 
						 | 
				
			
			@ -37,6 +67,7 @@ test("message2event: simple message link", async t => {
 | 
			
		|||
	const events = await messageToEvent(data.message.simple_message_link, data.guild.general)
 | 
			
		||||
	t.deepEqual(events, [{
 | 
			
		||||
		$type: "m.room.message",
 | 
			
		||||
		"m.mentions": {},
 | 
			
		||||
		msgtype: "m.text",
 | 
			
		||||
		body: "https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg",
 | 
			
		||||
		format: "org.matrix.custom.html",
 | 
			
		||||
| 
						 | 
				
			
			@ -48,6 +79,7 @@ test("message2event: attachment with no content", async t => {
 | 
			
		|||
	const events = await messageToEvent(data.message.attachment_no_content, data.guild.general)
 | 
			
		||||
	t.deepEqual(events, [{
 | 
			
		||||
		$type: "m.room.message",
 | 
			
		||||
		"m.mentions": {},
 | 
			
		||||
		msgtype: "m.image",
 | 
			
		||||
		url: "mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM",
 | 
			
		||||
		body: "image.png",
 | 
			
		||||
| 
						 | 
				
			
			@ -65,10 +97,12 @@ test("message2event: stickers", async t => {
 | 
			
		|||
	const events = await messageToEvent(data.message.sticker, data.guild.general)
 | 
			
		||||
	t.deepEqual(events, [{
 | 
			
		||||
		$type: "m.room.message",
 | 
			
		||||
		"m.mentions": {},
 | 
			
		||||
		msgtype: "m.text",
 | 
			
		||||
		body: "can have attachments too"
 | 
			
		||||
	}, {
 | 
			
		||||
		$type: "m.room.message",
 | 
			
		||||
		"m.mentions": {},
 | 
			
		||||
		msgtype: "m.image",
 | 
			
		||||
		url: "mxc://cadence.moe/ZDCNYnkPszxGKgObUIFmvjus",
 | 
			
		||||
		body: "image.png",
 | 
			
		||||
| 
						 | 
				
			
			@ -81,6 +115,7 @@ test("message2event: stickers", async t => {
 | 
			
		|||
		},
 | 
			
		||||
	}, {
 | 
			
		||||
		$type: "m.sticker",
 | 
			
		||||
		"m.mentions": {},
 | 
			
		||||
		body: "pomu puff - damn that tiny lil bitch really chuffing. puffing that fat ass dart",
 | 
			
		||||
		info: {
 | 
			
		||||
			mimetype: "image/png"
 | 
			
		||||
| 
						 | 
				
			
			@ -90,3 +125,94 @@ test("message2event: stickers", async t => {
 | 
			
		|||
		url: "mxc://cadence.moe/UuUaLwXhkxFRwwWCXipDlBHn"
 | 
			
		||||
	}])
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
test("message2event: skull webp attachment with content", async t => {
 | 
			
		||||
	const events = await messageToEvent(data.message.skull_webp_attachment_with_content, data.guild.general)
 | 
			
		||||
	t.deepEqual(events, [{
 | 
			
		||||
		$type: "m.room.message",
 | 
			
		||||
		"m.mentions": {},
 | 
			
		||||
		msgtype: "m.text",
 | 
			
		||||
		body: "Image"
 | 
			
		||||
	}, {
 | 
			
		||||
		$type: "m.room.message",
 | 
			
		||||
		"m.mentions": {},
 | 
			
		||||
		msgtype: "m.image",
 | 
			
		||||
		body: "skull.webp",
 | 
			
		||||
		info: {
 | 
			
		||||
			w: 1200,
 | 
			
		||||
			h: 628,
 | 
			
		||||
			mimetype: "image/webp",
 | 
			
		||||
			size: 74290
 | 
			
		||||
		},
 | 
			
		||||
		external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1128084747910918195/skull.webp",
 | 
			
		||||
		url: "mxc://cadence.moe/sDxWmDErBhYBxtDcJQgBETes"
 | 
			
		||||
	}])
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
test("message2event: reply to skull webp attachment with content", async t => {
 | 
			
		||||
	const events = await messageToEvent(data.message.reply_to_skull_webp_attachment_with_content, data.guild.general)
 | 
			
		||||
	t.deepEqual(events, [{
 | 
			
		||||
		$type: "m.room.message",
 | 
			
		||||
		"m.relates_to": {
 | 
			
		||||
			"m.in_reply_to": {
 | 
			
		||||
				event_id: "$oLyUTyZ_7e_SUzGNWZKz880ll9amLZvXGbArJCKai2Q"
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
		"m.mentions": {},
 | 
			
		||||
		msgtype: "m.text",
 | 
			
		||||
		body: "> Extremity: Image\n\nReply",
 | 
			
		||||
		format: "org.matrix.custom.html",
 | 
			
		||||
		formatted_body:
 | 
			
		||||
			'<mx-reply><blockquote><a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$oLyUTyZ_7e_SUzGNWZKz880ll9amLZvXGbArJCKai2Q">In reply to</a> Extremity'
 | 
			
		||||
			+ '<br>Image</blockquote></mx-reply>'
 | 
			
		||||
			+ 'Reply'
 | 
			
		||||
	}, {
 | 
			
		||||
		$type: "m.room.message",
 | 
			
		||||
		"m.mentions": {},
 | 
			
		||||
		msgtype: "m.image",
 | 
			
		||||
		body: "RDT_20230704_0936184915846675925224905.jpg",
 | 
			
		||||
		info: {
 | 
			
		||||
			w: 2048,
 | 
			
		||||
			h: 1536,
 | 
			
		||||
			mimetype: "image/jpeg",
 | 
			
		||||
			size: 85906
 | 
			
		||||
		},
 | 
			
		||||
		external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1128084851023675515/RDT_20230704_0936184915846675925224905.jpg",
 | 
			
		||||
		url: "mxc://cadence.moe/WlAbFSiNRIHPDEwKdyPeGywa"
 | 
			
		||||
	}])
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
test("message2event: simple reply to matrix user", async t => {
 | 
			
		||||
	const events = await messageToEvent(data.message.simple_reply_to_matrix_user, data.guild.general, {
 | 
			
		||||
		getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", {
 | 
			
		||||
			type: "m.room.message",
 | 
			
		||||
			content: {
 | 
			
		||||
				msgtype: "m.text",
 | 
			
		||||
				body: "so can you reply to my webhook uwu"
 | 
			
		||||
			},
 | 
			
		||||
			sender: "@cadence:cadence.moe"
 | 
			
		||||
		})
 | 
			
		||||
	})
 | 
			
		||||
	t.deepEqual(events, [{
 | 
			
		||||
		$type: "m.room.message",
 | 
			
		||||
		"m.relates_to": {
 | 
			
		||||
			"m.in_reply_to": {
 | 
			
		||||
				event_id: "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4"
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
		"m.mentions": {
 | 
			
		||||
			user_ids: [
 | 
			
		||||
				"@cadence:cadence.moe"
 | 
			
		||||
			]
 | 
			
		||||
		},
 | 
			
		||||
		msgtype: "m.text",
 | 
			
		||||
		body: "> cadence: so can you reply to my webhook uwu\n\nReply",
 | 
			
		||||
		format: "org.matrix.custom.html",
 | 
			
		||||
		formatted_body:
 | 
			
		||||
			'<mx-reply><blockquote><a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4">In reply to</a> <a href="https://matrix.to/#/@cadence:cadence.moe">cadence</a>'
 | 
			
		||||
			+ '<br>so can you reply to my webhook uwu</blockquote></mx-reply>'
 | 
			
		||||
			+ 'Reply'
 | 
			
		||||
	}])
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
// TODO: read "edits of replies" in the spec
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue