Better text spoilers

Remove spoiler content from plaintext body
Don't bridge embeds if their link is spoilered (deliberately imprecise)
This commit is contained in:
Cadence Ember
2026-01-13 22:19:54 +13:00
parent 3d3671e05a
commit 536ab56048
5 changed files with 60 additions and 26 deletions

37
package-lock.json generated
View File

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

View File

@@ -19,7 +19,7 @@
},
"dependencies": {
"@chriscdn/promise-semaphore": "^3.0.1",
"@cloudrac3r/discord-markdown": "^2.6.7",
"@cloudrac3r/discord-markdown": "^2.6.10",
"@cloudrac3r/giframe": "^0.4.3",
"@cloudrac3r/html-template-tag": "^5.0.1",
"@cloudrac3r/in-your-element": "^1.1.1",
@@ -64,6 +64,7 @@
"scripts": {
"start": "node --enable-source-maps start.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",
"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",

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 => {
const events = await messageToEvent(data.message_with_embeds.tenor_gif, data.guild.general, {}, {})
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.APIGuild} guild
* @param {boolean} useHTML
* @param {string[]} spoilers
*/
function getDiscordParseCallbacks(message, guild, useHTML) {
function getDiscordParseCallbacks(message, guild, useHTML, spoilers = []) {
return {
/** @param {{id: string, type: "discordUser"}} node */
user: node => {
@@ -90,6 +91,10 @@ function getDiscordParseCallbacks(message, guild, useHTML) {
here: () => {
if (message.mention_everyone) return "@room"
return "@here"
},
spoiler: node => {
spoilers.push(node.raw)
return useHTML
}
}
}
@@ -392,6 +397,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))
}
const spoilers = []
/**
* 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
@@ -431,7 +437,7 @@ async function messageToEvent(message, guild, options = {}, di) {
}
let html = await markdown.toHtmlWithPostParser(content, transformParsedVia, {
discordCallback: getDiscordParseCallbacks(message, guild, true),
discordCallback: getDiscordParseCallbacks(message, guild, true, spoilers),
...customOptions
}, customParser, customHtmlOutput)
@@ -692,6 +698,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
}
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
const rep = new mxUtils.MatrixStringBuilder()

View File

@@ -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")
})
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 => {
const events = await messageToEvent({
content: "hey",