, I respond: Stop drinking paint. (No), I respond: Stop drinking paint. (No), I respond: Stop drinking paint. (No), I respond: Stop drinking paint. (No)In reply to Extremity' + + '
Image
❭ Brad used /stats — interaction loading...",
- "m.mentions": {},
- msgtype: "m.notice",
- }])
-})
-
test("message2event embeds: nothing but a field", async t => {
const events = await messageToEvent(data.message_with_embeds.nothing_but_a_field, data.guild.general, {})
t.deepEqual(events, [{
+ $type: "m.room.message",
+ body: "> ↪️ @papiophidian: used `/stats`",
+ format: "org.matrix.custom.html",
+ formatted_body: "↪️ @papiophidian used /stats",
+ "m.mentions": {},
+ msgtype: "m.text",
+ }, {
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.notice",
- body: "❭ PapiOphidian used `/stats`"
- + "\n| ### Amanda 🎵#2192 :online:"
+ body: "| ### Amanda 🎵#2192 :online:"
+ "\n| willow tree, branch 0"
+ "\n| **❯ Uptime:**\n| 3m 55s\n| **❯ Memory:**\n| 64.45MB",
format: "org.matrix.custom.html",
- formatted_body: '❭ PapiOphidian used /stats'
- + '", "m.mentions": {} }]) - t.equal(called, 1, "should call getJoinedMembers once") + t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each") }) -test("message2event embeds: extreme html is all escaped", async t => { - const events = await messageToEvent(data.message_with_embeds.extreme_html_escaping, data.guild.general) +test("message2event embeds: crazy html is all escaped", async t => { + const events = await messageToEvent(data.message_with_embeds.escaping_crazy_html_tags, data.guild.general) t.deepEqual(events, [{ $type: "m.room.message", msgtype: "m.notice", @@ -152,12 +154,17 @@ test("message2event embeds: title without url", async t => { const events = await messageToEvent(data.message_with_embeds.title_without_url, data.guild.general) t.deepEqual(events, [{ $type: "m.room.message", - msgtype: "m.notice", - body: "❭ PapiOphidian used `/stats`" - + "\n| ## Hi, I'm Amanda!\n| \n| I condone pirating music!", + body: "> ↪️ @papiophidian: used `/stats`", format: "org.matrix.custom.html", - formatted_body: 'Amanda 🎵#2192
' + formatted_body: '
' @@ -94,7 +86,17 @@ test("message2event embeds: blockquote in embed", async t => { let called = 0 const events = await messageToEvent(data.message_with_embeds.blockquote_in_embed, data.guild.general, {}, { api: { - getEffectivePower: mockGetEffectivePower(), + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return { + users: { + "@_ooye_bot:cadence.moe": 100 + } + } + }, async getJoinedMembers(roomID) { called++ t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") @@ -122,11 +124,11 @@ test("message2event embeds: blockquote in embed", async t => { formatted_body: "Amanda 🎵#2192
' + '
willow tree, branch 0' + '
❯ Uptime:
3m 55s' + '
❯ Memory:
64.45MBreply draft
The following is a message composed via consensus of the Stinker Council.
For those who are not currently aware of our existence, we represent the organization known as Wonderland. Our previous mission centered around the assortment and study of puzzling objects, entities and other assorted phenomena. This mission was the focus of our organization for more than 28 years.
Due to circumstances outside of our control, this directive has now changed. Our new mission will be the extermination of the stinker race.
There will be no further communication.
❭ PapiOphidian used /stats'
- + ``, + formatted_body: "Hi, I'm Amanda!
I condone pirating music!
↪️ @papiophidian used /stats",
+ "m.mentions": {},
+ msgtype: "m.text",
+ }, {
+ $type: "m.room.message",
+ msgtype: "m.notice",
+ body: "| ## Hi, I'm Amanda!\n| \n| I condone pirating music!",
+ format: "org.matrix.custom.html",
+ formatted_body: ``, "m.mentions": {} }]) }) @@ -166,12 +173,17 @@ test("message2event embeds: url without title", async t => { const events = await messageToEvent(data.message_with_embeds.url_without_title, data.guild.general) t.deepEqual(events, [{ $type: "m.room.message", - msgtype: "m.notice", - body: "❭ PapiOphidian used `/stats`" - + "\n| I condone pirating music!", + body: "> ↪️ @papiophidian: used `/stats`", format: "org.matrix.custom.html", - formatted_body: 'Hi, I'm Amanda!
I condone pirating music!
❭ PapiOphidian used /stats'
- + ``, + formatted_body: "I condone pirating music!
↪️ @papiophidian used /stats",
+ "m.mentions": {},
+ msgtype: "m.text",
+ }, {
+ $type: "m.room.message",
+ msgtype: "m.notice",
+ body: "| I condone pirating music!",
+ format: "org.matrix.custom.html",
+ formatted_body: ``, "m.mentions": {} }]) }) @@ -180,12 +192,17 @@ test("message2event embeds: author without url", async t => { const events = await messageToEvent(data.message_with_embeds.author_without_url, data.guild.general) t.deepEqual(events, [{ $type: "m.room.message", - msgtype: "m.notice", - body: "❭ PapiOphidian used `/stats`" - + "\n| ## Amanda\n| \n| I condone pirating music!", + body: "> ↪️ @papiophidian: used `/stats`", format: "org.matrix.custom.html", - formatted_body: 'I condone pirating music!
❭ PapiOphidian used /stats'
- + ``, + formatted_body: "Amanda
I condone pirating music!
↪️ @papiophidian used /stats",
+ "m.mentions": {},
+ msgtype: "m.text",
+ }, {
+ $type: "m.room.message",
+ msgtype: "m.notice",
+ body: "| ## Amanda\n| \n| I condone pirating music!",
+ format: "org.matrix.custom.html",
+ formatted_body: ``, "m.mentions": {} }]) }) @@ -194,50 +211,17 @@ test("message2event embeds: author url without name", async t => { const events = await messageToEvent(data.message_with_embeds.author_url_without_name, data.guild.general) t.deepEqual(events, [{ $type: "m.room.message", - msgtype: "m.notice", - body: "❭ PapiOphidian used `/stats`" - + "\n| I condone pirating music!", + body: "> ↪️ @papiophidian: used `/stats`", format: "org.matrix.custom.html", - formatted_body: 'Amanda
I condone pirating music!
❭ PapiOphidian used /stats'
- + ``, - "m.mentions": {} - }]) -}) - -test("message2event embeds: 4 images", async t => { - const events = await messageToEvent(data.message_with_embeds.four_images, data.guild.general) - t.deepEqual(events, [{ - $type: "m.room.message", + formatted_body: "I condone pirating music!
↪️ @papiophidian used /stats",
+ "m.mentions": {},
msgtype: "m.text",
- body: "[↷ Forwarded message]\n» https://fixupx.com/i/status/2032003668787020046",
- format: "org.matrix.custom.html",
- formatted_body: "↷ Forwarded messagehttps://fixupx.com/i/status/2032003668787020046", - "m.mentions": {} }, { $type: "m.room.message", msgtype: "m.notice", - body: "» | ## ⏺️ AUTOMATON WEST (@AUTOMATON_ENG) https://x.com/AUTOMATON_ENG/status/2032003668787020046" - + "\n» | " - + "\n» | 4chan owner Hiroyuki, Evangelion director Hideaki Anno and GACKT to participate in “humanity’s last non\\-AI made social network”" - + "\n» | ︀︀" - + "\n» | ︀︀[automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/](https://automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/)" - + "\n» | " - + "\n» | **[💬](https://x.com/intent/tweet?in_reply_to=2032003668787020046) 36 [🔁](https://x.com/intent/retweet?tweet_id=2032003668787020046) 212 [❤](https://x.com/intent/like?tweet_id=2032003668787020046) 3\\.0K 👁 131\\.7K **" - + "\n» | " - + "\n» | 📸 https://pbs.twimg.com/media/HDMUyf6bQAM3yts.jpg?name=orig" - + "\n» | — FixupX" - + "\n» | 📸 https://pbs.twimg.com/media/HDMUgxybQAE4FtJ.jpg?name=orig" - + "\n» | 📸 https://pbs.twimg.com/media/HDMUrPobgAAeb90.jpg?name=orig" - + "\n» | 📸 https://pbs.twimg.com/media/HDMUuy5bgAAInj5.jpg?name=orig", + body: "| I condone pirating music!", format: "org.matrix.custom.html", - formatted_body: "
", + formatted_body: `" - + "⏺️ AUTOMATON WEST (@AUTOMATON_ENG)
" - + "4chan owner Hiroyuki, Evangelion director Hideaki Anno and GACKT to participate in “humanity’s last non-AI made social network”" - + "
" - + "
︀︀
︀︀automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/" - + "
💬 36 🔁 212 ❤ 3.0K 👁 131.7K📸 https://pbs.twimg.com/media/HDMUyf6bQAM3yts.jpg?name=orig
— FixupX📸 https://pbs.twimg.com/media/HDMUgxybQAE4FtJ.jpg?name=orig
" - + "📸 https://pbs.twimg.com/media/HDMUrPobgAAeb90.jpg?name=orig
" - + "📸 https://pbs.twimg.com/media/HDMUuy5bgAAInj5.jpg?name=orig
`, "m.mentions": {} }]) }) @@ -337,21 +321,6 @@ 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: `https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_EI condone pirating music!
➿ Cute Corgi Waddle", - "m.mentions": {} - }]) -}) - test("message2event embeds: if discord creates an embed preview for a discord channel link, don't copy that embed", async t => { const events = await messageToEvent(data.message_with_embeds.discord_server_included_punctuation_bad_discord, data.guild.general, {}, { api: { - getEffectivePower: mockGetEffectivePower(), + async getStateEvent(roomID, type, key) { + t.equal(roomID, "!TqlyQmifxGUggEmdBN:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return { + users: { + "@_ooye_bot:cadence.moe": 100 + } + } + }, async getJoinedMembers(roomID) { t.equal(roomID, "!TqlyQmifxGUggEmdBN:cadence.moe") return { diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 83fab1b..1d6288a 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -14,33 +14,30 @@ const file = sync.require("../../matrix/file") const emojiToKey = sync.require("./emoji-to-key") /** @type {import("../actions/lottie")} */ const lottie = sync.require("../actions/lottie") -/** @type {import("../../matrix/utils")} */ -const mxUtils = sync.require("../../matrix/utils") +/** @type {import("../../m2d/converters/utils")} */ +const mxUtils = sync.require("../../m2d/converters/utils") /** @type {import("../../discord/utils")} */ const dUtils = sync.require("../../discord/utils") -/** @type {import("./find-mentions")} */ -const findMentions = sync.require("./find-mentions") -/** @type {import("../../discord/interactions/poll-responses")} */ -const pollResponses = sync.require("../../discord/interactions/poll-responses") const {reg} = require("../../matrix/read-registration") +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, spoilers = []) { +function getDiscordParseCallbacks(message, guild, useHTML) { return { /** @param {{id: string, type: "discordUser"}} node */ user: node => { const mxid = select("sim", "mxid", {user_id: node.id}).pluck().get() - const interactionMetadata = message.interaction_metadata + const interaction = message.interaction_metadata || message.interaction const username = message.mentions?.find(ment => ment.id === node.id)?.username || message.referenced_message?.mentions?.find(ment => ment.id === node.id)?.username - || (interactionMetadata?.user.id === node.id ? interactionMetadata.user.username : null) - || (message.author?.id === node.id ? message.author.username : null) - || "unknown-user" + || (interaction?.user.id === node.id ? interaction.user.username : null) + || (message.author.id === node.id ? message.author.username : null) + || node.id if (mxid && useHTML) { return `@${username}` } else { @@ -93,10 +90,6 @@ function getDiscordParseCallbacks(message, guild, useHTML, spoilers = []) { here: () => { if (message.mention_everyone) return "@room" return "@here" - }, - spoiler: node => { - spoilers.push(node.raw) - return useHTML } } } @@ -109,10 +102,9 @@ const embedTitleParser = markdown.markdownEngine.parserFor({ /** * @param {{room?: boolean, user_ids?: string[]}} mentions - * @param {Omit
❭ ${mxid ? tag`${username}` : username} used /${interaction.name}${thinkingText}`
- }
-}
-
-/**
- * @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, 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)
- const typesPermitted = !forceSameMsgtype || ne?.msgtype === prev?.msgtype
- if (isAllText && typesPermitted) {
- const rep = new mxUtils.MatrixStringBuilder()
- rep.body = prev.body
- rep.formattedBody = prev.formatted_body
- 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)
- }
- }
-}
-
/**
* @param {DiscordTypes.APIMessage} message
* @param {DiscordTypes.APIGuild} guild
@@ -296,10 +207,8 @@ function mergeTextEvents(newEvents, events, forceSameMsgtype, forceMerge = false
* - alwaysReturnFormattedBody: false - formatted_body will be skipped if it is the same as body because the message is plaintext. if you want the formatted_body to be returned anyway, for example to merge it with another message, then set this to true.
* - scanTextForMentions: true - needs to be set to false when converting forwarded messages etc which may be from a different channel that can't be scanned.
* @param {{api: import("../../matrix/api"), snow?: import("snowtransfer").SnowTransfer}} di simple-as-nails dependency injection for the matrix API
- * @returns {Promise<{$type: string, $sender?: string, [x: string]: any}[]>}
*/
async function messageToEvent(message, guild, options = {}, di) {
- message = structuredClone(message)
const events = []
/* c8 ignore next 7 */
@@ -311,38 +220,6 @@ async function messageToEvent(message, guild, options = {}, di) {
return []
}
- if (message.type === DiscordTypes.MessageType.PollResult) {
- const pollMessageID = message.message_reference?.message_id
- if (!pollMessageID) return []
- const event_id = select("event_message", "event_id", {message_id: pollMessageID}).pluck().get()
- const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
- const pollQuestionText = select("poll", "question_text", {message_id: pollMessageID}).pluck().get()
- if (!event_id || !roomID || !pollQuestionText) return [] // drop it if the corresponding poll start was not bridged
-
- const rep = new mxUtils.MatrixStringBuilder()
- rep.addLine(`The poll ${pollQuestionText} has closed.`, tag`The poll ${pollQuestionText} has closed.`)
-
- const {messageString} = pollResponses.getCombinedResults(pollMessageID, true) // poll results have already been double-checked before this point, so these totals will be accurate
- rep.addLine(markdown.toHTML(messageString, {discordOnly: true, escapeHTML: false}), markdown.toHTML(messageString, {}))
-
- const {body, formatted_body} = rep.get()
-
- return [{
- $type: "org.matrix.msc3381.poll.end",
- "m.relates_to": {
- rel_type: "m.reference",
- event_id
- },
- "org.matrix.msc3381.poll.end": {},
- "org.matrix.msc1767.text": body,
- "org.matrix.msc1767.html": formatted_body,
- body: body,
- format: "org.matrix.custom.html",
- formatted_body: formatted_body,
- msgtype: "m.text"
- }]
- }
-
if (message.type === DiscordTypes.MessageType.ThreadStarterMessage) {
// This is the message that appears at the top of a thread when the thread was based off an existing message.
// It's just a message reference, no content.
@@ -360,20 +237,16 @@ async function messageToEvent(message, guild, options = {}, di) {
}]
}
- if (message.type === DiscordTypes.MessageType.ChannelFollowAdd) {
- return [{
- $type: "m.room.message",
- msgtype: "m.emote",
- body: `set this room to receive announcements from ${message.content}`,
- format: "org.matrix.custom.html",
- formatted_body: tag`set this room to receive announcements from ${message.content}`,
- "m.mentions": {}
- }]
+ const interaction = message.interaction_metadata || message.interaction
+ if (message.type === DiscordTypes.MessageType.ChatInputCommand && interaction && "name" in interaction) {
+ // Commands are sent by the responding bot. Need to attach the metadata of the person using the command at the top.
+ let content = message.content
+ if (content) content = `\n${content}`
+ else if ((message.flags || 0) & DiscordTypes.MessageFlags.Loading) content = " — interaction loading..."
+ content = `> ↪️ <@${interaction.user.id}> used \`/${interaction.name}\`${content}`
+ message = {...message, content} // editToChanges reuses the object so we can't mutate it. have to clone it
}
- let isInteraction = (message.type === DiscordTypes.MessageType.ChatInputCommand || message.type === DiscordTypes.MessageType.ContextMenuCommand) && message.interaction && "name" in message.interaction
- let isThinkingInteraction = isInteraction && !!((message.flags || 0) & DiscordTypes.MessageFlags.Loading)
-
/**
@type {{room?: boolean, user_ids?: string[]}}
We should consider the following scenarios for mentions:
@@ -391,9 +264,8 @@ async function messageToEvent(message, guild, options = {}, di) {
- So make sure we don't do anything in this case.
*/
const mentions = {}
- /** @type {{event_id: string, room_id: string, source: number, channel_id: string}?} */
+ /** @type {{event_id: string, room_id: string, source: number}?} */
let repliedToEventRow = null
- let repliedToEventInDifferentRoom = false
let repliedToUnknownEvent = false
let repliedToEventSenderMxid = null
@@ -407,22 +279,12 @@ async function messageToEvent(message, guild, options = {}, di) {
// 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.)
if (message.type === DiscordTypes.MessageType.Reply && message.message_reference?.message_id) {
- const row = await getHistoricalEventRow(message.message_reference?.message_id)
- if (row && "event_id" in row) {
- repliedToEventRow = Object.assign(row, {channel_id: row.reference_channel_id})
+ const row = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id").select("event_id", "room_id", "source").and("WHERE message_id = ? AND part = 0").get(message.message_reference.message_id)
+ if (row) {
+ repliedToEventRow = row
} else if (message.referenced_message) {
repliedToUnknownEvent = true
}
- } else if (message.type === DiscordTypes.MessageType.ContextMenuCommand && message.interaction && message.message_reference?.message_id) {
- // It could be a /plu/ral emulated reply
- if (message.interaction.name.startsWith("Reply ") && message.content.startsWith("-# [↪](")) {
- const row = await getHistoricalEventRow(message.message_reference?.message_id)
- if (row && "event_id" in row) {
- repliedToEventRow = Object.assign(row, {channel_id: row.reference_channel_id})
- message.content = message.content.replace(/^.*\n/, "")
- isInteraction = false // declutter
- }
- }
} else if (dUtils.isWebhookMessage(message) && message.embeds[0]?.author?.name?.endsWith("↩️")) {
// It could be a PluralKit emulated reply, let's see if it has a message link
const isEmulatedReplyToText = message.embeds[0].description?.startsWith("**[Reply to:]")
@@ -431,8 +293,8 @@ async function messageToEvent(message, guild, options = {}, di) {
assert(message.embeds[0].description)
const match = message.embeds[0].description.match(/\/channels\/[0-9]*\/[0-9]*\/([0-9]{2,})/)
if (match) {
- const row = await getHistoricalEventRow(match[1])
- if (row && "event_id" in row) {
+ const row = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id").select("event_id", "room_id", "source").and("WHERE message_id = ? AND part = 0").get(match[1])
+ if (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.
the following properties are necessary:
@@ -450,7 +312,7 @@ async function messageToEvent(message, guild, options = {}, di) {
}
}
message.embeds.shift()
- repliedToEventRow = Object.assign(row, {channel_id: row.reference_channel_id})
+ repliedToEventRow = row
}
}
}
@@ -477,34 +339,6 @@ async function messageToEvent(message, guild, options = {}, di) {
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 | undefined} */
- 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.
* If OOYE has handled this message in the past, this is an instant database lookup.
@@ -516,13 +350,27 @@ async function messageToEvent(message, guild, options = {}, di) {
for (const match of [...content.matchAll(/https:\/\/(?:ptb\.|canary\.|www\.)?discord(?:app)?\.com\/channels\/[0-9]+\/([0-9]+)\/([0-9]+)/g)]) {
assert(typeof match.index === "number")
const [_, channelID, messageID] = match
- const result = await (async () => {
- const row = await getHistoricalEventRow(messageID, channelID)
- if (!row) return `${match[0]} [event is from another server]`
- const via = await getViaServersMemo(row.room_id)
- if (!("event_id" in row)) return `[unknown event in https://matrix.to/#/${row.room_id}?${via}]`
- return `https://matrix.to/#/${row.room_id}/${row.event_id}?${via}`
- })()
+ let result
+
+ const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
+ if (roomID) {
+ const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get()
+ const via = await getViaServersMemo(roomID)
+ if (eventID && roomID) {
+ result = `https://matrix.to/#/${roomID}/${eventID}?${via}`
+ } else {
+ const ts = dUtils.snowflakeToTimestampExact(messageID)
+ try {
+ const {event_id} = await di.api.getEventForTimestamp(roomID, ts)
+ result = `https://matrix.to/#/${roomID}/${event_id}?${via}`
+ } catch (e) {
+ // M_NOT_FOUND: Unable to find event from In reply to ${repliedToUserHtml}` - + `` - + html - body = `${repliedToDisplayName}: ${repliedToBody}`.split("\n").map(line => "> " + line).join("\n") // scenario 1 part B for mentions - + "\n\n" + body - } else if (referenced.type === DiscordTypes.MessageType.UserJoin) { - // Discord user join messages are bridged as joins, not text events. Generate substitute text for reply. - const joinerMxid = select("sim", "mxid", {user_id: referenced.author.id}).pluck().get() - const joinerHtml = joinerMxid ? tag`${repliedToDisplayName}` : tag`${repliedToDisplayName}` - html = `
${repliedToHtml}
${joinerHtml} joined the room` + html - body = `> ${repliedToDisplayName} joined the room\n\n` + body - } else { // repliedToUnknownEvent - const dateDisplay = dUtils.howOldUnbridgedMessage(referenced.timestamp, message.timestamp) - html = `
In reply to ${dateDisplay} from ${repliedToDisplayName}:` - + `` - + html - body = `In reply to ${dateDisplay}:\n${repliedToDisplayName}: ${repliedToBody}`.split("\n").map(line => "> " + line).join("\n") - + "\n\n" + body - } + if ((repliedToEventRow || repliedToUnknownEvent) && options.includeReplyFallback !== false) { + let repliedToDisplayName + let repliedToUserHtml + if (repliedToEventRow?.source === 0 && repliedToEventSenderMxid) { + const match = repliedToEventSenderMxid.match(/^@([^:]*)/) + assert(match) + repliedToDisplayName = message.referenced_message?.author.username || match[1] || "a Matrix user" // grab the localpart as the display name, whatever + repliedToUserHtml = `${repliedToDisplayName}` + } else { + repliedToDisplayName = message.referenced_message?.author.global_name || message.referenced_message?.author.username || "a Discord user" + repliedToUserHtml = repliedToDisplayName + } + let repliedToContent = message.referenced_message?.content + if (repliedToContent?.match(/^(-# )?> (-# )?<:L1:/)) { + // If the Discord user is replying to a Matrix user's reply, the fallback is going to contain the emojis and stuff from the bridged rep of the Matrix user's reply quote. + // Need to remove that previous reply rep from this fallback body. The fallbody body should only contain the Matrix user's actual message. + // ┌──────A─────┐ A reply rep starting with >quote or -#smalltext >quote. Match until the end of the line. + // ┆ ┆┌─B─┐ There may be up to 2 reply rep lines in a row if it was created in the old format. Match all lines. + repliedToContent = repliedToContent.replace(/^((-# )?> .*\n){1,2}/, "") + } + if (repliedToContent == "") repliedToContent = "[Media]" + else if (!repliedToContent) repliedToContent = "[Replied-to message content wasn't provided by Discord]" + const {body: repliedToBody, html: repliedToHtml} = await transformContent(repliedToContent) + if (repliedToEventRow) { + // Generate a reply pointing to the Matrix event we found + html = `
${repliedToHtml}
In reply to ${repliedToUserHtml}` + + `
${repliedToHtml}
In reply to ${dateDisplay} from ${repliedToDisplayName}:` + + `` + + html + body = (`In reply to ${dateDisplay}:\n${repliedToDisplayName}: ` + + repliedToBody).split("\n").map(line => "> " + line).join("\n") + + "\n\n" + body } - } - - if (isInteraction && !isThinkingInteraction && message.interaction && events.length === 0) { - const formattedInteraction = getFormattedInteraction(message.interaction, false) - body = `${formattedInteraction.body}\n${body}` - html = `${formattedInteraction.html}${html}` } const newTextMessageEvent = { $type: "m.room.message", "m.mentions": mentions, msgtype, - body: body, - format: "org.matrix.custom.html", - formatted_body: html + body: body + } + + const isPlaintext = body === html + + if (!isPlaintext || options.alwaysReturnFormattedBody) { + Object.assign(newTextMessageEvent, { + format: "org.matrix.custom.html", + formatted_body: html + }) } events.push(newTextMessageEvent) @@ -740,51 +530,30 @@ async function messageToEvent(message, guild, options = {}, di) { message.content = "changed the channel name to **" + message.content + "**" } - // Handle message type 63, new emoji announcement - // @ts-expect-error - should be changed to a DiscordTypes reference once it has been documented - if (message.type === 63) { - const match = message.content.match(/^<(a?):([^:>]{1,64}):([0-9]+)>$/) - assert(match, `message type 63, which announces a new emoji, did not include an emoji. the actual content was: "${message.content}"`) - const name = match[2] - msgtype = "m.emote" - message.content = `added a new emoji, ${message.content} :${name}:` - } - - // Send Klipy GIFs in customised form - let isKlipyGIF = false - let isOnlyKlipyGIF = false - if (message.embeds?.length === 1 && message.embeds[0].provider?.name === "Klipy" && message.embeds[0].video?.url) { - isKlipyGIF = true - if (message.content.match(/^https?:\/\/klipy\.com[^ \n]+$/)) { - isOnlyKlipyGIF = true - } - } - // Forwarded content appears first - if (message.message_reference?.type === DiscordTypes.MessageReferenceType.Forward && message.message_reference.message_id && message.message_snapshots?.length) { + if (message.message_reference?.type === DiscordTypes.MessageReferenceType.Forward && message.message_snapshots?.length) { // Forwarded notice - const row = await getHistoricalEventRow(message.message_reference.message_id, message.message_reference.channel_id) + const eventID = select("event_message", "event_id", {message_id: message.message_reference.message_id}).pluck().get() const room = select("channel_room", ["room_id", "name", "nick"], {channel_id: message.message_reference.channel_id}).get() const forwardedNotice = new mxUtils.MatrixStringBuilder() if (room) { const roomName = room && (room.nick || room.name) - if (row && "event_id" in row) { - const via = await getViaServersMemo(row.room_id) + const via = await getViaServersMemo(room.room_id) + if (eventID) { forwardedNotice.addLine( - `[↷ Forwarded from #${roomName}]`, - tag`↷ Forwarded from ${roomName} [jump to event]` + `[🔀 Forwarded from #${roomName}]`, + tag`🔀 Forwarded from ${roomName}` ) } else { - const via = await getViaServersMemo(room.room_id) forwardedNotice.addLine( - `[↷ Forwarded from #${roomName}]`, - tag`↷ Forwarded from ${roomName} [jump to room]` + `[🔀 Forwarded from #${roomName}]`, + tag`🔀 Forwarded from ${roomName}` ) } } else { forwardedNotice.addLine( - `[↷ Forwarded message]`, - tag`↷ Forwarded message` + `[🔀 Forwarded message]`, + tag`🔀 Forwarded message` ) } @@ -795,6 +564,7 @@ async function messageToEvent(message, guild, options = {}, di) { // Indent for (const event of forwardedEvents) { if (["m.text", "m.notice"].includes(event.msgtype)) { + event.msgtype = "m.notice" event.body = event.body.split("\n").map(l => "» " + l).join("\n") event.formatted_body = `
${repliedToHtml}
${event.formatted_body}` } @@ -811,41 +581,37 @@ async function messageToEvent(message, guild, options = {}, di) { events.push(...forwardedEvents) } - if (isInteraction && isThinkingInteraction && message.interaction) { - const formattedInteraction = getFormattedInteraction(message.interaction, true) - await addTextEvent(formattedInteraction.body, formattedInteraction.html, "m.notice") - } - // Then text content - if (message.content && !isOnlyKlipyGIF && !isThinkingInteraction) { - // Scan the content for emojihax and replace them with real emojis - let content = message.content.replaceAll(/\[([a-zA-Z0-9_-]{2,32})(?:~[0-9]+)?\]\(https:\/\/cdn\.discordapp\.com\/emojis\/([0-9]+)\.[^ \n)`]+\)/g, (_, name, id) => { - return `<:${name}:${id}>` - }) + if (message.content) { + // Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention. + const matches = [...message.content.matchAll(/@ ?([a-z0-9._]+)\b/gi)] + if (options.scanTextForMentions !== false && matches.length && matches.some(m => m[1].match(/[a-z]/i) && m[1] !== "everyone" && m[1] !== "here")) { + const writtenMentionsText = matches.map(m => m[1].toLowerCase()) + const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get() + assert(roomID) + const {joined} = await di.api.getJoinedMembers(roomID) + for (const [mxid, member] of Object.entries(joined)) { + if (!userRegex.some(rx => mxid.match(rx))) { + const localpart = mxid.match(/@([^:]*)/) + assert(localpart) + const displayName = member.display_name || localpart[1] + if (writtenMentionsText.includes(localpart[1].toLowerCase()) || writtenMentionsText.includes(displayName.toLowerCase())) addMention(mxid) + } + } + } - const {body, html} = await transformContent(content, {isTheMessageContent: true}) + const {body, html} = await transformContent(message.content) await addTextEvent(body, html, msgtype) } // Then scheduled events if (message.content && di?.snow) { for (const match of [...message.content.matchAll(/discord\.gg\/([A-Za-z0-9]+)\?event=([0-9]{18,})/g)]) { // snowflake has minimum 18 because the events feature is at least that old - let invite - try { - invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]}) - } catch (e) { - // Skip expired/invalid invites and events - if (e.message === `{"message": "Unknown Invite", "code": 10006}`) { - break - } else { - throw e - } - } - + const invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]}) const event = invite.guild_scheduled_event if (!event) continue // the event ID provided was not valid - const formatter = new Intl.DateTimeFormat("en-NZ", {month: "long", day: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "shortGeneric", timeZone: reg.ooye.time_zone}) // 9 June at 3:00 pm NZT + const formatter = new Intl.DateTimeFormat("en-NZ", {month: "long", day: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "shortGeneric"}) // 9 June at 3:00 pm NZT const rep = new mxUtils.MatrixStringBuilder() // Add time @@ -883,127 +649,8 @@ async function messageToEvent(message, guild, options = {}, di) { // Then attachments if (message.attachments) { - const attachmentEvents = await Promise.all(message.attachments.map(attachment => attachmentToEvent(mentions, attachment))) - - // Try to merge attachment events with the previous event - // This means that if the attachments ended up as a text link, and especially if there were many of them, the events will be joined together. - mergeTextEvents(attachmentEvents, events, false) - } - - // Then components - if (message.components?.length) { - const stack = new mxUtils.MatrixStringBuilderStack() - /** @param {DiscordTypes.APIMessageComponent} component */ - async function processComponent(component) { - // Standalone components - if (component.type === DiscordTypes.ComponentType.TextDisplay) { - const {body, html} = await transformContent(component.content) - stack.msb.addParagraph(body, html) - } - else if (component.type === DiscordTypes.ComponentType.Separator) { - stack.msb.addParagraph("----", "
${formatted_body}` - if (stack.msb.body) stack.msb.body += "\n\n" - stack.msb.add(body, formatted_body) - } - else if (component.type === DiscordTypes.ComponentType.Section) { - // May contain text display, possibly more in the future - // Accessory may be button or thumbnail - stack.bump() - for (const innerComponent of component.components) { - await processComponent(innerComponent) - } - if (component.accessory) { - stack.bump() - await processComponent(component.accessory) - const {body, formatted_body} = stack.shift().get() - stack.msb.addLine(body, formatted_body) - } - const {body, formatted_body} = stack.shift().get() - stack.msb.addParagraph(body, formatted_body) - } - else if (component.type === DiscordTypes.ComponentType.ActionRow) { - const linkButtons = component.components.filter(c => c.type === DiscordTypes.ComponentType.Button && c.style === DiscordTypes.ButtonStyle.Link) - if (linkButtons.length) { - stack.msb.addLine("") - for (const linkButton of linkButtons) { - await processComponent(linkButton) - } - } - } - // Components that can only be inside things - else if (component.type === DiscordTypes.ComponentType.Thumbnail) { - // May only be a section accessory - stack.msb.add(`🖼️ ${component.media.url}`, tag`🖼️ ${component.media.url}`) - } - else if (component.type === DiscordTypes.ComponentType.Button) { - // 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 - const link = await transformContentMessageLinks(component.url) - stack.msb.add(`[${component.label} ${link}] `, tag`${component.label} `) - } - } - - // Not handling file upload or label because they are modal-only components - } - - for (const component of message.components) { - await processComponent(component) - } - - const {body, formatted_body} = stack.msb.get() - if (body.trim().length) { - // 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) - } - } - } - - // Then polls - if (message.poll) { - const pollEvent = await pollToEvent(message.poll) - events.push(pollEvent) + const attachmentEvents = await Promise.all(message.attachments.map(attachmentToEvent.bind(null, mentions))) + events.push(...attachmentEvents) } // Then embeds @@ -1017,43 +664,12 @@ async function messageToEvent(message, guild, options = {}, di) { continue // Matrix's own URL previews are fine for images. } - if (embed.type === "video" && embed.video?.url && !embed.title && message.content.includes(embed.video.url)) { - continue // Doesn't add extra information and the direct video URL is already there. - } - - if (embed.type === "poll_result") { - // The code here is only for the message to be bridged to Matrix. Dealing with the Discord-side updates is in d2m/actions/poll-end.js. - } - if (embed.url?.startsWith("https://discord.com/")) { 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() - let isAdditionalImage = false - - if (isKlipyGIF) { - assert(embed.video?.url) - rep.add("[GIF] ", "➿ ") - if (embed.title) { - rep.add(`${embed.title} ${embed.video.url}`, tag`${embed.title}`) - } else { - rep.add(embed.video.url) - } - - let {body, formatted_body: html} = rep.get() - html = `
${html}` - await addTextEvent(body, html, "m.text") - continue - } // Provider if (embed.provider?.name && embed.provider.name !== "Tenor") { @@ -1105,11 +721,7 @@ async function messageToEvent(message, guild, options = {}, di) { let chosenImage = embed.image?.url // the thumbnail seems to be used for "article" type but displayed big at the bottom by discord if (embed.type === "article" && embed.thumbnail?.url && !chosenImage) chosenImage = embed.thumbnail.url - - if (chosenImage) { - isAdditionalImage = !rep.body && !!events.length - rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`) - } + if (chosenImage) rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`) if (embed.video?.url) rep.addParagraph(`🎞️ ${dUtils.getPublicUrlForCdn(embed.video.url)}`) @@ -1118,11 +730,6 @@ async function messageToEvent(message, guild, options = {}, di) { body = body.split("\n").map(l => "| " + l).join("\n") html = `
${html}` - if (isAdditionalImage) { - mergeTextEvents([{...rep.get(), body, html, msgtype: "m.notice"}], events, true) - continue - } - // Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person await addTextEvent(body, html, "m.notice") } @@ -1143,7 +750,7 @@ async function messageToEvent(message, guild, options = {}, di) { } } else { let body = stickerItem.name - const sticker = guild.stickers?.find(sticker => sticker.id === stickerItem.id) + const sticker = guild.stickers.find(sticker => sticker.id === stickerItem.id) if (sticker && sticker.description) body += ` - ${sticker.description}` return { $type: "m.sticker", @@ -1160,7 +767,7 @@ async function messageToEvent(message, guild, options = {}, di) { } // Rich replies - if (repliedToEventRow && !repliedToEventInDifferentRoom) { + if (repliedToEventRow) { Object.assign(events[0], { "m.relates_to": { "m.in_reply_to": { @@ -1170,16 +777,6 @@ async function messageToEvent(message, guild, options = {}, di) { }) } - // Strip formatted_body where equivalent to body - if (!options.alwaysReturnFormattedBody) { - for (const event of events) { - if (event.$type === "m.room.message" && "msgtype" in event && ["m.text", "m.notice"].includes(event.msgtype) && event.body === event.formatted_body) { - delete event.format - delete event.formatted_body - } - } - } - return events } diff --git a/src/d2m/converters/message-to-event.test.pk.js b/src/d2m/converters/message-to-event.pk.test.js similarity index 72% rename from src/d2m/converters/message-to-event.test.pk.js rename to src/d2m/converters/message-to-event.pk.test.js index 1323280..ce83d54 100644 --- a/src/d2m/converters/message-to-event.test.pk.js +++ b/src/d2m/converters/message-to-event.pk.test.js @@ -50,7 +50,11 @@ test("message2event: pk reply to matrix is converted to native matrix reply", as ] }, msgtype: "m.text", - body: "this is a reply", + body: "> cadence [they]: now for my next experiment:\n\nthis is a reply", + format: "org.matrix.custom.html", + formatted_body: '
In reply to cadence [they]
' + + "now for my next experiment:
In reply to wing
' + + "some text
In reply to Ampflower 🌺
' + + "[Media]
" - + "" - + "Lillith (INX)
" - + "Display name: Lillith (she/her)" - + "
" - + `🖼️ https://files.inx.moe/p/cdn/lillith.webp` - + "
Pronouns: She/Her" - + "
Message count: 3091
" - + "Proxy tags:" - + "
l;text" - + "l:text" - + "l.text" - + "textl." - + "textl;" - + "textl:
System ID: xffgnx ∙ Member ID: pphhoh
"
- + "Created: 2025-12-31 03:16:45 UTC
` - + "System: INX (
" - + `🖼️ https://files.inx.moe/p/cdn/lillith.webp` - + "xffgnx)" - + "
Member: Lillith (pphhoh)" - + "
Sent by: infinidoge1337 (@unknown-user)" - + "
Account Roles (7)" - + "
§b, !, ‼, Ears Port Ping, Ears Update Ping, Yttr Ping, unsup Ping
" - + "Same hat
" - + `🖼️ Image: image.png
Original Message ID: 1466556003645657118 · <t:1769724599:f>
", - "m.mentions": {}, - 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: "❭ cadence used /🔔 Ping authorPsst, Red (@cadence.worm), you have been pinged by @cadence.worm.In reply to Extremity' + + '
Image
In reply to cadence' + + '
so can you reply to my webhook uwu
In reply to okay 🤍 yay 🤍' + + '
@extremity you owe me $30
In reply to cadence [they]
What about them?
In reply to Ami (she/her)
let me guess they got a lot of bug reports like "empty chest with no loot?"
In reply to Ami (she/her)
let me guess they got a lot of bug reports like "empty chest with no loot?"
PEASANT!! joined the roomwhen the broke friend who we pay to bring food shows up at the medieval lord party", - "m.mentions": {} - }]) -}) - -test("message2event: reply to a Discord member join (who did join on Matrix)", async t => { - db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES ('1461677775554478161', 'peasant321_76775', 'peasant321_76775', '@_ooye_peasant321_76775:cadence.moe')").run() - const events = await messageToEvent(data.message.reply_to_member_join, data.guild.general) - t.deepEqual(events, [{ - $type: "m.room.message", - msgtype: "m.text", - body: "> PEASANT!! joined the room\n\nwhen the broke friend who we pay to bring food shows up at the medieval lord party", - format: "org.matrix.custom.html", - formatted_body: `
PEASANT!! joined the roomwhen the broke friend who we pay to bring food shows up at the medieval lord party`, - "m.mentions": {} - }]) -}) - test("message2event: simple written @mention for matrix user", async t => { const events = await messageToEvent(data.message.simple_written_at_mention_for_matrix, data.guild.general, {}, { api: { @@ -815,13 +831,11 @@ test("message2event: simple written @mention for matrix user", async t => { ] }, msgtype: "m.text", - body: "@ash do you need anything from the store btw as I'm heading there after gym", - format: "org.matrix.custom.html", - formatted_body: `@ash do you need anything from the store btw as I'm heading there after gym` + body: "@ash do you need anything from the store btw as I'm heading there after gym" }]) }) -test("message2event: many written @mentions for matrix users", async t => { +test("message2event: advanced written @mentions for matrix users", async t => { let called = 0 const events = await messageToEvent(data.message.advanced_written_at_mention_for_matrix, data.guild.general, {}, { api: { @@ -859,230 +873,16 @@ test("message2event: many written @mentions for matrix users", async t => { $type: "m.room.message", "m.mentions": { user_ids: [ - "@huckleton:cadence.moe", - "@cadence:cadence.moe" + "@cadence:cadence.moe", + "@huckleton:cadence.moe" ] }, msgtype: "m.text", - body: "@Cadence, tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and @huck", - format: "org.matrix.custom.html", - formatted_body: `@Cadence, tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and @huck` + body: "@Cadence, tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and @huck" }]) t.equal(called, 1, "should only look up the member list once") }) -test("message2event: written @mentions may match part of the name", async t => { - let called = 0 - const events = await messageToEvent({ - ...data.message.advanced_written_at_mention_for_matrix, - content: "I wonder if @cadence saw this?" - }, data.guild.general, {}, { - api: { - async getJoinedMembers(roomID) { - called++ - t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") - return new Promise(resolve => { - setTimeout(() => { - resolve({ - joined: { - "@secret:cadence.moe": { - display_name: "cadence [they]", - avatar_url: "whatever" - }, - "@huckleton:cadence.moe": { - display_name: "huck", - avatar_url: "whatever" - }, - "@_ooye_botrac4r:cadence.moe": { - display_name: "botrac4r", - avatar_url: "whatever" - }, - "@_ooye_bot:cadence.moe": { - display_name: "Out Of Your Element", - avatar_url: "whatever" - } - } - }) - }) - }) - } - } - }) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": { - user_ids: [ - "@secret:cadence.moe", - ] - }, - msgtype: "m.text", - body: "I wonder if @cadence saw this?", - format: "org.matrix.custom.html", - formatted_body: `I wonder if @cadence saw this?` - }]) -}) - -test("message2event: written @mentions may match part of the mxid", async t => { - let called = 0 - const events = await messageToEvent({ - ...data.message.advanced_written_at_mention_for_matrix, - content: "I wonder if @huck saw this?" - }, data.guild.general, {}, { - api: { - async getJoinedMembers(roomID) { - called++ - t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") - return new Promise(resolve => { - setTimeout(() => { - resolve({ - joined: { - "@cadence:cadence.moe": { - display_name: "cadence [they]", - avatar_url: "whatever" - }, - "@huckleton:cadence.moe": { - display_name: "wa", - avatar_url: "whatever" - }, - "@_ooye_botrac4r:cadence.moe": { - display_name: "botrac4r", - avatar_url: "whatever" - }, - "@_ooye_bot:cadence.moe": { - display_name: "Out Of Your Element", - avatar_url: "whatever" - } - } - }) - }) - }) - } - } - }) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": { - user_ids: [ - "@huckleton:cadence.moe", - ] - }, - msgtype: "m.text", - body: "I wonder if @huck saw this?", - format: "org.matrix.custom.html", - formatted_body: `I wonder if @huck saw this?` - }]) -}) - -test("message2event: written @mentions do not match in URLs", async t => { - const events = await messageToEvent({ - ...data.message.advanced_written_at_mention_for_matrix, - content: "the fucking around with pixel composer continues https://pub.mastodon.sleeping.town/@exa/116037641900024965" - }, data.guild.general, {}, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.text", - body: "the fucking around with pixel composer continues https://pub.mastodon.sleeping.town/@exa/116037641900024965", - format: "org.matrix.custom.html", - formatted_body: `the fucking around with pixel composer continues https://pub.mastodon.sleeping.town/@exa/116037641900024965` - }]) -}) - -test("message2event: written @mentions do not match in inline code", async t => { - const events = await messageToEvent({ - ...data.message.advanced_written_at_mention_for_matrix, - content: "`public @Nullable EntityType>`" - }, data.guild.general, {}, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.text", - body: "`public @Nullable EntityType>`", - format: "org.matrix.custom.html", - formatted_body: `
public @Nullable EntityType<?>`
- }])
-})
-
-test("message2event: written @mentions do not match in code block", async t => {
- const events = await messageToEvent({
- ...data.message.advanced_written_at_mention_for_matrix,
- content: "```java\npublic @Nullable EntityType>\n```"
- }, data.guild.general, {}, {})
- t.deepEqual(events, [{
- $type: "m.room.message",
- "m.mentions": {},
- msgtype: "m.text",
- body: "```java\npublic @Nullable EntityType>\n```",
- format: "org.matrix.custom.html",
- formatted_body: `public @Nullable EntityType<?>`
- }])
-})
-
-test("message2event: entire message may match elaborate display name", async t => {
- let called = 0
- const events = await messageToEvent({
- ...data.message.advanced_written_at_mention_for_matrix,
- content: "@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆"
- }, data.guild.general, {}, {
- api: {
- async getJoinedMembers(roomID) {
- called++
- t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
- return new Promise(resolve => {
- setTimeout(() => {
- resolve({
- joined: {
- "@wa:cadence.moe": {
- display_name: "Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆",
- avatar_url: "whatever"
- },
- "@huckleton:cadence.moe": {
- display_name: "huck",
- avatar_url: "whatever"
- },
- "@_ooye_botrac4r:cadence.moe": {
- display_name: "botrac4r",
- avatar_url: "whatever"
- },
- "@_ooye_bot:cadence.moe": {
- display_name: "Out Of Your Element",
- avatar_url: "whatever"
- }
- }
- })
- })
- })
- }
- }
- })
- t.deepEqual(events, [{
- $type: "m.room.message",
- "m.mentions": {
- user_ids: [
- "@wa:cadence.moe",
- ]
- },
- msgtype: "m.text",
- body: "@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆",
- format: "org.matrix.custom.html",
- formatted_body: `@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆`
- }])
-})
-
-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: `beatrice`
- }])
-})
-
test("message2event: very large attachment is linked instead of being uploaded", async t => {
const events = await messageToEvent({
content: "hey",
@@ -1093,66 +893,18 @@ test("message2event: very large attachment is linked instead of being uploaded",
size: 100e6
}]
})
- t.deepEqual(events, [{
- $type: "m.room.message",
- "m.mentions": {},
- msgtype: "m.text",
- body: "hey\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)",
- format: "org.matrix.custom.html",
- formatted_body: 'hey📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg (38 KB)` - + `📄 Uploaded file: hey.jpg (100 MB)` }, { $type: "m.room.message", "m.mentions": {}, - msgtype: "m.file", - body: "my enemies.txt", - filename: "my enemies.txt", - external_url: "https://bridge.example.org/download/discordcdn/123/456/my_enemies.txt", - url: "mxc://cadence.moe/y89EOTRp2lbeOkgdsEleGOge", - info: { - mimetype: "text/plain", - size: 8911 - } + msgtype: "m.text", + body: "📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)", + format: "org.matrix.custom.html", + formatted_body: '📄 Uploaded file: hey.jpg (100 MB)' }]) }) @@ -1168,19 +920,6 @@ test("message2event: type 4 channel name change", async t => { }]) }) -test("message2event: type 12 channel follow add", async t => { - const events = await messageToEvent(data.special_message.channel_follow_add, data.guild.general) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.emote", - body: "set this room to receive announcements from PluralKit #downtime", - format: "org.matrix.custom.html", - formatted_body: "set this room to receive announcements from PluralKit #downtime", - "m.mentions": {} - }]) -}) - test("message2event: thread start message reference", async t => { const events = await messageToEvent(data.special_message.thread_start_context, data.guild.general, {}, { api: { @@ -1240,18 +979,6 @@ test("message2event: emoji that hasn't been registered yet", async t => { }]) }) -test("message2event: emojihax", async t => { - const events = await messageToEvent(data.message.emojihax, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.text", - body: "I only violate the don't modify our console part of terms of service :troll:", - format: "org.matrix.custom.html", - formatted_body: `I only violate the don't modify our console part of terms of service
What's cooking, good looking?`, "m.mentions": {}, - msgtype: "m.text", + msgtype: "m.notice", }, { $type: "m.room.message", @@ -1430,7 +1156,6 @@ test("message2event: constructed forwarded message", async t => { test("message2event: constructed forwarded text", async t => { const events = await messageToEvent(data.message.constructed_forwarded_text, {}, {}, { api: { - getEffectivePower: mockGetEffectivePower(), async getJoinedMembers() { return { joined: { @@ -1444,13 +1169,13 @@ test("message2event: constructed forwarded text", async t => { t.deepEqual(events, [ { $type: "m.room.message", - body: "[↷ Forwarded from #amanda-spam]" + body: "[🔀 Forwarded from #amanda-spam]" + "\n» What's cooking, good looking?", format: "org.matrix.custom.html", - formatted_body: `↷ Forwarded from amanda-spam [jump to room]` + formatted_body: `🔀 Forwarded from amanda-spam` + `
What's cooking, good looking?`, "m.mentions": {}, - msgtype: "m.text", + msgtype: "m.notice", }, { $type: "m.room.message", @@ -1467,13 +1192,13 @@ test("message2event: don't scan forwarded messages for mentions", async t => { t.deepEqual(events, [ { $type: "m.room.message", - body: "[↷ Forwarded message]" + body: "[🔀 Forwarded message]" + "\n» If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile https://social.luca.run/@luca/113950834185678114", format: "org.matrix.custom.html", - formatted_body: `↷ Forwarded message` + formatted_body: `🔀 Forwarded message` + `
If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile https://social.luca.run/@luca/113950834185678114`, "m.mentions": {}, - msgtype: "m.text" + msgtype: "m.notice" } ]) }) @@ -1568,7 +1293,6 @@ test("message2event: vc invite event renders embed", async t => { test("message2event: vc invite event renders embed with room link", async t => { const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381174024801095751"}, {}, {}, { api: { - getEffectivePower: mockGetEffectivePower(), getJoinedMembers: async () => ({ joined: { "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, @@ -1607,28 +1331,6 @@ test("message2event: vc invite event renders embed with room link", async t => { ]) }) -test("message2event: expired/invalid invites are sent as-is", async t => { - const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381190945646710824"}, {}, {}, { - snow: { - invite: { - async getInvite() { - throw new Error(`{"message": "Unknown Invite", "code": 10006}`) - } - } - } - }) - t.deepEqual(events, [ - { - $type: "m.room.message", - body: "https://discord.gg/placeholder?event=1381190945646710824", - format: "org.matrix.custom.html", - formatted_body: "https://discord.gg/placeholder?event=1381190945646710824", - "m.mentions": {}, - msgtype: "m.text", - } - ]) -}) - test("message2event: channel links are converted even inside lists (parser post-processer descends into list items)", async t => { let called = 0 const events = await messageToEvent({ @@ -1640,7 +1342,6 @@ test("message2event: channel links are converted even inside lists (parser post- + "\nThis list will probably change in the future" }, data.guild.general, {}, { api: { - getEffectivePower: mockGetEffectivePower(), getJoinedMembers(roomID) { called++ t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") @@ -1672,226 +1373,3 @@ test("message2event: channel links are converted even inside lists (parser post- ]) t.equal(called, 1) }) - -test("message2event: emoji added special message", async t => { - const events = await messageToEvent(data.special_message.emoji_added) - t.deepEqual(events, [ - { - $type: "m.room.message", - msgtype: "m.emote", - body: "added a new emoji, :cx_marvelous: :cx_marvelous:", - format: "org.matrix.custom.html", - formatted_body: `added a new emoji,
In reply to Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆cross-room reply`, - "m.mentions": { - user_ids: [ - "@cadence:cadence.moe" - ] - } - } - ]) -}) - -test("message2event: forwarded message with unreferenced mention", async t => { - const events = await messageToEvent({ - type: 0, - content: "", - attachments: [], - embeds: [], - timestamp: "2026-01-20T14:14:21.281Z", - edited_timestamp: null, - flags: 16384, - components: [], - id: "1463174818823405651", - channel_id: "893634327722721290", - author: { - id: "100031256988766208", - username: "leo60228", - discriminator: "0", - avatar: "8a164f29946f23eb4f45cde71a75e5a6", - avatar_decoration_data: null, - public_flags: 768, - global_name: "leo vriska", - primary_guild: null, - collectibles: null, - display_name_styles: null - }, - bot: false, - pinned: false, - mentions: [], - mention_roles: [], - mention_everyone: false, - tts: false, - message_reference: { - type: 1, - channel_id: "937181373943382036", - message_id: "1032034158261846038", - guild_id: "936370934292549712" - }, - message_snapshots: [ - { - message: { - type: 0, - content: "<@77084495118868480>", - attachments: [ - { - id: "1463174815119704114", - filename: "2022-10-18_16-49-46.mp4", - size: 51238885, - url: "https://cdn.discordapp.com/attachments/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4?ex=6970df3c&is=696f8dbc&hm=515d3cbcc8464bdada7f4c3d9ccc8174f671cb75391ce21a46a804fcb1e4befe&", - proxy_url: "https://media.discordapp.net/attachments/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4?ex=6970df3c&is=696f8dbc&hm=515d3cbcc8464bdada7f4c3d9ccc8174f671cb75391ce21a46a804fcb1e4befe&", - width: 1920, - height: 1080, - content_type: "video/mp4", - content_scan_version: 3, - spoiler: false - } - ], - embeds: [], - timestamp: "2022-10-18T20:55:17.597Z", - edited_timestamp: null, - flags: 0, - components: [] - } - } - ] - }) - t.deepEqual(events, [{ - $type: "m.room.message", - msgtype: "m.text", - body: "[↷ Forwarded message]\n» @unknown-user:\n» 🎞️ Uploaded file: https://bridge.example.org/download/discordcdn/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4 (51 MB)", - format: "org.matrix.custom.html", - formatted_body: "↷ Forwarded message
[Media]
@unknown-user:", - "m.mentions": {} - }]) -}) - -test("message2event: single-choice poll", async t => { - const events = await messageToEvent(data.message.poll_single_choice, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "org.matrix.msc3381.poll.start", - "org.matrix.msc3381.poll.start": { - question: { - "org.matrix.msc1767.text": "only one answer allowed!", - body: "only one answer allowed!", - msgtype: "m.text" - }, - kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that. - max_selections: 1, - answers: [{ - id: "1", - "org.matrix.msc1767.text": "[\ud83d\udc4d] answer one" - }, { - id: "2", - "org.matrix.msc1767.text": "[\ud83d\udc4e] answer two" - }, { - id: "3", - "org.matrix.msc1767.text": "answer three" - }] - }, - "org.matrix.msc1767.text": "only one answer allowed!\n1. [\ud83d\udc4d] answer one\n2. [\ud83d\udc4e] answer two\n3. answer three" - }]) -}) - -test("message2event: multiple-choice poll", async t => { - const events = await messageToEvent(data.message.poll_multiple_choice, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "org.matrix.msc3381.poll.start", - "org.matrix.msc3381.poll.start": { - question: { - "org.matrix.msc1767.text": "more than one answer allowed", - body: "more than one answer allowed", - msgtype: "m.text" - }, - kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that. - max_selections: 3, - answers: [{ - id: "1", - "org.matrix.msc1767.text": "[😭] no" - }, { - id: "2", - "org.matrix.msc1767.text": "oh no" - }, { - id: "3", - "org.matrix.msc1767.text": "oh noooooo" - }] - }, - "org.matrix.msc1767.text": "more than one answer allowed\n1. [😭] no\n2. oh no\n3. oh noooooo" - }]) -}) - -test("message2event: smalltext from regular user", async t => { - const events = await messageToEvent({ - content: "-# hmm", - author: { - bot: false - } - }) - t.deepEqual(events, [{ - $type: "m.room.message", - msgtype: "m.text", - "m.mentions": {}, - body: "...hmm" - }]) -}) diff --git a/src/d2m/converters/pins-to-list.js b/src/d2m/converters/pins-to-list.js index 4ad8800..3e890ea 100644 --- a/src/d2m/converters/pins-to-list.js +++ b/src/d2m/converters/pins-to-list.js @@ -3,11 +3,10 @@ const {select} = require("../../passthrough") /** - * @param {import("discord-api-types/v10").RESTGetAPIChannelMessagesPinsResult} pins + * @param {import("discord-api-types/v10").RESTGetAPIChannelPinsResult} pins * @param {{"m.room.pinned_events/"?: {pinned?: string[]}}} kstate */ function pinsToList(pins, kstate) { - /** Most recent last. */ let alreadyPinned = kstate["m.room.pinned_events/"]?.pinned || [] // If any of the already pinned messages are bridged messages then remove them from the already pinned list. @@ -16,13 +15,13 @@ function pinsToList(pins, kstate) { // * Matrix-only unbridged messages that are pinned will remain pinned. alreadyPinned = alreadyPinned.filter(event_id => { const messageID = select("event_message", "message_id", {event_id}).pluck().get() - return !messageID || pins.items.find(m => m.message.id === messageID) // if it is bridged then remove it from the filter + return !messageID || pins.find(m => m.id === messageID) // if it is bridged then remove it from the filter }) /** @type {string[]} */ const result = [] - for (const pin of pins.items) { - const eventID = select("event_message", "event_id", {message_id: pin.message.id}, "ORDER BY part ASC").pluck().get() + for (const message of pins) { + const eventID = select("event_message", "event_id", {message_id: message.id, part: 0}).pluck().get() if (eventID && !alreadyPinned.includes(eventID)) result.push(eventID) } result.reverse() diff --git a/src/d2m/converters/pins-to-list.test.js b/src/d2m/converters/pins-to-list.test.js index 571735e..d0657cb 100644 --- a/src/d2m/converters/pins-to-list.test.js +++ b/src/d2m/converters/pins-to-list.test.js @@ -1,7 +1,6 @@ const {test} = require("supertape") const data = require("../../../test/data") const {pinsToList} = require("./pins-to-list") -const mixin = require("@cloudrac3r/mixin-deep") test("pins2list: converts known IDs, ignores unknown IDs", t => { const result = pinsToList(data.pins.faked, {}) @@ -47,9 +46,7 @@ test("pins2list: already pinned unknown items are not moved", t => { }) test("pins2list: bridged messages can be unpinned", t => { - const shortPins = mixin({}, data.pins.faked) - shortPins.items = shortPins.items.slice(0, -2) - const result = pinsToList(shortPins, { + const result = pinsToList(data.pins.faked.slice(0, -2), { "m.room.pinned_events/": { pinned: [ "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", diff --git a/src/d2m/converters/remove-member-mxids.js b/src/d2m/converters/remove-member-mxids.js deleted file mode 100644 index de26662..0000000 --- a/src/d2m/converters/remove-member-mxids.js +++ /dev/null @@ -1,38 +0,0 @@ -// @ts-check - -const passthrough = require("../../passthrough") -const {db, select, from} = passthrough - -/** - * @param {string} userID discord user ID that left - * @param {string} guildID discord guild ID that they left - */ -function removeMemberMxids(userID, guildID) { - // Get sims for user and remove - let membership = from("sim").join("sim_member", "mxid").join("channel_room", "room_id") - .select("room_id", "mxid").where({user_id: userID, guild_id: guildID}).and("ORDER BY room_id, mxid").all() - membership = membership.concat(from("sim_proxy").join("sim", "user_id").join("sim_member", "mxid").join("channel_room", "room_id") - .select("room_id", "mxid").where({proxy_owner_id: userID, guild_id: guildID}).and("ORDER BY room_id, mxid").all()) - - // Get user installed apps and remove - /** @type {string[]} */ - let userAppDeletions = [] - // 1. Select apps that have 1 user remaining - /** @type {Set
🎞️ Uploaded file: 2022-10-18_16-49-46.mp4 (51 MB)