diff --git a/d2m/converters/edit-to-changes.js b/d2m/converters/edit-to-changes.js index 08f787cf..ef170ca4 100644 --- a/d2m/converters/edit-to-changes.js +++ b/d2m/converters/edit-to-changes.js @@ -3,13 +3,22 @@ const assert = require("assert").strict const passthrough = require("../../passthrough") -const {discord, sync, db, select, from} = passthrough +const {sync, select, from} = passthrough /** @type {import("./message-to-event")} */ const messageToEvent = sync.require("../converters/message-to-event") -/** @type {import("../actions/register-user")} */ -const registerUser = sync.require("../actions/register-user") -/** @type {import("../actions/create-room")} */ -const createRoom = sync.require("../actions/create-room") + +function eventCanBeEdited(ev) { + // Discord does not allow files, images, attachments, or videos to be edited. + if (ev.old.event_type === "m.room.message" && ev.old.event_subtype !== "m.text" && ev.old.event_subtype !== "m.emote" && ev.old.event_subtype !== "m.notice") { + return false + } + // Discord does not allow stickers to be edited. + if (ev.old.event_type === "m.sticker") { + return false + } + // Anything else is fair game. + return true +} /** * @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message @@ -19,12 +28,16 @@ const createRoom = sync.require("../actions/create-room") * @param {import("../../matrix/api")} api simple-as-nails dependency injection for the matrix API */ async function editToChanges(message, guild, api) { + // If it is a user edit, allow deleting old messages (e.g. they might have removed text from an image). If it is the system adding a generated embed to a message, don't delete old messages since the system only sends partial data. + + const isGeneratedEmbed = !("content" in message) + // Figure out what events we will be replacing const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get() assert(roomID) /** @type {string?} Null if we don't have a sender in the room, which will happen if it's a webhook's message. The bridge bot will do the edit instead. */ - const senderMxid = from("sim").join("sim_member", "mxid").where({user_id: message.author.id, room_id: roomID}).pluck("mxid").get() || null + const senderMxid = message.author && from("sim").join("sim_member", "mxid").where({user_id: message.author.id, room_id: roomID}).pluck("mxid").get() || null const oldEventRows = select("event_message", ["event_id", "event_type", "event_subtype", "part", "reaction_part"], {message_id: message.id}).all() @@ -48,7 +61,8 @@ async function editToChanges(message, guild, api) { let eventsToRedact = [] /** 3. Events that are present in the new version only, and should be sent as new, with references back to the context */ 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. + /** 4. Events that are matched and have definitely not changed, so they don't need to be edited or replaced at all. */ + let unchangedEvents = [] function shift() { newFallbackContent.shift() @@ -81,22 +95,35 @@ async function editToChanges(message, guild, api) { shift() } // Anything remaining in oldEventRows is present in the old version only and should be redacted. - eventsToRedact = oldEventRows + eventsToRedact = oldEventRows.map(e => ({old: e})) + + // If this is a generated embed update, only allow the embeds to be updated, since the system only sends data about events. Ignore changes to other things. + if (isGeneratedEmbed) { + unchangedEvents.push(...eventsToRedact.filter(e => e.old.event_subtype !== "m.notice")) // Move them from eventsToRedact to unchangedEvents. + eventsToRedact = eventsToRedact.filter(e => e.old.event_subtype === "m.notice") + } + + // Now, everything in eventsToSend and eventsToRedact is a real change, but everything in eventsToReplace might not have actually changed! + // (Example: a MESSAGE_UPDATE for a text+image message - Discord does not allow the image to be changed, but the text might have been.) + // So we'll remove entries from eventsToReplace that *definitely* cannot have changed. (This is category 4 mentioned above.) Everything remaining *may* have changed. + unchangedEvents.push(...eventsToReplace.filter(ev => !eventCanBeEdited(ev))) // Move them from eventsToRedact to unchangedEvents. + eventsToReplace = eventsToReplace.filter(eventCanBeEdited) // We want to maintain exactly one part = 0 and one reaction_part = 0 database row at all times. /** @type {({column: string, eventID: string} | {column: string, nextEvent: true})[]} */ const promotions = [] for (const column of ["part", "reaction_part"]) { + const candidatesForParts = unchangedEvents.concat(eventsToReplace) // If no events with part = 0 exist (or will exist), we need to do some management. - if (!eventsToReplace.some(e => e.old[column] === 0)) { - if (eventsToReplace.length) { + if (!candidatesForParts.some(e => e.old[column] === 0)) { + if (candidatesForParts.length) { // We can choose an existing event to promote. Bigger order is better. - const order = e => 2*+(e.event_type === "m.room.message") + 1*+(e.event_subtype === "m.text") - eventsToReplace.sort((a, b) => order(b) - order(a)) + const order = e => 2*+(e.event_type === "m.room.message") + 1*+(e.old.event_subtype === "m.text") + candidatesForParts.sort((a, b) => order(b) - order(a)) if (column === "part") { - promotions.push({column, eventID: eventsToReplace[0].old.event_id}) // part should be the first one + promotions.push({column, eventID: candidatesForParts[0].old.event_id}) // part should be the first one } else { - promotions.push({column, eventID: eventsToReplace[eventsToReplace.length - 1].old.event_id}) // reaction_part should be the last one + promotions.push({column, eventID: candidatesForParts[candidatesForParts.length - 1].old.event_id}) // reaction_part should be the last one } } else { // No existing events to promote, but new events are being sent. Whatever gets sent will be the next part = 0. @@ -105,24 +132,8 @@ async function editToChanges(message, guild, api) { } } - // Now, everything in eventsToSend and eventsToRedact is a real change, but everything in eventsToReplace might not have actually changed! - // (Example: a MESSAGE_UPDATE for a text+image message - Discord does not allow the image to be changed, but the text might have been.) - // So we'll remove entries from eventsToReplace that *definitely* cannot have changed. (This is category 4 mentioned above.) Everything remaining *may* have changed. - eventsToReplace = eventsToReplace.filter(ev => { - // Discord does not allow files, images, attachments, or videos to be edited. - if (ev.old.event_type === "m.room.message" && ev.old.event_subtype !== "m.text" && ev.old.event_subtype !== "m.emote" && ev.old.event_subtype !== "m.notice") { - return false - } - // Discord does not allow stickers to be edited. - if (ev.old.event_type === "m.sticker") { - return false - } - // Anything else is fair game. - return true - }) - // Removing unnecessary properties before returning - eventsToRedact = eventsToRedact.map(e => e.event_id) + eventsToRedact = eventsToRedact.map(e => e.old.event_id) eventsToReplace = eventsToReplace.map(e => ({oldID: e.old.event_id, newContent: makeReplacementEventContent(e.old.event_id, e.newFallbackContent, e.newInnerContent)})) return {roomID, eventsToReplace, eventsToRedact, eventsToSend, senderMxid, promotions} diff --git a/d2m/converters/edit-to-changes.test.js b/d2m/converters/edit-to-changes.test.js index 04f5568c..1004c347 100644 --- a/d2m/converters/edit-to-changes.test.js +++ b/d2m/converters/edit-to-changes.test.js @@ -235,3 +235,28 @@ test("edit2changes: promotes the text event when multiple rows have part = 1 (sh } ]) }) + +test("edit2changes: generated embed", async t => { + const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_social_media_image, data.guild.general, {}) + t.deepEqual(eventsToRedact, []) + t.deepEqual(eventsToReplace, []) + t.deepEqual(eventsToSend, [{ + $type: "m.room.message", + msgtype: "m.notice", + body: "| via hthrflwrs on cohost" + + "\n| \n| ## This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO https://cohost.org/jkap/post/4794219-empty" + + "\n| \n| 1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:" + + "\n| \n| * Both players draw eight cards" + + "\n| * Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand" + + "\n| * Both players present their best five-or-less-card pok...", + format: "org.matrix.custom.html", + formatted_body: `
`, + "m.mentions": {} + }]) + t.deepEqual(promotions, []) // TODO: it would be ideal to promote this to reaction_part = 0. this is OK to do because the main message won't have had any reactions yet. +}) diff --git a/d2m/converters/message-to-event.js b/d2m/converters/message-to-event.js index 3ede0a4d..518d041b 100644 --- a/d2m/converters/message-to-event.js +++ b/d2m/converters/message-to-event.js @@ -480,32 +480,35 @@ async function messageToEvent(message, guild, options = {}, di) { message.content = "changed the channel name to **" + message.content + "**" } - // Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention. - const matches = [...message.content.matchAll(/@ ?([a-z0-9._]+)\b/gi)] - if (matches.length && matches.some(m => m[1].match(/[a-z]/i) && m[1] !== "everyone" && m[1] !== "here")) { - const writtenMentionsText = matches.map(m => m[1].toLowerCase()) - const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get() - assert(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(/@([^:]*)/) - assert(localpart) - const displayName = member.display_name || localpart[1] - if (writtenMentionsText.includes(localpart[1].toLowerCase()) || writtenMentionsText.includes(displayName.toLowerCase())) addMention(mxid) + + if (message.content) { + // Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention. + const matches = [...message.content.matchAll(/@ ?([a-z0-9._]+)\b/gi)] + if (matches.length && matches.some(m => m[1].match(/[a-z]/i) && m[1] !== "everyone" && m[1] !== "here")) { + const writtenMentionsText = matches.map(m => m[1].toLowerCase()) + const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get() + assert(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(/@([^:]*)/) + assert(localpart) + const displayName = member.display_name || localpart[1] + if (writtenMentionsText.includes(localpart[1].toLowerCase()) || writtenMentionsText.includes(displayName.toLowerCase())) addMention(mxid) + } } } - } - // Text content appears first - if (message.content) { + // Text content appears first const {body, html} = await transformContent(message.content) await addTextEvent(body, html, msgtype, {scanMentions: true}) } // Then attachments - const attachmentEvents = await Promise.all(message.attachments.map(attachmentToEvent.bind(null, mentions))) - events.push(...attachmentEvents) + if (message.attachments) { + const attachmentEvents = await Promise.all(message.attachments.map(attachmentToEvent.bind(null, mentions))) + events.push(...attachmentEvents) + } // Then embeds for (const embed of message.embeds || []) { diff --git a/test/data.js b/test/data.js index c1653221..17b92cf9 100644 --- a/test/data.js +++ b/test/data.js @@ -3469,6 +3469,31 @@ module.exports = { } ], guild_id: "112760669178241024" + }, + embed_generated_social_media_image: { + channel_id: "112760669178241024", + embeds: [ + { + color: 8594767, + description: "1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:\n\n * Both players draw eight cards\n * Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand\n * Both players present their best five-or-less-card pok...", + provider: { + name: "hthrflwrs on cohost" + }, + thumbnail: { + height: 1587, + placeholder: "GpoKP5BJZphshnhwmmmYlmh3l7+m+mwJ", + placeholder_version: 1, + proxy_url: "https://images-ext-2.discordapp.net/external/9vTXIzlXU4wyUZvWfmlmQkck8nGLUL-A090W4lWsZ48/https/staging.cohostcdn.org/avatar/292-6b64b03c-4ada-42f6-8452-109275bfe68d-profile.png", + url: "https://staging.cohostcdn.org/avatar/292-6b64b03c-4ada-42f6-8452-109275bfe68d-profile.png", + width: 1644 + }, + title: "This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO", + type: "link", + url: "https://cohost.org/jkap/post/4794219-empty" + } + ], + guild_id: "112760669178241024", + id: "1210387798297682020" } }, special_message: { diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql index 573d7a4c..35368f48 100644 --- a/test/ooye-test-data.sql +++ b/test/ooye-test-data.sql @@ -53,7 +53,8 @@ INSERT INTO message_channel (message_id, channel_id) VALUES ('1158842413025071135', '176333891320283136'), ('1197612733600895076', '112760669178241024'), ('1202543413652881428', '1160894080998461480'), -('1207486471489986620', '1160894080998461480'); +('1207486471489986620', '1160894080998461480'), +('1210387798297682020', '112760669178241024'); INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES ('$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg', 'm.room.message', 'm.text', '1126786462646550579', 0, 0, 1), @@ -87,7 +88,8 @@ INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part ('$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo', 'm.room.message', 'm.text', '1158842413025071135', 0, 0, 1), ('$7tJoMw1h44n2gxgLUE1T_YinGrLbK0x-TDY1z6M7GBw', 'm.room.message', 'm.text', '1197612733600895076', 0, 0, 1), ('$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU', 'm.room.message', 'm.text', '1202543413652881428', 0, 0, 0), -('$OEEK-Wam2FTh6J-6kVnnJ6KnLA_lLRnLTHatKKL62-Y', 'm.room.message', 'm.image', '1207486471489986620', 0, 0, 0); +('$OEEK-Wam2FTh6J-6kVnnJ6KnLA_lLRnLTHatKKL62-Y', 'm.room.message', 'm.image', '1207486471489986620', 0, 0, 0), +('$mPSzglkCu-6cZHbYro0RW2u5mHvbH9aXDjO5FCzosc0', 'm.room.message', 'm.text', '1210387798297682020', 0, 0, 1); INSERT INTO file (discord_url, mxc_url) VALUES ('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'),hthrflwrs on cohost` + + `
This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO` + + `
1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:` + + `
- Both players draw eight cards` + + `
- Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand` + + `
- Both players present their best five-or-less-card pok...