Compare commits

...
Sign in to create a new pull request.

11 commits

Author SHA1 Message Date
73f410bcad Emoji name in messages may be uncapped 2026-06-21 20:28:44 +12:00
34fc21fa15 Fix PluralKit replies being duplicated
There was a race condition on create/update that was skipping the
remedial code due to a bad assumption about the speedbump.
2026-06-16 20:11:39 +12:00
ab051f301f Fix polls in threads 2026-06-13 20:27:48 +12:00
51d57051f6 Fix not giving speedbump info when it's bypassed 2026-06-12 18:08:43 +12:00
f7609b2040 Only speedbump users that have used PK 2026-06-06 23:38:49 +12:00
b576869764 v3.6 2026-06-04 18:07:39 +12:00
47dc0504ff Consistent font colour 2026-06-03 00:36:51 +12:00
fbade33ff0 Update language to sound more warningcore 2026-06-03 00:34:37 +12:00
e2ab9fa9bf Improve PK ping message 2026-06-03 00:02:48 +12:00
18b6efdd18 Fix editing permissions interactions not working
Co-authored-by: Cadence Ember <cadence@disroot.org>
2026-06-01 16:55:11 +12:00
313efb29d8 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 <cadence@disroot.org>
Reviewed-on: cadence/out-of-your-element#85
2026-06-01 04:54:38 +00:00
21 changed files with 489 additions and 145 deletions

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "out-of-your-element",
"version": "3.5.1",
"version": "3.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "out-of-your-element",
"version": "3.5.1",
"version": "3.6.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@chriscdn/promise-semaphore": "^3.0.1",

View file

