Move everything to src folder... it had to happen

This commit is contained in:
Cadence Ember 2024-09-12 17:05:13 +12:00
commit 4247a3114a
103 changed files with 1 additions and 1 deletions

View file

@ -0,0 +1,190 @@
// @ts-check
const assert = require("assert").strict
const passthrough = require("../../passthrough")
const {sync, select, from} = passthrough
/** @type {import("./message-to-event")} */
const messageToEvent = sync.require("../converters/message-to-event")
/** @type {import("../../m2d/converters/utils")} */
const utils = sync.require("../../m2d/converters/utils")
function eventCanBeEdited(ev) {
// Discord does not allow files, images, attachments, or videos to be edited.
if (ev.old.event_type === "m.room.message" && ev.old.event_subtype !== "m.text" && ev.old.event_subtype !== "m.emote" && ev.old.event_subtype !== "m.notice") {
return false
}
// Discord does not allow stickers to be edited.
if (ev.old.event_type === "m.sticker") {
return false
}
// Anything else is fair game.
return true
}
/**
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
* @param {import("discord-api-types/v10").APIGuild} guild
* @param {import("../../matrix/api")} api simple-as-nails dependency injection for the matrix API
*/
async function editToChanges(message, guild, api) {
// If it is a user edit, allow deleting old messages (e.g. they might have removed text from an image).
// If it is the system adding a generated embed to a message, don't delete old messages since the system only sends partial data.
// Since an update in August 2024, the system always provides the full data of message updates. I'll leave in the old code since it won't cause problems.
const isGeneratedEmbed = !("content" in message)
// Figure out what events we will be replacing
const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
assert(roomID)
const oldEventRows = select("event_message", ["event_id", "event_type", "event_subtype", "part", "reaction_part"], {message_id: message.id}).all()
/** @type {string?} Null if we don't have a sender in the room, which will happen if it's a webhook's message. The bridge bot will do the edit instead. */
let senderMxid = null
if (message.author) {
senderMxid = from("sim").join("sim_member", "mxid").where({user_id: message.author.id, room_id: roomID}).pluck("mxid").get() || null
} else {
// Should be a system generated embed. We want the embed to be sent by the same user who sent the message, so that the messages get grouped in most clients.
const eventID = oldEventRows[0].event_id // a calling function should have already checked that there is at least one message to edit
const event = await api.getEvent(roomID, eventID)
if (utils.eventSenderIsFromDiscord(event.sender)) {
senderMxid = event.sender
}
}
// Figure out what we will be replacing them with
const newFallbackContent = await messageToEvent.messageToEvent(message, guild, {includeEditFallbackStar: true}, {api})
const newInnerContent = await messageToEvent.messageToEvent(message, guild, {includeReplyFallback: false}, {api})
assert.ok(newFallbackContent.length === newInnerContent.length)
// Match the new events to the old events
/*
Rules:
+ The events must have the same type.
+ The events must have the same subtype.
Events will therefore be divided into four categories:
*/
/** 1. Events that are matched, and should be edited by sending another m.replace event */
let eventsToReplace = []
/** 2. Events that are present in the old version only, and should be blanked or redacted */
let eventsToRedact = []
/** 3. Events that are present in the new version only, and should be sent as new, with references back to the context */
let eventsToSend = []
/** 4. Events that are matched and have definitely not changed, so they don't need to be edited or replaced at all. */
let unchangedEvents = []
function shift() {
newFallbackContent.shift()
newInnerContent.shift()
}
// For each old event...
outer: while (newFallbackContent.length) {
const newe = newFallbackContent[0]
// Find a new event to pair it with...
for (let i = 0; i < oldEventRows.length; i++) {
const olde = oldEventRows[i]
if (olde.event_type === newe.$type && olde.event_subtype === (newe.msgtype || null)) { // The spec does allow subtypes to change, so I can change this condition later if I want to
// Found one!
// Set up the pairing
eventsToReplace.push({
old: olde,
newFallbackContent: newFallbackContent[0],
newInnerContent: newInnerContent[0]
})
// These events have been handled now, so remove them from the source arrays
shift()
oldEventRows.splice(i, 1)
// Go all the way back to the start of the next iteration of the outer loop
continue outer
}
}
// If we got this far, we could not pair it to an existing event, so it'll have to be a new one
eventsToSend.push(newInnerContent[0])
shift()
}
// Anything remaining in oldEventRows is present in the old version only and should be redacted.
eventsToRedact = oldEventRows.map(e => ({old: e}))
// If this is a generated embed update, only allow the embeds to be updated, since the system only sends data about events. Ignore changes to other things.
if (isGeneratedEmbed) {
unchangedEvents.push(...eventsToRedact.filter(e => e.old.event_subtype !== "m.notice")) // Move them from eventsToRedact to unchangedEvents.
eventsToRedact = eventsToRedact.filter(e => e.old.event_subtype === "m.notice")
}
// Now, everything in eventsToSend and eventsToRedact is a real change, but everything in eventsToReplace might not have actually changed!
// (Example: a MESSAGE_UPDATE for a text+image message - Discord does not allow the image to be changed, but the text might have been.)
// So we'll remove entries from eventsToReplace that *definitely* cannot have changed. (This is category 4 mentioned above.) Everything remaining *may* have changed.
unchangedEvents.push(...eventsToReplace.filter(ev => !eventCanBeEdited(ev))) // Move them from eventsToRedact to unchangedEvents.
eventsToReplace = eventsToReplace.filter(eventCanBeEdited)
// We want to maintain exactly one part = 0 and one reaction_part = 0 database row at all times.
/** @type {({column: string, eventID: string, value?: number} | {column: string, nextEvent: true})[]} */
const promotions = []
for (const column of ["part", "reaction_part"]) {
const candidatesForParts = unchangedEvents.concat(eventsToReplace)
// If no events with part = 0 exist (or will exist), we need to do some management.
if (!candidatesForParts.some(e => e.old[column] === 0)) {
if (candidatesForParts.length) {
// We can choose an existing event to promote. Bigger order is better.
const order = e => 2*+(e.event_type === "m.room.message") + 1*+(e.old.event_subtype === "m.text")
candidatesForParts.sort((a, b) => order(b) - order(a))
if (column === "part") {
promotions.push({column, eventID: candidatesForParts[0].old.event_id}) // part should be the first one
} else {
promotions.push({column, eventID: candidatesForParts[candidatesForParts.length - 1].old.event_id}) // reaction_part should be the last one
}
} else {
// No existing events to promote, but new events are being sent. Whatever gets sent will be the next part = 0.
promotions.push({column, nextEvent: true})
}
}
// If adding events, try to keep reactions attached to the bottom of the group (unless reactions have already been added)
if (eventsToSend.length && !promotions.length) {
const existingReaction = select("reaction", "message_id", {message_id: message.id}).pluck().get()
if (!existingReaction) {
const existingPartZero = candidatesForParts.find(p => p.old.reaction_part === 0)
assert(existingPartZero) // will exist because a reaction_part=0 always exists and no events are being removed
promotions.push({column: "reaction_part", eventID: existingPartZero.old.event_id, value: 1}) // update the current reaction_part to 1
promotions.push({column: "reaction_part", nextEvent: true}) // the newly created event will have reaction_part = 0
}
}
}
// Removing unnecessary properties before returning
eventsToRedact = eventsToRedact.map(e => e.old.event_id)
eventsToReplace = eventsToReplace.map(e => ({oldID: e.old.event_id, newContent: makeReplacementEventContent(e.old.event_id, e.newFallbackContent, e.newInnerContent)}))
return {roomID, eventsToReplace, eventsToRedact, eventsToSend, senderMxid, promotions}
}
/**
* @template T
* @param {string} oldID
* @param {T} newFallbackContent
* @param {T} newInnerContent
* @returns {import("../../types").Event.ReplacementContent<T>} content
*/
function makeReplacementEventContent(oldID, newFallbackContent, newInnerContent) {
const content = {
...newFallbackContent,
"m.mentions": {},
"m.new_content": {
...newInnerContent
},
"m.relates_to": {
rel_type: "m.replace",
event_id: oldID
}
}
delete content["m.new_content"]["$type"]
// Client-Server API spec 11.37.3: Any m.relates_to property within m.new_content is ignored.
delete content["m.new_content"]["m.relates_to"]
return content
}
module.exports.editToChanges = editToChanges
module.exports.makeReplacementEventContent = makeReplacementEventContent

View file

