, 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
❭ ${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
@@ -207,8 +296,10 @@ async function attachmentToEvent(mentions, attachment) {
* - 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 */
@@ -220,6 +311,38 @@ 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.
@@ -237,16 +360,20 @@ async function messageToEvent(message, guild, options = {}, di) {
}]
}
- 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
+ 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": {}
+ }]
}
+ 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:
@@ -264,8 +391,9 @@ 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}?} */
+ /** @type {{event_id: string, room_id: string, source: number, channel_id: string}?} */
let repliedToEventRow = null
+ let repliedToEventInDifferentRoom = false
let repliedToUnknownEvent = false
let repliedToEventSenderMxid = null
@@ -279,12 +407,22 @@ 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 = 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
+ 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})
} 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:]")
@@ -293,8 +431,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 = 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) {
+ const row = await getHistoricalEventRow(match[1])
+ 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.
the following properties are necessary:
@@ -312,7 +450,7 @@ async function messageToEvent(message, guild, options = {}, di) {
}
}
message.embeds.shift()
- repliedToEventRow = row
+ repliedToEventRow = Object.assign(row, {channel_id: row.reference_channel_id})
}
}
}
@@ -339,6 +477,34 @@ 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.
@@ -350,27 +516,13 @@ 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
- 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}` - + `
${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 ((repliedToEventRow || repliedToUnknownEvent) && options.includeReplyFallback !== false && events.length === 0) { + const latestRoomID = repliedToEventRow ? select("channel_room", "room_id", {channel_id: repliedToEventRow.channel_id}).pluck().get() : null + if (latestRoomID !== repliedToEventRow?.room_id) repliedToEventInDifferentRoom = true + + // check that condition 1 or 2 is met + if (repliedToEventInDifferentRoom || repliedToUnknownEvent) { + let referenced = message.referenced_message + /* c8 ignore next 4 - backend couldn't be bothered to dereference the message, have to do it ourselves */ + if (!referenced) { + assert(message.message_reference?.message_id) + referenced = await discord.snow.channel.getChannelMessage(message.message_reference.channel_id, message.message_reference.message_id) + } + + // Username + let repliedToDisplayName + let repliedToUserHtml + if (repliedToEventRow?.source === 0 && repliedToEventSenderMxid) { + const match = repliedToEventSenderMxid.match(/^@([^:]*)/) + assert(match) + repliedToDisplayName = referenced.author.username || match[1] || "a Matrix user" // grab the localpart as the display name, whatever + repliedToUserHtml = tag`${repliedToDisplayName}` + } else { + repliedToDisplayName = referenced.author.global_name || referenced.author.username || "a Discord user" + repliedToUserHtml = repliedToDisplayName + } + + // Content + let repliedToContent = referenced.content + if (repliedToContent?.match(/^(-# )?> (-# )?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]" + const {body: repliedToBody, html: repliedToHtml} = await transformContent(repliedToContent) + + // Now branch on condition 1 or 2 for a different kind of fallback + if (repliedToEventRow) { + html = `
${repliedToHtml}
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 (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 - } - - const isPlaintext = body === html - - if (!isPlaintext || options.alwaysReturnFormattedBody) { - Object.assign(newTextMessageEvent, { - format: "org.matrix.custom.html", - formatted_body: html - }) + body: body, + format: "org.matrix.custom.html", + formatted_body: html } events.push(newTextMessageEvent) @@ -530,30 +740,51 @@ 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_snapshots?.length) { + if (message.message_reference?.type === DiscordTypes.MessageReferenceType.Forward && message.message_reference.message_id && message.message_snapshots?.length) { // Forwarded notice - const eventID = select("event_message", "event_id", {message_id: message.message_reference.message_id}).pluck().get() + const row = await getHistoricalEventRow(message.message_reference.message_id, message.message_reference.channel_id) 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) - const via = await getViaServersMemo(room.room_id) - if (eventID) { + if (row && "event_id" in row) { + const via = await getViaServersMemo(row.room_id) forwardedNotice.addLine( - `[🔀 Forwarded from #${roomName}]`, - tag`🔀 Forwarded from ${roomName}` + `[↷ Forwarded from #${roomName}]`, + tag`↷ Forwarded from ${roomName} [jump to event]` ) } else { + const via = await getViaServersMemo(room.room_id) forwardedNotice.addLine( - `[🔀 Forwarded from #${roomName}]`, - tag`🔀 Forwarded from ${roomName}` + `[↷ Forwarded from #${roomName}]`, + tag`↷ Forwarded from ${roomName} [jump to room]` ) } } else { forwardedNotice.addLine( - `[🔀 Forwarded message]`, - tag`🔀 Forwarded message` + `[↷ Forwarded message]`, + tag`↷ Forwarded message` ) } @@ -564,7 +795,6 @@ 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}` } @@ -581,33 +811,37 @@ async function messageToEvent(message, guild, options = {}, di) { events.push(...forwardedEvents) } - // Then text content - 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) - } - } - } + if (isInteraction && isThinkingInteraction && message.interaction) { + const formattedInteraction = getFormattedInteraction(message.interaction, true) + await addTextEvent(formattedInteraction.body, formattedInteraction.html, "m.notice") + } - const {body, html} = await transformContent(message.content) + // 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}>` + }) + + const {body, html} = await transformContent(content, {isTheMessageContent: true}) 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 - const invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]}) + 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 event = invite.guild_scheduled_event if (!event) continue // the event ID provided was not valid @@ -649,8 +883,127 @@ async function messageToEvent(message, guild, options = {}, di) { // Then attachments if (message.attachments) { - const attachmentEvents = await Promise.all(message.attachments.map(attachmentToEvent.bind(null, mentions))) - events.push(...attachmentEvents) + 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) } // Then embeds @@ -664,12 +1017,43 @@ 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") { @@ -721,7 +1105,11 @@ 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) rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`) + + if (chosenImage) { + isAdditionalImage = !rep.body && !!events.length + rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`) + } if (embed.video?.url) rep.addParagraph(`🎞️ ${dUtils.getPublicUrlForCdn(embed.video.url)}`) @@ -730,6 +1118,11 @@ 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") } @@ -750,7 +1143,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", @@ -767,7 +1160,7 @@ async function messageToEvent(message, guild, options = {}, di) { } // Rich replies - if (repliedToEventRow) { + if (repliedToEventRow && !repliedToEventInDifferentRoom) { Object.assign(events[0], { "m.relates_to": { "m.in_reply_to": { @@ -777,6 +1170,16 @@ 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.components.js b/src/d2m/converters/message-to-event.test.components.js new file mode 100644 index 00000000..1ef83c36 --- /dev/null +++ b/src/d2m/converters/message-to-event.test.components.js @@ -0,0 +1,101 @@ +const {test} = require("supertape") +const {messageToEvent} = require("./message-to-event") +const data = require("../../../test/data") +const {mockGetEffectivePower} = require("../../matrix/utils.test") + +test("message2event components: pk question mark output", async t => { + const events = await messageToEvent(data.message_with_components.pk_question_mark_response, data.guild.general, {}) + t.deepEqual(events, [{ + $type: "m.room.message", + body: + "| ### Lillith (INX)" + + "\n| " + + "\n| **Display name:** Lillith (she/her)" + + "\n| **Pronouns:** She/Her" + + "\n| **Message count:** 3091" + + "\n| 🖼️ https://files.inx.moe/p/cdn/lillith.webp" + + "\n| " + + "\n| ----" + + "\n| " + + "\n| **Proxy tags:**" + + "\n| ``l;text``" + + "\n| ``l:text``" + + "\n| ``l.text``" + + "\n| ``textl.``" + + "\n| ``textl;``" + + "\n| ``textl:``" + + "\n" + + "\n-# System ID: `xffgnx` ∙ Member ID: `pphhoh`" + + "\n-# Created: 2025-12-31 03:16:45 UTC" + + "\n[View on dashboard https://dash.pluralkit.me/profile/m/pphhoh] " + + "\n" + + "\n----" + + "\n" + + "\n| **System:** INX (`xffgnx`)" + + "\n| **Member:** Lillith (`pphhoh`)" + + "\n| **Sent by:** infinidoge1337 (@unknown-user:)" + + "\n| " + + "\n| **Account Roles (7)**" + + "\n| §b, !, ‼, Ears Port Ping, Ears Update Ping, Yttr Ping, unsup Ping" + + "\n| 🖼️ https://files.inx.moe/p/cdn/lillith.webp" + + "\n| " + + "\n| ----" + + "\n| " + + "\n| Same hat" + + "\n| 🖼️ Image: https://bridge.example.org/download/discordcdn/934955898965729280/1466556006527012987/image.png" + + "\n" + + "\n-# Original Message ID: 1466556003645657118 ·
" + + "" + + "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.❭ 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: "| ### Amanda 🎵#2192 :online:"
+ body: "❭ PapiOphidian used `/stats`"
+ + "\n| ### 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: '", "m.mentions": {} }]) - t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each") + t.equal(called, 1, "should call getJoinedMembers once") }) -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) +test("message2event embeds: extreme html is all escaped", async t => { + const events = await messageToEvent(data.message_with_embeds.extreme_html_escaping, data.guild.general) t.deepEqual(events, [{ $type: "m.room.message", msgtype: "m.notice", @@ -153,18 +151,13 @@ test("message2event embeds: crazy html is all escaped", async t => { 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", - body: "> ↪️ @papiophidian: used `/stats`", - format: "org.matrix.custom.html", - formatted_body: "Amanda 🎵#2192
' + formatted_body: '
❭ PapiOphidian used' + + '/stats' @@ -86,17 +94,7 @@ 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: { - 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 - } - } - }, + getEffectivePower: mockGetEffectivePower(), async getJoinedMembers(roomID) { called++ t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") @@ -124,11 +122,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",
- "m.mentions": {},
- msgtype: "m.text",
- }, {
$type: "m.room.message",
msgtype: "m.notice",
- body: "| ## Hi, I'm Amanda!\n| \n| I condone pirating music!",
+ body: "❭ PapiOphidian used `/stats`"
+ + "\n| ## Hi, I'm Amanda!\n| \n| I condone pirating music!",
format: "org.matrix.custom.html",
- formatted_body: ``, + formatted_body: 'Hi, I'm Amanda!
I condone pirating music!
❭ PapiOphidian used /stats'
+ + ``, "m.mentions": {} }]) }) @@ -172,18 +165,13 @@ test("message2event embeds: title without url", async t => { 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", - body: "> ↪️ @papiophidian: used `/stats`", - format: "org.matrix.custom.html", - 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: "| I condone pirating music!",
+ body: "❭ PapiOphidian used `/stats`"
+ + "\n| I condone pirating music!",
format: "org.matrix.custom.html",
- formatted_body: ``, + formatted_body: 'I condone pirating music!
❭ PapiOphidian used /stats'
+ + ``, "m.mentions": {} }]) }) @@ -191,18 +179,13 @@ test("message2event embeds: url without title", async t => { 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", - body: "> ↪️ @papiophidian: used `/stats`", - format: "org.matrix.custom.html", - formatted_body: "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!",
+ body: "❭ PapiOphidian used `/stats`"
+ + "\n| ## Amanda\n| \n| I condone pirating music!",
format: "org.matrix.custom.html",
- formatted_body: ``, + formatted_body: 'Amanda
I condone pirating music!
❭ PapiOphidian used /stats'
+ + ``, "m.mentions": {} }]) }) @@ -211,17 +194,50 @@ 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", - body: "> ↪️ @papiophidian: used `/stats`", + msgtype: "m.notice", + body: "❭ PapiOphidian used `/stats`" + + "\n| I condone pirating music!", format: "org.matrix.custom.html", - formatted_body: "Amanda
I condone pirating music!
↪️ @papiophidian used /stats",
- "m.mentions": {},
+ formatted_body: '❭ 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", msgtype: "m.text", + body: "[↷ Forwarded message]\n» https://fixupx.com/i/status/2032003668787020046", + format: "org.matrix.custom.html", + formatted_body: "↷ Forwarded messageI condone pirating music!
https://fixupx.com/i/status/2032003668787020046", + "m.mentions": {} }, { $type: "m.room.message", msgtype: "m.notice", - body: "| I condone pirating music!", + 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", format: "org.matrix.custom.html", - formatted_body: `
`, + formatted_body: "I condone pirating music!
", "m.mentions": {} }]) }) @@ -321,6 +337,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: `https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E" + + "⏺️ 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
➿ 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: { - 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 - } - } - }, + getEffectivePower: mockGetEffectivePower(), async getJoinedMembers(roomID) { t.equal(roomID, "!TqlyQmifxGUggEmdBN:cadence.moe") return { diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index ee4ec037..be1d99f0 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -2,7 +2,9 @@ const {test} = require("supertape") const {messageToEvent} = require("./message-to-event") const {MatrixServerError} = require("../../matrix/mreq") const data = require("../../../test/data") +const {mockGetEffectivePower} = require("../../matrix/utils.test") const Ty = require("../../types") +const {db} = require("../../passthrough") /** * @param {string} roomID @@ -66,17 +68,7 @@ test("message2event: simple room mention", async t => { let called = 0 const events = await messageToEvent(data.message.simple_room_mention, data.guild.general, {}, { api: { - async getStateEvent(roomID, type, key) { - called++ - t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe") - t.equal(type, "m.room.power_levels") - t.equal(key, "") - return { - users: { - "@_ooye_bot:cadence.moe": 100 - } - } - }, + getEffectivePower: mockGetEffectivePower(), async getJoinedMembers(roomID) { called++ t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe") @@ -97,24 +89,14 @@ test("message2event: simple room mention", async t => { format: "org.matrix.custom.html", formatted_body: '#worm-farm' }]) - t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each") + t.equal(called, 1, "should call getJoinedMembers") }) test("message2event: simple room link", async t => { let called = 0 const events = await messageToEvent(data.message.simple_room_link, data.guild.general, {}, { api: { - async getStateEvent(roomID, type, key) { - called++ - t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe") - t.equal(type, "m.room.power_levels") - t.equal(key, "") - return { - users: { - "@_ooye_bot:cadence.moe": 100 - } - } - }, + getEffectivePower: mockGetEffectivePower(), async getJoinedMembers(roomID) { called++ t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe") @@ -135,24 +117,14 @@ test("message2event: simple room link", async t => { format: "org.matrix.custom.html", formatted_body: '#worm-farm' }]) - t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each") + t.equal(called, 1, "should call getJoinedMembers once") }) test("message2event: nicked room mention", async t => { let called = 0 const events = await messageToEvent(data.message.nicked_room_mention, data.guild.general, {}, { api: { - async getStateEvent(roomID, type, key) { - called++ - t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") - t.equal(type, "m.room.power_levels") - t.equal(key, "") - return { - users: { - "@_ooye_bot:cadence.moe": 100 - } - } - }, + getEffectivePower: mockGetEffectivePower(), async getJoinedMembers(roomID) { called++ t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") @@ -173,7 +145,7 @@ test("message2event: nicked room mention", async t => { format: "org.matrix.custom.html", formatted_body: '#main' }]) - t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each") + t.equal(called, 1, "should call getJoinedMembers once") }) test("message2event: unknown room mention", async t => { @@ -224,17 +196,7 @@ test("message2event: simple message link", async t => { let called = 0 const events = await messageToEvent(data.message.simple_message_link, data.guild.general, {}, { api: { - async getStateEvent(roomID, type, key) { - called++ - t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") - t.equal(type, "m.room.power_levels") - t.equal(key, "") - return { - users: { - "@_ooye_bot:cadence.moe": 100 - } - } - }, + getEffectivePower: mockGetEffectivePower(), async getJoinedMembers(roomID) { called++ t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") @@ -255,13 +217,14 @@ test("message2event: simple message link", async t => { format: "org.matrix.custom.html", formatted_body: 'https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg?via=cadence.moe&via=super.invalid' }]) - t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each") + t.equal(called, 1, "should call getJoinedMembers once") }) test("message2event: message link that OOYE doesn't know about", async t => { let called = 0 const events = await messageToEvent(data.message.message_link_to_before_ooye, data.guild.general, {}, { api: { + getEffectivePower: mockGetEffectivePower(), async getEventForTimestamp(roomID, ts) { called++ t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") @@ -270,17 +233,6 @@ test("message2event: message link that OOYE doesn't know about", async t => { origin_server_ts: 1613287812754 } }, - async getStateEvent(roomID, type, key) { // for ?via calculation - called++ - t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") - t.equal(type, "m.room.power_levels") - t.equal(key, "") - return { - users: { - "@_ooye_bot:cadence.moe": 100 - } - } - }, async getJoinedMembers(roomID) { // for ?via calculation called++ t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") @@ -303,7 +255,7 @@ test("message2event: message link that OOYE doesn't know about", async t => { formatted_body: "Me: I'll scroll up to find a certain message I'll send
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: { @@ -869,11 +815,13 @@ 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" + 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` }]) }) -test("message2event: advanced written @mentions for matrix users", async t => { +test("message2event: many 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: { @@ -911,16 +859,230 @@ test("message2event: advanced written @mentions for matrix users", async t => { $type: "m.room.message", "m.mentions": { user_ids: [ - "@cadence:cadence.moe", - "@huckleton:cadence.moe" + "@huckleton:cadence.moe", + "@cadence: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" + 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` }]) 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",
@@ -935,14 +1097,62 @@ test("message2event: very large attachment is linked instead of being uploaded",
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
- body: "hey"
- }, {
+ 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 + } }]) }) @@ -958,6 +1168,19 @@ 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: { @@ -1017,6 +1240,18 @@ 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.notice", + msgtype: "m.text", }, { $type: "m.room.message", @@ -1194,6 +1430,7 @@ 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: { @@ -1207,13 +1444,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` + formatted_body: `↷ Forwarded from amanda-spam [jump to room]` + `
What's cooking, good looking?`, "m.mentions": {}, - msgtype: "m.notice", + msgtype: "m.text", }, { $type: "m.room.message", @@ -1230,13 +1467,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.notice" + msgtype: "m.text" } ]) }) @@ -1331,6 +1568,7 @@ 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}, @@ -1369,6 +1607,28 @@ 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({ @@ -1380,6 +1640,7 @@ 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") @@ -1411,3 +1672,226 @@ 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/message-to-event.pk.test.js b/src/d2m/converters/message-to-event.test.pk.js similarity index 72% rename from src/d2m/converters/message-to-event.pk.test.js rename to src/d2m/converters/message-to-event.test.pk.js index ce83d544..1323280c 100644 --- a/src/d2m/converters/message-to-event.pk.test.js +++ b/src/d2m/converters/message-to-event.test.pk.js @@ -50,11 +50,7 @@ test("message2event: pk reply to matrix is converted to native matrix reply", as ] }, msgtype: "m.text", - body: "> cadence [they]: now for my next experiment:\n\nthis is a reply", - format: "org.matrix.custom.html", - formatted_body: '
🎞️ Uploaded file: 2022-10-18_16-49-46.mp4 (51 MB)
In reply to cadence [they]
' - + "now for my next experiment:
In reply to wing
' - + "some text
In reply to Ampflower 🌺
' - + "[Media]