@ -1,6 +1,6 @@
{
"name": "out-of-your-element",
"version": "3.5.1",
"version": "3.6.0",
"description": "A bridge between Matrix and Discord",
"main": "index.js",
"repository": {

View file

@ -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<string>} */
paused = new Set()
/** @private @type {Map<string, ((found: Boolean) => any)[]>} id -> list of resolvers */
resolves = new Map()
/** @private @type {Map<string, ReturnType<setTimeout>>} 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<T>} 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<boolean>} 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<boolean>} 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<boolean>} 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<T>} promise
* @returns {Promise<T>}
*/
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

View file

@ -60,8 +60,7 @@ async function sendMessage(message, channel, guild, row) {
const detailedResultsMessage = await pollEnd.endPoll(message)
if (detailedResultsMessage) {
const threadParent = select("channel_room", "thread_parent", {channel_id: message.channel_id}).pluck().get()
const channelID = threadParent ? threadParent : message.channel_id
const threadID = threadParent ? message.channel_id : undefined
const {channelID, threadID} = dUtils.swapThreadID(message.channel_id, threadParent)
sentResultsMessage = await channelWebhook.sendMessageWithWebhook(channelID, detailedResultsMessage, threadID)
}
}

View file

@ -1,6 +1,5 @@
// @ts-check
const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough")
const {discord, select, db} = passthrough
@ -70,12 +69,18 @@ async function doSpeedbump(messageID) {
* Check whether to slow down a message, and do it. After it passes the speedbump, return whether it's okay or if it's been deleted.
* @param {string} channelID
* @param {string} messageID
* @param {string} [userID] if provided, only slow down the message when the user has used PK before
* @returns whether it was deleted, and data about the channel's (not thread's) speedbump
*/
async function maybeDoSpeedbump(channelID, messageID) {
let row = select("channel_room", ["thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: channelID}).get()
if (row?.thread_parent) row = select("channel_room", ["thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: row.thread_parent}).get() // webhooks belong to the channel, not the thread
if (!row?.speedbump_webhook_id) return {affected: false, row: null} // not affected, no speedbump
async function maybeDoSpeedbump(channelID, messageID, userID) {
let row = select("channel_room", ["room_id", "thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: channelID}).get()
if (row?.thread_parent) row = select("channel_room", ["room_id", "thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: row.thread_parent}).get() // webhooks belong to the channel, not the thread
if (!row?.speedbump_webhook_id) return {affected: false, row: null} // channel not affected, no speedbump
if (userID) {
if (row.speedbump_webhook_id === userID) return {affected: false, row} // shortcut
const userHasProxy = select("sim_proxy", "user_id", {proxy_owner_id: userID}).pluck().get()
if (!userHasProxy) return {affected: false, row} // user has not used PK before, no speedbump
}
const affected = await doSpeedbump(messageID)
return {affected, row} // maybe affected, and there is a speedbump
}

View file

@ -265,8 +265,9 @@ function getFormattedInteraction(interaction, isThinkingInteraction) {
* @param {any} newEvents merge into events
* @param {any} events will be modified
* @param {boolean} forceSameMsgtype whether m.text may only be combined with m.text, etc
* @param {boolean} [forceMerge] if true, must merge event, will error if it had to append
*/
function mergeTextEvents(newEvents, events, forceSameMsgtype) {
function mergeTextEvents(newEvents, events, forceSameMsgtype, forceMerge = false) {
let prev = events.at(-1)
for (const ne of newEvents) {
const isAllText = prev?.body && prev?.formatted_body && ["m.text", "m.notice"].includes(ne.msgtype) && ["m.text", "m.notice"].includes(prev?.msgtype)
@ -278,6 +279,8 @@ function mergeTextEvents(newEvents, events, forceSameMsgtype) {
rep.addLine(ne.body, ne.formatted_body)
prev.body = rep.body
prev.formatted_body = rep.formattedBody
} else if (forceMerge) {
throw new Error("Unable to merge events")
} else {
events.push(ne)
}
@ -554,7 +557,7 @@ async function messageToEvent(message, guild, options = {}, di) {
// Handling emojis that we don't know about. The emoji has to be present in the DB for it to be picked up in the emoji markdown converter.
// So we scan the message ahead of time for all its emojis and ensure they are in the DB.
const emojiMatches = [...content.matchAll(/<(a?):([^:>]{1,64}):([0-9]+)>/g)]
const emojiMatches = [...content.matchAll(/<(a?):([^:>]+):([0-9]+)>/g)]
await Promise.all(emojiMatches.map(match => {
const id = match[3]
const name = match[2]
@ -967,7 +970,8 @@ async function messageToEvent(message, guild, options = {}, di) {
// May only be a section accessory or in an action row (up to 5)
if (component.style === DiscordTypes.ButtonStyle.Link) {
assert(component.label) // required for Discord to validate link buttons
stack.msb.add(`[${component.label} ${component.url}] `, tag`<a href="${component.url}">${component.label}</a> `)
const link = await transformContentMessageLinks(component.url)
stack.msb.add(`[${component.label} ${link}] `, tag`<a href="${link}">${component.label}</a> `)
}
}
@ -980,7 +984,19 @@ async function messageToEvent(message, guild, options = {}, di) {
const {body, formatted_body} = stack.msb.get()
if (body.trim().length) {
await addTextEvent(body, formatted_body, "m.text")
// Create new message if Components V2 (cannot have regular content)
if ((message.flags ?? 0) & DiscordTypes.MessageFlags.IsComponentsV2) {
await addTextEvent(body, formatted_body, "m.text")
}
// Add to existing message if legacy components https://docs.discord.com/developers/components/reference#legacy-message-component-behavior
else {
mergeTextEvents([{
msgtype: "m.text",
body,
format: "org.matrix.custom.html",
formatted_body
}], events, false, true)
}
}
}

View file

@ -1,6 +1,7 @@
const {test} = require("supertape")
const {messageToEvent} = require("./message-to-event")
const data = require("../../../test/data")
const {mockGetEffectivePower} = require("../../matrix/utils.test")
test("message2event components: pk question mark output", async t => {
const events = await messageToEvent(data.message_with_components.pk_question_mark_response, data.guild.general, {})
@ -77,3 +78,24 @@ test("message2event components: pk question mark output", async t => {
msgtype: "m.text",
}])
})
test("message2event components: pk ping message legacy components", async t => {
const events = await messageToEvent(data.message_with_components.pk_ping_components_v1, data.guild.general, {}, {
api: {
async getJoinedMembers() {
return {joined: {}}
},
getEffectivePower: mockGetEffectivePower()
}
})
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "❭ cadence used `/🔔 Ping author`"
+ "\nPsst, **Red** (@cadence.worm:), you have been pinged by @cadence.worm:."
+ "\n[Jump https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$l9FMmsEbh9K0NUReeEpWOMZYGRlUOE8yLcm6P-TYHSM?via=cadence.moe] ",
format: "org.matrix.custom.html",
formatted_body: "<blockquote><sub>❭ <a href=\"https://matrix.to/#/@_ooye_cadence:cadence.moe\">cadence</a> used <code>/🔔 Ping author</code></sub></blockquote>Psst, <strong>Red</strong> (<a href=\"https://matrix.to/#/@_ooye_cadence:cadence.moe\">@cadence.worm</a>), you have been pinged by <a href=\"https://matrix.to/#/@_ooye_cadence:cadence.moe\">@cadence.worm</a>.<br><a href=\"https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$l9FMmsEbh9K0NUReeEpWOMZYGRlUOE8yLcm6P-TYHSM?via=cadence.moe\">Jump</a> ",
"m.mentions": {}
}])
})

View file

@ -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) {

View file

@ -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")
@ -310,13 +313,13 @@ module.exports = {
if (!createRoom.existsOrAutocreatable(channel, guild.id)) return // Check that the sending-to room exists or is autocreatable
const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id)
const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id, message.author.id)
if (affected) return
// @ts-ignore
await sendMessage.sendMessage(message, channel, guild, row)
retrigger.messageFinishedBridging(message.id)
retrigger.finishedBridging(message.id)
},
/**
@ -332,13 +335,11 @@ module.exports = {
if (dUtils.isEphemeralMessage(data)) return // Ephemeral messages are for the eyes of the receiver only!
// Edits need to go through the speedbump as well. If the message is delayed but the edit isn't, we don't have anything to edit from.
const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id)
const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id, data.author.id)
if (affected) return
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
}
// Check that the sending-to room exists, and deal with Eventual Consistency(TM)
if (!await retrigger.waitForMessage(data.id)) return
/** @type {DiscordTypes.GatewayMessageCreateDispatchData} */
// @ts-ignore
@ -375,6 +376,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 +395,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 +443,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)
},

View file

@ -104,6 +104,16 @@ class From {
return r
}
pluckUnsafe(col) {
/** @type {Pluck<Table, any>} */
// @ts-ignore
const r = this
r.cols = [col]
r.makeColsSafe = false
r.isPluck = true
return r
}
/**
* @param {string} sql
*/

View file

@ -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)
})

View file

@ -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

View file

@ -182,6 +182,18 @@ function filterTo(xs, fn) {
return filtered
}
/**
* The parameters correspond to the columns of the channel_room table.
* @param {string} rowChannelID thread ID, OR channel ID if there is no thread
* @param {string | null | undefined} rowThreadParent channel ID if there is a thread
*/
function swapThreadID(rowChannelID, rowThreadParent) {
return {
channelID: rowThreadParent ? rowThreadParent : rowChannelID,
threadID: rowThreadParent ? rowChannelID : undefined
}
}
const supportedPlaintextPreviewExtensions = new Set([
"4d",
"abnf",
@ -582,4 +594,5 @@ module.exports.timestampToSnowflakeInexact = timestampToSnowflakeInexact
module.exports.getPublicUrlForCdn = getPublicUrlForCdn
module.exports.howOldUnbridgedMessage = howOldUnbridgedMessage
module.exports.filterTo = filterTo
module.exports.swapThreadID = swapThreadID
module.exports.supportedPlaintextPreviewExtensions = supportedPlaintextPreviewExtensions

View file

@ -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

View file

@ -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

View file

@ -1,18 +1,12 @@
// @ts-check
const Ty = require("../../types")
const DiscordTypes = require("discord-api-types/v10")
const {Readable} = require("stream")
const assert = require("assert").strict
const crypto = require("crypto")
const passthrough = require("../../passthrough")
const {sync, discord, db, select} = passthrough
const {sync, db, select} = passthrough
const {reg} = require("../../matrix/read-registration")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/utils")} */
const utils = sync.require("../../matrix/utils")
/** @type {import("../../discord/utils")} */
const dUtils = sync.require("../../discord/utils")
/** @type {import("../converters/poll-components")} */
const pollComponents = sync.require("../converters/poll-components")
/** @type {import("./channel-webhook")} */
@ -33,10 +27,11 @@ async function updateVote(event) {
// If poll was started on Matrix, the Discord version is using components, so we can update that to the current status
if (messageRow.source === 0) {
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
assert(channelID)
await webhook.editMessageWithWebhook(channelID, messageID, pollComponents.getPollComponentsFromDatabase(messageID))
const row = select("channel_room", ["channel_id", "thread_parent"], {room_id: event.room_id}).get()
assert(row)
const {channelID, threadID} = dUtils.swapThreadID(row.channel_id, row.thread_parent)
await webhook.editMessageWithWebhook(channelID, messageID, pollComponents.getPollComponentsFromDatabase(messageID), threadID)
}
}
module.exports.updateVote = updateVote
module.exports.updateVote = updateVote

View file

@ -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)
}))

View file

@ -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() {

View file

@ -122,7 +122,7 @@ block body
#role-add.s-popover(popover style="display: revert").ws2.px0.py4.bs-lg.overflow-visible
.s-popover--arrow.s-popover--arrow__tc
+add-roles-menu(guild, guild_id)
p.fc-medium.mb0.mt8 Matrix users will start with these roles. If your main channels are gated by a role, use this to let Matrix users skip the gate.
p.fc-light.mb0.mt8 Matrix users will start with these roles. If your main channels are gated by a role, use this to let Matrix users skip the gate.
h3.mt32.fs-category Features
.s-card.d-grid.px0.g16
@ -191,14 +191,14 @@ block body
label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id)
+discord(channel, true, "Announcement")
else
.s-empty-state.p8 All Discord channels are linked.
.s-empty-state.p8 No Discord channels available.
.fl-grow1.s-btn-group.fd-column.w30
each room in unlinkedRooms
input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id)
label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id)
+matrix(room, true)
else
.s-empty-state.p8 All Matrix rooms are linked.
.s-empty-state.p8 No Matrix rooms available.
input(type="hidden" name="guild_id" value=guild_id)
div
button.s-btn.s-btn__icon.s-btn__filled#link-button

View file

@ -5473,6 +5473,189 @@ module.exports = {
content: '-# Original Message ID: 1466556003645657118 · <t:1769724599:f>'
}
]
},
pk_ping_components_v1: {
type: 23,
content: "Psst, **Red** (<@772659086046658620>), you have been pinged by <@772659086046658620>.",
mentions: [
{
id: "772659086046658620",
username: "cadence.worm",
avatar: "466df0c98b1af1e1388f595b4c1ad1b9",
discriminator: "0",
public_flags: 0,
flags: 0,
banner: null,
accent_color: null,
global_name: "cadence",
avatar_decoration_data: null,
collectibles: null,
display_name_styles: null,
banner_color: null,
clan: {
identity_guild_id: "532245108070809601",
identity_enabled: true,
tag: "doll",
badge: "dba08126b4e810a0e096cc7cd5bc37f0"
},
primary_guild: {
identity_guild_id: "532245108070809601",
identity_enabled: true,
tag: "doll",
badge: "dba08126b4e810a0e096cc7cd5bc37f0"
}
}
],
mention_roles: [],
attachments: [],
embeds: [],
timestamp: "2026-03-25T07:07:02.626000+00:00",
edited_timestamp: null,
flags: 0,
components: [
{
type: 1,
id: 1,
components: [
{
type: 2,
id: 2,
style: 5,
label: "Jump",
url: "https://discord.com/channels/1160893336324931584/1160894080998461480/1440549403667468320"
}
]
}
],
id: "1486260105908457653",
channel_id: "1160894080998461480",
author: {
id: "466378653216014359",
username: "PluralKit",
avatar: "b78ef67a081737a830b60aa47d9ebcd9",
discriminator: "4020",
public_flags: 65536,
flags: 65536,
bot: true,
banner: null,
accent_color: null,
global_name: null,
avatar_decoration_data: null,
collectibles: null,
display_name_styles: null,
banner_color: null,
clan: null,
primary_guild: null
},
pinned: false,
mention_everyone: false,
tts: false,
application_id: "466378653216014359",
interaction: {
id: "1486260103928614932",
type: 2,
name: "🔔 Ping author",
user: {
id: "772659086046658620",
username: "cadence.worm",
avatar: "466df0c98b1af1e1388f595b4c1ad1b9",
discriminator: "0",
public_flags: 0,
flags: 0,
banner: null,
accent_color: null,
global_name: "cadence",
avatar_decoration_data: null,
collectibles: null,
display_name_styles: null,
banner_color: null,
clan: {
identity_guild_id: "532245108070809601",
identity_enabled: true,
tag: "doll",
badge: "dba08126b4e810a0e096cc7cd5bc37f0"
},
primary_guild: {
identity_guild_id: "532245108070809601",
identity_enabled: true,
tag: "doll",
badge: "dba08126b4e810a0e096cc7cd5bc37f0"
}
}
},
webhook_id: "466378653216014359",
message_reference: {
type: 0,
channel_id: "1160894080998461480",
message_id: "1440549403667468320",
guild_id: "1160893336324931584"
},
interaction_metadata: {
id: "1486260103928614932",
type: 2,
user: {
id: "772659086046658620",
username: "cadence.worm",
avatar: "466df0c98b1af1e1388f595b4c1ad1b9",
discriminator: "0",
public_flags: 0,
flags: 0,
banner: null,
accent_color: null,
global_name: "cadence",
avatar_decoration_data: null,
collectibles: null,
display_name_styles: null,
banner_color: null,
clan: {
identity_guild_id: "532245108070809601",
identity_enabled: true,
tag: "doll",
badge: "dba08126b4e810a0e096cc7cd5bc37f0"
},
primary_guild: {
identity_guild_id: "532245108070809601",
identity_enabled: true,
tag: "doll",
badge: "dba08126b4e810a0e096cc7cd5bc37f0"
}
},
authorizing_integration_owners: { "0": "1160893336324931584" },
name: "🔔 Ping author",
command_type: 3,
target_message_id: "1440549403667468320"
},
referenced_message: {
type: 0,
content: "test",
mentions: [],
mention_roles: [],
attachments: [],
embeds: [],
timestamp: "2025-11-19T03:49:01.948000+00:00",
edited_timestamp: null,
flags: 0,
components: [],
id: "1440549403667468320",
channel_id: "1160894080998461480",
author: {
id: "1195662438662680720",
username: "special name",
avatar: "a82347890f2739e5880cd82b8c1a708e",
discriminator: "0000",
public_flags: 0,
flags: 0,
bot: true,
global_name: null,
clan: null,
primary_guild: null
},
pinned: false,
mention_everyone: false,
tts: false,
application_id: "466378653216014359",
webhook_id: "1195662438662680720"
}
}
},
message_update: {

View file

@ -95,7 +95,8 @@ WITH a (message_id, channel_id) AS (VALUES
('1381212840957972480', '112760669178241024'),
('1401760355339862066', '112760669178241024'),
('1439351590262800565', '1438284564815548418'),
('1404133238414376971', '112760669178241024'))
('1404133238414376971', '112760669178241024'),
('1440549403667468320', '1160894080998461480'))
SELECT message_id, max(historical_room_index) as historical_room_index FROM a INNER JOIN historical_channel_room ON historical_channel_room.reference_channel_id = a.channel_id GROUP BY message_id;
INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES
@ -143,7 +144,8 @@ INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part
('$7P2O_VTQNHvavX5zNJ35DV-dbJB1Ag80tGQP_JzGdhk', 'm.room.message', 'm.text', '1401760355339862066', 0, 0, 0),
('$ielAnR6geu0P1Tl5UXfrbxlIf-SV9jrNprxrGXP3v7M', 'm.room.message', 'm.image', '1439351590262800565', 0, 0, 0),
('$uUKLcTQvik5tgtTGDKuzn0Ci4zcCvSoUcYn2X7mXm9I', 'm.room.message', 'm.text', '1404133238414376971', 0, 1, 1),
('$LhmoWWvYyn5_AHkfb6FaXmLI6ZOC1kloql5P40YDmIk', 'm.room.message', 'm.notice', '1404133238414376971', 1, 0, 1);
('$LhmoWWvYyn5_AHkfb6FaXmLI6ZOC1kloql5P40YDmIk', 'm.room.message', 'm.notice', '1404133238414376971', 1, 0, 1),
('$l9FMmsEbh9K0NUReeEpWOMZYGRlUOE8yLcm6P-TYHSM', 'm.room.message', 'm.text', '1440549403667468320', 0, 0, 1);
INSERT INTO file (discord_url, mxc_url) VALUES
('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'),