@ -0,0 +1,327 @@
const {test} = require("supertape")
const {editToChanges} = require("./edit-to-changes")
const data = require("../../test/data")
const Ty = require("../../types")
test("edit2changes: edit by webhook", async t => {
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edit_by_webhook, data.guild.general, {})
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, [])
t.deepEqual(eventsToReplace, [{
oldID: "$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10",
newContent: {
$type: "m.room.message",
msgtype: "m.text",
body: "* test 2",
"m.mentions": {},
"m.new_content": {
// *** Replaced With: ***
msgtype: "m.text",
body: "test 2",
"m.mentions": {}
},
"m.relates_to": {
rel_type: "m.replace",
event_id: "$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10"
}
}
}])
t.equal(senderMxid, null)
t.deepEqual(promotions, [])
})
test("edit2changes: bot response", async t => {
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.bot_response, data.guild.general, {
async getJoinedMembers(roomID) {
t.equal(roomID, "!hYnGGlPHlbujVVfktC:cadence.moe")
return new Promise(resolve => {
setTimeout(() => {
resolve({
joined: {
"@cadence:cadence.moe": {
displayname: "cadence [they]",
avatar_url: "whatever"
},
"@_ooye_botrac4r:cadence.moe": {
displayname: "botrac4r",
avatar_url: "whatever"
}
}
})
})
})
}
})
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, [])
t.deepEqual(eventsToReplace, [{
oldID: "$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY",
newContent: {
$type: "m.room.message",
msgtype: "m.text",
body: "* :ae_botrac4r: @cadence asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.",
format: "org.matrix.custom.html",
formatted_body: '* <img data-mx-emoticon height="32" src="mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs" title=":ae_botrac4r:" alt=":ae_botrac4r:"> @cadence asked <code>­</code>, I respond: Stop drinking paint. (No)<br><br>Hit <img data-mx-emoticon height="32" src="mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT" title=":bn_re:" alt=":bn_re:"> to reroll.',
"m.mentions": {
// Client-Server API spec 11.37.7: Copy Discord's behaviour by not re-notifying anyone that an *edit occurred*
},
// *** Replaced With: ***
"m.new_content": {
msgtype: "m.text",
body: ":ae_botrac4r: @cadence asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.",
format: "org.matrix.custom.html",
formatted_body: '<img data-mx-emoticon height="32" src="mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs" title=":ae_botrac4r:" alt=":ae_botrac4r:"> @cadence asked <code>­</code>, I respond: Stop drinking paint. (No)<br><br>Hit <img data-mx-emoticon height="32" src="mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT" title=":bn_re:" alt=":bn_re:"> to reroll.',
"m.mentions": {
// Client-Server API spec 11.37.7: This should contain the mentions for the final version of the event
"user_ids": ["@cadence:cadence.moe"]
}
},
"m.relates_to": {
rel_type: "m.replace",
event_id: "$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY"
}
}
}])
t.equal(senderMxid, "@_ooye_bojack_horseman:cadence.moe")
t.deepEqual(promotions, [])
})
test("edit2changes: remove caption from image", async t => {
const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.removed_caption_from_image, data.guild.general, {})
t.deepEqual(eventsToRedact, ["$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA"])
t.deepEqual(eventsToSend, [])
t.deepEqual(eventsToReplace, [])
t.deepEqual(promotions, [{column: "part", eventID: "$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCI"}])
})
test("edit2changes: change file type", async t => {
const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.changed_file_type, data.guild.general, {})
t.deepEqual(eventsToRedact, ["$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCJ"])
t.deepEqual(eventsToSend, [{
$type: "m.room.message",
body: "📝 Uploaded file: https://cdn.discordapp.com/attachments/112760669178241024/1141501302497615912/gaze_into_my_dark_mind.txt (20 MB)",
format: "org.matrix.custom.html",
formatted_body: "📝 Uploaded file: <a href=\"https://cdn.discordapp.com/attachments/112760669178241024/1141501302497615912/gaze_into_my_dark_mind.txt\">gaze_into_my_dark_mind.txt</a> (20 MB)",
"m.mentions": {},
msgtype: "m.text"
}])
t.deepEqual(eventsToReplace, [])
t.deepEqual(promotions, [{column: "part", nextEvent: true}, {column: "reaction_part", nextEvent: true}])
})
test("edit2changes: add caption back to that image (due to it having a reaction, the reaction_part will not be moved)", async t => {
const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.added_caption_to_image, data.guild.general, {})
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, [{
$type: "m.room.message",
msgtype: "m.text",
body: "some text",
"m.mentions": {}
}])
t.deepEqual(eventsToReplace, [])
t.deepEqual(promotions, [])
})
test("edit2changes: stickers and attachments are not changed, only the content can be edited", async t => {
const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments, data.guild.general, {})
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, [])
t.deepEqual(eventsToReplace, [{
oldID: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4",
newContent: {
$type: "m.room.message",
msgtype: "m.text",
body: "* only the content can be edited",
"m.mentions": {},
// *** Replaced With: ***
"m.new_content": {
msgtype: "m.text",
body: "only the content can be edited",
"m.mentions": {}
},
"m.relates_to": {
rel_type: "m.replace",
event_id: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4"
}
}
}])
})
test("edit2changes: edit of reply to skull webp attachment with content", async t => {
const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edit_of_reply_to_skull_webp_attachment_with_content, data.guild.general, {})
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, [])
t.deepEqual(eventsToReplace, [{
oldID: "$vgTKOR5ZTYNMKaS7XvgEIDaOWZtVCEyzLLi5Pc5Gz4M",
newContent: {
$type: "m.room.message",
msgtype: "m.text",
body: "> Extremity: Image\n\n* Edit",
format: "org.matrix.custom.html",
formatted_body:
'<mx-reply><blockquote><a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$oLyUTyZ_7e_SUzGNWZKz880ll9amLZvXGbArJCKai2Q">In reply to</a> Extremity'
+ '<br>Image</blockquote></mx-reply>'
+ '* Edit',
"m.mentions": {},
"m.new_content": {
msgtype: "m.text",
body: "Edit",
"m.mentions": {}
},
"m.relates_to": {
rel_type: "m.replace",
event_id: "$vgTKOR5ZTYNMKaS7XvgEIDaOWZtVCEyzLLi5Pc5Gz4M"
}
}
}])
})
test("edit2changes: edits the text event when multiple rows have part = 0 (should never happen in real life, but make sure the safety net works)", async t => {
const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments_but_all_parts_equal_0, data.guild.general, {})
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, [])
t.deepEqual(eventsToReplace, [{
oldID: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd999",
newContent: {
$type: "m.room.message",
msgtype: "m.text",
body: "* only the content can be edited",
"m.mentions": {},
// *** Replaced With: ***
"m.new_content": {
msgtype: "m.text",
body: "only the content can be edited",
"m.mentions": {}
},
"m.relates_to": {
rel_type: "m.replace",
event_id: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd999"
}
}
}])
})
test("edit2changes: promotes the text event when multiple rows have part = 1 (should never happen in real life, but make sure the safety net works)", async t => {
const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments_but_all_parts_equal_1, data.guild.general, {})
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, [])
t.deepEqual(eventsToReplace, [{
oldID: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd111",
newContent: {
$type: "m.room.message",
msgtype: "m.text",
body: "* only the content can be edited",
"m.mentions": {},
// *** Replaced With: ***
"m.new_content": {
msgtype: "m.text",
body: "only the content can be edited",
"m.mentions": {}
},
"m.relates_to": {
rel_type: "m.replace",
event_id: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd111"
}
}
}])
t.deepEqual(promotions, [
{
column: "part",
eventID: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd111"
},
{
column: "reaction_part",
eventID: "$f9cjKiacXI9qPF_nUAckzbiKnJEi0LM399kOkhdd111"
}
])
})
test("edit2changes: generated embed", async t => {
let called = 0
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_social_media_image, data.guild.general, {
async getEvent(roomID, eventID) {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
t.equal(eventID, "$mPSzglkCu-6cZHbYro0RW2u5mHvbH9aXDjO5FCzosc0")
return {sender: "@_ooye_cadence:cadence.moe"}
}
})
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToReplace, [])
t.deepEqual(eventsToSend, [{
$type: "m.room.message",
msgtype: "m.notice",
body: "| via hthrflwrs on cohost"
+ "\n| \n| ## This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO https://cohost.org/jkap/post/4794219-empty"
+ "\n| \n| 1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:"
+ "\n| \n| * Both players draw eight cards"
+ "\n| * Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand"
+ "\n| * Both players present their best five-or-less-card pok...",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><sub>hthrflwrs on cohost</sub>`
+ `</p><p><strong><a href="https://cohost.org/jkap/post/4794219-empty">This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO</a></strong>`
+ `</p><p>1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:`
+ `<br><br><ul><li>Both players draw eight cards`
+ `</li><li>Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand`
+ `</li><li>Both players present their best five-or-less-card pok...</li></ul></p></blockquote>`,
"m.mentions": {}
}])
t.deepEqual(promotions, [{
"column": "reaction_part",
"eventID": "$mPSzglkCu-6cZHbYro0RW2u5mHvbH9aXDjO5FCzosc0",
"value": 1,
}, {
"column": "reaction_part",
"nextEvent": true,
}])
t.equal(senderMxid, "@_ooye_cadence:cadence.moe")
t.equal(called, 1)
})
test("edit2changes: generated embed on a reply", async t => {
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_on_reply, data.guild.general, {})
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToReplace, [{
oldID: "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF",
newContent: {
$type: "m.room.message",
// Unfortunately the edited message doesn't include the message_reference field. Fine. Whatever. It looks normal if you're using a good client.
body: "> a Discord user: [Replied-to message content wasn't provided by Discord]"
+ "\n\n* https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM",
format: "org.matrix.custom.html",
formatted_body: "<mx-reply><blockquote><a href=\"https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM\">In reply to</a> a Discord user<br>[Replied-to message content wasn't provided by Discord]</blockquote></mx-reply>* <a href=\"https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM\">https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM</a>",
"m.mentions": {},
"m.new_content": {
body: "https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM",
format: "org.matrix.custom.html",
formatted_body: "<a href=\"https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM\">https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM</a>",
"m.mentions": {},
msgtype: "m.text",
},
"m.relates_to": {
event_id: "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF",
rel_type: "m.replace",
},
msgtype: "m.text",
},
}])
t.deepEqual(eventsToSend, [{
$type: "m.room.message",
msgtype: "m.notice",
body: "| ## Matrix - Decentralised and secure communication https://matrix.to/"
+ "\n| \n| You're invited to talk on Matrix. If you don't already have a client this link will help you pick one, and join the conversation. If you already have one, this link will help you join the conversation",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><strong><a href="https://matrix.to/">Matrix - Decentralised and secure communication</a></strong>`
+ `</p><p>You're invited to talk on Matrix. If you don't already have a client this link will help you pick one, and join the conversation. If you already have one, this link will help you join the conversation</p></blockquote>`,
"m.mentions": {}
}])
t.deepEqual(promotions, [{
"column": "reaction_part",
"eventID": "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF",
"value": 1,
}, {
"column": "reaction_part",
"nextEvent": true,
}])
t.equal(senderMxid, "@_ooye_cadence:cadence.moe")
})

View file

@ -0,0 +1,38 @@
// @ts-check
const assert = require("assert").strict
const passthrough = require("../../passthrough")
const {discord, sync, db, select} = passthrough
/** @type {import("../../matrix/file")} */
const file = sync.require("../../matrix/file")
/**
* @param {import("discord-api-types/v10").APIEmoji} emoji
* @returns {Promise<string>}
*/
async function emojiToKey(emoji) {
let key
if (emoji.id) {
// Custom emoji
const mxc = select("emoji", "mxc_url", {emoji_id: emoji.id}).pluck().get()
if (mxc) {
// The custom emoji is registered and we should send it
key = mxc
} else {
// The custom emoji is not registered. We will register it and then add it.
assert(emoji.name) // The docs say: "name may be null when custom emoji data is not available, for example, if it was deleted from the guild"
const mxc = await file.uploadDiscordFileToMxc(file.emoji(emoji.id, emoji.animated))
db.prepare("INSERT OR IGNORE INTO emoji (emoji_id, name, animated, mxc_url) VALUES (?, ?, ?, ?)").run(emoji.id, emoji.name, +!!emoji.animated, mxc)
key = mxc
// TODO: what happens if the matrix user also tries adding this reaction? the bridge bot isn't able to use that emoji...
}
} else {
// Default emoji
const name = emoji.name
assert(name)
key = name
}
return key
}
module.exports.emojiToKey = emojiToKey

View file

@ -0,0 +1,21 @@
// @ts-check
const {test} = require("supertape")
const {emojiToKey} = require("./emoji-to-key")
const data = require("../../test/data")
const Ty = require("../../types")
test("emoji2key: unicode emoji works", async t => {
const result = await emojiToKey({id: null, name: "🐈"})
t.equal(result, "🐈")
})
test("emoji2key: custom emoji works", async t => {
const result = await emojiToKey({id: "230201364309868544", name: "hippo", animated: false})
t.equal(result, "mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC")
})
test("emoji2key: custom animated emoji works", async t => {
const result = await emojiToKey({id: "393635038903926784", name: "hipposcope", animated: true})
t.equal(result, "mxc://cadence.moe/WbYqNlACRuicynBfdnPYtmvc")
})

View file

@ -0,0 +1,48 @@
// @ts-check
const assert = require("assert")
const stream = require("stream")
const {PNG} = require("@cloudrac3r/pngjs")
const SIZE = 160 // Discord's display size on 1x displays is 160
/**
* @typedef RlottieWasm
* @prop {(string) => boolean} load load lottie data from string of json
* @prop {() => number} frames get number of frames
* @prop {(frameCount: number, width: number, height: number) => Uint8Array} render render lottie data to bitmap
*/
const Rlottie = (async () => {
const Rlottie = require("./rlottie-wasm.js")
await new Promise(resolve => Rlottie.onRuntimeInitialized = resolve)
return Rlottie
})()
/**
* @param {string} text
* @returns {Promise<import("stream").Readable>}
*/
async function convert(text) {
const r = await Rlottie
/** @type RlottieWasm */
const rh = new r.RlottieWasm()
const status = rh.load(text)
assert(status, `Rlottie unable to load ${text.length} byte data file.`)
const rendered = rh.render(0, SIZE, SIZE)
let png = new PNG({
width: SIZE,
height: SIZE,
bitDepth: 8, // 8 red + 8 green + 8 blue + 8 alpha
colorType: 6, // RGBA
inputColorType: 6, // RGBA
inputHasAlpha: true,
})
png.data = Buffer.from(rendered)
// png.pack() is a bad stream and will throw away any data it sends if it's not connected to a destination straight away.
// We use Duplex.from to convert it into a good stream.
return stream.Duplex.from(png.pack())
}
module.exports.convert = convert
module.exports.SIZE = SIZE

View file

@ -0,0 +1,34 @@
// @ts-check
const fs = require("fs")
const stream = require("stream")
const {test} = require("supertape")
const {convert} = require("./lottie")
const WRITE_PNG = false
test("lottie: can convert and save PNG", async t => {
const input = await fs.promises.readFile("test/res/lottie-bee.json", "utf8")
const resultStream = await convert(input)
/* c8 ignore next 3 */
if (WRITE_PNG) {
resultStream.pipe(fs.createWriteStream("test/res/lottie-bee.png"))
t.fail("PNG written to /test/res/lottie-bee.png, please manually check it")
} else {
const expected = await fs.promises.readFile("test/res/lottie-bee.png")
const actual = Buffer.alloc(expected.length)
let i = 0
await stream.promises.pipeline(
resultStream,
async function* (source) {
for await (const chunk of source) {
chunk.copy(actual, i)
i += chunk.length
}
},
new stream.PassThrough()
)
t.equal(i, actual.length, `allocated ${actual.length} bytes, but wrote ${i}`)
t.deepEqual(actual, expected)
}
})

View file

