From 8f6bb86b927792b70f3cb49f2faf48ef647082e1 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 16 Aug 2023 20:44:38 +1200 Subject: [PATCH] record more update events --- d2m/actions/send-message.js | 2 +- d2m/converters/edit-to-changes.js | 47 +++++----- d2m/converters/edit-to-changes.test.js | 9 +- d2m/converters/message-to-event.js | 20 +++- d2m/converters/message-to-event.test.js | 116 +++++++++++++++--------- scripts/events.db | Bin 8192 -> 98304 bytes 6 files changed, 122 insertions(+), 72 deletions(-) diff --git a/d2m/actions/send-message.js b/d2m/actions/send-message.js index 258efcf..cf87d35 100644 --- a/d2m/actions/send-message.js +++ b/d2m/actions/send-message.js @@ -27,7 +27,7 @@ async function sendMessage(message, guild) { await registerUser.syncUser(message.author, message.member, message.guild_id, roomID) } - const events = await messageToEvent.messageToEvent(message, guild, api) + const events = await messageToEvent.messageToEvent(message, guild, {}, {api}) const eventIDs = [] let eventPart = 0 // 0 is primary, 1 is supporting for (const event of events) { diff --git a/d2m/converters/edit-to-changes.js b/d2m/converters/edit-to-changes.js index 87e769b..4afa3ce 100644 --- a/d2m/converters/edit-to-changes.js +++ b/d2m/converters/edit-to-changes.js @@ -29,7 +29,9 @@ async function editToChanges(message, guild) { // Figure out what we will be replacing them with - const newEvents = await messageToEvent.messageToEvent(message, guild, api) + const newFallbackContent = await messageToEvent.messageToEvent(message, guild, {includeEditFallbackStar: true}, {api}) + const newInnerContent = await messageToEvent.messageToEvent(message, guild, {includeReplyFallback: false}, {api}) + assert.ok(newFallbackContent.length === newInnerContent.length) // Match the new events to the old events @@ -47,21 +49,27 @@ async function editToChanges(message, guild) { let eventsToSend = [] // 4. Events that are matched and have definitely not changed, so they don't need to be edited or replaced at all. This is represented as nothing. + function shift() { + newFallbackContent.shift() + newInnerContent.shift() + } + // For each old event... - outer: while (newEvents.length) { - const newe = newEvents[0] + outer: while (newFallbackContent.length) { + const newe = newFallbackContent[0] // Find a new event to pair it with... for (let i = 0; i < oldEventRows.length; i++) { const olde = oldEventRows[i] - if (olde.event_type === newe.$type && olde.event_subtype === (newe.msgtype || null)) { + if (olde.event_type === newe.$type && olde.event_subtype === (newe.msgtype ?? null)) { // The spec does allow subtypes to change, so I can change this condition later if I want to // Found one! // Set up the pairing eventsToReplace.push({ old: olde, - new: newe + newFallbackContent: newFallbackContent[0], + newInnerContent: newInnerContent[0] }) // These events have been handled now, so remove them from the source arrays - newEvents.shift() + shift() oldEventRows.splice(i, 1) // Go all the way back to the start of the next iteration of the outer loop continue outer @@ -69,7 +77,7 @@ async function editToChanges(message, guild) { } // If we got this far, we could not pair it to an existing event, so it'll have to be a new one eventsToSend.push(newe) - newEvents.shift() + shift() } // Anything remaining in oldEventRows is present in the old version only and should be redacted. eventsToRedact = oldEventRows @@ -92,7 +100,7 @@ async function editToChanges(message, guild) { // Removing unnecessary properties before returning eventsToRedact = eventsToRedact.map(e => e.event_id) - eventsToReplace = eventsToReplace.map(e => ({oldID: e.old.event_id, new: eventToReplacementEvent(e.old.event_id, e.new)})) + eventsToReplace = eventsToReplace.map(e => ({oldID: e.old.event_id, new: eventToReplacementEvent(e.old.event_id, e.newFallbackContent, e.newInnerContent)})) return {eventsToReplace, eventsToRedact, eventsToSend} } @@ -100,31 +108,26 @@ async function editToChanges(message, guild) { /** * @template T * @param {string} oldID - * @param {T} content + * @param {T} newFallbackContent + * @param {T} newInnerContent * @returns {import("../../types").Event.ReplacementContent} content */ -function eventToReplacementEvent(oldID, content) { - const newContent = { - ...content, +function eventToReplacementEvent(oldID, newFallbackContent, newInnerContent) { + const content = { + ...newFallbackContent, "m.mentions": {}, "m.new_content": { - ...content + ...newInnerContent }, "m.relates_to": { rel_type: "m.replace", event_id: oldID } } - if (typeof newContent.body === "string") { - newContent.body = "* " + newContent.body - } - if (typeof newContent.formatted_body === "string") { - newContent.formatted_body = "* " + newContent.formatted_body - } - delete newContent["m.new_content"]["$type"] + delete content["m.new_content"]["$type"] // Client-Server API spec 11.37.3: Any m.relates_to property within m.new_content is ignored. - delete newContent["m.new_content"]["m.relates_to"] - return newContent + delete content["m.new_content"]["m.relates_to"] + return content } module.exports.editToChanges = editToChanges diff --git a/d2m/converters/edit-to-changes.test.js b/d2m/converters/edit-to-changes.test.js index b3e6e0c..f6ecc8d 100644 --- a/d2m/converters/edit-to-changes.test.js +++ b/d2m/converters/edit-to-changes.test.js @@ -47,9 +47,13 @@ test("edit2changes: edit of reply to skull webp attachment with content", async oldID: "$vgTKOR5ZTYNMKaS7XvgEIDaOWZtVCEyzLLi5Pc5Gz4M", new: { $type: "m.room.message", - // TODO: read "edits of replies" in the spec!!! msgtype: "m.text", - body: "* Edit", + body: "> Extremity: Image\n\n* Edit", + format: "org.matrix.custom.html", + formatted_body: + '
In reply to Extremity' + + '
Image
' + + '* Edit', "m.mentions": {}, "m.new_content": { msgtype: "m.text", @@ -60,7 +64,6 @@ test("edit2changes: edit of reply to skull webp attachment with content", async rel_type: "m.replace", event_id: "$vgTKOR5ZTYNMKaS7XvgEIDaOWZtVCEyzLLi5Pc5Gz4M" } - // TODO: read "edits of replies" in the spec!!! } }]) }) diff --git a/d2m/converters/message-to-event.js b/d2m/converters/message-to-event.js index a2c4915..c128595 100644 --- a/d2m/converters/message-to-event.js +++ b/d2m/converters/message-to-event.js @@ -55,9 +55,12 @@ 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 + * @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean}} options default values: + * - includeReplyFallback: true + * - includeEditFallbackStar: false + * @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API */ -async function messageToEvent(message, guild, api) { +async function messageToEvent(message, guild, options = {}, di) { const events = [] /** @@ -99,7 +102,7 @@ async function messageToEvent(message, guild, api) { } if (repliedToEventOriginallyFromMatrix) { // Need to figure out who sent that event... - const event = await api.getEvent(repliedToEventRoomId, repliedToEventId) + const event = await di.api.getEvent(repliedToEventRoomId, repliedToEventId) repliedToEventSenderMxid = event.sender // Need to add the sender to m.mentions addMention(repliedToEventSenderMxid) @@ -133,7 +136,7 @@ async function messageToEvent(message, guild, api) { if (matches.length && matches.some(m => m[1].match(/[a-z]/i))) { const writtenMentionsText = matches.map(m => m[1].toLowerCase()) const roomID = db.prepare("SELECT room_id FROM channel_room WHERE channel_id = ?").pluck().get(message.channel_id) - const {joined} = await api.getJoinedMembers(roomID) + const {joined} = await di.api.getJoinedMembers(roomID) for (const [mxid, member] of Object.entries(joined)) { if (!userRegex.some(rx => mxid.match(rx))) { const localpart = mxid.match(/@([^:]*)/) @@ -143,8 +146,15 @@ async function messageToEvent(message, guild, api) { } } + // Star * prefix for fallback edits + if (options.includeEditFallbackStar) { + body = "* " + body + html = "* " + html + } + // Fallback body/formatted_body for replies - if (repliedToEventId) { + // This branch is optional - do NOT change anything apart from the reply fallback, since it may not be run + if (repliedToEventId && options.includeReplyFallback !== false) { let repliedToDisplayName let repliedToUserHtml if (repliedToEventOriginallyFromMatrix && repliedToEventSenderMxid) { diff --git a/d2m/converters/message-to-event.test.js b/d2m/converters/message-to-event.test.js index 17079e5..4200afe 100644 --- a/d2m/converters/message-to-event.test.js +++ b/d2m/converters/message-to-event.test.js @@ -30,7 +30,7 @@ function mockGetEvent(t, roomID_in, eventID_in, outer) { } test("message2event: simple plaintext", async t => { - const events = await messageToEvent(data.message.simple_plaintext, data.guild.general) + const events = await messageToEvent(data.message.simple_plaintext, data.guild.general, {}) t.deepEqual(events, [{ $type: "m.room.message", "m.mentions": {}, @@ -40,7 +40,7 @@ test("message2event: simple plaintext", async t => { }) test("message2event: simple user mention", async t => { - const events = await messageToEvent(data.message.simple_user_mention, data.guild.general) + const events = await messageToEvent(data.message.simple_user_mention, data.guild.general, {}) t.deepEqual(events, [{ $type: "m.room.message", "m.mentions": {}, @@ -52,7 +52,7 @@ test("message2event: simple user mention", async t => { }) test("message2event: simple room mention", async t => { - const events = await messageToEvent(data.message.simple_room_mention, data.guild.general) + const events = await messageToEvent(data.message.simple_room_mention, data.guild.general, {}) t.deepEqual(events, [{ $type: "m.room.message", "m.mentions": {}, @@ -64,7 +64,7 @@ test("message2event: simple room mention", async t => { }) test("message2event: simple message link", async t => { - const events = await messageToEvent(data.message.simple_message_link, data.guild.general) + const events = await messageToEvent(data.message.simple_message_link, data.guild.general, {}) t.deepEqual(events, [{ $type: "m.room.message", "m.mentions": {}, @@ -76,7 +76,7 @@ test("message2event: simple message link", async t => { }) test("message2event: attachment with no content", async t => { - const events = await messageToEvent(data.message.attachment_no_content, data.guild.general) + const events = await messageToEvent(data.message.attachment_no_content, data.guild.general, {}) t.deepEqual(events, [{ $type: "m.room.message", "m.mentions": {}, @@ -94,7 +94,7 @@ test("message2event: attachment with no content", async t => { }) test("message2event: stickers", async t => { - const events = await messageToEvent(data.message.sticker, data.guild.general) + const events = await messageToEvent(data.message.sticker, data.guild.general, {}) t.deepEqual(events, [{ $type: "m.room.message", "m.mentions": {}, @@ -127,7 +127,7 @@ test("message2event: stickers", async t => { }) test("message2event: skull webp attachment with content", async t => { - const events = await messageToEvent(data.message.skull_webp_attachment_with_content, data.guild.general) + const events = await messageToEvent(data.message.skull_webp_attachment_with_content, data.guild.general, {}) t.deepEqual(events, [{ $type: "m.room.message", "m.mentions": {}, @@ -150,7 +150,7 @@ test("message2event: skull webp attachment with content", async t => { }) 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) + 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": { @@ -183,15 +183,17 @@ test("message2event: reply to skull webp attachment with content", async t => { }) 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" - }) + const events = await messageToEvent(data.message.simple_reply_to_matrix_user, data.guild.general, {}, { + api: { + 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", @@ -215,34 +217,66 @@ test("message2event: simple reply to matrix user", async t => { }]) }) +test("message2event: simple reply to matrix user, reply fallbacks disabled", async t => { + const events = await messageToEvent(data.message.simple_reply_to_matrix_user, data.guild.general, {includeReplyFallback: false}, { + api: { + 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: "Reply" + }]) +}) + test("message2event: simple written @mention for matrix user", async t => { - const events = await messageToEvent(data.message.simple_written_at_mention_for_matrix, data.guild.general, { - async getJoinedMembers(roomID) { - t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") - return new Promise(resolve => { - setTimeout(() => { - resolve({ - joined: { - "@cadence:cadence.moe": { - display_name: "cadence [they]", - avatar_url: "whatever" - }, - "@huckleton:cadence.moe": { - display_name: "huck", - avatar_url: "whatever" - }, - "@_ooye_botrac4r:cadence.moe": { - display_name: "botrac4r", - avatar_url: "whatever" - }, - "@_ooye_bot:cadence.moe": { - display_name: "Out Of Your Element", - avatar_url: "whatever" + const events = await messageToEvent(data.message.simple_written_at_mention_for_matrix, data.guild.general, {}, { + api: { + async getJoinedMembers(roomID) { + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") + return new Promise(resolve => { + setTimeout(() => { + resolve({ + joined: { + "@cadence:cadence.moe": { + display_name: "cadence [they]", + avatar_url: "whatever" + }, + "@huckleton:cadence.moe": { + display_name: "huck", + avatar_url: "whatever" + }, + "@_ooye_botrac4r:cadence.moe": { + display_name: "botrac4r", + avatar_url: "whatever" + }, + "@_ooye_bot:cadence.moe": { + display_name: "Out Of Your Element", + avatar_url: "whatever" + } } - } + }) }) }) - }) + } } }) t.deepEqual(events, [{ diff --git a/scripts/events.db b/scripts/events.db index c8f5bad88858b0ddc7a3b54ec36577c94791c001..3356cbe2ba67631f1efdf34292de8256ab6ee024 100644 GIT binary patch literal 98304 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 RzZa(6kc%W!yx3`f{{Qk~$=Lt^ delta 59 zcmZo@U~6!gAkE6kz`(#XQNf;(bz{N;ejZ*Bm*X)5?<$VR8w;;W-ZNYYyh}%5On|m