From de8e9b693c7ca861f68a960136a477ada08044de Mon Sep 17 00:00:00 2001 From: Ellie Algase Date: Sat, 4 Apr 2026 18:42:58 -0500 Subject: [PATCH 1/8] Don't delete our reaction on Discord unless we have 0 of that reaction coming from Matrix. --- src/m2d/actions/redact.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/m2d/actions/redact.js b/src/m2d/actions/redact.js index 3135d31..83cccf6 100644 --- a/src/m2d/actions/redact.js +++ b/src/m2d/actions/redact.js @@ -45,8 +45,9 @@ async function removeReaction(event) { const row = from("reaction").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") .select("reference_channel_id", "message_id", "encoded_emoji").where({hashed_event_id: hash}).get() if (!row) return - await discord.snow.channel.deleteReactionSelf(row.reference_channel_id, row.message_id, row.encoded_emoji) db.prepare("DELETE FROM reaction WHERE hashed_event_id = ?").run(hash) + const remainingReactions = db.prepare("SELECT count(*) as count FROM REACTION WHERE message_id = ? AND encoded_emoji = ?").pluck().get(row.message_id, row.encoded_emoji) // see if we have any remaining Matrix-side reactions + if (remainingReactions == 0) await discord.snow.channel.deleteReactionSelf(row.reference_channel_id, row.message_id, row.encoded_emoji) } /** From e21cb15c11557069ce55abe858b6376f91275d86 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 5 Apr 2026 13:30:47 +1200 Subject: [PATCH 2/8] make it better --- src/m2d/actions/redact.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/m2d/actions/redact.js b/src/m2d/actions/redact.js index 83cccf6..f7b562f 100644 --- a/src/m2d/actions/redact.js +++ b/src/m2d/actions/redact.js @@ -45,9 +45,12 @@ async function removeReaction(event) { const row = from("reaction").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") .select("reference_channel_id", "message_id", "encoded_emoji").where({hashed_event_id: hash}).get() if (!row) return + // See how many Matrix-side reactions there are, and delete if it's the last one + const numberOfReactions = from("reaction").selectUnsafe("count(*)").where({message_id: row.message_id, encoded_emoji: row.encoded_emoji}).prepare().pluck().get() + if (numberOfReactions === 1) { + await discord.snow.channel.deleteReactionSelf(row.reference_channel_id, row.message_id, row.encoded_emoji) + } db.prepare("DELETE FROM reaction WHERE hashed_event_id = ?").run(hash) - const remainingReactions = db.prepare("SELECT count(*) as count FROM REACTION WHERE message_id = ? AND encoded_emoji = ?").pluck().get(row.message_id, row.encoded_emoji) // see if we have any remaining Matrix-side reactions - if (remainingReactions == 0) await discord.snow.channel.deleteReactionSelf(row.reference_channel_id, row.message_id, row.encoded_emoji) } /** From 1879eac26c53d9c8b0b3b23ebf67e54c3fa82a34 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 5 Apr 2026 15:06:07 +1200 Subject: [PATCH 3/8] Add pluckUnsafe to ORM --- src/db/orm.js | 10 ++++++++++ src/db/orm.test.js | 5 +++++ src/m2d/actions/redact.js | 2 +- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/db/orm.js b/src/db/orm.js index 4d9b6f1..8763314 100644 --- a/src/db/orm.js +++ b/src/db/orm.js @@ -104,6 +104,16 @@ class From { return r } + pluckUnsafe(col) { + /** @type {Pluck} */ + // @ts-ignore + const r = this + r.cols = [col] + r.makeColsSafe = false + r.isPluck = true + return r + } + /** * @param {string} sql */ diff --git a/src/db/orm.test.js b/src/db/orm.test.js index 6f6018e..4639090 100644 --- a/src/db/orm.test.js +++ b/src/db/orm.test.js @@ -68,3 +68,8 @@ test("orm: select unsafe works (to select complex column names that can't be typ .all() t.equal(results[0].power_level, 150) }) + +test("orm: pluck unsafe works (to select complex column names that can't be type verified)", t => { + const result = from("channel_room").where({guild_id: "112760669178241024"}).pluckUnsafe("count(*)").get() + t.equal(result, 7) +}) diff --git a/src/m2d/actions/redact.js b/src/m2d/actions/redact.js index f7b562f..c072c53 100644 --- a/src/m2d/actions/redact.js +++ b/src/m2d/actions/redact.js @@ -46,7 +46,7 @@ async function removeReaction(event) { .select("reference_channel_id", "message_id", "encoded_emoji").where({hashed_event_id: hash}).get() if (!row) return // See how many Matrix-side reactions there are, and delete if it's the last one - const numberOfReactions = from("reaction").selectUnsafe("count(*)").where({message_id: row.message_id, encoded_emoji: row.encoded_emoji}).prepare().pluck().get() + const numberOfReactions = from("reaction").where({message_id: row.message_id, encoded_emoji: row.encoded_emoji}).pluckUnsafe("count(*)").get() if (numberOfReactions === 1) { await discord.snow.channel.deleteReactionSelf(row.reference_channel_id, row.message_id, row.encoded_emoji) } From 5f27fedd8680e56003e49ec74012c10c8c82146f Mon Sep 17 00:00:00 2001 From: Ellie Algase Date: Sun, 5 Apr 2026 03:26:28 -0500 Subject: [PATCH 4/8] Add retriggering code for reactions. --- src/d2m/actions/retrigger.js | 29 ++++++++++++++++++++++++++++- src/m2d/actions/add-reaction.js | 2 ++ src/m2d/actions/redact.js | 2 ++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/d2m/actions/retrigger.js b/src/d2m/actions/retrigger.js index 66ef19e..d4cef62 100644 --- a/src/d2m/actions/retrigger.js +++ b/src/d2m/actions/retrigger.js @@ -2,7 +2,9 @@ const {EventEmitter} = require("events") const passthrough = require("../../passthrough") -const {select} = passthrough +const {select, sync} = passthrough +/** @type {import("../../matrix/utils")} */ +const utils = sync.require("../../matrix/utils") const DEBUG_RETRIGGER = false @@ -57,6 +59,30 @@ function eventNotFoundThenRetrigger(inputID, fn, ...rest) { return true // event was not found, then retrigger } +function reactionNotFoundThenRetrigger(reactionEventID, fn, ...rest){ + const reactionEventHash = utils.getEventIDHash(reactionEventID) + const reaction = select("reaction", "encoded_emoji", {hashed_event_id: reactionEventHash}) + if (reaction) { + debugRetrigger(`[retrigger] OK eid <-> reaction = ${reactionEventID} <-> ${reactionEventHash}`) + return false + } + + debugRetrigger(`[retrigger] WAIT id = ${reactionEventID}`) + emitter.once(reactionEventID, () => { + debugRetrigger(`[retrigger] TRIGGER id = ${reactionEventID}`) + fn(...rest) + }) + // if the event never arrives, don't trigger the callback, just clean up + setTimeout(() => { + if (emitter.listeners(reactionEventID).length) { + debugRetrigger(`[retrigger] EXPIRE id = ${reactionEventID}`) + } + emitter.removeAllListeners(reactionEventID) + }, 60 * 1000) // 1 minute + return true // event was not found, then retrigger + +} + /** * Anything calling retrigger during the callback will be paused and retriggered after the callback resolves. * @template T @@ -88,5 +114,6 @@ function messageFinishedBridging(messageID) { } module.exports.eventNotFoundThenRetrigger = eventNotFoundThenRetrigger +module.exports.reactionNotFoundThenRetrigger = reactionNotFoundThenRetrigger module.exports.messageFinishedBridging = messageFinishedBridging module.exports.pauseChanges = pauseChanges diff --git a/src/m2d/actions/add-reaction.js b/src/m2d/actions/add-reaction.js index e4981fb..0c07363 100644 --- a/src/m2d/actions/add-reaction.js +++ b/src/m2d/actions/add-reaction.js @@ -50,6 +50,8 @@ async function addReaction(event) { } db.prepare("REPLACE INTO reaction (hashed_event_id, message_id, encoded_emoji, original_encoding) VALUES (?, ?, ?, ?)").run(utils.getEventIDHash(event.event_id), messageID, discordPreferredEncoding, key) + + retrigger.messageFinishedBridging(event.event_id) } module.exports.addReaction = addReaction diff --git a/src/m2d/actions/redact.js b/src/m2d/actions/redact.js index c072c53..d647602 100644 --- a/src/m2d/actions/redact.js +++ b/src/m2d/actions/redact.js @@ -41,6 +41,8 @@ async function suppressEmbeds(event) { * @param {Ty.Event.Outer_M_Room_Redaction} event */ async function removeReaction(event) { + if (retrigger.reactionNotFoundThenRetrigger(event.redacts, () => as.emit("type:m.room.redaction", event))) return + const hash = utils.getEventIDHash(event.redacts) const row = from("reaction").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") .select("reference_channel_id", "message_id", "encoded_emoji").where({hashed_event_id: hash}).get() From a0f8b02c5589d381ebdace2c756b6c7727dc307a Mon Sep 17 00:00:00 2001 From: Ellie Algase Date: Wed, 8 Apr 2026 23:02:40 -0500 Subject: [PATCH 5/8] Rewrite retriggering to use async, and make sure we don't double-bridge when we remove the bot's reaction on Discord. Co-authored-by: Cadence Ember --- src/d2m/actions/remove-reaction.js | 1 + src/d2m/actions/retrigger-original.js | 143 +++++++++++++++++++ src/d2m/actions/retrigger.js | 190 ++++++++++++++++---------- src/d2m/converters/remove-reaction.js | 3 +- src/d2m/event-dispatcher.js | 24 +++- src/m2d/actions/add-reaction.js | 4 +- src/m2d/actions/redact.js | 39 ++++-- src/m2d/event-dispatcher.js | 4 +- src/stdin.js | 2 + 9 files changed, 312 insertions(+), 98 deletions(-) create mode 100644 src/d2m/actions/retrigger-original.js diff --git a/src/d2m/actions/remove-reaction.js b/src/d2m/actions/remove-reaction.js index af7fd6a..9a9a279 100644 --- a/src/d2m/actions/remove-reaction.js +++ b/src/d2m/actions/remove-reaction.js @@ -33,6 +33,7 @@ async function removeSomeReactions(data) { ( "user_id" in data ? removeReaction(data, reactions) : "emoji" in data ? removeEmojiReaction(data, reactions) : removeAllReactions(data, reactions)) + console.log("ELLIE TEST: data:", data, "removals:", removals) // Redact the events and delete individual stored events in the database for (const removal of removals) { diff --git a/src/d2m/actions/retrigger-original.js b/src/d2m/actions/retrigger-original.js new file mode 100644 index 0000000..026158c --- /dev/null +++ b/src/d2m/actions/retrigger-original.js @@ -0,0 +1,143 @@ +// we are going to delete this file before committing +// just here so it's easy to see what our prior code was while programming the new version. + +// @ts-check + +const {EventEmitter} = require("events") +const passthrough = require("../../passthrough") +const {select, sync} = passthrough +/** @type {import("../../matrix/utils")} */ +const utils = sync.require("../../matrix/utils") + +/* + Due to Eventual Consistency(TM) an update/delete may arrive before the original message arrives + (or before the it has finished being bridged to an event). + In this case, wait until the original message has finished bridging, then retrigger the passed function. +*/ + +const DEBUG_RETRIGGER = false + +function debugRetrigger(message) { + if (DEBUG_RETRIGGER) { + console.log(message) + } +} + +const paused = new Set() +const emitter = new EventEmitter() + +/** + * @template {(...args: any[]) => any} T + * @param {string} eventID + * @param {T} fn + * @param {Parameters} rest + * @returns {boolean} false if the event was found and the function will be ignored, true if the event was not found and the function will be retriggered + */ +function eventNotFoundThenRetrigger(eventID, fn, ...rest) { + if (!paused.has(eventID)) { + const messageID = select("event_message", "message_id", {event_id: eventID}).pluck().get() + if (messageID) { + debugRetrigger(`[retrigger] OK eid <-> mid = ${eventID} <-> ${messageID}`) + return false // message was found so don't retrigger + } + } + + waitThenRetrigger(eventID, fn, ...rest) + return true +} + +/** + * @template {(...args: any[]) => any} T + * @param {string} messageID + * @param {T} fn + * @param {Parameters} rest + * @returns {boolean} false if the event was found and the function will be ignored, true if the event was not found and the function will be retriggered + */ +function messageNotFoundThenRetrigger(messageID, fn, ...rest) { + if (!paused.has(messageID)) { + const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get() + if (eventID) { + debugRetrigger(`[retrigger] OK mid <-> eid = ${messageID} <-> ${eventID}`) + return false // event was found so don't retrigger + } + } + + waitThenRetrigger(messageID, fn, ...rest) + return true +} + +/** + * @template {(...args: any[]) => any} T + * @param {string} reactionEventID + * @param {T} fn + * @param {Parameters} rest + * @returns {boolean} false if the event was found and the function will be ignored, true if the event was not found and the function will be retriggered + */ +function reactionNotFoundThenRetrigger(reactionEventID, fn, ...rest){ + const reactionEventHash = utils.getEventIDHash(reactionEventID) + const reaction = select("reaction", "encoded_emoji", {hashed_event_id: reactionEventHash}) + if (reaction) { + debugRetrigger(`[retrigger] OK eid <-> reaction = ${reactionEventID} <-> ${reactionEventHash}`) + return false + } + + waitThenRetrigger(reactionEventID, fn, ...rest) + return true +} + +/** + * @template {(...args: any[]) => any} T + * @param {string} id + * @param {T} fn + * @param {Parameters} rest + */ +function waitThenRetrigger(id, fn, ...rest){ + debugRetrigger(`[retrigger] WAIT id = ${id}`) + emitter.once(id, () => { + debugRetrigger(`[retrigger] TRIGGER id = ${id}`) + fn(...rest) + }) + // if the event never arrives, don't trigger the callback, just clean up + setTimeout(() => { + if (emitter.listeners(id).length) { + debugRetrigger(`[retrigger] EXPIRE id = ${id}`) + } + emitter.removeAllListeners(id) + }, 60 * 1000) // 1 minute +} + +/** + * Anything calling retrigger during the callback will be paused and retriggered after the callback resolves. + * @template T + * @param {string} messageID + * @param {Promise} promise + * @returns {Promise} + */ +async function pauseChanges(messageID, promise) { + try { + debugRetrigger(`[retrigger] PAUSE id = ${messageID}`) + paused.add(messageID) + return await promise + } finally { + debugRetrigger(`[retrigger] RESUME id = ${messageID}`) + paused.delete(messageID) + finishedBridging(messageID) + } +} + +/** + * Triggers any pending operations that were waiting on the corresponding event ID. + * @param {string} id + */ +function finishedBridging(id) { + if (emitter.listeners(id).length) { + debugRetrigger(`[retrigger] EMIT id = ${id}`) + } + emitter.emit(id) +} + +module.exports.eventNotFoundThenRetrigger = eventNotFoundThenRetrigger +module.exports.messageNotFoundThenRetrigger = messageNotFoundThenRetrigger +module.exports.reactionNotFoundThenRetrigger = reactionNotFoundThenRetrigger +module.exports.finishedBridging = finishedBridging +module.exports.pauseChanges = pauseChanges diff --git a/src/d2m/actions/retrigger.js b/src/d2m/actions/retrigger.js index d4cef62..2e241ec 100644 --- a/src/d2m/actions/retrigger.js +++ b/src/d2m/actions/retrigger.js @@ -2,11 +2,17 @@ const {EventEmitter} = require("events") const passthrough = require("../../passthrough") -const {select, sync} = passthrough +const {select, sync, from} = passthrough /** @type {import("../../matrix/utils")} */ const utils = sync.require("../../matrix/utils") -const DEBUG_RETRIGGER = false +/* + Due to Eventual Consistency(TM) an update/delete may arrive before the original message arrives + (or before the it has finished being bridged to an event). + In this case, wait until the original message has finished bridging, then retrigger the passed function. +*/ + +const DEBUG_RETRIGGER = true function debugRetrigger(message) { if (DEBUG_RETRIGGER) { @@ -14,106 +20,140 @@ function debugRetrigger(message) { } } -const paused = new Set() -const emitter = new EventEmitter() +const storage = new class { + /** @private @type {Set} */ + paused = new Set() + /** @private @type {Map any)[]>} id -> list of resolvers */ + resolves = new Map() + /** @private @type {Map>} id -> timer */ + timers = new Map() -/** - * Due to Eventual Consistency(TM) an update/delete may arrive before the original message arrives - * (or before the it has finished being bridged to an event). - * In this case, wait until the original message has finished bridging, then retrigger the passed function. - * @template {(...args: any[]) => any} T - * @param {string} inputID - * @param {T} fn - * @param {Parameters} rest - * @returns {boolean} false if the event was found and the function will be ignored, true if the event was not found and the function will be retriggered - */ -function eventNotFoundThenRetrigger(inputID, fn, ...rest) { - if (!paused.has(inputID)) { - if (inputID.match(/^[0-9]+$/)) { - const eventID = select("event_message", "event_id", {message_id: inputID}).pluck().get() - if (eventID) { - debugRetrigger(`[retrigger] OK mid <-> eid = ${inputID} <-> ${eventID}`) - return false // event was found so don't retrigger - } - } else if (inputID.match(/^\$/)) { - const messageID = select("event_message", "message_id", {event_id: inputID}).pluck().get() - if (messageID) { - debugRetrigger(`[retrigger] OK eid <-> mid = ${inputID} <-> ${messageID}`) - return false // message was found so don't retrigger - } + /** + * The purpose of storage is to store `resolve` and call it at a later time. + * @param {string} id + * @param {(found: Boolean) => any} resolve + */ + store(id, resolve) { + debugRetrigger(`[retrigger] STORE id = ${id}`) + this.resolves.set(id, (this.resolves.get(id) || []).concat(resolve)) // add to list in map value + if (!this.timers.has(id)) { + debugRetrigger(`[retrigger] SET TIMER id = ${id}`) + this.timers.set(id, setTimeout(() => this.resolve(id, false), 60 * 1000).unref()) // 1 minute } } + + /** @param {string} id */ + isNotPaused(id) { + return !storage.paused.has(id) + } - debugRetrigger(`[retrigger] WAIT id = ${inputID}`) - emitter.once(inputID, () => { - debugRetrigger(`[retrigger] TRIGGER id = ${inputID}`) - fn(...rest) - }) - // if the event never arrives, don't trigger the callback, just clean up - setTimeout(() => { - if (emitter.listeners(inputID).length) { - debugRetrigger(`[retrigger] EXPIRE id = ${inputID}`) + /** @param {string} id */ + pause(id) { + debugRetrigger(`[retrigger] PAUSE id = ${id}`) + this.paused.add(id) + } + + /** + * Go through `resolves` storage and resolve them all. (Also resets timer/paused.) + * @param {string} id + * @param {boolean} value + */ + resolve(id, value) { + if (this.paused.has(id)) { + debugRetrigger(`[retrigger] RESUME id = ${id}`) + this.paused.delete(id) } - emitter.removeAllListeners(inputID) - }, 60 * 1000) // 1 minute - return true // event was not found, then retrigger + + if (this.resolves.has(id)) { + debugRetrigger(`[retrigger] RESOLVE ${value} id = ${id}`) + const fns = this.resolves.get(id) || [] + this.resolves.delete(id) + for (const fn of fns) { + fn(value) + } + } + + if (this.timers.has(id)) { + clearTimeout(this.timers.get(id)) + this.timers.delete(id) + } + } } -function reactionNotFoundThenRetrigger(reactionEventID, fn, ...rest){ - const reactionEventHash = utils.getEventIDHash(reactionEventID) - const reaction = select("reaction", "encoded_emoji", {hashed_event_id: reactionEventHash}) - if (reaction) { - debugRetrigger(`[retrigger] OK eid <-> reaction = ${reactionEventID} <-> ${reactionEventHash}`) - return false +/** + * @param {string} id + * @param {(found: Boolean) => any} resolve + * @param {boolean} existsInDatabase + */ +function waitFor(id, resolve, existsInDatabase) { + if (existsInDatabase && storage.isNotPaused(id)) { // if event already exists and isn't paused then resolve immediately + debugRetrigger(`[retrigger] EXISTS id = ${id}`) + return resolve(true) } - debugRetrigger(`[retrigger] WAIT id = ${reactionEventID}`) - emitter.once(reactionEventID, () => { - debugRetrigger(`[retrigger] TRIGGER id = ${reactionEventID}`) - fn(...rest) - }) - // if the event never arrives, don't trigger the callback, just clean up - setTimeout(() => { - if (emitter.listeners(reactionEventID).length) { - debugRetrigger(`[retrigger] EXPIRE id = ${reactionEventID}`) - } - emitter.removeAllListeners(reactionEventID) - }, 60 * 1000) // 1 minute - return true // event was not found, then retrigger + // doesn't exist. wait for it to exist. storage will resolve true if it exists or false if it timed out + return storage.store(id, resolve) +} +const GET_EVENT_PREPARED = from("event_message").select("event_id").and("WHERE event_id = ?").prepare().raw() +/** + * @param {string} eventID + * @returns {Promise} if true then the message did not arrive + */ +function waitForEvent(eventID) { + const {promise, resolve} = Promise.withResolvers() + waitFor(eventID, resolve, !!GET_EVENT_PREPARED.get(eventID)) + return promise +} + +const GET_MESSAGE_PREPARED = from("event_message").select("message_id").and("WHERE message_id = ?").prepare().raw() +/** + * @param {string} messageID + * @returns {Promise} if true then the message did not arrive + */ +function waitForMessage(messageID) { + const {promise, resolve} = Promise.withResolvers() + waitFor(messageID, resolve, !!GET_MESSAGE_PREPARED.get(messageID)) + return promise +} + +const GET_REACTION_EVENT_PREPARED = from("reaction").select("hashed_event_id").and("WHERE hashed_event_id = ?").prepare().raw() +/** + * @param {string} eventID + * @returns {Promise} if true then the message did not arrive + */ +function waitForReactionEvent(eventID) { + const {promise, resolve} = Promise.withResolvers() + waitFor(eventID, resolve, !!GET_REACTION_EVENT_PREPARED.get(utils.getEventIDHash(eventID))) + return promise } /** * Anything calling retrigger during the callback will be paused and retriggered after the callback resolves. * @template T - * @param {string} messageID + * @param {string} id * @param {Promise} promise * @returns {Promise} */ -async function pauseChanges(messageID, promise) { +async function pauseChanges(id, promise) { try { - debugRetrigger(`[retrigger] PAUSE id = ${messageID}`) - paused.add(messageID) + storage.pause(id) return await promise } finally { - debugRetrigger(`[retrigger] RESUME id = ${messageID}`) - paused.delete(messageID) - messageFinishedBridging(messageID) + finishedBridging(id) } } /** * Triggers any pending operations that were waiting on the corresponding event ID. - * @param {string} messageID + * @param {string} id */ -function messageFinishedBridging(messageID) { - if (emitter.listeners(messageID).length) { - debugRetrigger(`[retrigger] EMIT id = ${messageID}`) - } - emitter.emit(messageID) +function finishedBridging(id) { + storage.resolve(id, true) } -module.exports.eventNotFoundThenRetrigger = eventNotFoundThenRetrigger -module.exports.reactionNotFoundThenRetrigger = reactionNotFoundThenRetrigger -module.exports.messageFinishedBridging = messageFinishedBridging +module.exports.waitForMessage = waitForMessage +module.exports.waitForEvent = waitForEvent +module.exports.waitForReactionEvent = waitForReactionEvent module.exports.pauseChanges = pauseChanges +module.exports.finishedBridging = finishedBridging \ No newline at end of file diff --git a/src/d2m/converters/remove-reaction.js b/src/d2m/converters/remove-reaction.js index 4ca22b6..a0529db 100644 --- a/src/d2m/converters/remove-reaction.js +++ b/src/d2m/converters/remove-reaction.js @@ -34,7 +34,8 @@ function removeReaction(data, reactions, key) { // Even though the bridge bot only reacted once on Discord-side, multiple Matrix users may have // reacted on Matrix-side. Semantically, we want to remove the reaction from EVERY Matrix user. // Also need to clean up the database. - const hash = utils.getEventIDHash(event.event_id) + console.log("ELLIE TEST: we are removing all matrix reactions, because we got a removal from Discord") + const hash = utils.getEventIDHash(eventID) removals.push({eventID, mxid: null, hash}) } if (!lookingAtMatrixReaction && !wantToRemoveMatrixReaction) { diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index b6593ec..b9e969c 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -2,6 +2,7 @@ const assert = require("assert").strict const DiscordTypes = require("discord-api-types/v10") +const {id: botID} = require("../../addbot") const {sync, db, select, from} = require("../passthrough") /** @type {import("./actions/send-message")}) */ @@ -38,6 +39,8 @@ const removeMember = sync.require("./actions/remove-member") const vote = sync.require("./actions/poll-vote") /** @type {import("../m2d/event-dispatcher")} */ const matrixEventDispatcher = sync.require("../m2d/event-dispatcher") +/** @type {import("../m2d/actions/redact.js")} */ +const redact = sync.require("../m2d/actions/redact.js") /** @type {import("../discord/interactions/matrix-info")} */ const matrixInfoInteraction = sync.require("../discord/interactions/matrix-info") /** @type {import("../agi/listener")} */ @@ -321,7 +324,7 @@ module.exports = { // @ts-ignore await sendMessage.sendMessage(message, channel, guild, row) - retrigger.messageFinishedBridging(message.id) + retrigger.finishedBridging(message.id) }, /** @@ -342,7 +345,7 @@ module.exports = { if (!row) { // Check that the sending-to room exists, and deal with Eventual Consistency(TM) - if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.MESSAGE_UPDATE, client, data)) return + if (!await retrigger.waitForMessage(data.id)) return } /** @type {DiscordTypes.GatewayMessageCreateDispatchData} */ @@ -380,6 +383,17 @@ module.exports = { * @param {DiscordTypes.GatewayMessageReactionRemoveDispatchData | DiscordTypes.GatewayMessageReactionRemoveEmojiDispatchData | DiscordTypes.GatewayMessageReactionRemoveAllDispatchData} data */ async onSomeReactionsRemoved(client, data) { + // Don't attempt to double-bridge our own deleted reactions, this would go badly if there are race conditions + if ("emoji" in data && !data.emoji.name) console.error(":(", data) + if ("user_id" in data && data.user_id === botID && data.emoji.name) { // sure hope data.emoji.name exists here + const encodedEmoji = encodeURIComponent(data.emoji.id ? `${data.emoji.name}:${data.emoji.id}` : data.emoji.name) + const i = redact.ourDeletedReactions.findIndex(x => data.message_id === x.message_id && encodedEmoji === x.encoded_emoji) + if (i !== -1) { + redact.ourDeletedReactions.splice(i, 1) + return + } + } + await removeReaction.removeSomeReactions(data) }, @@ -389,7 +403,7 @@ module.exports = { */ async MESSAGE_DELETE(client, data) { speedbump.onMessageDelete(data.id) - if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.MESSAGE_DELETE, client, data)) return + if (!await retrigger.waitForMessage(data.id)) return await deleteMessage.deleteMessage(data) }, @@ -437,12 +451,12 @@ module.exports = { * @param {DiscordTypes.GatewayMessagePollVoteDispatchData} data */ async MESSAGE_POLL_VOTE_ADD(client, data) { - if (retrigger.eventNotFoundThenRetrigger(data.message_id, module.exports.MESSAGE_POLL_VOTE_ADD, client, data)) return + if (!await retrigger.waitForMessage(data.message_id)) return await vote.addVote(data) }, async MESSAGE_POLL_VOTE_REMOVE(client, data) { - if (retrigger.eventNotFoundThenRetrigger(data.message_id, module.exports.MESSAGE_POLL_VOTE_REMOVE, client, data)) return + if (!await retrigger.waitForMessage(data.message_id)) return await vote.removeVote(data) }, diff --git a/src/m2d/actions/add-reaction.js b/src/m2d/actions/add-reaction.js index 0c07363..c453244 100644 --- a/src/m2d/actions/add-reaction.js +++ b/src/m2d/actions/add-reaction.js @@ -17,7 +17,7 @@ const retrigger = sync.require("../../d2m/actions/retrigger") */ async function addReaction(event) { // Wait until the corresponding channel and message have already been bridged - if (retrigger.eventNotFoundThenRetrigger(event.content["m.relates_to"].event_id, () => as.emit("type:m.reaction", event))) return + if (!await retrigger.waitForEvent(event.content["m.relates_to"].event_id)) return // These will exist because it passed retrigger const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") @@ -51,7 +51,7 @@ async function addReaction(event) { db.prepare("REPLACE INTO reaction (hashed_event_id, message_id, encoded_emoji, original_encoding) VALUES (?, ?, ?, ?)").run(utils.getEventIDHash(event.event_id), messageID, discordPreferredEncoding, key) - retrigger.messageFinishedBridging(event.event_id) + retrigger.finishedBridging(event.event_id) } module.exports.addReaction = addReaction diff --git a/src/m2d/actions/redact.js b/src/m2d/actions/redact.js index d647602..3a174f5 100644 --- a/src/m2d/actions/redact.js +++ b/src/m2d/actions/redact.js @@ -10,6 +10,9 @@ const utils = sync.require("../../matrix/utils") /** @type {import("../../d2m/actions/retrigger")} */ const retrigger = sync.require("../../d2m/actions/retrigger") +/** @type {{message_id: string, encoded_emoji: string}[]} */ +const ourDeletedReactions = [] + /** * @param {Ty.Event.Outer_M_Room_Redaction} event */ @@ -24,6 +27,21 @@ async function deleteMessage(event) { db.prepare("DELETE FROM message_room WHERE message_id = ?").run(rows[0].message_id) } +/** + * @param {Ty.Event.Outer_M_Room_Redaction} event + */ +async function removeMessageEvent(event) { + // Could be for removing a message or suppressing embeds. For more information, the message needs to be bridged first. + if (!await retrigger.waitForEvent(event.redacts)) return + + const row = select("event_message", ["event_type", "event_subtype", "part"], {event_id: event.redacts}).get() + if (row && row.event_type === "m.room.message" && row.event_subtype === "m.notice" && row.part === 1) { + await suppressEmbeds(event) + } else { + await deleteMessage(event) + } +} + /** * @param {Ty.Event.Outer_M_Room_Redaction} event */ @@ -41,7 +59,7 @@ async function suppressEmbeds(event) { * @param {Ty.Event.Outer_M_Room_Redaction} event */ async function removeReaction(event) { - if (retrigger.reactionNotFoundThenRetrigger(event.redacts, () => as.emit("type:m.room.redaction", event))) return + if (!await retrigger.waitForReactionEvent(event.redacts)) return const hash = utils.getEventIDHash(event.redacts) const row = from("reaction").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") @@ -50,6 +68,7 @@ async function removeReaction(event) { // See how many Matrix-side reactions there are, and delete if it's the last one const numberOfReactions = from("reaction").where({message_id: row.message_id, encoded_emoji: row.encoded_emoji}).pluckUnsafe("count(*)").get() if (numberOfReactions === 1) { + ourDeletedReactions.push(row) await discord.snow.channel.deleteReactionSelf(row.reference_channel_id, row.message_id, row.encoded_emoji) } db.prepare("DELETE FROM reaction WHERE hashed_event_id = ?").run(hash) @@ -60,18 +79,12 @@ async function removeReaction(event) { * @param {Ty.Event.Outer_M_Room_Redaction} event */ async function handle(event) { - // If this is for removing a reaction, try it - await removeReaction(event) - - // Or, it might be for removing a message or suppressing embeds. But to do that, the message needs to be bridged first. - if (retrigger.eventNotFoundThenRetrigger(event.redacts, () => as.emit("type:m.room.redaction", event))) return - - const row = select("event_message", ["event_type", "event_subtype", "part"], {event_id: event.redacts}).get() - if (row && row.event_type === "m.room.message" && row.event_subtype === "m.notice" && row.part === 1) { - await suppressEmbeds(event) - } else { - await deleteMessage(event) - } + // Don't know if it's a redaction for a reaction or an event, try both at the same time (otherwise waitFor will block) + await Promise.all([ + removeMessageEvent(event), + removeReaction(event) + ]) } module.exports.handle = handle +module.exports.ourDeletedReactions = ourDeletedReactions \ No newline at end of file diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index c11b696..3d1e090 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -211,7 +211,7 @@ async event => { // @ts-ignore await matrixCommandHandler.execute(event) } - retrigger.messageFinishedBridging(event.event_id) + retrigger.finishedBridging(event.event_id) await api.ackEvent(event) })) @@ -222,7 +222,7 @@ sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker", async event => { if (utils.eventSenderIsFromDiscord(event.sender)) return const messageResponses = await sendEvent.sendEvent(event) - retrigger.messageFinishedBridging(event.event_id) + retrigger.finishedBridging(event.event_id) await api.ackEvent(event) })) diff --git a/src/stdin.js b/src/stdin.js index 2548d42..581ca7a 100644 --- a/src/stdin.js +++ b/src/stdin.js @@ -21,6 +21,8 @@ const speedbump = sync.require("./d2m/actions/speedbump") const ks = sync.require("./matrix/kstate") const setPresence = sync.require("./d2m/actions/set-presence") const channelWebhook = sync.require("./m2d/actions/channel-webhook") +const dUtils = sync.require("./discord/utils") +const mxUtils = sync.require("./matrix/utils") const guildID = "112760669178241024" async function ping() { From 22bebaf0641d84afd101b57d50d24548bccb634d Mon Sep 17 00:00:00 2001 From: Ellie Algase Date: Wed, 8 Apr 2026 23:11:00 -0500 Subject: [PATCH 6/8] cleanup --- src/d2m/actions/retrigger-original.js | 143 -------------------------- src/d2m/converters/remove-reaction.js | 1 - src/stdin.js | 1 + 3 files changed, 1 insertion(+), 144 deletions(-) delete mode 100644 src/d2m/actions/retrigger-original.js diff --git a/src/d2m/actions/retrigger-original.js b/src/d2m/actions/retrigger-original.js deleted file mode 100644 index 026158c..0000000 --- a/src/d2m/actions/retrigger-original.js +++ /dev/null @@ -1,143 +0,0 @@ -// we are going to delete this file before committing -// just here so it's easy to see what our prior code was while programming the new version. - -// @ts-check - -const {EventEmitter} = require("events") -const passthrough = require("../../passthrough") -const {select, sync} = passthrough -/** @type {import("../../matrix/utils")} */ -const utils = sync.require("../../matrix/utils") - -/* - Due to Eventual Consistency(TM) an update/delete may arrive before the original message arrives - (or before the it has finished being bridged to an event). - In this case, wait until the original message has finished bridging, then retrigger the passed function. -*/ - -const DEBUG_RETRIGGER = false - -function debugRetrigger(message) { - if (DEBUG_RETRIGGER) { - console.log(message) - } -} - -const paused = new Set() -const emitter = new EventEmitter() - -/** - * @template {(...args: any[]) => any} T - * @param {string} eventID - * @param {T} fn - * @param {Parameters} rest - * @returns {boolean} false if the event was found and the function will be ignored, true if the event was not found and the function will be retriggered - */ -function eventNotFoundThenRetrigger(eventID, fn, ...rest) { - if (!paused.has(eventID)) { - const messageID = select("event_message", "message_id", {event_id: eventID}).pluck().get() - if (messageID) { - debugRetrigger(`[retrigger] OK eid <-> mid = ${eventID} <-> ${messageID}`) - return false // message was found so don't retrigger - } - } - - waitThenRetrigger(eventID, fn, ...rest) - return true -} - -/** - * @template {(...args: any[]) => any} T - * @param {string} messageID - * @param {T} fn - * @param {Parameters} rest - * @returns {boolean} false if the event was found and the function will be ignored, true if the event was not found and the function will be retriggered - */ -function messageNotFoundThenRetrigger(messageID, fn, ...rest) { - if (!paused.has(messageID)) { - const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get() - if (eventID) { - debugRetrigger(`[retrigger] OK mid <-> eid = ${messageID} <-> ${eventID}`) - return false // event was found so don't retrigger - } - } - - waitThenRetrigger(messageID, fn, ...rest) - return true -} - -/** - * @template {(...args: any[]) => any} T - * @param {string} reactionEventID - * @param {T} fn - * @param {Parameters} rest - * @returns {boolean} false if the event was found and the function will be ignored, true if the event was not found and the function will be retriggered - */ -function reactionNotFoundThenRetrigger(reactionEventID, fn, ...rest){ - const reactionEventHash = utils.getEventIDHash(reactionEventID) - const reaction = select("reaction", "encoded_emoji", {hashed_event_id: reactionEventHash}) - if (reaction) { - debugRetrigger(`[retrigger] OK eid <-> reaction = ${reactionEventID} <-> ${reactionEventHash}`) - return false - } - - waitThenRetrigger(reactionEventID, fn, ...rest) - return true -} - -/** - * @template {(...args: any[]) => any} T - * @param {string} id - * @param {T} fn - * @param {Parameters} rest - */ -function waitThenRetrigger(id, fn, ...rest){ - debugRetrigger(`[retrigger] WAIT id = ${id}`) - emitter.once(id, () => { - debugRetrigger(`[retrigger] TRIGGER id = ${id}`) - fn(...rest) - }) - // if the event never arrives, don't trigger the callback, just clean up - setTimeout(() => { - if (emitter.listeners(id).length) { - debugRetrigger(`[retrigger] EXPIRE id = ${id}`) - } - emitter.removeAllListeners(id) - }, 60 * 1000) // 1 minute -} - -/** - * Anything calling retrigger during the callback will be paused and retriggered after the callback resolves. - * @template T - * @param {string} messageID - * @param {Promise} promise - * @returns {Promise} - */ -async function pauseChanges(messageID, promise) { - try { - debugRetrigger(`[retrigger] PAUSE id = ${messageID}`) - paused.add(messageID) - return await promise - } finally { - debugRetrigger(`[retrigger] RESUME id = ${messageID}`) - paused.delete(messageID) - finishedBridging(messageID) - } -} - -/** - * Triggers any pending operations that were waiting on the corresponding event ID. - * @param {string} id - */ -function finishedBridging(id) { - if (emitter.listeners(id).length) { - debugRetrigger(`[retrigger] EMIT id = ${id}`) - } - emitter.emit(id) -} - -module.exports.eventNotFoundThenRetrigger = eventNotFoundThenRetrigger -module.exports.messageNotFoundThenRetrigger = messageNotFoundThenRetrigger -module.exports.reactionNotFoundThenRetrigger = reactionNotFoundThenRetrigger -module.exports.finishedBridging = finishedBridging -module.exports.pauseChanges = pauseChanges diff --git a/src/d2m/converters/remove-reaction.js b/src/d2m/converters/remove-reaction.js index a0529db..b6b0407 100644 --- a/src/d2m/converters/remove-reaction.js +++ b/src/d2m/converters/remove-reaction.js @@ -34,7 +34,6 @@ function removeReaction(data, reactions, key) { // Even though the bridge bot only reacted once on Discord-side, multiple Matrix users may have // reacted on Matrix-side. Semantically, we want to remove the reaction from EVERY Matrix user. // Also need to clean up the database. - console.log("ELLIE TEST: we are removing all matrix reactions, because we got a removal from Discord") const hash = utils.getEventIDHash(eventID) removals.push({eventID, mxid: null, hash}) } diff --git a/src/stdin.js b/src/stdin.js index 581ca7a..43f9607 100644 --- a/src/stdin.js +++ b/src/stdin.js @@ -15,6 +15,7 @@ const mreq = sync.require("./matrix/mreq") const api = sync.require("./matrix/api") const file = sync.require("./matrix/file") const sendEvent = sync.require("./m2d/actions/send-event") +const redact = sync.require("./m2d/actions/redact") const eventDispatcher = sync.require("./d2m/event-dispatcher") const updatePins = sync.require("./d2m/actions/update-pins") const speedbump = sync.require("./d2m/actions/speedbump") From 622738fcf4e793ef78bf1f7804a4e88168329a5a Mon Sep 17 00:00:00 2001 From: Ellie Algase Date: Thu, 9 Apr 2026 00:26:06 -0500 Subject: [PATCH 7/8] here too --- src/d2m/actions/remove-reaction.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/d2m/actions/remove-reaction.js b/src/d2m/actions/remove-reaction.js index 9a9a279..af7fd6a 100644 --- a/src/d2m/actions/remove-reaction.js +++ b/src/d2m/actions/remove-reaction.js @@ -33,7 +33,6 @@ async function removeSomeReactions(data) { ( "user_id" in data ? removeReaction(data, reactions) : "emoji" in data ? removeEmojiReaction(data, reactions) : removeAllReactions(data, reactions)) - console.log("ELLIE TEST: data:", data, "removals:", removals) // Redact the events and delete individual stored events in the database for (const removal of removals) { From 3160e979a02f70458a40640d7c938aa961ada03e Mon Sep 17 00:00:00 2001 From: Ellie Algase Date: Thu, 9 Apr 2026 00:27:55 -0500 Subject: [PATCH 8/8] oh and here too ugh vscode is annoying --- src/d2m/event-dispatcher.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index b9e969c..4a16097 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -384,7 +384,6 @@ module.exports = { */ async onSomeReactionsRemoved(client, data) { // Don't attempt to double-bridge our own deleted reactions, this would go badly if there are race conditions - if ("emoji" in data && !data.emoji.name) console.error(":(", data) if ("user_id" in data && data.user_id === botID && data.emoji.name) { // sure hope data.emoji.name exists here const encodedEmoji = encodeURIComponent(data.emoji.id ? `${data.emoji.name}:${data.emoji.id}` : data.emoji.name) const i = redact.ourDeletedReactions.findIndex(x => data.message_id === x.message_id && encodedEmoji === x.encoded_emoji)