@ -0,0 +1,353 @@
const {test} = require("supertape")
const {messageToEvent} = require("./message-to-event")
const data = require("../../test/data")
const Ty = require("../../types")
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: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
"m.mentions": {},
msgtype: "m.text",
}, {
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.notice",
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: '<blockquote><p><strong>Amanda 🎵#2192 <img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/LCEqjStXCxvRQccEkuslXEyZ\" title=\":online:\" alt=\":online:\">'
+ '<br>willow tree, branch 0</strong>'
+ '<br><strong> Uptime:</strong><br>3m 55s'
+ '<br><strong> Memory:</strong><br>64.45MB</p></blockquote>'
}])
})
test("message2event embeds: reply with just an embed", async t => {
const events = await messageToEvent(data.message_with_embeds.reply_with_only_embed, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.notice",
"m.mentions": {},
body: "| ## ⏺️ dynastic (@dynastic) https://twitter.com/i/user/719631291747078145"
+ "\n| \n| does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?"
+ "\n| \n| ### Retweets"
+ "\n| 119"
+ "\n| \n| ### Likes"
+ "\n| 5581"
+ "\n| — Twitter",
format: "org.matrix.custom.html",
formatted_body: '<blockquote><p><strong><a href="https://twitter.com/i/user/719631291747078145">⏺️ dynastic (@dynastic)</a></strong>'
+ '</p><p>does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?'
+ '</p><p><strong>Retweets</strong><br>119</p><p><strong>Likes</strong><br>5581</p>— Twitter</blockquote>'
}])
})
test("message2event embeds: image embed and attachment", async t => {
const events = await messageToEvent(data.message_with_embeds.image_embed_and_attachment, data.guild.general, {}, {
api: {
async getJoinedMembers(roomID) {
return {joined: []}
}
}
})
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "https://tootsuite.net/Warp-Gate2.gif\ntanget: @ monster spawner",
format: "org.matrix.custom.html",
formatted_body: '<a href="https://tootsuite.net/Warp-Gate2.gif">https://tootsuite.net/Warp-Gate2.gif</a><br>tanget: @ monster spawner',
"m.mentions": {}
}, {
$type: "m.room.message",
msgtype: "m.image",
url: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR",
body: "Screenshot_20231001_034036.jpg",
external_url: "https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg?ex=651a1faa&is=6518ce2a&hm=eb5ca80a3fa7add8765bf404aea2028a28a2341e4a62435986bcdcf058da82f3&",
filename: "Screenshot_20231001_034036.jpg",
info: {
h: 1170,
w: 1080,
size: 51981,
mimetype: "image/jpeg"
},
"m.mentions": {}
}])
})
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
}
}
},
async getJoinedMembers(roomID) {
called++
t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe")
return {
joined: {
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
"@user:example.invalid": {display_name: null, avatar_url: null}
}
}
}
}
})
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: ":emoji: **4 |** #wonderland",
format: "org.matrix.custom.html",
formatted_body: `<img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/mwZaCtRGAQQyOItagDeCocEO\" title=\":emoji:\" alt=\":emoji:\"> <strong>4 |</strong> <a href=\"https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe?via=cadence.moe&via=example.invalid\">#wonderland</a>`,
"m.mentions": {}
}, {
$type: "m.room.message",
msgtype: "m.notice",
body: "| ## ⏺️ minimus https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo?via=cadence.moe&via=example.invalid\n| \n| reply draft\n| > The following is a message composed via consensus of the Stinker Council.\n| > \n| > 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.\n| > \n| > Due to circumstances outside of our control, this directive has now changed. Our new mission will be the extermination of the stinker race.\n| > \n| > There will be no further communication.\n| \n| [Go to Message](https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo?via=cadence.moe&via=example.invalid)",
format: "org.matrix.custom.html",
formatted_body: "<blockquote><p><strong><a href=\"https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo?via=cadence.moe&amp;via=example.invalid\">⏺️ minimus</a></strong></p><p>reply draft<br><blockquote>The following is a message composed via consensus of the Stinker Council.<br><br>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.<br><br>Due to circumstances outside of our control, this directive has now changed. Our new mission will be the extermination of the stinker race.<br><br>There will be no further communication.</blockquote></p><p><a href=\"https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo?via=cadence.moe&amp;via=example.invalid\">Go to Message</a></p></blockquote>",
"m.mentions": {}
}])
t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each")
})
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",
body: "| ## ⏺️ <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;) https://a.co/&amp;<script>"
+ "\n| \n| ## <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;) https://a.co/&amp;<script>"
+ "\n| \n| <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;)"
+ "\n| \n| ### <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;)"
+ "\n| <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;)"
+ "\n| — <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;)",
format: "org.matrix.custom.html",
formatted_body: `<blockquote>`
+ `<p><strong><a href="https://a.co/&amp;amp;&lt;script&gt;">⏺️ &lt;strong&gt;[&lt;span data-mx-color=&#39;#123456&#39;&gt;Hey&lt;script&gt;](https://a.co/&amp;amp;)</a></strong></p>`
+ `<p><strong><a href=\"https://a.co/&amp;amp;&lt;script&gt;">&lt;strong&gt;[&lt;span data-mx-color='#123456'&gt;Hey&lt;script&gt;](<a href="https://a.co/&amp;amp">https://a.co/&amp;amp</a>;)</a></strong></p>`
+ `<p>&lt;strong&gt;<a href="https://a.co/&amp;amp;">&lt;span data-mx-color='#123456'&gt;Hey&lt;script&gt;</a></p>`
+ `<p><strong>&lt;strong&gt;[&lt;span data-mx-color='#123456'&gt;Hey&lt;script&gt;](<a href=\"https://a.co/&amp;amp\">https://a.co/&amp;amp</a>;)</strong>`
+ `<br>&lt;strong&gt;<a href="https://a.co/&amp;amp;">&lt;span data-mx-color='#123456'&gt;Hey&lt;script&gt;</a></p>`
+ `— &lt;strong&gt;[&lt;span data-mx-color=&#39;#123456&#39;&gt;Hey&lt;script&gt;](https://a.co/&amp;amp;)</blockquote>`,
"m.mentions": {}
}])
})
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: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
"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: `<blockquote><p><strong>Hi, I'm Amanda!</strong></p><p>I condone pirating music!</p></blockquote>`,
"m.mentions": {}
}])
})
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: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
"m.mentions": {},
msgtype: "m.text",
}, {
$type: "m.room.message",
msgtype: "m.notice",
body: "| I condone pirating music!",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p>I condone pirating music!</p></blockquote>`,
"m.mentions": {}
}])
})
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: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
"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: `<blockquote><p><strong>Amanda</strong></p><p>I condone pirating music!</p></blockquote>`,
"m.mentions": {}
}])
})
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`",
format: "org.matrix.custom.html",
formatted_body: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
"m.mentions": {},
msgtype: "m.text",
}, {
$type: "m.room.message",
msgtype: "m.notice",
body: "| I condone pirating music!",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p>I condone pirating music!</p></blockquote>`,
"m.mentions": {}
}])
})
test("message2event embeds: vx image", async t => {
const events = await messageToEvent(data.message_with_embeds.vx_image, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "https://vxtwitter.com/TomorrowCorp/status/1760330671074287875 we got a release date!!!",
format: "org.matrix.custom.html",
formatted_body: '<a href="https://vxtwitter.com/TomorrowCorp/status/1760330671074287875">https://vxtwitter.com/TomorrowCorp/status/1760330671074287875</a> we got a release date!!!',
"m.mentions": {}
}, {
$type: "m.room.message",
msgtype: "m.notice",
body: "| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix"
+ "\n| "
+ "\n| ## Twitter https://twitter.com/tomorrowcorp/status/1760330671074287875"
+ "\n| "
+ "\n| ## Tomorrow Corporation (@TomorrowCorp) https://vxtwitter.com/TomorrowCorp/status/1760330671074287875"
+ "\n| "
+ "\n| Mark your calendar with a wet black stain! World of Goo 2 releases on May 23, 2024 on Nintendo Switch, Epic Games Store (Win/Mac), and http://WorldOfGoo2.com (Win/Mac/Linux)."
+ "\n| "
+ "\n| https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms"
+ "\n| "
+ "\n| 💖 123 🔁 36"
+ "\n| "
+ "\n| 📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><sub><a href="https://github.com/dylanpdx/BetterTwitFix">vxTwitter / fixvx</a></sub>`
+ `</p><p><strong><a href="https://twitter.com/tomorrowcorp/status/1760330671074287875">Twitter</a></strong>`
+ `</p><p><strong><a href="https://vxtwitter.com/TomorrowCorp/status/1760330671074287875">Tomorrow Corporation (@TomorrowCorp)</a></strong>`
+ `</p><p>Mark your calendar with a wet black stain! World of Goo 2 releases on May 23, 2024 on Nintendo Switch, Epic Games Store (Win/Mac), and <a href="http://WorldOfGoo2.com">http://WorldOfGoo2.com</a> (Win/Mac/Linux).`
+ `<br><br><a href="https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms">https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms</a>`
+ `<br><br>💖 123 🔁 36`
+ `</p><p>📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg</p></blockquote>`,
"m.mentions": {}
}])
})
test("message2event embeds: vx video", async t => {
const events = await messageToEvent(data.message_with_embeds.vx_video, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "https://vxtwitter.com/McDonalds/status/1759971752254341417",
format: "org.matrix.custom.html",
formatted_body: '<a href="https://vxtwitter.com/McDonalds/status/1759971752254341417">https://vxtwitter.com/McDonalds/status/1759971752254341417</a>',
"m.mentions": {}
}, {
$type: "m.room.message",
msgtype: "m.notice",
body: "| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix"
+ "\n| \n| ## McDonalds🤝@studiopierrot"
+ "\n| \n| 💖 89 🔁 21 https://twitter.com/McDonalds/status/1759971752254341417"
+ "\n| \n| ## McDonald's (@McDonalds) https://vxtwitter.com/McDonalds/status/1759971752254341417"
+ "\n| \n| 🎞️ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><sub><a href="https://github.com/dylanpdx/BetterTwitFix">vxTwitter / fixvx</a></sub>`
+ `</p><p><strong><a href="https://twitter.com/McDonalds/status/1759971752254341417">McDonalds🤝@studiopierrot\n\n💖 89 🔁 21</a></strong>`
+ `</p><p><strong><a href="https://vxtwitter.com/McDonalds/status/1759971752254341417">McDonald's (@McDonalds)</a></strong>`
+ `</p><p>🎞️ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12</p></blockquote>`,
"m.mentions": {}
}])
})
test("message2event embeds: youtube video", async t => {
const events = await messageToEvent(data.message_with_embeds.youtube_video, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E\n\n\nJutomi I'm gonna make these sounds in your walls tonight",
format: "org.matrix.custom.html",
formatted_body: `<a href="https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E">https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E</a><br><br><br>Jutomi I'm gonna make these sounds in your walls tonight`,
"m.mentions": {}
}, {
$type: "m.room.message",
msgtype: "m.notice",
body: "| via YouTube https://www.youtube.com"
+ "\n| \n| ## Happy O Funny https://www.youtube.com/channel/UCEpQ9aEb1NafpvWp5Aoizrg"
+ "\n| \n| ## Shoebill stork clattering sounds like machine guun~!! (Japan Matsue... https://www.youtube.com/watch?v=kDMHHw8JqLE"
+ "\n| \n| twitter"
+ "\n| https://twitter.com/matsuevogelpark"
+ "\n| \n| The shoebill (Balaeniceps rex) also known as whalehead, whale-headed stork, or shoe-billed stork, is a very large stork-like bird. It derives its name from its enormous shoe-shaped bill"
+ "\n| some people also called them the living dinosaur~~"
+ "\n| \n| #shoebill #livingdinosaur #happyofunny #weirdcreature #weirdsoun..."
+ "\n| \n| 🎞️ https://www.youtube.com/embed/kDMHHw8JqLE",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><sub><a href="https://www.youtube.com">YouTube</a></sub></p>`
+ `<p><strong><a href="https://www.youtube.com/channel/UCEpQ9aEb1NafpvWp5Aoizrg">Happy O Funny</a></strong>`
+ `</p><p><strong><a href="https://www.youtube.com/watch?v=kDMHHw8JqLE">Shoebill stork clattering sounds like machine guun~!! (Japan Matsue...</a></strong>`
+ `</p><p>twitter<br><a href="https://twitter.com/matsuevogelpark">https://twitter.com/matsuevogelpark</a><br><br>The shoebill (Balaeniceps rex) also known as whalehead, whale-headed stork, or shoe-billed stork, is a very large stork-like bird. It derives its name from its enormous shoe-shaped bill<br>some people also called them the living dinosaur~~<br><br>#shoebill #livingdinosaur #happyofunny #weirdcreature #weirdsoun...`
+ `</p><p>🎞️ https://www.youtube.com/embed/kDMHHw8JqLE`
+ `</p></blockquote>`,
"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
}
}
},
async getJoinedMembers(roomID) {
t.equal(roomID, "!TqlyQmifxGUggEmdBN:cadence.moe")
return {
joined: {
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
"@user:matrix.org": {display_name: null, avatar_url: null}
}
}
}
}
})
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "(test https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU?via=cadence.moe&via=matrix.org)",
format: "org.matrix.custom.html",
formatted_body: `(test <a href="https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU?via=cadence.moe&amp;via=matrix.org">https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU?via=cadence.moe&amp;via=matrix.org</a>)`,
"m.mentions": {}
}])
})

View file

