From de8e9b693c7ca861f68a960136a477ada08044de Mon Sep 17 00:00:00 2001 From: Ellie Algase Date: Sat, 4 Apr 2026 18:42:58 -0500 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] 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 06/11] 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 07/11] 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 08/11] 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) From 28e5bd91a61d54919fe8ae2503c140417e1d36d7 Mon Sep 17 00:00:00 2001 From: Ellie Algase Date: Mon, 1 Jun 2026 00:43:32 -0400 Subject: [PATCH 09/11] deal with situations where Discord doesn't send us an emoji's name when removing it --- src/d2m/actions/retrigger.js | 2 +- src/d2m/event-dispatcher.js | 10 +++++----- src/m2d/actions/redact.js | 10 ++++++---- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/d2m/actions/retrigger.js b/src/d2m/actions/retrigger.js index 2e241ec..43f400d 100644 --- a/src/d2m/actions/retrigger.js +++ b/src/d2m/actions/retrigger.js @@ -12,7 +12,7 @@ const utils = sync.require("../../matrix/utils") In this case, wait until the original message has finished bridging, then retrigger the passed function. */ -const DEBUG_RETRIGGER = true +const DEBUG_RETRIGGER = false function debugRetrigger(message) { if (DEBUG_RETRIGGER) { diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index 4a16097..ffe93b8 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -383,12 +383,12 @@ 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 ("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) + // Don't attempt to double-bridge our own m2d deleted reactions back to Matrix + if ("user_id" in data && data.user_id === botID) { + const emojiIdOrName = data.emoji.id || data.emoji.name + const i = redact.m2dDeletedReactions.findIndex(x => data.message_id === x.messageID && emojiIdOrName === x.emojiIdOrName) if (i !== -1) { - redact.ourDeletedReactions.splice(i, 1) + redact.m2dDeletedReactions.splice(i, 1) return } } diff --git a/src/m2d/actions/redact.js b/src/m2d/actions/redact.js index 3a174f5..7e49753 100644 --- a/src/m2d/actions/redact.js +++ b/src/m2d/actions/redact.js @@ -10,8 +10,8 @@ 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 = [] +/** @type {{messageID: string, emojiIdOrName: string}[]} */ +const m2dDeletedReactions = [] /** * @param {Ty.Event.Outer_M_Room_Redaction} event @@ -68,7 +68,9 @@ 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) + // If a unicode emoji, the name is already the Discord preferred version because that's what was added and stored to encoded_emoji + const emojiIdOrName = decodeURIComponent(row.encoded_emoji).split(":").slice(-1)[0] + m2dDeletedReactions.push({messageID: row.message_id, emojiIdOrName}) 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) @@ -87,4 +89,4 @@ async function handle(event) { } module.exports.handle = handle -module.exports.ourDeletedReactions = ourDeletedReactions \ No newline at end of file +module.exports.m2dDeletedReactions = m2dDeletedReactions \ No newline at end of file From 313efb29d81c7817e028d9b3c2388f2aadfca835 Mon Sep 17 00:00:00 2001 From: Ellie Algase Date: Mon, 1 Jun 2026 04:54:38 +0000 Subject: [PATCH 10/11] Fix m->d reaction deletion counting (#85) Fixes a bug where, if multiple Matrix users had used the same reaction on a message, and then one of those Matrix users removed their reactions, the bot would forcibly remove all of that reactions. Now, we check and make sure there are no remaining reactions from Matrix before removal. This also rewrote the retrigger system to be more generic and to use promises instead of re-entry (would lose call stack). Co-authored-by: Cadence Ember Reviewed-on: https://gitdab.com/cadence/out-of-your-element/pulls/85 --- src/d2m/actions/retrigger.js | 175 ++++++++++++++++++-------- src/d2m/converters/remove-reaction.js | 2 +- src/d2m/event-dispatcher.js | 23 +++- src/db/orm.js | 10 ++ src/db/orm.test.js | 5 + src/m2d/actions/add-reaction.js | 4 +- src/m2d/actions/redact.js | 47 +++++-- src/m2d/event-dispatcher.js | 4 +- src/stdin.js | 3 +- 9 files changed, 196 insertions(+), 77 deletions(-) diff --git a/src/d2m/actions/retrigger.js b/src/d2m/actions/retrigger.js index 66ef19e..43f400d 100644 --- a/src/d2m/actions/retrigger.js +++ b/src/d2m/actions/retrigger.js @@ -2,7 +2,15 @@ const {EventEmitter} = require("events") const passthrough = require("../../passthrough") -const {select} = passthrough +const {select, sync, from} = 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 @@ -12,81 +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) + } + } +} + +/** + * @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) + } + + // 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.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..b6b0407 100644 --- a/src/d2m/converters/remove-reaction.js +++ b/src/d2m/converters/remove-reaction.js @@ -34,7 +34,7 @@ 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) + 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 90824ac..8101a03 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") @@ -316,7 +319,7 @@ module.exports = { // @ts-ignore await sendMessage.sendMessage(message, channel, guild, row) - retrigger.messageFinishedBridging(message.id) + retrigger.finishedBridging(message.id) }, /** @@ -337,7 +340,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} */ @@ -375,6 +378,16 @@ module.exports = { * @param {DiscordTypes.GatewayMessageReactionRemoveDispatchData | DiscordTypes.GatewayMessageReactionRemoveEmojiDispatchData | DiscordTypes.GatewayMessageReactionRemoveAllDispatchData} data */ async onSomeReactionsRemoved(client, data) { + // Don't attempt to double-bridge our own m2d deleted reactions back to Matrix + if ("user_id" in data && data.user_id === botID) { + const emojiIdOrName = data.emoji.id || data.emoji.name + const i = redact.m2dDeletedReactions.findIndex(x => data.message_id === x.messageID && emojiIdOrName === x.emojiIdOrName) + if (i !== -1) { + redact.m2dDeletedReactions.splice(i, 1) + return + } + } + await removeReaction.removeSomeReactions(data) }, @@ -384,7 +397,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) }, @@ -432,12 +445,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/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/add-reaction.js b/src/m2d/actions/add-reaction.js index e4981fb..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") @@ -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.finishedBridging(event.event_id) } module.exports.addReaction = addReaction diff --git a/src/m2d/actions/redact.js b/src/m2d/actions/redact.js index 3135d31..7e49753 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 {{messageID: string, emojiIdOrName: string}[]} */ +const m2dDeletedReactions = [] + /** * @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,11 +59,20 @@ async function suppressEmbeds(event) { * @param {Ty.Event.Outer_M_Room_Redaction} event */ async function removeReaction(event) { + 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") .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) + // 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) { + // If a unicode emoji, the name is already the Discord preferred version because that's what was added and stored to encoded_emoji + const emojiIdOrName = decodeURIComponent(row.encoded_emoji).split(":").slice(-1)[0] + m2dDeletedReactions.push({messageID: row.message_id, emojiIdOrName}) + 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) } @@ -54,18 +81,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.m2dDeletedReactions = m2dDeletedReactions \ No newline at end of file diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 352ca41..3580d1b 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -225,7 +225,7 @@ async event => { // @ts-ignore await matrixCommandHandler.execute(event) } - retrigger.messageFinishedBridging(event.event_id) + retrigger.finishedBridging(event.event_id) await api.ackEvent(event) })) @@ -236,7 +236,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 04b0151..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") @@ -22,7 +23,7 @@ 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 mUtils = sync.require("./matrix/utils") +const mxUtils = sync.require("./matrix/utils") const guildID = "112760669178241024" async function ping() { From 18b6efdd1863dbd88519305862fcfb2587fb5eb4 Mon Sep 17 00:00:00 2001 From: Ellie Algase Date: Mon, 1 Jun 2026 00:08:36 -0400 Subject: [PATCH 11/11] Fix editing permissions interactions not working Co-authored-by: Cadence Ember --- src/discord/register-interactions.js | 54 ++++++++++++---------------- 1 file changed, 23 insertions(+), 31 deletions(-) diff --git a/src/discord/register-interactions.js b/src/discord/register-interactions.js index e3d58c4..66012b4 100644 --- a/src/discord/register-interactions.js +++ b/src/discord/register-interactions.js @@ -91,40 +91,32 @@ function registerInteractions() { async function dispatchInteraction(interaction) { const interactionId = interaction.data?.["custom_id"] || interaction.data?.["name"] try { - if (interaction.type === DiscordTypes.InteractionType.MessageComponent || interaction.type === DiscordTypes.InteractionType.ModalSubmit) { - // All we get is custom_id, don't know which context the button was clicked in. - // So we namespace these ourselves in the custom_id. Currently the only existing namespace is POLL_. - if (interaction.data.custom_id.startsWith("POLL_")) { - await poll.interact(interaction) + if (interactionId === "Matrix info") { + await matrixInfo.interact(interaction) + } else if (interactionId === "invite") { + await invite.interact(interaction) + } else if (interactionId === "invite_channel") { + await invite.interactButton(interaction) + } else if (interactionId === "Permissions") { + await permissions.interact(interaction) + } else if (interactionId === "permissions_edit") { + await permissions.interactEdit(interaction) + } else if (interactionId === "Responses") { + /** @type {DiscordTypes.APIMessageApplicationCommandGuildInteraction} */ // @ts-ignore + const messageInteraction = interaction + if (select("poll", "message_id", {message_id: messageInteraction.data.target_id}).get()) { + await pollResponses.interact(messageInteraction) } else { - throw new Error(`Unknown message component ${interaction.data.custom_id}`) + await reactions.interact(messageInteraction) } + } else if (interactionId === "ping") { + await ping.interact(interaction) + } else if (interactionId === "privacy") { + await privacy.interact(interaction) + } else if (interactionId.startsWith("POLL_")) { + await poll.interact(interaction) } else { - if (interactionId === "Matrix info") { - await matrixInfo.interact(interaction) - } else if (interactionId === "invite") { - await invite.interact(interaction) - } else if (interactionId === "invite_channel") { - await invite.interactButton(interaction) - } else if (interactionId === "Permissions") { - await permissions.interact(interaction) - } else if (interactionId === "permissions_edit") { - await permissions.interactEdit(interaction) - } else if (interactionId === "Responses") { - /** @type {DiscordTypes.APIMessageApplicationCommandGuildInteraction} */ // @ts-ignore - const messageInteraction = interaction - if (select("poll", "message_id", {message_id: messageInteraction.data.target_id}).get()) { - await pollResponses.interact(messageInteraction) - } else { - await reactions.interact(messageInteraction) - } - } else if (interactionId === "ping") { - await ping.interact(interaction) - } else if (interactionId === "privacy") { - await privacy.interact(interaction) - } else { - throw new Error(`Unknown interaction ${interactionId}`) - } + throw new Error(`Unknown interaction ${interactionId}`) } } catch (e) { let stackLines = null