Compare commits

...

3 commits

Author SHA1 Message Date
c8b0f23db3 Retrigger m->d reactions and removals 2026-01-13 22:57:52 +13:00
fcd4eb4e51 Refactor cross-room event detection 2026-01-13 22:20:11 +13:00
536ab56048 Better text spoilers
Remove spoiler content from plaintext body
Don't bridge embeds if their link is spoilered (deliberately imprecise)
2026-01-13 22:19:54 +13:00
9 changed files with 156 additions and 84 deletions

37
package-lock.json generated
View file

@ -10,7 +10,7 @@
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@chriscdn/promise-semaphore": "^3.0.1", "@chriscdn/promise-semaphore": "^3.0.1",
"@cloudrac3r/discord-markdown": "^2.6.7", "@cloudrac3r/discord-markdown": "^2.6.10",
"@cloudrac3r/giframe": "^0.4.3", "@cloudrac3r/giframe": "^0.4.3",
"@cloudrac3r/html-template-tag": "^5.0.1", "@cloudrac3r/html-template-tag": "^5.0.1",
"@cloudrac3r/in-your-element": "^1.1.1", "@cloudrac3r/in-your-element": "^1.1.1",
@ -242,9 +242,9 @@
} }
}, },
"node_modules/@cloudrac3r/discord-markdown": { "node_modules/@cloudrac3r/discord-markdown": {
"version": "2.6.8", "version": "2.6.10",
"resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.6.8.tgz", "resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.6.10.tgz",
"integrity": "sha512-ZrSimHqmLqXR+W3U1n6ge6poAjmQaMzXyWrTkT36znrgKhfuQAYxLBtKTf7m+cmr3VlaDVM2P+iPdSeTeaM0qg==", "integrity": "sha512-E+F9UYDUHP2kHDCciX63SBzgsUnHpu2Pp/h98x9Zo+vKuzXjCQ5PcFNdUlH6M18bvHDZPoIsKVmjnON8UYaAPQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"simple-markdown": "^0.7.3" "simple-markdown": "^0.7.3"
@ -1227,26 +1227,15 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/prop-types": {
"version": "15.7.11",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz",
"integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng=="
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "18.2.55", "version": "19.2.8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.55.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz",
"integrity": "sha512-Y2Tz5P4yz23brwm2d7jNon39qoAtMMmalOQv6+fEFt1mT+FcM3D841wDpoUvFXhaYenuROCy3FZYqdTjM7qVyA==", "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
"license": "MIT",
"dependencies": { "dependencies": {
"@types/prop-types": "*", "csstype": "^3.2.2"
"@types/scheduler": "*",
"csstype": "^3.0.2"
} }
}, },
"node_modules/@types/scheduler": {
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
"integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A=="
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "7.4.1", "version": "7.4.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
@ -1668,9 +1657,10 @@
} }
}, },
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.1.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
}, },
"node_modules/data-uri-to-buffer": { "node_modules/data-uri-to-buffer": {
"version": "2.0.2", "version": "2.0.2",
@ -2825,6 +2815,7 @@
"version": "0.7.3", "version": "0.7.3",
"resolved": "https://registry.npmjs.org/simple-markdown/-/simple-markdown-0.7.3.tgz", "resolved": "https://registry.npmjs.org/simple-markdown/-/simple-markdown-0.7.3.tgz",
"integrity": "sha512-uGXIc13NGpqfPeFJIt/7SHHxd6HekEJYtsdoCM06mEBPL9fQH/pSD7LRM6PZ7CKchpSvxKL4tvwMamqAaNDAyg==", "integrity": "sha512-uGXIc13NGpqfPeFJIt/7SHHxd6HekEJYtsdoCM06mEBPL9fQH/pSD7LRM6PZ7CKchpSvxKL4tvwMamqAaNDAyg==",
"license": "MIT",
"dependencies": { "dependencies": {
"@types/react": ">=16.0.0" "@types/react": ">=16.0.0"
} }

View file

@ -19,7 +19,7 @@
}, },
"dependencies": { "dependencies": {
"@chriscdn/promise-semaphore": "^3.0.1", "@chriscdn/promise-semaphore": "^3.0.1",
"@cloudrac3r/discord-markdown": "^2.6.7", "@cloudrac3r/discord-markdown": "^2.6.10",
"@cloudrac3r/giframe": "^0.4.3", "@cloudrac3r/giframe": "^0.4.3",
"@cloudrac3r/html-template-tag": "^5.0.1", "@cloudrac3r/html-template-tag": "^5.0.1",
"@cloudrac3r/in-your-element": "^1.1.1", "@cloudrac3r/in-your-element": "^1.1.1",
@ -64,6 +64,7 @@
"scripts": { "scripts": {
"start": "node --enable-source-maps start.js", "start": "node --enable-source-maps start.js",
"setup": "node --enable-source-maps scripts/setup.js", "setup": "node --enable-source-maps scripts/setup.js",
"build": "mkdir -p dist/out-of-your-element && cp -R src dist/out-of-your-element && cp -R docs dist/out-of-your-element && npx tsdown",
"addbot": "node addbot.js", "addbot": "node addbot.js",
"test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js | tap-dot", "test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js | tap-dot",
"test-slow": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js -- --slow | tap-dot", "test-slow": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js -- --slow | tap-dot",

View file

@ -20,31 +20,39 @@ const emitter = new EventEmitter()
* (or before the it has finished being bridged to an event). * (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. * In this case, wait until the original message has finished bridging, then retrigger the passed function.
* @template {(...args: any[]) => Promise<any>} T * @template {(...args: any[]) => Promise<any>} T
* @param {string} messageID * @param {string} inputID
* @param {T} fn * @param {T} fn
* @param {Parameters<T>} rest * @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 * @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(messageID, fn, ...rest) { function eventNotFoundThenRetrigger(inputID, fn, ...rest) {
if (!paused.has(messageID)) { if (!paused.has(inputID)) {
const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get() if (inputID.match(/^[0-9]+$/)) {
if (eventID) { const eventID = select("event_message", "event_id", {message_id: inputID}).pluck().get()
debugRetrigger(`[retrigger] OK mid <-> eid = ${messageID} <-> ${eventID}`) if (eventID) {
return false // event was found so don't retrigger 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
}
} }
} }
debugRetrigger(`[retrigger] WAIT mid = ${messageID}`) debugRetrigger(`[retrigger] WAIT id = ${inputID}`)
emitter.once(messageID, () => { emitter.once(inputID, () => {
debugRetrigger(`[retrigger] TRIGGER mid = ${messageID}`) debugRetrigger(`[retrigger] TRIGGER id = ${inputID}`)
fn(...rest) fn(...rest)
}) })
// if the event never arrives, don't trigger the callback, just clean up // if the event never arrives, don't trigger the callback, just clean up
setTimeout(() => { setTimeout(() => {
if (emitter.listeners(messageID).length) { if (emitter.listeners(inputID).length) {
debugRetrigger(`[retrigger] EXPIRE mid = ${messageID}`) debugRetrigger(`[retrigger] EXPIRE id = ${inputID}`)
} }
emitter.removeAllListeners(messageID) emitter.removeAllListeners(inputID)
}, 60 * 1000) // 1 minute }, 60 * 1000) // 1 minute
return true // event was not found, then retrigger return true // event was not found, then retrigger
} }
@ -58,11 +66,11 @@ function eventNotFoundThenRetrigger(messageID, fn, ...rest) {
*/ */
async function pauseChanges(messageID, promise) { async function pauseChanges(messageID, promise) {
try { try {
debugRetrigger(`[retrigger] PAUSE mid = ${messageID}`) debugRetrigger(`[retrigger] PAUSE id = ${messageID}`)
paused.add(messageID) paused.add(messageID)
return await promise return await promise
} finally { } finally {
debugRetrigger(`[retrigger] RESUME mid = ${messageID}`) debugRetrigger(`[retrigger] RESUME id = ${messageID}`)
paused.delete(messageID) paused.delete(messageID)
messageFinishedBridging(messageID) messageFinishedBridging(messageID)
} }
@ -74,7 +82,7 @@ async function pauseChanges(messageID, promise) {
*/ */
function messageFinishedBridging(messageID) { function messageFinishedBridging(messageID) {
if (emitter.listeners(messageID).length) { if (emitter.listeners(messageID).length) {
debugRetrigger(`[retrigger] EMIT mid = ${messageID}`) debugRetrigger(`[retrigger] EMIT id = ${messageID}`)
} }
emitter.emit(messageID) emitter.emit(messageID)
} }

View file

@ -312,6 +312,21 @@ test("message2event embeds: youtube video", async t => {
}]) }])
}) })
test("message2event embeds: embed not bridged if its link was spoilered", async t => {
const events = await messageToEvent({
...data.message_with_embeds.youtube_video,
content: "||https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E\n\n\nJutomi I'm gonna make these sounds in your walls tonight||"
}, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "[spoiler]",
format: "org.matrix.custom.html",
formatted_body: `<span data-mx-spoiler=""><a href="https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E">https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E</a><br><br><br>Jutomi I'm gonna make these sounds in your walls tonight</span>`,
"m.mentions": {}
}])
})
test("message2event embeds: tenor gif should show a video link without a provider", async t => { test("message2event embeds: tenor gif should show a video link without a provider", async t => {
const events = await messageToEvent(data.message_with_embeds.tenor_gif, data.guild.general, {}, {}) const events = await messageToEvent(data.message_with_embeds.tenor_gif, data.guild.general, {}, {})
t.deepEqual(events, [{ t.deepEqual(events, [{

View file

@ -26,8 +26,9 @@ const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
* @param {DiscordTypes.APIMessage} message * @param {DiscordTypes.APIMessage} message
* @param {DiscordTypes.APIGuild} guild * @param {DiscordTypes.APIGuild} guild
* @param {boolean} useHTML * @param {boolean} useHTML
* @param {string[]} spoilers
*/ */
function getDiscordParseCallbacks(message, guild, useHTML) { function getDiscordParseCallbacks(message, guild, useHTML, spoilers = []) {
return { return {
/** @param {{id: string, type: "discordUser"}} node */ /** @param {{id: string, type: "discordUser"}} node */
user: node => { user: node => {
@ -90,6 +91,10 @@ function getDiscordParseCallbacks(message, guild, useHTML) {
here: () => { here: () => {
if (message.mention_everyone) return "@room" if (message.mention_everyone) return "@room"
return "@here" return "@here"
},
spoiler: node => {
spoilers.push(node.raw)
return useHTML
} }
} }
} }
@ -281,8 +286,8 @@ async function messageToEvent(message, guild, options = {}, di) {
// Mentions scenarios 1 and 2, part A. i.e. translate relevant message.mentions to m.mentions // Mentions scenarios 1 and 2, part A. i.e. translate relevant message.mentions to m.mentions
// (Still need to do scenarios 1 and 2 part B, and scenario 3.) // (Still need to do scenarios 1 and 2 part B, and scenario 3.)
if (message.type === DiscordTypes.MessageType.Reply && message.message_reference?.message_id) { if (message.type === DiscordTypes.MessageType.Reply && message.message_reference?.message_id) {
const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index").select("event_id", "room_id", "reference_channel_id", "source").and("WHERE message_id = ? ORDER BY part ASC").get(message.message_reference.message_id) const row = await getHistoricalEventRow(message.message_reference?.message_id)
if (row) { if (row && "event_id" in row) {
repliedToEventRow = Object.assign(row, {channel_id: row.reference_channel_id}) repliedToEventRow = Object.assign(row, {channel_id: row.reference_channel_id})
} else if (message.referenced_message) { } else if (message.referenced_message) {
repliedToUnknownEvent = true repliedToUnknownEvent = true
@ -295,8 +300,8 @@ async function messageToEvent(message, guild, options = {}, di) {
assert(message.embeds[0].description) assert(message.embeds[0].description)
const match = message.embeds[0].description.match(/\/channels\/[0-9]*\/[0-9]*\/([0-9]{2,})/) const match = message.embeds[0].description.match(/\/channels\/[0-9]*\/[0-9]*\/([0-9]{2,})/)
if (match) { if (match) {
const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index").select("event_id", "room_id", "reference_channel_id", "source").and("WHERE message_id = ? ORDER BY part ASC").get(match[1]) const row = await getHistoricalEventRow(match[1])
if (row) { if (row && "event_id" in row) {
/* /*
we generate a partial referenced_message based on what PK provided. we don't need everything, since this will only be used for further message-to-event converting. we generate a partial referenced_message based on what PK provided. we don't need everything, since this will only be used for further message-to-event converting.
the following properties are necessary: the following properties are necessary:
@ -341,6 +346,34 @@ async function messageToEvent(message, guild, options = {}, di) {
return promise return promise
} }
/**
* @param {string} messageID
* @param {string} [timestampChannelID]
*/
async function getHistoricalEventRow(messageID, timestampChannelID) {
/** @type {{room_id: string} | {event_id: string, room_id: string, reference_channel_id: string, source: number} | null} */
let row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
.select("event_id", "room_id", "reference_channel_id", "source").where({message_id: messageID}).and("ORDER BY part ASC").get()
if (!row && timestampChannelID) {
const ts = dUtils.snowflakeToTimestampExact(messageID)
const oldestRow = from("historical_channel_room").selectUnsafe("max(upgraded_timestamp)", "room_id")
.where({reference_channel_id: timestampChannelID}).and("and upgraded_timestamp < ?").get(ts)
if (oldestRow?.room_id) {
row = {room_id: oldestRow.room_id}
try {
const {event_id} = await di.api.getEventForTimestamp(oldestRow.room_id, ts)
row = {
event_id,
room_id: oldestRow.room_id,
reference_channel_id: oldestRow.reference_channel_id,
source: 1
}
} catch (e) {}
}
}
return row
}
/** /**
* Translate Discord message links to Matrix event links. * Translate Discord message links to Matrix event links.
* If OOYE has handled this message in the past, this is an instant database lookup. * If OOYE has handled this message in the past, this is an instant database lookup.
@ -353,30 +386,11 @@ async function messageToEvent(message, guild, options = {}, di) {
assert(typeof match.index === "number") assert(typeof match.index === "number")
const [_, channelID, messageID] = match const [_, channelID, messageID] = match
const result = await (async () => { const result = await (async () => {
const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") const row = await getHistoricalEventRow(messageID, channelID)
.select("event_id", "room_id").where({message_id: messageID}).get() if (!row) return `${match[0]} [event is from another server]`
// const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get() const via = await getViaServersMemo(row.room_id)
if (row) { if (!("event_id" in row)) return `[unknown event in https://matrix.to/#/${row.room_id}?${via}]`
const via = await getViaServersMemo(row.room_id) return `https://matrix.to/#/${row.room_id}/${row.event_id}?${via}`
return `https://matrix.to/#/${row.room_id}/${row.event_id}?${via}`
}
const ts = dUtils.snowflakeToTimestampExact(messageID)
const oldestRow = from("historical_channel_room").selectUnsafe("max(upgraded_timestamp)", "room_id")
.where({reference_channel_id: channelID}).and("and upgraded_timestamp < ?").get(ts)
if (oldestRow?.room_id) {
const via = await getViaServersMemo(oldestRow.room_id)
try {
const {event_id} = await di.api.getEventForTimestamp(oldestRow.room_id, ts)
return `https://matrix.to/#/${oldestRow.room_id}/${event_id}?${via}`
} catch (e) {
// M_NOT_FOUND: Unable to find event from <ts> in direction Direction.FORWARDS
// not supported in Conduit and descendants
return `[unknown event, timestamp resolution failed, in room: https://matrix.to/#/${oldestRow.room_id}?${via}]`
}
}
return `${match[0]} [event is from another server]`
})() })()
content = content.slice(0, match.index + offset) + result + content.slice(match.index + match[0].length + offset) content = content.slice(0, match.index + offset) + result + content.slice(match.index + match[0].length + offset)
@ -392,6 +406,7 @@ async function messageToEvent(message, guild, options = {}, di) {
return content.replace(/https:\/\/(cdn|media)\.discordapp\.(?:com|net)\/attachments\/([0-9]+)\/([0-9]+)\/([-A-Za-z0-9_.,]+)/g, url => dUtils.getPublicUrlForCdn(url)) return content.replace(/https:\/\/(cdn|media)\.discordapp\.(?:com|net)\/attachments\/([0-9]+)\/([0-9]+)\/([-A-Za-z0-9_.,]+)/g, url => dUtils.getPublicUrlForCdn(url))
} }
const spoilers = []
/** /**
* Translate links and emojis and mentions and stuff. Give back the text and HTML so they can be combined into bigger events. * Translate links and emojis and mentions and stuff. Give back the text and HTML so they can be combined into bigger events.
* @param {string} content Partial or complete Discord message content * @param {string} content Partial or complete Discord message content
@ -431,7 +446,7 @@ async function messageToEvent(message, guild, options = {}, di) {
} }
let html = await markdown.toHtmlWithPostParser(content, transformParsedVia, { let html = await markdown.toHtmlWithPostParser(content, transformParsedVia, {
discordCallback: getDiscordParseCallbacks(message, guild, true), discordCallback: getDiscordParseCallbacks(message, guild, true, spoilers),
...customOptions ...customOptions
}, customParser, customHtmlOutput) }, customParser, customHtmlOutput)
@ -555,17 +570,16 @@ async function messageToEvent(message, guild, options = {}, di) {
// Forwarded content appears first // Forwarded content appears first
if (message.message_reference?.type === DiscordTypes.MessageReferenceType.Forward && message.message_snapshots?.length) { if (message.message_reference?.type === DiscordTypes.MessageReferenceType.Forward && message.message_snapshots?.length) {
// Forwarded notice // Forwarded notice
const event = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") const row = await getHistoricalEventRow(message.message_reference.message_id, message.message_reference.channel_id)
.select("event_id", "room_id").where({message_id: message.message_reference.message_id}).get()
const room = select("channel_room", ["room_id", "name", "nick"], {channel_id: message.message_reference.channel_id}).get() const room = select("channel_room", ["room_id", "name", "nick"], {channel_id: message.message_reference.channel_id}).get()
const forwardedNotice = new mxUtils.MatrixStringBuilder() const forwardedNotice = new mxUtils.MatrixStringBuilder()
if (room) { if (room) {
const roomName = room && (room.nick || room.name) const roomName = room && (room.nick || room.name)
if (event) { if ("event_id" in row) {
const via = await getViaServersMemo(event.room_id) const via = await getViaServersMemo(row.room_id)
forwardedNotice.addLine( forwardedNotice.addLine(
`[🔀 Forwarded from #${roomName}]`, `[🔀 Forwarded from #${roomName}]`,
tag`🔀 <em>Forwarded from ${roomName} <a href="https://matrix.to/#/${room.room_id}/${event.event_id}?${via}">[jump to event]</a></em>` tag`🔀 <em>Forwarded from ${roomName} <a href="https://matrix.to/#/${room.room_id}/${row.event_id}?${via}">[jump to event]</a></em>`
) )
} else { } else {
const via = await getViaServersMemo(room.room_id) const via = await getViaServersMemo(room.room_id)
@ -692,6 +706,13 @@ async function messageToEvent(message, guild, options = {}, di) {
continue // If discord creates an embed preview for a discord channel link, don't copy that embed continue // If discord creates an embed preview for a discord channel link, don't copy that embed
} }
if (embed.url && spoilers.some(sp => sp.match(/\bhttps?:\/\/[a-z]/))) {
// If the original message had spoilered URLs, don't generate any embeds for links.
// This logic is the same as the Discord desktop client. It doesn't match specific embeds to specific spoilered text, it's all or nothing.
// It's not easy to do much better because posting a link like youtu.be generates an embed.url with youtube.com/watch, so you can't match up the text without making at least that a special case.
continue
}
// Start building up a replica ("rep") of the embed in Discord-markdown format, which we will convert into both plaintext and formatted body at once // Start building up a replica ("rep") of the embed in Discord-markdown format, which we will convert into both plaintext and formatted body at once
const rep = new mxUtils.MatrixStringBuilder() const rep = new mxUtils.MatrixStringBuilder()

View file

@ -287,10 +287,10 @@ test("message2event: message timestamp failed to fetch", async t => {
"m.mentions": {}, "m.mentions": {},
msgtype: "m.text", msgtype: "m.text",
body: "Me: I'll scroll up to find a certain message I'll send\n_scrolls up and clicks message links for god knows how long_\n_completely forgets what they were looking for and simply begins scrolling up to find some fun moments_\n_stumbles upon:_ " body: "Me: I'll scroll up to find a certain message I'll send\n_scrolls up and clicks message links for god knows how long_\n_completely forgets what they were looking for and simply begins scrolling up to find some fun moments_\n_stumbles upon:_ "
+ "[unknown event, timestamp resolution failed, in room: https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&via=matrix.org]", + "[unknown event in https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&via=matrix.org]",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: "Me: I'll scroll up to find a certain message I'll send<br><em>scrolls up and clicks message links for god knows how long</em><br><em>completely forgets what they were looking for and simply begins scrolling up to find some fun moments</em><br><em>stumbles upon:</em> " formatted_body: "Me: I'll scroll up to find a certain message I'll send<br><em>scrolls up and clicks message links for god knows how long</em><br><em>completely forgets what they were looking for and simply begins scrolling up to find some fun moments</em><br><em>stumbles upon:</em> "
+ '[unknown event, timestamp resolution failed, in room: <a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&amp;via=matrix.org">https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&amp;via=matrix.org</a>]' + '[unknown event in <a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&amp;via=matrix.org">https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&amp;via=matrix.org</a>]'
}]) }])
t.equal(called, 2, "getEventForTimestamp and getJoinedMembers should be called once each") t.equal(called, 2, "getEventForTimestamp and getJoinedMembers should be called once each")
}) })
@ -862,6 +862,20 @@ test("message2event: advanced written @mentions for matrix users", async t => {
t.equal(called, 1, "should only look up the member list once") t.equal(called, 1, "should only look up the member list once")
}) })
test("message2event: spoilers are removed from plaintext body", async t => {
const events = await messageToEvent({
content: "||**beatrice**||"
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "[spoiler]",
format: "org.matrix.custom.html",
formatted_body: `<span data-mx-spoiler=""><strong>beatrice</strong></span>`
}])
})
test("message2event: very large attachment is linked instead of being uploaded", async t => { test("message2event: very large attachment is linked instead of being uploaded", async t => {
const events = await messageToEvent({ const events = await messageToEvent({
content: "hey", content: "hey",

View file

@ -4,20 +4,27 @@ const assert = require("assert").strict
const Ty = require("../../types") const Ty = require("../../types")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {discord, sync, db, select} = passthrough const {discord, as, sync, db, select, from} = passthrough
/** @type {import("../../matrix/utils")} */ /** @type {import("../../matrix/utils")} */
const utils = sync.require("../../matrix/utils") const utils = sync.require("../../matrix/utils")
/** @type {import("../converters/emoji")} */ /** @type {import("../converters/emoji")} */
const emoji = sync.require("../converters/emoji") const emoji = sync.require("../converters/emoji")
/** @type {import("../../d2m/actions/retrigger")} */
const retrigger = sync.require("../../d2m/actions/retrigger")
/** /**
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>} event * @param {Ty.Event.Outer<Ty.Event.M_Reaction>} event
*/ */
async function addReaction(event) { async function addReaction(event) {
const channelID = select("historical_channel_room", "reference_channel_id", {room_id: event.room_id}).pluck().get() // Wait until the corresponding channel and message have already been bridged
if (!channelID) return // We just assume the bridge has already been created if (retrigger.eventNotFoundThenRetrigger(event.content["m.relates_to"].event_id, as.emit.bind(as, "type:m.reaction", event))) return
const messageID = select("event_message", "message_id", {event_id: event.content["m.relates_to"].event_id}, "ORDER BY reaction_part").pluck().get()
if (!messageID) return // Nothing can be done if the parent message was never bridged. // These will exist because it passed retrigger
const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
.select("message_id", "reference_channel_id").where({event_id: event.content["m.relates_to"].event_id}).and("ORDER BY reaction_part ASC").get()
assert(row)
const messageID = row.message_id
const channelID = row.reference_channel_id
const key = event.content["m.relates_to"].key const key = event.content["m.relates_to"].key
const discordPreferredEncoding = await emoji.encodeEmoji(key, event.content.shortcode) const discordPreferredEncoding = await emoji.encodeEmoji(key, event.content.shortcode)
@ -35,6 +42,10 @@ async function addReaction(event) {
// happens if a matrix user tries to add on to a super reaction // happens if a matrix user tries to add on to a super reaction
return return
} }
if (e.message?.includes("Unknown Message")) {
// happens under a race condition where a message is deleted after it passes the database check above
return
}
throw e throw e
} }

View file

@ -4,9 +4,11 @@ const DiscordTypes = require("discord-api-types/v10")
const Ty = require("../../types") const Ty = require("../../types")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {discord, sync, db, select, from} = passthrough const {discord, as, sync, db, select, from} = passthrough
/** @type {import("../../matrix/utils")} */ /** @type {import("../../matrix/utils")} */
const utils = sync.require("../../matrix/utils") const utils = sync.require("../../matrix/utils")
/** @type {import("../../d2m/actions/retrigger")} */
const retrigger = sync.require("../../d2m/actions/retrigger")
/** /**
* @param {Ty.Event.Outer_M_Room_Redaction} event * @param {Ty.Event.Outer_M_Room_Redaction} event
@ -52,13 +54,18 @@ async function removeReaction(event) {
* @param {Ty.Event.Outer_M_Room_Redaction} event * @param {Ty.Event.Outer_M_Room_Redaction} event
*/ */
async function handle(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.bind(as, "type:m.room.redaction", event))) return
const row = select("event_message", ["event_type", "event_subtype", "part"], {event_id: event.redacts}).get() 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) { if (row && row.event_type === "m.room.message" && row.event_subtype === "m.notice" && row.part === 1) {
await suppressEmbeds(event) await suppressEmbeds(event)
} else { } else {
await deleteMessage(event) await deleteMessage(event)
} }
await removeReaction(event)
} }
module.exports.handle = handle module.exports.handle = handle

View file

@ -28,6 +28,8 @@ const api = sync.require("../matrix/api")
const createRoom = sync.require("../d2m/actions/create-room") const createRoom = sync.require("../d2m/actions/create-room")
/** @type {import("../matrix/room-upgrade")} */ /** @type {import("../matrix/room-upgrade")} */
const roomUpgrade = require("../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") const {reg} = require("../matrix/read-registration")
let lastReportedEvent = 0 let lastReportedEvent = 0
@ -201,6 +203,7 @@ async event => {
// @ts-ignore // @ts-ignore
await matrixCommandHandler.execute(event) await matrixCommandHandler.execute(event)
} }
retrigger.messageFinishedBridging(event.event_id)
await api.ackEvent(event) await api.ackEvent(event)
})) }))
@ -211,6 +214,7 @@ sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker",
async event => { async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return if (utils.eventSenderIsFromDiscord(event.sender)) return
const messageResponses = await sendEvent.sendEvent(event) const messageResponses = await sendEvent.sendEvent(event)
retrigger.messageFinishedBridging(event.event_id)
await api.ackEvent(event) await api.ackEvent(event)
})) }))