@ -0,0 +1,647 @@
// @ts-check
const assert = require("assert").strict
const markdown = require("@cloudrac3r/discord-markdown")
const pb = require("prettier-bytes")
const DiscordTypes = require("discord-api-types/v10")
const {tag} = require("@cloudrac3r/html-template-tag")
const passthrough = require("../../passthrough")
const {sync, db, discord, select, from} = passthrough
/** @type {import("../../matrix/file")} */
const file = sync.require("../../matrix/file")
/** @type {import("./emoji-to-key")} */
const emojiToKey = sync.require("./emoji-to-key")
/** @type {import("../actions/lottie")} */
const lottie = sync.require("../actions/lottie")
/** @type {import("../../m2d/converters/utils")} */
const mxUtils = sync.require("../../m2d/converters/utils")
/** @type {import("../../discord/utils")} */
const dUtils = sync.require("../../discord/utils")
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
*/
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 interaction = message.interaction_metadata || message.interaction
const username = message.mentions.find(ment => ment.id === node.id)?.username
|| (interaction?.user.id === node.id ? interaction.user.username : null)
|| node.id
if (mxid && useHTML) {
return `<a href="https://matrix.to/#/${mxid}">@${username}</a>`
} else {
return `@${username}:`
}
},
/** @param {{id: string, type: "discordChannel", row: {room_id: string, name: string, nick: string?}?, via: string}} node */
channel: node => {
if (!node.row) { // fallback for when this channel is not bridged
const channel = discord.channels.get(node.id)
if (channel) {
return `#${channel.name} [channel not bridged]`
} else {
return `#unknown-channel [channel from an unbridged server]`
}
} else if (useHTML) {
return `<a href="https://matrix.to/#/${node.row.room_id}?${node.via}">#${node.row.nick || node.row.name}</a>`
} else {
return `#${node.row.nick || node.row.name}`
}
},
/** @param {{animated: boolean, name: string, id: string, type: "discordEmoji"}} node */
emoji: node => {
if (useHTML) {
const mxc = select("emoji", "mxc_url", {emoji_id: node.id}).pluck().get()
assert(mxc, `Emoji consistency assertion failed for ${node.name}:${node.id}`) // All emojis should have been added ahead of time in the messageToEvent function.
return `<img data-mx-emoticon height="32" src="${mxc}" title=":${node.name}:" alt=":${node.name}:">`
} else {
return `:${node.name}:`
}
},
role: node => {
const role = guild.roles.find(r => r.id === node.id)
if (!role) {
// This fallback should only trigger if somebody manually writes a silly message, or if the cache breaks (hasn't happened yet).
// If the cache breaks, fix discord-packets.js to store role info properly.
return "@&" + node.id
} else if (useHTML && role.color) {
return `<font color="#${role.color.toString(16)}">@${role.name}</font>`
} else if (useHTML) {
return `<span data-mx-color="#ffffff" data-mx-bg-color="#414eef">@${role.name}</span>`
} else {
return `@${role.name}:`
}
},
everyone: () => {
if (message.mention_everyone) return "@room"
return "@everyone"
},
here: () => {
if (message.mention_everyone) return "@room"
return "@here"
}
}
}
const embedTitleParser = markdown.markdownEngine.parserFor({
...markdown.rules,
autolink: undefined,
link: undefined
})
/**
* @param {{room?: boolean, user_ids?: string[]}} mentions
* @param {DiscordTypes.APIAttachment} attachment
*/
async function attachmentToEvent(mentions, attachment) {
const emoji =
attachment.content_type?.startsWith("image/jp") ? "📸"
: attachment.content_type?.startsWith("image/") ? "🖼️"
: attachment.content_type?.startsWith("video/") ? "🎞️"
: attachment.content_type?.startsWith("text/") ? "📝"
: attachment.content_type?.startsWith("audio/") ? "🎶"
: "📄"
// no native media spoilers in Element, so we'll post a link instead, forcing it to not preview using a blockquote
if (attachment.filename.startsWith("SPOILER_")) {
return {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.text",
body: `${emoji} Uploaded SPOILER file: ${attachment.url} (${pb(attachment.size)})`,
format: "org.matrix.custom.html",
formatted_body: `<blockquote>${emoji} Uploaded SPOILER file: <a href="${attachment.url}">${attachment.url}</a> (${pb(attachment.size)})</blockquote>`
}
}
// for large files, always link them instead of uploading so I don't use up all the space in the content repo
else if (attachment.size > reg.ooye.max_file_size) {
return {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.text",
body: `${emoji} Uploaded file: ${attachment.url} (${pb(attachment.size)})`,
format: "org.matrix.custom.html",
formatted_body: `${emoji} Uploaded file: <a href="${attachment.url}">${attachment.filename}</a> (${pb(attachment.size)})`
}
} else if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) {
return {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.image",
url: await file.uploadDiscordFileToMxc(attachment.url),
external_url: attachment.url,
body: attachment.description || attachment.filename,
filename: attachment.filename,
info: {
mimetype: attachment.content_type,
w: attachment.width,
h: attachment.height,
size: attachment.size
}
}
} else if (attachment.content_type?.startsWith("video/") && attachment.width && attachment.height) {
return {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.video",
url: await file.uploadDiscordFileToMxc(attachment.url),
external_url: attachment.url,
body: attachment.description || attachment.filename,
filename: attachment.filename,
info: {
mimetype: attachment.content_type,
w: attachment.width,
h: attachment.height,
size: attachment.size
}
}
} else if (attachment.content_type?.startsWith("audio/")) {
return {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.audio",
url: await file.uploadDiscordFileToMxc(attachment.url),
external_url: attachment.url,
body: attachment.description || attachment.filename,
filename: attachment.filename,
info: {
mimetype: attachment.content_type,
size: attachment.size,
duration: attachment.duration_secs ? attachment.duration_secs * 1000 : undefined
}
}
} else {
return {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.file",
url: await file.uploadDiscordFileToMxc(attachment.url),
external_url: attachment.url,
body: attachment.description || attachment.filename,
filename: attachment.filename,
info: {
mimetype: attachment.content_type,
size: attachment.size
}
}
}
}
/**
* @param {import("discord-api-types/v10").APIMessage} message
* @param {import("discord-api-types/v10").APIGuild} guild
* @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean}} options default values:
* - includeReplyFallback: true
* - includeEditFallbackStar: false
* @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API
*/
async function messageToEvent(message, guild, options = {}, di) {
const events = []
/* c8 ignore next 7 */
if (message.type === DiscordTypes.MessageType.ThreadCreated) {
// This is the kind of message that appears when somebody makes a thread which isn't close enough to the message it's based off.
// It lacks the lines and the pill, so it looks kind of like a member join message, and it says:
// [#] NICKNAME started a thread: __THREAD NAME__. __See all threads__
// We're already bridging the THREAD_CREATED gateway event to make a comparable message, so drop this one.
return []
}
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.
const ref = message.message_reference
assert(ref)
assert(ref.message_id)
const eventID = select("event_message", "event_id", {message_id: ref.message_id}).pluck().get()
const roomID = select("channel_room", "room_id", {channel_id: ref.channel_id}).pluck().get()
if (!eventID || !roomID) return []
const event = await di.api.getEvent(roomID, eventID)
return [{
...event.content,
$type: event.type,
$sender: null
}]
}
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.
if (message.content) message.content = `\n${message.content}`
message.content = `> ↪️ <@${interaction.user.id}> used \`/${interaction.name}\`${message.content}`
}
/**
@type {{room?: boolean, user_ids?: string[]}}
We should consider the following scenarios for mentions:
1. A discord user rich-replies to a matrix user with a text post
+ The matrix user needs to be m.mentioned in the text event
+ The matrix user needs to have their name/mxid/link in the text event (notification fallback)
- So prepend their `@name:` to the start of the plaintext body
2. A discord user rich-replies to a matrix user with an image event only
+ The matrix user needs to be m.mentioned in the image event
+ TODO The matrix user needs to have their name/mxid in the image event's body field, alongside the filename (notification fallback)
- So append their name to the filename body, I guess!!!
3. A discord user `@`s a matrix user in the text body of their text box
+ The matrix user needs to be m.mentioned in the text event
+ No change needed to the text event content: it already has their name
- So make sure we don't do anything in this case.
*/
const mentions = {}
let repliedToEventRow = null
let repliedToEventSenderMxid = null
if (message.mention_everyone) mentions.room = true
function addMention(mxid) {
if (!mentions.user_ids) mentions.user_ids = []
if (!mentions.user_ids.includes(mxid)) mentions.user_ids.push(mxid)
}
// 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
}
} 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:]")
const isEmulatedReplyToAttachment = message.embeds[0].description?.startsWith("*[(click to see attachment")
if (isEmulatedReplyToText || isEmulatedReplyToAttachment) {
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) {
/*
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:
- content: used for generating the reply fallback
- author: used for the top of the reply fallback (only used for discord authors. for matrix authors, repliedToEventSenderMxid is set.)
*/
const emulatedMessageContent =
( isEmulatedReplyToAttachment ? "[Media]"
: message.embeds[0].description.replace(/^.*?\)\*\*\s*/, ""))
message.referenced_message = {
content: emulatedMessageContent,
// @ts-ignore
author: {
username: message.embeds[0].author.name.replace(/\s*↩️\s*$/, "")
}
}
message.embeds.shift()
repliedToEventRow = row
}
}
}
}
if (repliedToEventRow && repliedToEventRow.source === 0) { // reply was originally from Matrix
// Need to figure out who sent that event...
const event = await di.api.getEvent(repliedToEventRow.room_id, repliedToEventRow.event_id)
repliedToEventSenderMxid = event.sender
// Need to add the sender to m.mentions
addMention(repliedToEventSenderMxid)
}
/** @type {Map<string, Promise<string>>} */
const viaMemo = new Map()
/**
* @param {string} roomID
* @returns {Promise<string>} string encoded URLSearchParams
*/
function getViaServersMemo(roomID) {
// @ts-ignore
if (viaMemo.has(roomID)) return viaMemo.get(roomID)
const promise = mxUtils.getViaServersQuery(roomID, di.api).then(p => p.toString())
viaMemo.set(roomID, promise)
return promise
}
/**
* Translate Discord message links to Matrix event links.
* If OOYE has handled this message in the past, this is an instant database lookup.
* Otherwise, if OOYE knows the channel, this is a multi-second request to /timestamp_to_event to approximate.
* @param {string} content Partial or complete Discord message content
*/
async function transformContentMessageLinks(content) {
let offset = 0
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)
const {event_id} = await di.api.getEventForTimestamp(roomID, ts)
result = `https://matrix.to/#/${roomID}/${event_id}?${via}`
}
} else {
result = `${match[0]} [event is from another server]`
}
content = content.slice(0, match.index + offset) + result + content.slice(match.index + match[0].length + offset)
offset += result.length - match[0].length
}
return content
}
/**
* Translate links and emojis and mentions and stuff. Give back the text and HTML so they can be combined into bigger events.
* @param {string} content Partial or complete Discord message content
* @param {any} customOptions
* @param {any} customParser
* @param {any} customHtmlOutput
*/
async function transformContent(content, customOptions = {}, customParser = null, customHtmlOutput = null) {
content = await transformContentMessageLinks(content)
// Handling emojis that we don't know about. The emoji has to be present in the DB for it to be picked up in the emoji markdown converter.
// So we scan the message ahead of time for all its emojis and ensure they are in the DB.
const emojiMatches = [...content.matchAll(/<(a?):([^:>]{1,64}):([0-9]+)>/g)]
await Promise.all(emojiMatches.map(match => {
const id = match[3]
const name = match[2]
const animated = !!match[1]
return emojiToKey.emojiToKey({id, name, animated}) // Register the custom emoji if needed
}))
async function transformParsedVia(parsed) {
for (const node of parsed) {
if (node.type === "discordChannel") {
node.row = select("channel_room", ["room_id", "name", "nick"], {channel_id: node.id}).get()
if (node.row?.room_id) {
node.via = await getViaServersMemo(node.row.room_id)
}
}
if (Array.isArray(node.content)) {
await transformParsedVia(node.content)
}
}
return parsed
}
let html = await markdown.toHtmlWithPostParser(content, transformParsedVia, {
discordCallback: getDiscordParseCallbacks(message, guild, true),
...customOptions
}, customParser, customHtmlOutput)
let body = await markdown.toHtmlWithPostParser(content, transformParsedVia, {
discordCallback: getDiscordParseCallbacks(message, guild, false),
discordOnly: true,
escapeHTML: false,
...customOptions
})
return {body, html}
}
// FIXME: What was the scanMentions parameter supposed to activate? It's unused.
async function addTextEvent(body, html, msgtype, {scanMentions}) {
// Star * prefix for fallback edits
if (options.includeEditFallbackStar) {
body = "* " + body
html = "* " + html
}
const flags = message.flags || 0
if (flags & 2) {
body = `[🔀 ${message.author.username}]\n` + body
html = `🔀 <strong>${message.author.username}</strong><br>` + html
}
// Fallback body/formatted_body for replies
// This branch is optional - do NOT change anything apart from the reply fallback, since it may not be run
if (repliedToEventRow && 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 = `<a href="https://matrix.to/#/${repliedToEventSenderMxid}">${repliedToDisplayName}</a>`
} 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 repliedToHtml = markdown.toHTML(repliedToContent, {
discordCallback: getDiscordParseCallbacks(message, guild, true)
})
const repliedToBody = markdown.toHTML(repliedToContent, {
discordCallback: getDiscordParseCallbacks(message, guild, false),
discordOnly: true,
escapeHTML: false,
})
html = `<mx-reply><blockquote><a href="https://matrix.to/#/${repliedToEventRow.room_id}/${repliedToEventRow.event_id}">In reply to</a> ${repliedToUserHtml}`
+ `<br>${repliedToHtml}</blockquote></mx-reply>`
+ html
body = (`${repliedToDisplayName}: ` // scenario 1 part B for mentions
+ repliedToBody).split("\n").map(line => "> " + line).join("\n")
+ "\n\n" + body
}
const newTextMessageEvent = {
$type: "m.room.message",
"m.mentions": mentions,
msgtype,
body: body
}
const isPlaintext = body === html
if (!isPlaintext) {
Object.assign(newTextMessageEvent, {
format: "org.matrix.custom.html",
formatted_body: html
})
}
events.push(newTextMessageEvent)
}
let msgtype = "m.text"
// Handle message type 4, channel name changed
if (message.type === DiscordTypes.MessageType.ChannelNameChange) {
msgtype = "m.emote"
message.content = "changed the channel name to **" + message.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 (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)
}
}
}
// Text content appears first
const {body, html} = await transformContent(message.content)
await addTextEvent(body, html, msgtype, {scanMentions: true})
}
// Then attachments
if (message.attachments) {
const attachmentEvents = await Promise.all(message.attachments.map(attachmentToEvent.bind(null, mentions)))
events.push(...attachmentEvents)
}
// Then embeds
for (const embed of message.embeds || []) {
if (embed.type === "image") {
continue // Matrix's own URL previews are fine for images.
}
if (embed.url?.startsWith("https://discord.com/")) {
continue // If discord creates an embed preview for a discord channel link, don't copy that embed
}
// 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()
// Provider
if (embed.provider?.name) {
if (embed.provider.url) {
rep.addParagraph(`via ${embed.provider.name} ${embed.provider.url}`, tag`<sub><a href="${embed.provider.url}">${embed.provider.name}</a></sub>`)
} else {
rep.addParagraph(`via ${embed.provider.name}`, tag`<sub>${embed.provider.name}</sub>`)
}
}
// Author and URL into a paragraph
let authorNameText = embed.author?.name || ""
if (authorNameText && embed.author?.icon_url) authorNameText = `⏺️ ${authorNameText}` // using the emoji instead of an image
if (authorNameText) {
if (embed.author?.url) {
const authorURL = await transformContentMessageLinks(embed.author.url)
rep.addParagraph(`## ${authorNameText} ${authorURL}`, tag`<strong><a href="${authorURL}">${authorNameText}</a></strong>`)
} else {
rep.addParagraph(`## ${authorNameText}`, tag`<strong>${authorNameText}</strong>`)
}
}
// Title and URL into a paragraph
if (embed.title) {
const {body, html} = await transformContent(embed.title, {}, embedTitleParser, markdown.htmlOutput)
if (embed.url) {
rep.addParagraph(`## ${body} ${embed.url}`, tag`<strong><a href="${embed.url}">$${html}</a></strong>`)
} else {
rep.addParagraph(`## ${body}`, `<strong>${html}</strong>`)
}
}
let embedTypeShouldShowDescription = embed.type !== "video" // Discord doesn't display descriptions for videos
if (embed.provider?.name === "YouTube") embedTypeShouldShowDescription = true // But I personally like showing the descriptions for YouTube videos specifically
if (embed.description && embedTypeShouldShowDescription) {
const {body, html} = await transformContent(embed.description)
rep.addParagraph(body, html)
}
for (const field of embed.fields || []) {
const name = field.name.match(/^[\s­]*$/) ? {body: "", html: ""} : await transformContent(field.name, {}, embedTitleParser, markdown.htmlOutput)
const value = await transformContent(field.value)
const fieldRep = new mxUtils.MatrixStringBuilder()
.addLine(`### ${name.body}`, `<strong>${name.html}</strong>`, name.body)
.addLine(value.body, value.html, !!value.body)
rep.addParagraph(fieldRep.get().body, fieldRep.get().formatted_body)
}
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(`📸 ${chosenImage}`)
if (embed.video?.url) rep.addParagraph(`🎞️ ${embed.video.url}`)
if (embed.footer?.text) rep.addLine(`${embed.footer.text}`, tag`${embed.footer.text}`)
let {body, formatted_body: html} = rep.get()
body = body.split("\n").map(l => "| " + l).join("\n")
html = `<blockquote>${html}</blockquote>`
// 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", {scanMentions: false})
}
// Then stickers
if (message.sticker_items) {
const stickerEvents = await Promise.all(message.sticker_items.map(async stickerItem => {
const format = file.stickerFormat.get(stickerItem.format_type)
assert(format?.mime)
if (format?.mime === "lottie") {
const {mxc_url, info} = await lottie.convert(stickerItem)
return {
$type: "m.sticker",
"m.mentions": mentions,
body: stickerItem.name,
info,
url: mxc_url
}
} else {
let body = stickerItem.name
const sticker = guild.stickers.find(sticker => sticker.id === stickerItem.id)
if (sticker && sticker.description) body += ` - ${sticker.description}`
return {
$type: "m.sticker",
"m.mentions": mentions,
body,
info: {
mimetype: format.mime
},
url: await file.uploadDiscordFileToMxc(file.sticker(stickerItem))
}
}
}))
events.push(...stickerEvents)
}
// Rich replies
if (repliedToEventRow) {
Object.assign(events[0], {
"m.relates_to": {
"m.in_reply_to": {
event_id: repliedToEventRow.event_id
}
}
})
}
return events
}
module.exports.messageToEvent = messageToEvent

