From 56fe710392d243d5ed1d10edb0db813f078cab35 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 29 Aug 2023 01:31:52 +1200 Subject: [PATCH] finalise message editing --- m2d/actions/channel-webhook.js | 14 +++ m2d/actions/send-event.js | 8 +- m2d/converters/event-to-message.js | 139 ++++++++++++++---------- m2d/converters/event-to-message.test.js | 59 +++++++++- 4 files changed, 157 insertions(+), 63 deletions(-) diff --git a/m2d/actions/channel-webhook.js b/m2d/actions/channel-webhook.js index b0bc072..404b555 100644 --- a/m2d/actions/channel-webhook.js +++ b/m2d/actions/channel-webhook.js @@ -58,6 +58,20 @@ async function sendMessageWithWebhook(channelID, data, threadID) { return result } +/** + * @param {string} channelID + * @param {string} messageID + * @param {DiscordTypes.RESTPatchAPIWebhookWithTokenMessageJSONBody & {files?: {name: string, file: Buffer}[]}} data + * @param {string} [threadID] + */ +async function editMessageWithWebhook(channelID, messageID, data, threadID) { + const result = await withWebhook(channelID, async webhook => { + return discord.snow.webhook.editWebhookMessage(webhook.id, webhook.token, messageID, {...data, thread_id: threadID}) + }) + return result +} + module.exports.ensureWebhook = ensureWebhook module.exports.withWebhook = withWebhook module.exports.sendMessageWithWebhook = sendMessageWithWebhook +module.exports.editMessageWithWebhook = editMessageWithWebhook diff --git a/m2d/actions/send-event.js b/m2d/actions/send-event.js index 6360d64..2667d26 100644 --- a/m2d/actions/send-event.js +++ b/m2d/actions/send-event.js @@ -34,9 +34,11 @@ async function sendEvent(event) { /** @type {DiscordTypes.APIMessage[]} */ const messageResponses = [] let eventPart = 0 // 0 is primary, 1 is supporting - // for (const message of messagesToEdit) { - // eventPart = 1 - // TODO ... + for (const data of messagesToEdit) { + const messageResponse = await channelWebhook.editMessageWithWebhook(channelID, data.id, data.message, threadID) + eventPart = 1 + messageResponses.push(messageResponse) + } for (const message of messagesToSend) { const messageResponse = await channelWebhook.sendMessageWithWebhook(channelID, message, threadID) db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(messageResponse.id, channelID) diff --git a/m2d/converters/event-to-message.js b/m2d/converters/event-to-message.js index 5e6fa55..92e5ca4 100644 --- a/m2d/converters/event-to-message.js +++ b/m2d/converters/event-to-message.js @@ -141,19 +141,10 @@ async function eventToMessage(event, guild, di) { if (member.displayname) displayName = member.displayname if (member.avatar_url) avatarURL = utils.getPublicUrlForMxc(member.avatar_url) - // Convert content depending on what the message is let content = event.content.body // ultimate fallback - 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}` - } - - // 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" + // Convert content depending on what the message is + if (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 () => { @@ -167,16 +158,26 @@ async function eventToMessage(event, guild, di) { if (!originalEventId) return messageIDsToEdit = db.prepare("SELECT message_id FROM event_message WHERE event_id = ? ORDER BY part").pluck().all(originalEventId) 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 everything else can handle it just like original messages. - Object.assign(event.content, event.content["m.new_content"]) - input = event.content.formatted_body || event.content.body - relatesTo["m.in_reply_to"] = {event_id: repliedToEventId} + // 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. @@ -206,51 +207,69 @@ async function eventToMessage(event, guild, di) { replyLine = `> ${replyLine}\n> ${contentPreview}\n` })() - // Handling mentions of Discord users - input = input.replace(/("https:\/\/matrix.to\/#\/(@[^"]+)")>/g, (whole, attributeValue, mxid) => { - if (!utils.eventSenderIsFromDiscord(mxid)) return whole - const userID = db.prepare("SELECT discord_id FROM sim WHERE mxid = ?").pluck().get(mxid) - if (!userID) return whole - return `${attributeValue} data-user-id="${userID}">` - }) - - // Handling mentions of Discord rooms - input = input.replace(/("https:\/\/matrix.to\/#\/(![^"]+)")>/g, (whole, attributeValue, roomID) => { - const channelID = db.prepare("SELECT channel_id FROM channel_room WHERE room_id = ?").pluck().get(roomID) - if (!channelID) return whole - return `${attributeValue} data-channel-id="${channelID}">` - }) - - // 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 "
" + 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}` } - beforeContext = beforeContext || "" - beforeTag = beforeTag || "" - afterContext = afterContext || "" - afterTag = afterTag || "" - if (!BLOCK_ELEMENTS.includes(beforeTag.toUpperCase()) && !BLOCK_ELEMENTS.includes(afterTag.toUpperCase())) { - return beforeContext + "
" + afterContext - } else { - return whole + + // Handling mentions of Discord users + input = input.replace(/("https:\/\/matrix.to\/#\/(@[^"]+)")>/g, (whole, attributeValue, mxid) => { + if (!utils.eventSenderIsFromDiscord(mxid)) return whole + const userID = db.prepare("SELECT discord_id FROM sim WHERE mxid = ?").pluck().get(mxid) + if (!userID) return whole + return `${attributeValue} data-user-id="${userID}">` + }) + + // Handling mentions of Discord rooms + input = input.replace(/("https:\/\/matrix.to\/#\/(![^"]+)")>/g, (whole, attributeValue, roomID) => { + const channelID = db.prepare("SELECT channel_id FROM channel_room WHERE room_id = ?").pluck().get(roomID) + if (!channelID) return whole + return `${attributeValue} data-channel-id="${channelID}">` + }) + + // 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 (!BLOCK_ELEMENTS.includes(beforeTag.toUpperCase()) && !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" + + // @ts-ignore bad type from turndown + content = turndownService.turndown(input) + + // It's optimised for commonmark, we need to replace the space-space-newline with just newline + content = content.replace(/ \n/g, "\n") + } else { + // Looks like we're using the plaintext body! + content = event.content.body + + if (event.content.msgtype === "m.emote") { + content = `* ${displayName} ${content}` } - }) - // @ts-ignore bad type from turndown - content = turndownService.turndown(input) - - // It's optimised for commonmark, we need to replace the space-space-newline with just newline - content = content.replace(/ \n/g, "\n") - } else { - // Looks like we're using the plaintext body! - // Markdown needs to be escaped - content = content.replace(/([*_~`#])/g, `\\$1`) + // Markdown needs to be escaped + content = content.replace(/([*_~`#])/g, `\\$1`) + } } content = replyLine + content @@ -266,8 +285,10 @@ async function eventToMessage(event, guild, di) { const messagesToEdit = [] const messagesToSend = [] for (let i = 0; i < messages.length; i++) { - if (messageIDsToEdit.length) { - messagesToEdit.push({id: messageIDsToEdit.shift(), message: messages[i]}) + const next = messageIDsToEdit[0] + if (next) { + messagesToEdit.push({id: next, message: messages[i]}) + messageIDsToEdit.shift() } else { messagesToSend.push(messages[i]) } diff --git a/m2d/converters/event-to-message.test.js b/m2d/converters/event-to-message.test.js index 8e494bb..d071f8d 100644 --- a/m2d/converters/event-to-message.test.js +++ b/m2d/converters/event-to-message.test.js @@ -584,7 +584,7 @@ test("event2message: editing a plaintext body message", async t => { "room_id": "!PnyBKvUBOhjuCucEfk:cadence.moe" }, data.guild.general, { api: { - getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$7LIdiJCEqjcWUrpzWzS8TELOlFfBEe4ytgS7zn2lbSs", { + getEvent: mockGetEvent(t, "!PnyBKvUBOhjuCucEfk:cadence.moe", "$7LIdiJCEqjcWUrpzWzS8TELOlFfBEe4ytgS7zn2lbSs", { type: "m.room.message", sender: "@cadence:cadence.moe", content: { @@ -609,6 +609,63 @@ test("event2message: editing a plaintext body message", async t => { ) }) +test("event2message: editing a formatted body message", async t => { + t.deepEqual( + await eventToMessage({ + "type": "m.room.message", + "sender": "@cadence:cadence.moe", + "content": { + "msgtype": "m.text", + "body": " * **well, I guess it's no longer brand new... it's existed for mere seconds...**", + "format": "org.matrix.custom.html", + "formatted_body": "* well, I guess it's no longer brand new... it's existed for mere seconds...", + "m.new_content": { + "msgtype": "m.text", + "body": "**well, I guess it's no longer brand new... it's existed for mere seconds...**", + "format": "org.matrix.custom.html", + "formatted_body": "well, I guess it's no longer brand new... it's existed for mere seconds..." + }, + "m.relates_to": { + "rel_type": "m.replace", + "event_id": "$7LIdiJCEqjcWUrpzWzS8TELOlFfBEe4ytgS7zn2lbSs" + } + }, + "origin_server_ts": 1693223873912, + "unsigned": { + "age": 42, + "transaction_id": "m1693223873796.842" + }, + "event_id": "$KxGwvVNzNcmlVbiI2m5kX-jMFNi3Jle71-uu1j7P7vM", + "room_id": "!PnyBKvUBOhjuCucEfk:cadence.moe" + }, data.guild.general, { + api: { + getEvent: mockGetEvent(t, "!PnyBKvUBOhjuCucEfk:cadence.moe", "$7LIdiJCEqjcWUrpzWzS8TELOlFfBEe4ytgS7zn2lbSs", { + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "**brand new, never before seen message**", + format: "org.matrix.custom.html", + formatted_body: "brand new, never before seen message" + } + }) + } + }), + { + messagesToDelete: [], + messagesToEdit: [{ + id: "1145688633186193479", + message: { + username: "cadence [they]", + content: "**well, I guess it's no longer brand new... it's existed for mere seconds...**", + avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU" + } + }], + messagesToSend: [] + } + ) +}) + test("event2message: rich reply to a matrix user's long message with formatting", async t => { t.deepEqual( await eventToMessage({