Files
delete-your-element/src/m2d/event-dispatcher.js
T
Ellie Algase 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: https://gitdab.com/cadence/out-of-your-element/pulls/85
2026-06-01 04:54:38 +00:00

522 lines
20 KiB
JavaScript

// @ts-check
/*
* Grab Matrix events we care about, check them, and bridge them.
*/
const util = require("util")
const Ty = require("../types")
const {discord, db, sync, as, select} = require("../passthrough")
const {tag} = require("@cloudrac3r/html-template-tag")
const {Semaphore} = require("@chriscdn/promise-semaphore")
/** @type {import("./actions/send-event")} */
const sendEvent = sync.require("./actions/send-event")
/** @type {import("./actions/add-reaction")} */
const addReaction = sync.require("./actions/add-reaction")
/** @type {import("./actions/redact")} */
const redact = sync.require("./actions/redact")
/** @type {import("./actions/update-pins")}) */
const updatePins = sync.require("./actions/update-pins")
/** @type {import("./actions/vote")}) */
const vote = sync.require("./actions/vote")
/** @type {import("../matrix/matrix-command-handler")} */
const matrixCommandHandler = sync.require("../matrix/matrix-command-handler")
/** @type {import("../matrix/utils")} */
const utils = sync.require("../matrix/utils")
/** @type {import("../matrix/api")}) */
const api = sync.require("../matrix/api")
/** @type {import("../d2m/actions/create-room")} */
const createRoom = sync.require("../d2m/actions/create-room")
/** @type {import("../matrix/room-upgrade")} */
const roomUpgrade = require("../matrix/room-upgrade")
/** @type {import("../d2m/actions/retrigger")} */
const retrigger = sync.require("../d2m/actions/retrigger")
const {reg} = require("../matrix/read-registration")
let lastReportedEvent = 0
/**
* This function is adapted from Evan Kaufman's fantastic work.
* The original function and my adapted function are both MIT licensed.
* @url https://github.com/EvanK/npm-loggable-error/
* @param {number} [depth]
* @returns {string}
*/
function stringifyErrorStack(err, depth = 0) {
let collapsed = " ".repeat(depth);
if (!(err instanceof Error)) {
return collapsed + err
}
// add full stack trace if one exists, otherwise convert to string
let stackLines = String(err?.stack ?? err).replace(/^/gm, " ".repeat(depth)).trim().split("\n")
let cloudstormLine = stackLines.findIndex(l => l.includes("/node_modules/cloudstorm/"))
if (cloudstormLine !== -1) {
stackLines = stackLines.slice(0, cloudstormLine - 2)
}
collapsed += stackLines.join("\n")
const props = Object.getOwnPropertyNames(err).filter(p => !["message", "stack"].includes(p))
// only break into object notation if we have additional props to dump
if (props.length) {
const dedent = " ".repeat(depth);
const indent = " ".repeat(depth + 2);
collapsed += " {\n";
// loop and print each (indented) prop name
for (let property of props) {
collapsed += `${indent}[${property}]: `;
// if another error object, stringify it too
if (err[property] instanceof Error) {
collapsed += stringifyErrorStack(err[property], depth + 2).trimStart();
}
// otherwise stringify as JSON
else {
collapsed += JSON.stringify(err[property]);
}
collapsed += "\n";
}
collapsed += `${dedent}}\n`;
}
return collapsed;
}
function printError(type, source, e, payload) {
console.error(`Error while processing a ${type} ${source} event:`)
console.error(e)
console.dir(payload, {depth: null})
}
/** @param {string} stack */
function cleanErrorStack(stack) {
return stack.replace(/(\/webhooks\/[0-9]+\/)[a-zA-Z0-9_-]+/g, "$1(redacted)")
}
/**
* @param {string} roomID
* @param {"Discord" | "Matrix"} source
* @param {any} type
* @param {any} e
* @param {any} payload
*/
async function sendError(roomID, source, type, e, payload) {
if (source === "Matrix") {
printError(type, source, e, payload)
}
if (Date.now() - lastReportedEvent < 5000) return null
lastReportedEvent = Date.now()
let errorIntroLine = e.toString()
if (e.cause) {
errorIntroLine += ` (cause: ${e.cause})`
}
const builder = new utils.MatrixStringBuilder()
const cloudflareErrorTitle = errorIntroLine.match(/<!DOCTYPE html>.*?<title>discord\.com \| ([^<]*)<\/title>/s)?.[1]
if (cloudflareErrorTitle) {
builder.addLine(
`\u26a0 Matrix event not delivered to Discord. Discord might be down right now. Cloudflare error: ${cloudflareErrorTitle}`,
`\u26a0 <strong>Matrix event not delivered to Discord</strong><br>Discord might be down right now. Cloudflare error: ${cloudflareErrorTitle}`
)
} else {
// What
const what = source === "Discord" ? "Bridged event from Discord not delivered" : "Matrix event not delivered to Discord"
builder.addLine(`\u26a0 ${what}`, `\u26a0 <strong>${what}</strong>`)
// Who
builder.addLine(`Event type: ${type}`)
// Why
builder.addLine(errorIntroLine)
// Where
const stack = cleanErrorStack(stringifyErrorStack(e))
builder.addLine(`Error trace:\n${stack}`, tag`<details><summary>Error trace</summary><pre>${stack}</pre></details>`)
// How
builder.addLine("", tag`<details><summary>Original payload</summary><pre>${util.inspect(payload, false, 4, false)}</pre></details>`)
}
// Send
try {
const errorEventID = await api.sendEvent(roomID, "m.room.message", {
...builder.get(),
"moe.cadence.ooye.error": {
source: source.toLowerCase(),
payload
},
"m.mentions": {
user_ids: ["@cadence:cadence.moe"]
}
})
// Add reaction indicating that errors may be retried
await api.sendEvent(roomID, "m.reaction", {
"m.relates_to": {
rel_type: "m.annotation",
event_id: errorEventID,
key: "🔁"
}
})
} catch (e) {}
}
function guard(type, fn) {
return async function(event, ...args) {
try {
return await fn(event, ...args)
} catch (e) {
await sendError(event.room_id, "Matrix", type, e, event)
}
}
}
const errorRetrySema = new Semaphore()
/**
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>} reactionEvent
*/
async function onRetryReactionAdd(reactionEvent) {
if (reactionEvent.sender === `@${reg.sender_localpart}:${reg.ooye.server_name}`) return // Don't respond to the bot's own indicative reaction
const roomID = reactionEvent.room_id
await errorRetrySema.request(async () => {
const event = await api.getEvent(roomID, reactionEvent.content["m.relates_to"]?.event_id)
// Check that it's a real error from OOYE
const error = event.content["moe.cadence.ooye.error"]
if (event.sender !== `@${reg.sender_localpart}:${reg.ooye.server_name}` || !error) return
// To stop people injecting misleading messages, the reaction needs to come from either the original sender or a room moderator
if (reactionEvent.sender !== error.payload.sender) {
// Check if it's a room moderator
const {powers: {[reactionEvent.sender]: senderPower}, powerLevels} = await utils.getEffectivePower(roomID, [reactionEvent.sender], api)
if (senderPower < (powerLevels.state_default ?? 50)) return
}
// Retry
if (error.source === "matrix") {
as.emit(`type:${error.payload.type}`, error.payload)
} else if (error.source === "discord") {
discord.cloud.emit("event", error.payload)
}
// Redact the error to stop people from executing multiple retries
await api.redactEvent(roomID, event.event_id)
}, roomID)
}
sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message",
/**
* @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} event it is a m.room.message because that's what this listener is filtering for
*/
async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return
const messageResponses = await sendEvent.sendEvent(event)
if (!messageResponses.length) return
if (event.type === "m.room.message" && event.content.msgtype === "m.text") {
// @ts-ignore
await matrixCommandHandler.execute(event)
}
retrigger.finishedBridging(event.event_id)
await api.ackEvent(event)
}))
sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker",
/**
* @param {Ty.Event.Outer_M_Sticker} event it is a m.sticker because that's what this listener is filtering for
*/
async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return
const messageResponses = await sendEvent.sendEvent(event)
retrigger.finishedBridging(event.event_id)
await api.ackEvent(event)
}))
sync.addTemporaryListener(as, "type:org.matrix.msc3381.poll.start", guard("org.matrix.msc3381.poll.start",
/**
* @param {Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Start} event it is a org.matrix.msc3381.poll.start because that's what this listener is filtering for
*/
async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return
const messageResponses = await sendEvent.sendEvent(event)
await api.ackEvent(event)
}))
sync.addTemporaryListener(as, "type:org.matrix.msc3381.poll.response", guard("org.matrix.msc3381.poll.response",
/**
* @param {Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Response} event it is a org.matrix.msc3381.poll.response because that's what this listener is filtering for
*/
async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return
await vote.updateVote(event) // Matrix votes can't be bridged, so all we do is store it in the database.
await api.ackEvent(event)
}))
sync.addTemporaryListener(as, "type:org.matrix.msc3381.poll.end", guard("org.matrix.msc3381.poll.end",
/**
* @param {Ty.Event.Outer_Org_Matrix_Msc3381_Poll_End} event it is a org.matrix.msc3381.poll.end because that's what this listener is filtering for
*/
async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return
const pollEventID = event.content["m.relates_to"]?.event_id
if (!pollEventID) return // Validity check
const messageID = select("event_message", "message_id", {event_id: pollEventID, event_type: "org.matrix.msc3381.poll.start", source: 0}).pluck().get()
if (!messageID) return // Nothing can be done if the parent message was never bridged. Also, Discord-native polls cannot be ended by others, so this only works for polls started on Matrix.
try {
var pollEvent = await api.getEvent(event.room_id, pollEventID) // Poll start event must exist for this to be valid
} catch (e) {
return
}
// According to the rules, the poll end is only allowed if it was sent by the poll starter, or by someone with redact powers.
if (pollEvent.sender !== event.sender) {
const {powerLevels, powers: {[event.sender]: enderPower}} = await utils.getEffectivePower(event.room_id, [event.sender], api)
if (enderPower < (powerLevels.redact ?? 50)) {
return // Not allowed
}
}
const messageResponses = await sendEvent.sendEvent(event)
await api.ackEvent(event)
}))
sync.addTemporaryListener(as, "type:m.reaction", guard("m.reaction",
/**
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>} event it is a m.reaction because that's what this listener is filtering for
*/
async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return
if (event.content["m.relates_to"].key === "🔁") {
// Try to bridge a failed event again?
await onRetryReactionAdd(event)
} else {
matrixCommandHandler.onReactionAdd(event)
await addReaction.addReaction(event)
}
}))
sync.addTemporaryListener(as, "type:m.room.redaction", guard("m.room.redaction",
/**
* @param {Ty.Event.Outer_M_Room_Redaction} event it is a m.room.redaction because that's what this listener is filtering for
*/
async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return
await redact.handle(event)
await api.ackEvent(event)
}))
sync.addTemporaryListener(as, "type:m.room.avatar", guard("m.room.avatar",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Avatar>} event
*/
async event => {
if (event.state_key !== "") return
if (utils.eventSenderIsFromDiscord(event.sender)) return
const url = event.content.url || null
db.prepare("UPDATE channel_room SET custom_avatar = ? WHERE room_id = ?").run(url, event.room_id)
}))
sync.addTemporaryListener(as, "type:m.room.name", guard("m.room.name",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Name>} event
*/
async event => {
if (event.state_key !== "") return
if (utils.eventSenderIsFromDiscord(event.sender)) return
const name = event.content.name || null
db.prepare("UPDATE channel_room SET nick = ? WHERE room_id = ?").run(name, event.room_id)
}))
sync.addTemporaryListener(as, "type:m.room.topic", guard("m.room.topic",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Topic>} event
*/
async event => {
if (event.state_key !== "") return
if (utils.eventSenderIsFromDiscord(event.sender)) return
const customTopic = +!!event.content.topic
const row = select("channel_room", ["channel_id", "custom_topic"], {room_id: event.room_id}).get()
if (!row) return
if (customTopic !== row.custom_topic) db.prepare("UPDATE channel_room SET custom_topic = ? WHERE channel_id = ?").run(customTopic, row.channel_id)
if (!customTopic) await createRoom.syncRoom(row.channel_id) // if it's cleared we should reset it to whatever's on discord
}))
sync.addTemporaryListener(as, "type:m.room.pinned_events", guard("m.room.pinned_events",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_PinnedEvents>} event
*/
async event => {
if (event.state_key !== "") return
if (utils.eventSenderIsFromDiscord(event.sender)) return
const pins = event.content.pinned
if (!Array.isArray(pins)) return
let prev = event.unsigned?.prev_content?.pinned
if (!Array.isArray(prev)) {
if (pins.length === 1) {
/*
In edge cases, prev_content isn't guaranteed to be provided by the server.
If prev_content is missing, we can't diff. Better safe than sorry: we'd like to ignore the change rather than wiping the whole channel's pins on Discord.
However, that would mean if the first ever pin came from Matrix-side, it would be ignored, because there would be no prev_content (it's the first pinned event!)
So to handle that edge case, we assume that if there's exactly 1 entry in `pinned`, this is the first ever pin and it should go through.
*/
prev = []
} else {
return
}
}
await updatePins.updatePins(pins, prev)
await api.ackEvent(event)
}))
sync.addTemporaryListener(as, "type:m.space.child", guard("m.space.child",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Space_Child>} event
*/
async event => {
if (Array.isArray(event.content.via) && event.content.via.length) { // space child is being added
try {
// try to join if able, it's okay if it doesn't want, bot will still respond to invites
await api.joinRoom(event.state_key)
// if autojoined a child space, store it in invite (otherwise the child space will be impossible to use with self-service in the future)
const hierarchy = await api.getHierarchy(event.state_key, {limit: 1})
const roomProperties = hierarchy.rooms?.[0]
if (roomProperties?.room_id === event.state_key && roomProperties.room_type === "m.space" && roomProperties.name) {
db.prepare("INSERT OR IGNORE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)")
.run(event.sender, event.state_key, roomProperties.room_type, roomProperties.name, roomProperties.topic, roomProperties.avatar_url)
await updateMemberCachePowerLevels(event.state_key) // store privileged users in member_cache so they are also allowed to perform self-service
}
} catch (e) {}
}
}))
sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Member>} event
*/
async event => {
if (event.state_key[0] !== "@") return
if (event.state_key === utils.bot) {
const upgraded = await roomUpgrade.onBotMembership(event, api, createRoom)
if (upgraded) return
}
if (event.content.membership === "invite" && event.state_key === utils.bot) {
// Supposed to be here already?
const guildID = select("guild_space", "guild_id", {space_id: event.room_id}).pluck().get()
if (guildID) {
await api.joinRoom(event.room_id)
return
}
// We were invited to a room. We should join, and register the invite details for future reference in web.
try {
var inviteRoomState = await api.getInviteState(event.room_id, event)
} catch (e) {
console.error(e)
return await api.leaveRoomWithReason(event.room_id, `I wasn't able to find out what this room is. Please report this as a bug. Check console for more details. (${e.toString()})`)
}
if (inviteRoomState?.encryption) return await api.leaveRoomWithReason(event.room_id, "Encrypted rooms are not supported for bridging. Please use an unencrypted room.")
if (!inviteRoomState?.name) return await api.leaveRoomWithReason(event.room_id, `Please only invite me to rooms that have a name/avatar set. Update the room details and reinvite.`)
await api.joinRoom(event.room_id)
db.prepare("REPLACE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, inviteRoomState.type, inviteRoomState.name, inviteRoomState.topic, inviteRoomState.avatar)
if (inviteRoomState.avatar) utils.getPublicUrlForMxc(inviteRoomState.avatar) // make sure it's available in the media_proxy allowed URLs
await updateMemberCachePowerLevels(event.room_id) // store privileged users in member_cache so they are also allowed to perform self-service
}
if (event.content.membership === "leave" || event.content.membership === "ban") {
// Member is gone
// if Matrix member, data was cached in member_cache
db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key)
// if Discord member (so kicked/banned by Matrix user), data was cached in sim_member
db.prepare("DELETE FROM sim_member WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key)
// Unregister room's use as a direct chat and/or an invite target if the bot itself left
if (event.state_key === utils.bot) {
db.prepare("DELETE FROM direct WHERE room_id = ?").run(event.room_id)
db.prepare("DELETE FROM invite WHERE room_id = ?").run(event.room_id)
}
}
if (utils.eventSenderIsFromDiscord(event.state_key)) return
const exists = select("channel_room", "room_id", {room_id: event.room_id}) ?? select("guild_space", "space_id", {space_id: event.room_id})
if (!exists) return // don't cache members in unbridged rooms
// Member is here
let {powers: {[event.state_key]: memberPower}, tombstone} = await utils.getEffectivePower(event.room_id, [event.state_key], api)
if (memberPower === Infinity) memberPower = tombstone // database storage compatibility
const displayname = event.content.displayname || null
const avatar_url = event.content.avatar_url
db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url, power_level) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?, power_level = ?, missing_profile = NULL").run(
event.room_id, event.state_key,
displayname, avatar_url, memberPower,
displayname, avatar_url, memberPower
)
}))
sync.addTemporaryListener(as, "type:m.room.power_levels", guard("m.room.power_levels",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Power_Levels>} event
*/
async event => {
if (event.state_key !== "") return
await updateMemberCachePowerLevels(event.room_id)
}))
/**
* @param {string} roomID
*/
async function updateMemberCachePowerLevels(roomID) {
const existingPower = select("member_cache", "mxid", {room_id: roomID}).pluck().all()
const {powerLevels, allCreators, tombstone} = await utils.getEffectivePower(roomID, [], api)
const newPower = powerLevels.users || {}
const newPowerUsers = Object.keys(newPower)
const relevantUsers = existingPower.concat(newPowerUsers).concat(allCreators)
for (const mxid of [...new Set(relevantUsers)]) {
const level = allCreators.includes(mxid) ? tombstone : newPower[mxid] ?? powerLevels.users_default ?? 0
db.prepare("INSERT INTO member_cache (room_id, mxid, power_level, missing_profile) VALUES (?, ?, ?, 1) ON CONFLICT DO UPDATE SET power_level = ?")
.run(roomID, mxid, level, level)
}
}
sync.addTemporaryListener(as, "type:m.room.tombstone", guard("m.room.tombstone",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Tombstone>} event
*/
async event => {
if (event.state_key !== "") return
if (!event.content.replacement_room) return
await roomUpgrade.onTombstone(event, api)
}))
sync.addTemporaryListener(as, "type:m.room.encryption", guard("m.room.encryption",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Encryption>} event
*/
async event => {
// Dramatically unbridge rooms if they become encrypted
if (event.state_key !== "") return
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
if (!channelID) return
const channel = discord.channels.get(channelID)
if (!channel) return
await createRoom.unbridgeChannel(channel, channel["guild_id"], "Encrypted rooms are not supported. This room was removed from the bridge.")
}))
module.exports.stringifyErrorStack = stringifyErrorStack
module.exports.cleanErrorStack = cleanErrorStack
module.exports.sendError = sendError
module.exports.printError = printError