View file

@ -0,0 +1,134 @@
const {test} = require("supertape")
const {messageToEvent} = require("./message-to-event")
const data = require("../../test/data")
const Ty = require("../../types")
/**
* @param {string} roomID
* @param {string} eventID
* @returns {(roomID: string, eventID: string) => Promise<Ty.Event.Outer<Ty.Event.M_Room_Message>>}
*/
function mockGetEvent(t, roomID_in, eventID_in, outer) {
return async function(roomID, eventID) {
t.equal(roomID, roomID_in)
t.equal(eventID, eventID_in)
return new Promise(resolve => {
setTimeout(() => {
resolve({
event_id: eventID_in,
room_id: roomID_in,
origin_server_ts: 1680000000000,
unsigned: {
age: 2245,
transaction_id: "$local.whatever"
},
...outer
})
})
})
}
}
test("message2event: pk reply to matrix is converted to native matrix reply", async t => {
const events = await messageToEvent(data.pk_message.pk_reply_to_matrix, {}, {}, {
api: {
getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU", {
type: "m.room.message",
sender: "@cadence:cadence.moe",
content: {
msgtype: "m.text",
body: "now for my next experiment:"
}
})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {
user_ids: [
"@cadence:cadence.moe"
]
},
msgtype: "m.text",
body: "> cadence [they]: now for my next experiment:\n\nthis is a reply",
format: "org.matrix.custom.html",
formatted_body: '<mx-reply><blockquote><a href="https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU">In reply to</a> <a href="https://matrix.to/#/@cadence:cadence.moe">cadence [they]</a><br>'
+ "now for my next experiment:</blockquote></mx-reply>"
+ "this is a reply",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU"
}
}
}])
})
test("message2event: pk reply to discord is converted to native matrix reply", async t => {
const events = await messageToEvent(data.pk_message.pk_reply_to_discord, {}, {}, {
api: {
getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU", {
type: "m.room.message",
sender: "@_ooye_.wing.:cadence.moe",
content: {
msgtype: "m.text",
body: "some text"
}
})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
"m.mentions": {},
body: "> wing: some text\n\nthis is a reply",
format: "org.matrix.custom.html",
formatted_body: '<mx-reply><blockquote><a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA">In reply to</a> wing<br>'
+ "some text</blockquote></mx-reply>"
+ "this is a reply",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA"
}
}
}])
})
test("message2event: pk reply to matrix attachment is converted to native matrix reply", async t => {
const events = await messageToEvent(data.pk_message.pk_reply_to_matrix_attachment, {}, {}, {
api: {
getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$OEEK-Wam2FTh6J-6kVnnJ6KnLA_lLRnLTHatKKL62-Y", {
sender: "@ampflower:matrix.org",
type: "m.room.message",
content: {
body: "catnod.gif",
filename: "catnod.gif",
info: {
h: 128,
mimetype: "image/gif",
size: 20816,
w: 128
},
msgtype: "m.image",
url: "mxc://matrix.org/jtzXIawXCkFIHSsMUNsKkUJX"
}
})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
"m.mentions": {
user_ids: ["@ampflower:matrix.org"]
},
body: "> Ampflower 🌺: [Media]\n\nCat nod",
format: "org.matrix.custom.html",
formatted_body: '<mx-reply><blockquote><a href="https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$OEEK-Wam2FTh6J-6kVnnJ6KnLA_lLRnLTHatKKL62-Y">In reply to</a> <a href="https://matrix.to/#/@ampflower:matrix.org">Ampflower 🌺</a><br>'
+ "[Media]</blockquote></mx-reply>"
+ "Cat nod",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$OEEK-Wam2FTh6J-6kVnnJ6KnLA_lLRnLTHatKKL62-Y"
}
}
}])
})

View file

