From c71044fdec882446810e7d4e436d5b3c7d709032 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 29 Aug 2025 00:09:18 +1200 Subject: [PATCH 1/2] Only edit events if the text has changed --- src/d2m/converters/edit-to-changes.js | 19 ++++- src/d2m/converters/edit-to-changes.test.js | 95 +++++++++++++++------- 2 files changed, 83 insertions(+), 31 deletions(-) diff --git a/src/d2m/converters/edit-to-changes.js b/src/d2m/converters/edit-to-changes.js index c38c24e..c615a3f 100644 --- a/src/d2m/converters/edit-to-changes.js +++ b/src/d2m/converters/edit-to-changes.js @@ -22,6 +22,10 @@ function eventCanBeEdited(ev) { return true } +function eventIsText(ev) { + return ev.old.event_type === "m.room.message" && (ev.old.event_subtype === "m.text" || ev.old.event_subtype === "m.notice") +} + /** * @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message * @param {import("discord-api-types/v10").APIGuild} guild @@ -121,6 +125,20 @@ async function editToChanges(message, guild, api) { unchangedEvents.push(...eventsToReplace.filter(ev => !eventCanBeEdited(ev))) // Move them from eventsToRedact to unchangedEvents. eventsToReplace = eventsToReplace.filter(eventCanBeEdited) + // Now, everything in eventsToReplace has the potential to have changed, but did it actually? + // (Example: if a URL preview was generated or updated, the message text won't have changed.) + // Only way to detect this is by text content. So we'll remove text events from eventsToReplace that have the same new text as text currently in the event. + for (let i = eventsToReplace.length; i--;) { // move backwards through array + const event = eventsToReplace[i] + if (!eventIsText(event)) continue // not text, can't analyse + const oldEvent = await api.getEvent(roomID, eventsToReplace[i].old.event_id) + const oldEventBodyWithoutQuotedReply = oldEvent.content.body?.replace(/^(>.*\n)*\n*/sm, "") + if (oldEventBodyWithoutQuotedReply !== event.newInnerContent.body) continue // event changed, must replace it + // Move it from eventsToRedact to unchangedEvents. + unchangedEvents.push(...eventsToReplace.filter(ev => ev.old.event_id === event.old.event_id)) + eventsToReplace = eventsToReplace.filter(ev => ev.old.event_id !== event.old.event_id) + } + // We want to maintain exactly one part = 0 and one reaction_part = 0 database row at all times. // This would be disrupted if existing events that are (reaction_)part = 0 will be redacted. // If that is the case, pick a different existing or newly sent event to be (reaction_)part = 0. @@ -193,4 +211,3 @@ function makeReplacementEventContent(oldID, newFallbackContent, newInnerContent) } module.exports.editToChanges = editToChanges -module.exports.makeReplacementEventContent = makeReplacementEventContent diff --git a/src/d2m/converters/edit-to-changes.test.js b/src/d2m/converters/edit-to-changes.test.js index 9721a85..30549c7 100644 --- a/src/d2m/converters/edit-to-changes.test.js +++ b/src/d2m/converters/edit-to-changes.test.js @@ -4,7 +4,14 @@ const data = require("../../../test/data") const Ty = require("../../types") test("edit2changes: edit by webhook", async t => { - const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edit_by_webhook, data.guild.general, {}) + let called = 0 + const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edit_by_webhook, data.guild.general, { + getEvent(roomID, eventID) { + called++ + t.equal(eventID, "$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10") + return {content: {body: "dummy"}} + } + }) t.deepEqual(eventsToRedact, []) t.deepEqual(eventsToSend, []) t.deepEqual(eventsToReplace, [{ @@ -28,10 +35,15 @@ test("edit2changes: edit by webhook", async t => { }]) t.equal(senderMxid, null) t.deepEqual(promotions, []) + t.equal(called, 1) }) test("edit2changes: bot response", async t => { const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.bot_response, data.guild.general, { + getEvent(roomID, eventID) { + t.equal(eventID, "$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY") + return {content: {body: "dummy"}} + }, async getJoinedMembers(roomID) { t.equal(roomID, "!hYnGGlPHlbujVVfktC:cadence.moe") return new Promise(resolve => { @@ -123,7 +135,14 @@ test("edit2changes: add caption back to that image (due to it having a reaction, }) test("edit2changes: stickers and attachments are not changed, only the content can be edited", async t => { - const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments, data.guild.general, {}) + let called = 0 + const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments, data.guild.general, { + getEvent(roomID, eventID) { + called++ + t.equal(eventID, "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4") + return {content: {body: "dummy"}} + } + }) t.deepEqual(eventsToRedact, []) t.deepEqual(eventsToSend, []) t.deepEqual(eventsToReplace, [{ @@ -145,10 +164,16 @@ test("edit2changes: stickers and attachments are not changed, only the content c } } }]) + t.equal(called, 1) }) test("edit2changes: edit of reply to skull webp attachment with content", async t => { - const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edit_of_reply_to_skull_webp_attachment_with_content, data.guild.general, {}) + const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edit_of_reply_to_skull_webp_attachment_with_content, data.guild.general, { + getEvent(roomID, eventID) { + t.equal(eventID, "$vgTKOR5ZTYNMKaS7XvgEIDaOWZtVCEyzLLi5Pc5Gz4M") + return {content: {body: "dummy"}} + } + }) t.deepEqual(eventsToRedact, []) t.deepEqual(eventsToSend, []) t.deepEqual(eventsToReplace, [{ @@ -177,7 +202,12 @@ test("edit2changes: edit of reply to skull webp attachment with content", async }) test("edit2changes: edits the text event when multiple rows have part = 0 (should never happen in real life, but make sure the safety net works)", async t => { - const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments_but_all_parts_equal_0, data.guild.general, {}) + const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments_but_all_parts_equal_0, data.guild.general, { + getEvent(roomID, eventID) { + t.equal(eventID, "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd999") + return {content: {body: "dummy"}} + } + }) t.deepEqual(eventsToRedact, []) t.deepEqual(eventsToSend, []) t.deepEqual(eventsToReplace, [{ @@ -202,7 +232,12 @@ test("edit2changes: edits the text event when multiple rows have part = 0 (shoul }) test("edit2changes: promotes the text event when multiple rows have part = 1 (should never happen in real life, but make sure the safety net works)", async t => { - const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments_but_all_parts_equal_1, data.guild.general, {}) + const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments_but_all_parts_equal_1, data.guild.general, { + getEvent(roomID, eventID) { + t.equal(eventID, "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd111") + return {content: {body: "dummy"}} + } + }) t.deepEqual(eventsToRedact, []) t.deepEqual(eventsToSend, []) t.deepEqual(eventsToReplace, [{ @@ -279,32 +314,31 @@ test("edit2changes: generated embed", async t => { }) test("edit2changes: generated embed on a reply", async t => { - const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_on_reply, data.guild.general, {}) + let called = 0 + const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_on_reply, data.guild.general, { + getEvent(roomID, eventID) { + called++ + t.equal(eventID, "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF") + return { + type: "m.room.message", + content: { + // Unfortunately the edited message doesn't include the message_reference field. Fine. Whatever. It looks normal if you're using a good client. + body: "> a Discord user: [Replied-to message content wasn't provided by Discord]" + + "\n\nhttps://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM", + format: "org.matrix.custom.html", + formatted_body: "
In reply to a Discord user
[Replied-to message content wasn't provided by Discord]
https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM", + "m.mentions": {}, + "m.relates_to": { + event_id: "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF", + rel_type: "m.replace", + }, + msgtype: "m.text", + } + } + } + }) t.deepEqual(eventsToRedact, []) - t.deepEqual(eventsToReplace, [{ - oldID: "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF", - newContent: { - $type: "m.room.message", - // Unfortunately the edited message doesn't include the message_reference field. Fine. Whatever. It looks normal if you're using a good client. - body: "> a Discord user: [Replied-to message content wasn't provided by Discord]" - + "\n\n* https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM", - format: "org.matrix.custom.html", - formatted_body: "
In reply to a Discord user
[Replied-to message content wasn't provided by Discord]
* https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM", - "m.mentions": {}, - "m.new_content": { - body: "https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM", - format: "org.matrix.custom.html", - formatted_body: "https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM", - "m.mentions": {}, - msgtype: "m.text", - }, - "m.relates_to": { - event_id: "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF", - rel_type: "m.replace", - }, - msgtype: "m.text", - }, - }]) + t.deepEqual(eventsToReplace, []) t.deepEqual(eventsToSend, [{ $type: "m.room.message", msgtype: "m.notice", @@ -324,4 +358,5 @@ test("edit2changes: generated embed on a reply", async t => { "nextEvent": true, }]) t.equal(senderMxid, "@_ooye_cadence:cadence.moe") + t.equal(called, 1) }) From a968bacffd47f5c85359cd380539f2d7ca17139a Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 3 Sep 2025 00:00:02 +1200 Subject: [PATCH 2/2] Update discord-markdown Interpret channel URLs the same as a channel #mention --- package-lock.json | 58 +++++++++++++++++++++++------------------------ package.json | 4 ++-- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/package-lock.json b/package-lock.json index 803fe53..aa7822f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "AGPL-3.0-or-later", "dependencies": { "@chriscdn/promise-semaphore": "^3.0.1", - "@cloudrac3r/discord-markdown": "^2.6.5", + "@cloudrac3r/discord-markdown": "^2.6.6", "@cloudrac3r/giframe": "^0.4.3", "@cloudrac3r/html-template-tag": "^5.0.1", "@cloudrac3r/in-your-element": "^1.1.1", @@ -35,7 +35,7 @@ "lru-cache": "^11.0.2", "prettier-bytes": "^1.0.4", "sharp": "^0.33.4", - "snowtransfer": "^0.14.2", + "snowtransfer": "^0.15.0", "stream-mime-type": "^1.0.2", "try-to-catch": "^3.0.1", "uqr": "^0.1.2", @@ -119,9 +119,9 @@ } }, "node_modules/@chriscdn/promise-semaphore": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@chriscdn/promise-semaphore/-/promise-semaphore-3.0.1.tgz", - "integrity": "sha512-fVlCnoYE4hDzpcYRPtmN7dmcpmd2zxyPWjyfjIKI9Y+gsI7rwZSkjtuwMi8HFtlkSmNh8L7Zr37hdqeL13sYrw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@chriscdn/promise-semaphore/-/promise-semaphore-3.1.1.tgz", + "integrity": "sha512-ALLLLYlPfd/QZLptcVi6HQRK1zaCDWZoqYYw+axLmCatFs4gVTSZ5nqlyxwFe4qwR/K84HvOMa9hxda881FqMA==", "license": "MIT" }, "node_modules/@cloudcmd/stub": { @@ -225,9 +225,9 @@ } }, "node_modules/@cloudrac3r/discord-markdown": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.6.5.tgz", - "integrity": "sha512-B4uQNsyva5JNW0CVYkcunMQwWfrok1Hd5FYww/cWcvb98zp/pJdJfE3hoRl9EbnxNK2l62IJQ9j8HmssMFHJ9Q==", + "version": "2.6.6", + "resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.6.6.tgz", + "integrity": "sha512-4FNO7WmACPvcTrQjeLQLr9WRuP7JDUVUGFrRJvmAjiMs2UlUAsShfSRuU2SCqz3QqmX8vyJ06wy2hkjTTyRtbw==", "license": "MIT", "dependencies": { "simple-markdown": "^0.7.3" @@ -949,9 +949,9 @@ "license": "MIT" }, "node_modules/@stackoverflow/stacks": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/@stackoverflow/stacks/-/stacks-2.8.3.tgz", - "integrity": "sha512-ZGBeuXJC7moK/f+lgl2dCAW85etD/RO0DNubocdH2qzpJMuuGXX0GMeEAfrTOe+B00I8E1OqTnS1cpkqGdHBdQ==", + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/@stackoverflow/stacks/-/stacks-2.8.4.tgz", + "integrity": "sha512-FfA7Bw7a0AQrMw3/bG6G4BUrZ698F7Cdk6HkR9T7jdaufORkiX5d16wI4j4b5Sqm1FwkaZAF+ZSKLL1w0tAsew==", "license": "MIT", "dependencies": { "@hotwired/stimulus": "^3.2.2", @@ -1107,9 +1107,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "22.17.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.1.tgz", - "integrity": "sha512-y3tBaz+rjspDTylNjAX37jEC3TETEFGNJL6uQDxwF9/8GLLIjW1rvVHlynyuUKMnMr1Roq8jOv3vkopBjC4/VA==", + "version": "22.18.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.0.tgz", + "integrity": "sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1452,13 +1452,13 @@ } }, "node_modules/cloudstorm": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.14.0.tgz", - "integrity": "sha512-EgjMGxb2Z+L6Acti6DzL/bEbR495AIqPThyW4DaG6Jpvd0ZuM5eC13EiyxV8wlqAME612QO2LjqbhkdXn/327Q==", + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.14.1.tgz", + "integrity": "sha512-x95WCKg818E1rE1Ru45NPD3RoIq0pg3WxwvF0GE7Eq07pAeLcjSRqM1lUmbmfjdOqZrWdSRYA1NETVZ8QhVrIA==", "license": "MIT", "dependencies": { - "discord-api-types": "^0.38.12", - "snowtransfer": "^0.14.2" + "discord-api-types": "^0.38.21", + "snowtransfer": "^0.15.0" }, "engines": { "node": ">=22.0.0" @@ -1616,9 +1616,9 @@ } }, "node_modules/discord-api-types": { - "version": "0.38.19", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.19.tgz", - "integrity": "sha512-NUNMTgjYrgxt7wrTNEqnEez4hIAYbfyBpsjxT5gW7+82GjQCPDZvN+em6t+4/P5kGWnnwDa4ci070BV7eI6GbA==", + "version": "0.38.22", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.22.tgz", + "integrity": "sha512-2gnYrgXN3yTlv2cKBISI/A8btZwsSZLwKpIQXeI1cS8a7W7wP3sFVQOm3mPuuinTD8jJCKGPGNH399zE7Un1kA==", "license": "MIT", "workspaces": [ "scripts/actions/documentation" @@ -2719,12 +2719,12 @@ } }, "node_modules/snowtransfer": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.14.2.tgz", - "integrity": "sha512-Fi8OdRmaIgeCj58oVej+tQAoY2I+Xp/6PAYV8X93jE/2E6Anc87SbTbDV6WZXCnuzTQz3gty8JOGz02qI7Qs9A==", + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.15.0.tgz", + "integrity": "sha512-kEDGKtFiH5nSkHsDZonEUuDx99lUasJoZ7AGrgvE8HzVG59vjvqc//C+pjWj4DuJqTj4Q+Z1L/M/MYNim8F2VA==", "license": "MIT", "dependencies": { - "discord-api-types": "^0.38.8" + "discord-api-types": "^0.38.21" }, "engines": { "node": ">=16.15.0" @@ -3447,9 +3447,9 @@ } }, "node_modules/zod": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.0.17.tgz", - "integrity": "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.5.tgz", + "integrity": "sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index a7d3eaa..2fb21f2 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ }, "dependencies": { "@chriscdn/promise-semaphore": "^3.0.1", - "@cloudrac3r/discord-markdown": "^2.6.5", + "@cloudrac3r/discord-markdown": "^2.6.6", "@cloudrac3r/giframe": "^0.4.3", "@cloudrac3r/html-template-tag": "^5.0.1", "@cloudrac3r/in-your-element": "^1.1.1", @@ -44,7 +44,7 @@ "lru-cache": "^11.0.2", "prettier-bytes": "^1.0.4", "sharp": "^0.33.4", - "snowtransfer": "^0.14.2", + "snowtransfer": "^0.15.0", "stream-mime-type": "^1.0.2", "try-to-catch": "^3.0.1", "uqr": "^0.1.2",