@ -0,0 +1,967 @@
const {test} = require("supertape")
const {messageToEvent} = require("./message-to-event")
const data = require("../../test/data")
const Ty = require("../../types")
/**
* @param {string} roomID
* @param {string} eventID
* @returns {(roomID: string, eventID: string) => Promise<Ty.Event.Outer<Ty.Event.M_Room_Message>>}
*/
function mockGetEvent(t, roomID_in, eventID_in, outer) {
return async function(roomID, eventID) {
t.equal(roomID, roomID_in)
t.equal(eventID, eventID_in)
return new Promise(resolve => {
setTimeout(() => {
resolve({
event_id: eventID_in,
room_id: roomID_in,
origin_server_ts: 1680000000000,
unsigned: {
age: 2245,
transaction_id: "$local.whatever"
},
...outer
})
})
})
}
}
test("message2event: simple plaintext", async t => {
const events = await messageToEvent(data.message.simple_plaintext, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "ayy lmao"
}])
})
test("message2event: simple plaintext with quotes", async t => {
const events = await messageToEvent(data.message.simple_plaintext_with_quotes, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: `then he said, "you and her aren't allowed in here!"`
}])
})
test("message2event: simple user mention", async t => {
const events = await messageToEvent(data.message.simple_user_mention, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "@crunch god: Tell me about Phil, renowned martial arts master and creator of the Chin Trick",
format: "org.matrix.custom.html",
formatted_body: '<a href="https://matrix.to/#/@_ooye_crunch_god:cadence.moe">@crunch god</a> Tell me about Phil, renowned martial arts master and creator of the Chin Trick'
}])
})
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
}
}
},
async getJoinedMembers(roomID) {
called++
t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe")
return {
joined: {
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
"@user:matrix.org": {display_name: null, avatar_url: null}
}
}
}
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "#worm-farm",
format: "org.matrix.custom.html",
formatted_body: '<a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe?via=cadence.moe&via=matrix.org">#worm-farm</a>'
}])
t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each")
})
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
}
}
},
async getJoinedMembers(roomID) {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
return {
joined: {
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
"@user:matrix.org": {display_name: null, avatar_url: null}
}
}
}
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "#main",
format: "org.matrix.custom.html",
formatted_body: '<a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&via=matrix.org">#main</a>'
}])
t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each")
})
test("message2event: unknown room mention", async t => {
const events = await messageToEvent(data.message.unknown_room_mention, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "#unknown-channel [channel from an unbridged server]"
}])
})
test("message2event: unbridged room mention", async t => {
const events = await messageToEvent(data.message.unbridged_room_mention, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "#bad-boots-prison [channel not bridged]"
}])
})
test("message2event: simple role mentions", async t => {
const events = await messageToEvent(data.message.simple_role_mentions, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "I'm just @!!DLCS!!: testing a few role pings @Master Wonder Mage: don't mind me",
format: "org.matrix.custom.html",
formatted_body: `I'm just <font color="#a901ff">@!!DLCS!!</font> testing a few role pings <span data-mx-color="#ffffff" data-mx-bg-color="#414eef">@Master Wonder Mage</span> don't mind me`
}])
})
test("message2event: manually constructed unknown roles should use fallback", async t => {
const events = await messageToEvent(data.message.unknown_role, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "I'm just @&4 testing a few role pings <@&B> don't mind me",
format: "org.matrix.custom.html",
formatted_body: "I'm just @&4 testing a few role pings &lt;@&amp;B&gt; don't mind me"
}])
})
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
}
}
},
async getJoinedMembers(roomID) {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
return {
joined: {
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
"@user:super.invalid": {display_name: null, avatar_url: null}
}
}
}
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg?via=cadence.moe&via=super.invalid",
format: "org.matrix.custom.html",
formatted_body: '<a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg?via=cadence.moe&amp;via=super.invalid">https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg?via=cadence.moe&amp;via=super.invalid</a>'
}])
t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each")
})
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: {
async getEventForTimestamp(roomID, ts) {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
return {
event_id: "$E8IQDGFqYzOU7BwY5Z74Bg-cwaU9OthXSroaWtgYc7U",
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")
return {
joined: {
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
"@user:matrix.org": {display_name: null, avatar_url: null}
}
}
}
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "Me: I'll scroll up to find a certain message I'll send\n_scrolls up and clicks message links for god knows how long_\n_completely forgets what they were looking for and simply begins scrolling up to find some fun moments_\n_stumbles upon:_ "
+ "https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$E8IQDGFqYzOU7BwY5Z74Bg-cwaU9OthXSroaWtgYc7U?via=cadence.moe&via=matrix.org",
format: "org.matrix.custom.html",
formatted_body: "Me: I'll scroll up to find a certain message I'll send<br><em>scrolls up and clicks message links for god knows how long</em><br><em>completely forgets what they were looking for and simply begins scrolling up to find some fun moments</em><br><em>stumbles upon:</em> "
+ '<a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$E8IQDGFqYzOU7BwY5Z74Bg-cwaU9OthXSroaWtgYc7U?via=cadence.moe&amp;via=matrix.org">https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$E8IQDGFqYzOU7BwY5Z74Bg-cwaU9OthXSroaWtgYc7U?via=cadence.moe&amp;via=matrix.org</a>'
}])
t.equal(called, 3, "getEventForTimestamp, getStateEvent, and getJoinedMembers should be called once each")
})
test("message2event: message link from another server", async t => {
const events = await messageToEvent(data.message.message_link_from_another_server, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "Neither of these servers are known to OOYE: https://discord.com/channels/111/222/333 [event is from another server] https://canary.discordapp.com/channels/444/555/666 [event is from another server]",
format: "org.matrix.custom.html",
formatted_body: 'Neither of these servers are known to OOYE: <a href="https://discord.com/channels/111/222/333">https://discord.com/channels/111/222/333</a> [event is from another server]'
+ ' <a href="https://canary.discordapp.com/channels/444/555/666">https://canary.discordapp.com/channels/444/555/666</a> [event is from another server]'
}])
})
test("message2event: attachment with no content", async t => {
const events = await messageToEvent(data.message.attachment_no_content, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.image",
url: "mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM",
body: "image.png",
external_url: "https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png",
filename: "image.png",
info: {
mimetype: "image/png",
w: 466,
h: 85,
size: 12919,
},
}])
})
test("message2event: spoiler attachment", async t => {
const events = await messageToEvent(data.message.spoiler_attachment, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "📄 Uploaded SPOILER file: https://cdn.discordapp.com/attachments/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)",
format: "org.matrix.custom.html",
formatted_body: "<blockquote>📄 Uploaded SPOILER file: <a href=\"https://cdn.discordapp.com/attachments/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci\">https://cdn.discordapp.com/attachments/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci</a> (74 KB)</blockquote>"
}])
})
test("message2event: stickers", async t => {
const events = await messageToEvent(data.message.sticker, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "can have attachments too"
}, {
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.image",
url: "mxc://cadence.moe/ZDCNYnkPszxGKgObUIFmvjus",
body: "image.png",
external_url: "https://cdn.discordapp.com/attachments/122155380120748034/1106366167486038016/image.png",
filename: "image.png",
info: {
mimetype: "image/png",
w: 333,
h: 287,
size: 127373,
},
}, {
$type: "m.sticker",
"m.mentions": {},
body: "pomu puff - damn that tiny lil bitch really chuffing. puffing that fat ass dart",
info: {
mimetype: "image/png"
// thumbnail_url
// thumbnail_info
},
url: "mxc://cadence.moe/UuUaLwXhkxFRwwWCXipDlBHn"
}])
})
test("message2event: lottie sticker", async t => {
const events = await messageToEvent(data.message.lottie_sticker, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.sticker",
"m.mentions": {},
body: "8",
info: {
mimetype: "image/png",
w: 160,
h: 160
},
url: "mxc://cadence.moe/ZtvvVbwMIdUZeovWVyGVFCeR"
}])
})
test("message2event: skull webp attachment with content", async t => {
const events = await messageToEvent(data.message.skull_webp_attachment_with_content, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "Image"
}, {
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.image",
body: "skull.webp",
info: {
w: 1200,
h: 628,
mimetype: "image/webp",
size: 74290
},
external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1128084747910918195/skull.webp",
filename: "skull.webp",
url: "mxc://cadence.moe/sDxWmDErBhYBxtDcJQgBETes"
}])
})
test("message2event: reply to skull webp attachment with content", async t => {
const events = await messageToEvent(data.message.reply_to_skull_webp_attachment_with_content, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$oLyUTyZ_7e_SUzGNWZKz880ll9amLZvXGbArJCKai2Q"
}
},
"m.mentions": {},
msgtype: "m.text",
body: "> Extremity: Image\n\nReply",
format: "org.matrix.custom.html",
formatted_body:
'<mx-reply><blockquote><a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$oLyUTyZ_7e_SUzGNWZKz880ll9amLZvXGbArJCKai2Q">In reply to</a> Extremity'
+ '<br>Image</blockquote></mx-reply>'
+ 'Reply'
}, {
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.image",
body: "RDT_20230704_0936184915846675925224905.jpg",
info: {
w: 2048,
h: 1536,
mimetype: "image/jpeg",
size: 85906
},
external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1128084851023675515/RDT_20230704_0936184915846675925224905.jpg",
filename: "RDT_20230704_0936184915846675925224905.jpg",
url: "mxc://cadence.moe/WlAbFSiNRIHPDEwKdyPeGywa"
}])
})
test("message2event: simple reply to matrix user", async t => {
const events = await messageToEvent(data.message.simple_reply_to_matrix_user, data.guild.general, {}, {
api: {
getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", {
type: "m.room.message",
content: {
msgtype: "m.text",
body: "so can you reply to my webhook uwu"
},
sender: "@cadence:cadence.moe"
})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4"
}
},
"m.mentions": {
user_ids: [
"@cadence:cadence.moe"
]
},
msgtype: "m.text",
body: "> cadence: so can you reply to my webhook uwu\n\nReply",
format: "org.matrix.custom.html",
formatted_body:
'<mx-reply><blockquote><a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4">In reply to</a> <a href="https://matrix.to/#/@cadence:cadence.moe">cadence</a>'
+ '<br>so can you reply to my webhook uwu</blockquote></mx-reply>'
+ 'Reply'
}])
})
test("message2event: simple reply to matrix user, reply fallbacks disabled", async t => {
const events = await messageToEvent(data.message.simple_reply_to_matrix_user, data.guild.general, {includeReplyFallback: false}, {
api: {
getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", {
type: "m.room.message",
content: {
msgtype: "m.text",
body: "so can you reply to my webhook uwu"
},
sender: "@cadence:cadence.moe"
})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4"
}
},
"m.mentions": {
user_ids: [
"@cadence:cadence.moe"
]
},
msgtype: "m.text",
body: "Reply"
}])
})
test("message2event: reply with a video", async t => {
const events = await messageToEvent(data.message.reply_with_video, data.guild.general, {
api: {
getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$7tJoMw1h44n2gxgLUE1T_YinGrLbK0x-TDY1z6M7GBw", {
type: "m.room.message",
content: {
msgtype: "m.text",
body: 'deadpicord "extremity you woke up at 4 am"'
},
sender: "@_ooye_extremity:cadence.moe"
})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.video",
body: "Ins_1960637570.mp4",
filename: "Ins_1960637570.mp4",
url: "mxc://cadence.moe/kMqLycqMURhVpwleWkmASpnU",
external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1197621094786531358/Ins_1960637570.mp4?ex=65bbee8f&is=65a9798f&hm=ae14f7824c3d526c5e11c162e012e1ee405fd5776e1e9302ed80ccd86503cfda&",
info: {
h: 854,
mimetype: "video/mp4",
size: 860559,
w: 480,
},
"m.mentions": {},
"m.relates_to": {
"m.in_reply_to": {
event_id: "$7tJoMw1h44n2gxgLUE1T_YinGrLbK0x-TDY1z6M7GBw"
}
}
}])
})
test("message2event: voice message", async t => {
const events = await messageToEvent(data.message.voice_message)
t.deepEqual(events, [{
$type: "m.room.message",
body: "voice-message.ogg",
external_url: "https://cdn.discordapp.com/attachments/1099031887500034088/1112476845502365786/voice-message.ogg?ex=65c92d4c&is=65b6b84c&hm=0654bab5027474cbe23875954fa117cf44d8914c144cd151879590fa1baf8b1c&",
filename: "voice-message.ogg",
info: {
duration: 3960.0000381469727,
mimetype: "audio/ogg",
size: 10584,
},
"m.mentions": {},
msgtype: "m.audio",
url: "mxc://cadence.moe/MRRPDggXQMYkrUjTpxQbmcxB"
}])
})
test("message2event: misc file", async t => {
const events = await messageToEvent(data.message.misc_file)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "final final final revised draft",
"m.mentions": {}
}, {
$type: "m.room.message",
body: "the.yml",
external_url: "https://cdn.discordapp.com/attachments/122155380120748034/1174514575220158545/the.yml?ex=65cd6270&is=65baed70&hm=8c5f1b571784e3c7f99628492298815884e351ae0dc7c2ae40dd22d97caf27d9&",
filename: "the.yml",
info: {
mimetype: "text/plain; charset=utf-8",
size: 2274
},
"m.mentions": {},
msgtype: "m.file",
url: "mxc://cadence.moe/HnQIYQmmlIKwOQsbFsIGpzPP"
}])
})
test("message2event: simple reply in thread to a matrix user's reply", async t => {
const events = await messageToEvent(data.message.simple_reply_to_reply_in_thread, data.guild.general, {}, {
api: {
getEvent: mockGetEvent(t, "!FuDZhlOAtqswlyxzeR:cadence.moe", "$nUM-ABBF8KdnvrhXwLlYAE9dgDl_tskOvvcNIBrtsVo", {
type: "m.room.message",
sender: "@cadence:cadence.moe",
content: {
msgtype: "m.text",
body: "> <@_ooye_cadence:cadence.moe> So what I'm wondering is about replies.\n\nWhat about them?",
format: "org.matrix.custom.html",
formatted_body: "<mx-reply><blockquote><a href=\"https://matrix.to/#/!FuDZhlOAtqswlyxzeR:cadence.moe/$fWQT8uOrzLzAXNVXz88VkGx7Oo724iS5uD8Qn5KUy9w?via=cadence.moe\">In reply to</a> <a href=\"https://matrix.to/#/@_ooye_cadence:cadence.moe\">@_ooye_cadence:cadence.moe</a><br>So what I&#39;m wondering is about replies.</blockquote></mx-reply>What about them?",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$fWQT8uOrzLzAXNVXz88VkGx7Oo724iS5uD8Qn5KUy9w"
}
}
},
event_id: "$nUM-ABBF8KdnvrhXwLlYAE9dgDl_tskOvvcNIBrtsVo",
room_id: "!FuDZhlOAtqswlyxzeR:cadence.moe"
})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$nUM-ABBF8KdnvrhXwLlYAE9dgDl_tskOvvcNIBrtsVo"
}
},
"m.mentions": {
user_ids: ["@cadence:cadence.moe"]
},
msgtype: "m.text",
body: "> cadence [they]: What about them?\n\nWell, they don't seem to...",
format: "org.matrix.custom.html",
formatted_body: "<mx-reply><blockquote><a href=\"https://matrix.to/#/!FuDZhlOAtqswlyxzeR:cadence.moe/$nUM-ABBF8KdnvrhXwLlYAE9dgDl_tskOvvcNIBrtsVo\">In reply to</a> <a href=\"https://matrix.to/#/@cadence:cadence.moe\">cadence [they]</a><br>What about them?</blockquote></mx-reply>Well, they don't seem to...",
}])
})
test("message2event: infinidoge's reply to ami's matrix smalltext reply to infinidoge", async t => {
const events = await messageToEvent(data.message.infinidoge_reply_to_ami_matrix_smalltext_reply_to_infinidoge, data.guild.general, {}, {
api: {
getEvent: mockGetEvent(t, "!BnKuBPCvyfOkhcUjEu:cadence.moe", "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4", {
type: "m.room.message",
sender: "@ami:the-apothecary.club",
content: {
msgtype: "m.text",
body: `> <@_ooye_infinidoge:cadence.moe> Neat that they thought of that\n\nlet me guess they got a lot of bug reports like "empty chest with no loot?"`,
format: "org.matrix.custom.html",
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$baby?via=cadence.moe">In reply to</a> <a href="https://matrix.to/#/@_ooye_infinidoge:cadence.moe">@_ooye_infinidoge:cadence.moe</a><br>Neat that they thought of that</blockquote></mx-reply>let me guess they got a lot of bug reports like "empty chest with no loot?"`,
"m.relates_to": {
"m.in_reply_to": {
event_id: "$baby"
}
}
},
event_id: "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4",
room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe"
})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4"
}
},
"m.mentions": {
user_ids: ["@ami:the-apothecary.club"]
},
msgtype: "m.text",
body: `> Ami (she/her): let me guess they got a lot of bug reports like "empty chest with no loot?"\n\nMost likely`,
format: "org.matrix.custom.html",
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4">In reply to</a> <a href="https://matrix.to/#/@ami:the-apothecary.club">Ami (she/her)</a><br>let me guess they got a lot of bug reports like "empty chest with no loot?"</blockquote></mx-reply>Most likely`,
}])
})
test("message2event: infinidoge's reply to ami's matrix smalltext singleline reply to infinidoge", async t => {
const events = await messageToEvent(data.message.infinidoge_reply_to_ami_matrix_smalltext_singleline_reply_to_infinidoge, data.guild.general, {}, {
api: {
getEvent: mockGetEvent(t, "!BnKuBPCvyfOkhcUjEu:cadence.moe", "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4", {
type: "m.room.message",
sender: "@ami:the-apothecary.club",
content: {
msgtype: "m.text",
body: `> <@_ooye_infinidoge:cadence.moe> Neat that they thought of that\n\nlet me guess they got a lot of bug reports like "empty chest with no loot?"`,
format: "org.matrix.custom.html",
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$baby?via=cadence.moe">In reply to</a> <a href="https://matrix.to/#/@_ooye_infinidoge:cadence.moe">@_ooye_infinidoge:cadence.moe</a><br>Neat that they thought of that</blockquote></mx-reply>let me guess they got a lot of bug reports like "empty chest with no loot?"`,
"m.relates_to": {
"m.in_reply_to": {
event_id: "$baby"
}
}
},
event_id: "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4",
room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe"
})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4"
}
},
"m.mentions": {
user_ids: ["@ami:the-apothecary.club"]
},
msgtype: "m.text",
body: `> Ami (she/her): let me guess they got a lot of bug reports like "empty chest with no loot?"\n\nMost likely`,
format: "org.matrix.custom.html",
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4">In reply to</a> <a href="https://matrix.to/#/@ami:the-apothecary.club">Ami (she/her)</a><br>let me guess they got a lot of bug reports like "empty chest with no loot?"</blockquote></mx-reply>Most likely`,
}])
})
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: {
async getJoinedMembers(roomID) {
t.equal(roomID, "!rEOspnYqdOalaIFniV:cadence.moe")
return new Promise(resolve => {
setTimeout(() => {
resolve({
joined: {
"@she_who_brings_destruction:matrix.org": {
avatar_url: "mxc://matrix.org/FKcfnfFZlEhspeMsERfYtCuO",
display_name: "ash (Old)"
},
"@tomskeleton:cadence.moe": {
avatar_url: "mxc://cadence.moe/OvYYicuOwfAACKaXKJCUPbVz",
display_name: "tomskeleton"
},
"@she_who_brings_destruction:cadence.moe": {
avatar_url: "mxc://cadence.moe/XDXLMbkieETPrjFupoeiwyyq",
display_name: "ash"
},
"@_ooye_bot:cadence.moe": {
avatar_url: "mxc://cadence.moe/jlrgFjYQHzfBvORedOmYqXVz",
display_name: "Out Of Your Element"
},
"@cadence:cadence.moe": {
avatar_url: "mxc://cadence.moe/GJDPWiryxIhyRBNJzRNYzAlh",
display_name: "cadence [they]"
},
"@_ooye_tomskeleton:cadence.moe": {
avatar_url: "mxc://cadence.moe/SdSrjjsrNVdyPTAKEGQUhKUK",
display_name: "tomskeleton"
},
"@_ooye_queergasm:cadence.moe": {
avatar_url: "mxc://cadence.moe/KqXYGbUqhPPJKifLmfpoLnmB",
display_name: "queergasm"
},
"@_ooye_.subtext:cadence.moe": {
avatar_url: "mxc://cadence.moe/heoCvaUmfCdpxdzaChwwkpEp",
display_name: ".subtext"
}
}
})
})
})
}
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {
user_ids: [
"@she_who_brings_destruction:cadence.moe"
]
},
msgtype: "m.text",
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 => {
let called = 0
const events = await messageToEvent(data.message.advanced_written_at_mention_for_matrix, 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: "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: [
"@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"
}])
t.equal(called, 1, "should only look up the member list once")
})
test("message2event: very large attachment is linked instead of being uploaded", async t => {
const events = await messageToEvent({
content: "hey",
attachments: [{
filename: "hey.jpg",
url: "https://discord.com/404/hey.jpg",
content_type: "application/i-made-it-up",
size: 100e6
}]
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "hey"
}, {
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "📄 Uploaded file: https://discord.com/404/hey.jpg (100 MB)",
format: "org.matrix.custom.html",
formatted_body: '📄 Uploaded file: <a href="https://discord.com/404/hey.jpg">hey.jpg</a> (100 MB)'
}])
})
test("message2event: type 4 channel name change", async t => {
const events = await messageToEvent(data.special_message.thread_name_change, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.emote",
body: "changed the channel name to **worming**",
format: "org.matrix.custom.html",
formatted_body: "changed the channel name to <strong>worming</strong>"
}])
})
test("message2event: thread start message reference", async t => {
const events = await messageToEvent(data.special_message.thread_start_context, data.guild.general, {}, {
api: {
getEvent: mockGetEvent(t, "!BnKuBPCvyfOkhcUjEu:cadence.moe", "$FchUVylsOfmmbj-VwEs5Z9kY49_dt2zd0vWfylzy5Yo", {
"type": "m.room.message",
"sender": "@_ooye_kyuugryphon:cadence.moe",
"content": {
"m.mentions": {},
"msgtype": "m.text",
"body": "layer 4"
}
})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
$sender: null,
msgtype: "m.text",
body: "layer 4",
"m.mentions": {}
}])
})
test("message2event: single large bridged emoji", async t => {
const events = await messageToEvent(data.message.single_emoji, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: ":hippo:",
format: "org.matrix.custom.html",
formatted_body: '<img data-mx-emoticon height="32" src="mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC" title=":hippo:" alt=":hippo:">'
}])
})
test("message2event: mid-message small bridged emoji", async t => {
const events = await messageToEvent(data.message.surrounded_emoji, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "h is for :hippo:!",
format: "org.matrix.custom.html",
formatted_body: 'h is for <img data-mx-emoticon height="32" src="mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC" title=":hippo:" alt=":hippo:">!'
}])
})
test("message2event: emoji that hasn't been registered yet", async t => {
const events = await messageToEvent(data.message.not_been_registered_emoji, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: ":Yeah:",
format: "org.matrix.custom.html",
formatted_body: '<img data-mx-emoticon height="32" src="mxc://cadence.moe/pgdGTxAyEltccRgZKxdqzHHP" title=":Yeah:" alt=":Yeah:">'
}])
})
test("message2event: emoji triple long name", async t => {
const events = await messageToEvent(data.message.emoji_triple_long_name, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: ":brillillillilliant_move::brillillillilliant_move::brillillillilliant_move:",
format: "org.matrix.custom.html",
formatted_body:
'<img data-mx-emoticon height="32" src="mxc://cadence.moe/scfRIDOGKWFDEBjVXocWYQHik" title=":brillillillilliant_move:" alt=":brillillillilliant_move:">'
+ '<img data-mx-emoticon height="32" src="mxc://cadence.moe/scfRIDOGKWFDEBjVXocWYQHik" title=":brillillillilliant_move:" alt=":brillillillilliant_move:">'
+ '<img data-mx-emoticon height="32" src="mxc://cadence.moe/scfRIDOGKWFDEBjVXocWYQHik" title=":brillillillilliant_move:" alt=":brillillillilliant_move:">'
}])
})
test("message2event: crossposted announcements say where they are crossposted from", async t => {
const events = await messageToEvent(data.special_message.crosspost_announcement, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "[🔀 Chewey Bot Official Server #announcements]\nAll text based commands are now inactive on Chewey Bot\nTo continue using commands you'll need to use them as slash commands",
format: "org.matrix.custom.html",
formatted_body: "🔀 <strong>Chewey Bot Official Server #announcements</strong><br>All text based commands are now inactive on Chewey Bot<br>To continue using commands you'll need to use them as slash commands"
}])
})
test("message2event: @everyone", async t => {
const events = await messageToEvent(data.message_mention_everyone.at_everyone)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "@room",
"m.mentions": {
room: true
}
}])
})
test("message2event: @here", async t => {
const events = await messageToEvent(data.message_mention_everyone.at_here)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "@room",
"m.mentions": {
room: true
}
}])
})
test("message2event: @everyone without permission", async t => {
const events = await messageToEvent(data.message_mention_everyone.at_everyone_without_permission)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "@everyone <-- this is testing that it DOESN'T mention. if this mentions everyone then my apologies.",
format: "org.matrix.custom.html",
formatted_body: "@everyone &lt;-- this is testing that it DOESN'T mention. if this mentions everyone then my apologies.",
"m.mentions": {}
}])
})
test("message2event: @here without permission", async t => {
const events = await messageToEvent(data.message_mention_everyone.at_here_without_permission)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "@here <-- this is testing that it DOESN'T mention. if this mentions people then my apologies.",
format: "org.matrix.custom.html",
formatted_body: "@here &lt;-- this is testing that it DOESN'T mention. if this mentions people then my apologies.",
"m.mentions": {}
}])
})
test("message2event: @everyone within a link", async t => {
const events = await messageToEvent(data.message_mention_everyone.at_everyone_within_link)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "https://github.com/@everyone",
format: "org.matrix.custom.html",
formatted_body: `<a href="https://github.com/@everyone">https://github.com/@everyone</a>`,
"m.mentions": {}
}])
})

View file

@ -0,0 +1,19 @@
// @ts-check
const {select} = require("../../passthrough")
/**
* @param {import("discord-api-types/v10").RESTGetAPIChannelPinsResult} pins
*/
function pinsToList(pins) {
/** @type {string[]} */
const result = []
for (const message of pins) {
const eventID = select("event_message", "event_id", {message_id: message.id, part: 0}).pluck().get()
if (eventID) result.push(eventID)
}
result.reverse()
return result
}
module.exports.pinsToList = pinsToList

View file

@ -0,0 +1,12 @@
const {test} = require("supertape")
const data = require("../../test/data")
const {pinsToList} = require("./pins-to-list")
test("pins2list: converts known IDs, ignores unknown IDs", t => {
const result = pinsToList(data.pins.faked)
t.deepEqual(result, [
"$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4",
"$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA",
"$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg"
])
})

View file

@ -0,0 +1,88 @@
// @ts-check
const Ty = require("../../types")
const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough")
const {discord, sync, select} = passthrough
/** @type {import("../../m2d/converters/utils")} */
const utils = sync.require("../../m2d/converters/utils")
/**
* @typedef ReactionRemoveRequest
* @prop {string} eventID
* @prop {string | null} mxid
* @prop {bigint} [hash]
*/
/**
* @param {DiscordTypes.GatewayMessageReactionRemoveDispatchData} data
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>[]} reactions
* @param {string} key
*/
function removeReaction(data, reactions, key) {
/** @type {ReactionRemoveRequest[]} */
const removals = []
const wantToRemoveMatrixReaction = data.user_id === discord.application.id
for (const event of reactions) {
const eventID = event.event_id
if (event.content["m.relates_to"].key === key) {
const lookingAtMatrixReaction = !utils.eventSenderIsFromDiscord(event.sender)
if (lookingAtMatrixReaction && wantToRemoveMatrixReaction) {
// We are removing a Matrix user's reaction, so we need to redact from the correct user ID (not @_ooye_matrix_bridge).
// Even though the bridge bot only reacted once on Discord-side, multiple Matrix users may have
// reacted on Matrix-side. Semantically, we want to remove the reaction from EVERY Matrix user.
// Also need to clean up the database.
const hash = utils.getEventIDHash(event.event_id)
removals.push({eventID, mxid: null, hash})
}
if (!lookingAtMatrixReaction && !wantToRemoveMatrixReaction) {
// We are removing a Discord user's reaction, so we just make the sim user remove it.
const mxid = select("sim", "mxid", {user_id: data.user_id}).pluck().get()
if (mxid === event.sender) {
removals.push({eventID, mxid})
}
}
}
}
return removals
}
/**
* @param {DiscordTypes.GatewayMessageReactionRemoveEmojiDispatchData} data
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>[]} relations
* @param {string} key
*/
function removeEmojiReaction(data, relations, key) {
/** @type {ReactionRemoveRequest[]} */
const removals = []
for (const event of relations) {
const eventID = event.event_id
if (event.content["m.relates_to"].key === key) {
const mxid = utils.eventSenderIsFromDiscord(event.sender) ? event.sender : null
removals.push({eventID, mxid})
}
}
return removals
}
/**
* @param {DiscordTypes.GatewayMessageReactionRemoveAllDispatchData} data
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>[]} relations
* @returns {ReactionRemoveRequest[]}
*/
function removeAllReactions(data, relations) {
return relations.map(event => {
const eventID = event.event_id
const mxid = utils.eventSenderIsFromDiscord(event.sender) ? event.sender : null
return {eventID, mxid}
})
}
module.exports.removeReaction = removeReaction
module.exports.removeEmojiReaction = removeEmojiReaction
module.exports.removeAllReactions = removeAllReactions

View file

@ -0,0 +1,170 @@
// @ts-check
const {test} = require("supertape")
const removeReaction = require("./remove-reaction")
const BRIDGE_ID = "684280192553844747"
function fakeSpecificReactionRemoval(userID, emoji, emojiID) {
return {
channel_id: "THE_CHANNEL",
message_id: "THE_MESSAGE",
user_id: userID,
emoji: {id: emojiID, name: emoji}
}
}
function fakeEmojiReactionRemoval(emoji, emojiID) {
return {
channel_id: "THE_CHANNEL",
message_id: "THE_MESSAGE",
emoji: {id: emojiID, name: emoji}
}
}
function fakeAllReactionRemoval() {
return {
channel_id: "THE_CHANNEL",
message_id: "THE_MESSAGE"
}
}
function fakeReactions(reactions) {
return reactions.map(({sender, key}, i) => ({
content: {
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$message",
key
}
},
event_id: `$reaction_${i}`,
sender,
type: "m.reaction",
origin_server_ts: 0,
room_id: "!THE_ROOM",
unsigned: null
}))
}
test("remove reaction: a specific discord user's reaction is removed", t => {
const removals = removeReaction.removeReaction(
fakeSpecificReactionRemoval("820865262526005258", "🐈", null),
fakeReactions([{key: "🐈", sender: "@_ooye_crunch_god:cadence.moe"}]),
"🐈"
)
t.deepEqual(removals, [{
eventID: "$reaction_0",
mxid: "@_ooye_crunch_god:cadence.moe"
}])
})
test("remove reaction: a specific matrix user's reaction is removed", t => {
const removals = removeReaction.removeReaction(
fakeSpecificReactionRemoval(BRIDGE_ID, "🐈", null),
fakeReactions([{key: "🐈", sender: "@cadence:cadence.moe"}]),
"🐈"
)
t.deepEqual(removals, [{
eventID: "$reaction_0",
mxid: null,
hash: 2842343637291700751n
}])
})
test("remove reaction: a specific discord user's reaction is removed when there are multiple reactions", t => {
const removals = removeReaction.removeReaction(
fakeSpecificReactionRemoval("820865262526005258", "🐈", null),
fakeReactions([
{key: "🐈‍⬛", sender: "@_ooye_crunch_god:cadence.moe"},
{key: "🐈", sender: "@_ooye_crunch_god:cadence.moe"},
{key: "🐈", sender: "@_ooye_extremity:cadence.moe"},
{key: "🐈", sender: "@cadence:cadence.moe"},
{key: "🐈", sender: "@zoe:cadence.moe"}
]),
"🐈"
)
t.deepEqual(removals, [{
eventID: "$reaction_1",
mxid: "@_ooye_crunch_god:cadence.moe"
}])
})
test("remove reaction: a specific reaction leads to all matrix users' reaction of the emoji being removed", t => {
const removals = removeReaction.removeReaction(
fakeSpecificReactionRemoval(BRIDGE_ID, "🐈", null),
fakeReactions([
{key: "🐈", sender: "@_ooye_crunch_god:cadence.moe"},
{key: "🐈", sender: "@cadence:cadence.moe"},
{key: "🐈‍⬛", sender: "@zoe:cadence.moe"},
{key: "🐈", sender: "@zoe:cadence.moe"},
{key: "🐈", sender: "@_ooye_extremity:cadence.moe"}
]),
"🐈"
)
t.deepEqual(removals, [{
eventID: "$reaction_1",
mxid: null,
hash: -8635141960139030904n
}, {
eventID: "$reaction_3",
mxid: null,
hash: 326222869084879263n
}])
})
test("remove reaction: an emoji removes all instances of the emoij from both sides", t => {
const removals = removeReaction.removeEmojiReaction(
fakeEmojiReactionRemoval("🐈", null),
fakeReactions([
{key: "🐈", sender: "@_ooye_crunch_god:cadence.moe"},
{key: "🐈", sender: "@cadence:cadence.moe"},
{key: "🐈‍⬛", sender: "@zoe:cadence.moe"},
{key: "🐈", sender: "@zoe:cadence.moe"},
{key: "🐈", sender: "@_ooye_extremity:cadence.moe"}
]),
"🐈"
)
t.deepEqual(removals, [{
eventID: "$reaction_0",
mxid: "@_ooye_crunch_god:cadence.moe"
}, {
eventID: "$reaction_1",
mxid: null
}, {
eventID: "$reaction_3",
mxid: null
}, {
eventID: "$reaction_4",
mxid: "@_ooye_extremity:cadence.moe"
}])
})
test("remove reaction: remove all removes all from both sides", t => {
const removals = removeReaction.removeAllReactions(
fakeAllReactionRemoval(),
fakeReactions([
{key: "🐈", sender: "@_ooye_crunch_god:cadence.moe"},
{key: "🐈", sender: "@cadence:cadence.moe"},
{key: "🐈‍⬛", sender: "@zoe:cadence.moe"},
{key: "🐈", sender: "@zoe:cadence.moe"},
{key: "🐈", sender: "@_ooye_extremity:cadence.moe"}
])
)
t.deepEqual(removals, [{
eventID: "$reaction_0",
mxid: "@_ooye_crunch_god:cadence.moe"
}, {
eventID: "$reaction_1",
mxid: null
}, {
eventID: "$reaction_2",
mxid: null
}, {
eventID: "$reaction_3",
mxid: null
}, {
eventID: "$reaction_4",
mxid: "@_ooye_extremity:cadence.moe"
}])
})

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,19 @@
rlottie library by Samsung:
https://github.com/Samsung/rlottie
rlottie-wasm.js and rlottie-wasm.wasm are outputs from rlottie's WASM build process.
rlottie uses the MIT license:
https://github.com/Samsung/rlottie/blob/master/COPYING
The text of the rlottie license follows.
Copyright 2020 (see AUTHORS)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

Binary file not shown.

View file

@ -0,0 +1,47 @@
// @ts-check
const assert = require("assert").strict
const passthrough = require("../../passthrough")
const {discord, sync, db, select} = passthrough
/** @type {import("../../m2d/converters/utils")} */
const mxUtils = sync.require("../../m2d/converters/utils")
const {reg} = require("../../matrix/read-registration.js")
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
/**
* @param {string} parentRoomID
* @param {string} threadRoomID
* @param {string?} creatorMxid
* @param {import("discord-api-types/v10").APIThreadChannel} thread
* @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API
*/
async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, di) {
const branchedFromEventID = select("event_message", "event_id", {message_id: thread.id}).pluck().get()
/** @type {{"m.mentions"?: any, "m.in_reply_to"?: any}} */
const context = {}
if (branchedFromEventID) {
// Need to figure out who sent that event...
const event = await di.api.getEvent(parentRoomID, branchedFromEventID)
context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}}
if (event.sender && !userRegex.some(rx => event.sender.match(rx))) context["m.mentions"] = {user_ids: [event.sender]}
}
const msgtype = creatorMxid ? "m.emote" : "m.text"
const template = creatorMxid ? "started a thread:" : "Thread started:"
const via = await mxUtils.getViaServersQuery(threadRoomID, di.api)
let body = `${template} ${thread.name} https://matrix.to/#/${threadRoomID}?${via.toString()}`
let html = `${template} <a href="https://matrix.to/#/${threadRoomID}?${via.toString()}">${thread.name}</a>`
return {
msgtype,
body,
format: "org.matrix.custom.html",
formatted_body: html,
"m.mentions": {},
...context
}
}
module.exports.threadToAnnouncement = threadToAnnouncement

View file

@ -0,0 +1,171 @@
const {test} = require("supertape")
const {threadToAnnouncement} = require("./thread-to-announcement")
const data = require("../../test/data")
const Ty = require("../../types")
/**
* @param {string} roomID
* @param {string} eventID
* @returns {(roomID: string, eventID: string) => Promise<Ty.Event.Outer<Ty.Event.M_Room_Message>>}
*/
function mockGetEvent(t, roomID_in, eventID_in, outer) {
return async function(roomID, eventID) {
t.equal(roomID, roomID_in)
t.equal(eventID, eventID_in)
return new Promise(resolve => {
setTimeout(() => {
resolve({
event_id: eventID_in,
room_id: roomID_in,
origin_server_ts: 1680000000000,
unsigned: {
age: 2245,
transaction_id: "$local.whatever"
},
...outer
})
})
})
}
}
const viaApi = {
async getStateEvent(roomID, type, key) {
return {
users: {
"@_ooye_bot:cadence.moe": 100
}
}
},
async getJoinedMembers(roomID) {
return {
joined: {
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
"@user:matrix.org": {display_name: null, avatar_url: null}
}
}
}
}
test("thread2announcement: no known creator, no branched from event", async t => {
const content = await threadToAnnouncement("!parent", "!thread", null, {
name: "test thread",
id: "-1"
}, {api: viaApi})
t.deepEqual(content, {
msgtype: "m.text",
body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
format: "org.matrix.custom.html",
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org">test thread</a>`,
"m.mentions": {}
})
})
test("thread2announcement: known creator, no branched from event", async t => {
const content = await threadToAnnouncement("!parent", "!thread", "@_ooye_crunch_god:cadence.moe", {
name: "test thread",
id: "-1"
}, {api: viaApi})
t.deepEqual(content, {
msgtype: "m.emote",
body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
format: "org.matrix.custom.html",
formatted_body: `started a thread: <a href="https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org">test thread</a>`,
"m.mentions": {}
})
})
test("thread2announcement: no known creator, branched from discord event", async t => {
const content = await threadToAnnouncement("!kLRqKKUQXcibIMtOpl:cadence.moe", "!thread", null, {
name: "test thread",
id: "1126786462646550579"
}, {
api: {
getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", {
type: 'm.room.message',
sender: '@_ooye_bot:cadence.moe',
content: {
msgtype: 'm.text',
body: 'testing testing testing'
}
}),
...viaApi
}
})
t.deepEqual(content, {
msgtype: "m.text",
body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
format: "org.matrix.custom.html",
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org">test thread</a>`,
"m.mentions": {},
"m.relates_to": {
"m.in_reply_to": {
event_id: "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg"
}
}
})
})
test("thread2announcement: known creator, branched from discord event", async t => {
const content = await threadToAnnouncement("!kLRqKKUQXcibIMtOpl:cadence.moe", "!thread", "@_ooye_crunch_god:cadence.moe", {
name: "test thread",
id: "1126786462646550579"
}, {
api: {
getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", {
type: 'm.room.message',
sender: '@_ooye_bot:cadence.moe',
content: {
msgtype: 'm.text',
body: 'testing testing testing'
}
}),
...viaApi
}
})
t.deepEqual(content, {
msgtype: "m.emote",
body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
format: "org.matrix.custom.html",
formatted_body: `started a thread: <a href="https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org">test thread</a>`,
"m.mentions": {},
"m.relates_to": {
"m.in_reply_to": {
event_id: "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg"
}
}
})
})
test("thread2announcement: no known creator, branched from matrix event", async t => {
const content = await threadToAnnouncement("!kLRqKKUQXcibIMtOpl:cadence.moe", "!thread", null, {
name: "test thread",
id: "1128118177155526666"
}, {
api: {
getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", {
type: "m.room.message",
content: {
msgtype: "m.text",
body: "so can you reply to my webhook uwu"
},
sender: "@cadence:cadence.moe"
}),
...viaApi
}
})
t.deepEqual(content, {
msgtype: "m.text",
body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
format: "org.matrix.custom.html",
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org">test thread</a>`,
"m.mentions": {
user_ids: ["@cadence:cadence.moe"]
},
"m.relates_to": {
"m.in_reply_to": {
event_id: "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4"
}
}
})
})

View file

@ -0,0 +1,88 @@
// @ts-check
const assert = require("assert")
const {reg} = require("../../matrix/read-registration")
const passthrough = require("../../passthrough")
const {select} = passthrough
const SPECIAL_USER_MAPPINGS = new Map([
["1081004946872352958", ["clyde_ai", "clyde"]]
])
/**
* Downcased and stripped username. Can only include a basic set of characters.
* https://spec.matrix.org/v1.6/appendices/#user-identifiers
* @param {import("discord-api-types/v10").APIUser} user
* @returns {string} localpart
*/
function downcaseUsername(user) {
// First, try to convert the username to the set of allowed characters
let downcased = user.username.toLowerCase()
// spaces to underscores...
.replace(/ /g, "_")
// remove disallowed characters...
.replace(/[^a-z0-9._=/-]*/g, "")
// remove leading and trailing dashes and underscores...
.replace(/(?:^[_-]*|[_-]*$)/g, "")
// If requested, also make the Discord user ID part of the username
if (reg.ooye.include_user_id_in_mxid) {
downcased = user.id + "_" + downcased
}
// The new length must be at least 2 characters (in other words, it should have some content)
if (downcased.length < 2) {
downcased = user.id
}
return downcased
}
/** @param {string[]} preferences */
function* generateLocalpartAlternatives(preferences) {
const best = preferences[0]
assert(best)
// First, suggest the preferences...
for (const localpart of preferences) {
yield localpart
}
// ...then fall back to generating number suffixes...
let i = 2
while (true) {
yield best + (i++)
/* c8 ignore next */
}
}
/**
* Whole process for checking the database and generating the right sim name.
* It is very important this is not an async function: once the name has been chosen, the calling function should be able to immediately claim that name into the database in the same event loop tick.
* @param {import("discord-api-types/v10").APIUser} user
* @returns {string}
*/
function userToSimName(user) {
if (!SPECIAL_USER_MAPPINGS.has(user.id)) { // skip this check for known special users
assert.notEqual(user.discriminator, "0000", `cannot create user for a webhook: ${JSON.stringify(user)}`)
}
// 1. Is sim user already registered?
const existing = select("sim", "sim_name", {user_id: user.id}).pluck().get()
assert.equal(existing, null, "Shouldn't try to create a new name for an existing sim")
// 2. Register based on username (could be new or old format)
// (Unless it's a special user, in which case copy their provided mappings.)
const downcased = downcaseUsername(user)
const preferences = SPECIAL_USER_MAPPINGS.get(user.id) || [downcased]
if (user.discriminator.length === 4) { // Old style tag? If user.username is unavailable, try the full tag next
preferences.push(downcased + user.discriminator)
}
// Check for conflicts with already registered sims
const matches = select("sim", "sim_name", {}, "WHERE sim_name LIKE ? ESCAPE '@'").pluck().all(downcased + "%")
// Keep generating until we get a suggestion that doesn't conflict
for (const suggestion of generateLocalpartAlternatives(preferences)) {
if (!matches.includes(suggestion)) return suggestion
}
/* c8 ignore next */
throw new Error(`Ran out of suggestions when generating sim name. downcased: "${downcased}"`)
}
module.exports.userToSimName = userToSimName

View file

@ -0,0 +1,54 @@
const {test} = require("supertape")
const tryToCatch = require("try-to-catch")
const assert = require("assert")
const data = require("../../test/data")
const {userToSimName} = require("./user-to-mxid")
test("user2name: cannot create user for a webhook", async t => {
const [error] = await tryToCatch(() => userToSimName({discriminator: "0000"}))
t.ok(error instanceof assert.AssertionError, error.message)
})
test("user2name: works on normal name", t => {
t.equal(userToSimName({username: "Harry Styles!", discriminator: "0001"}), "harry_styles")
})
test("user2name: works on emojis", t => {
t.equal(userToSimName({username: "🍪 Cookie Monster 🍪", discriminator: "0001"}), "cookie_monster")
})
test("user2name: works on single emoji at the end", t => {
t.equal(userToSimName({username: "Melody 🎵", discriminator: "2192"}), "melody")
})
test("user2name: works on crazy name", t => {
t.equal(userToSimName({username: "*** D3 &W (89) _7//-", discriminator: "0001"}), "d3_w_89__7//")
})
test("user2name: adds discriminator if name is unavailable (old tag format)", t => {
t.equal(userToSimName({username: "BOT$", discriminator: "1234"}), "bot1234")
})
test("user2name: adds number suffix if name is unavailable (new username format)", t => {
t.equal(userToSimName({username: "bot", discriminator: "0"}), "bot2")
})
test("user2name: uses ID if name becomes too short", t => {
t.equal(userToSimName({username: "f***", discriminator: "0001", id: "9"}), "9")
})
test("user2name: uses ID when name has only disallowed characters", t => {
t.equal(userToSimName({username: "!@#$%^&*", discriminator: "0001", id: "9"}), "9")
})
test("user2name: works on special user", t => {
t.equal(userToSimName(data.user.clyde_ai), "clyde_ai")
})
test("user2name: includes ID if requested in config", t => {
const {reg} = require("../../matrix/read-registration")
reg.ooye.include_user_id_in_mxid = true
t.equal(userToSimName({username: "Harry Styles!", discriminator: "0001", id: "123456"}), "123456_harry_styles")
t.equal(userToSimName({username: "f***", discriminator: "0001", id: "123456"}), "123456_f")
reg.ooye.include_user_id_in_mxid = false
})