Compare commits

...

10 Commits

15 changed files with 734 additions and 79 deletions

View File

@ -13,8 +13,8 @@ const api = sync.require("../../matrix/api")
const file = sync.require("../../matrix/file") const file = sync.require("../../matrix/file")
/** @type {import("./create-room")} */ /** @type {import("./create-room")} */
const createRoom = sync.require("./create-room") const createRoom = sync.require("./create-room")
/** @type {import("../converters/expression")} */ /** @type {import("./expression")} */
const expression = sync.require("../converters/expression") const expression = sync.require("./expression")
/** @type {import("../../matrix/kstate")} */ /** @type {import("../../matrix/kstate")} */
const ks = sync.require("../../matrix/kstate") const ks = sync.require("../../matrix/kstate")

View File

@ -1,10 +1,9 @@
// @ts-check // @ts-check
const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {discord, sync, db, select} = passthrough const {sync, db} = passthrough
/** @type {import("../../matrix/file")} */ /** @type {import("../../matrix/file")} */
const file = sync.require("../../matrix/file") const file = sync.require("../../matrix/file")

View File

@ -43,7 +43,7 @@ function getDiscordParseCallbacks(message, guild, useHTML) {
channel: node => { channel: node => {
const row = select("channel_room", ["room_id", "name", "nick"], {channel_id: node.id}).get() const row = select("channel_room", ["room_id", "name", "nick"], {channel_id: node.id}).get()
if (!row) { if (!row) {
return `<#${node.id}>` // fallback for when this channel is not bridged return `#[channel-from-an-unknown-server]` // fallback for when this channel is not bridged
} else if (useHTML) { } else if (useHTML) {
return `<a href="https://matrix.to/#/${row.room_id}">#${row.nick || row.name}</a>` return `<a href="https://matrix.to/#/${row.room_id}">#${row.nick || row.name}</a>`
} else { } else {
@ -497,25 +497,17 @@ async function messageToEvent(message, guild, options = {}, di) {
if (message.sticker_items) { if (message.sticker_items) {
const stickerEvents = await Promise.all(message.sticker_items.map(async stickerItem => { const stickerEvents = await Promise.all(message.sticker_items.map(async stickerItem => {
const format = file.stickerFormat.get(stickerItem.format_type) const format = file.stickerFormat.get(stickerItem.format_type)
assert(format?.mime)
if (format?.mime === "lottie") { if (format?.mime === "lottie") {
try { const {mxc_url, info} = await lottie.convert(stickerItem)
const {mxc_url, info} = await lottie.convert(stickerItem) return {
return { $type: "m.sticker",
$type: "m.sticker", "m.mentions": mentions,
"m.mentions": mentions, body: stickerItem.name,
body: stickerItem.name, info,
info, url: mxc_url
url: mxc_url
}
} catch (e) {
return {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.notice",
body: `Failed to convert Lottie sticker:\n${e.toString()}\n${e.stack}`
}
} }
} else if (format?.mime) { } else {
let body = stickerItem.name let body = stickerItem.name
const sticker = guild.stickers.find(sticker => sticker.id === stickerItem.id) const sticker = guild.stickers.find(sticker => sticker.id === stickerItem.id)
if (sticker && sticker.description) body += ` - ${sticker.description}` if (sticker && sticker.description) body += ` - ${sticker.description}`
@ -529,12 +521,6 @@ async function messageToEvent(message, guild, options = {}, di) {
url: await file.uploadDiscordFileToMxc(file.sticker(stickerItem)) url: await file.uploadDiscordFileToMxc(file.sticker(stickerItem))
} }
} }
return {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.notice",
body: `Unsupported sticker format ${format?.mime}. Name: ${stickerItem.name}`
}
})) }))
events.push(...stickerEvents) events.push(...stickerEvents)
} }

View File

@ -73,6 +73,16 @@ test("message2event: simple room mention", async t => {
}]) }])
}) })
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: "#[channel-from-an-unknown-server]"
}])
})
test("message2event: simple role mentions", async t => { test("message2event: simple role mentions", async t => {
const events = await messageToEvent(data.message.simple_role_mentions, data.guild.general, {}) const events = await messageToEvent(data.message.simple_role_mentions, data.guild.general, {})
t.deepEqual(events, [{ t.deepEqual(events, [{
@ -214,6 +224,21 @@ test("message2event: stickers", async t => {
}]) }])
}) })
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 => { test("message2event: skull webp attachment with content", async t => {
const events = await messageToEvent(data.message.skull_webp_attachment_with_content, data.guild.general, {}) const events = await messageToEvent(data.message.skull_webp_attachment_with_content, data.guild.general, {})
t.deepEqual(events, [{ t.deepEqual(events, [{
@ -337,6 +362,41 @@ test("message2event: simple reply to matrix user, reply fallbacks disabled", asy
}]) }])
}) })
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: simple reply in thread to a matrix user's reply", async t => { 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, {}, { const events = await messageToEvent(data.message.simple_reply_to_reply_in_thread, data.guild.general, {}, {
api: { api: {

View File

@ -26,9 +26,11 @@ const updatePins = sync.require("./actions/update-pins")
/** @type {import("../matrix/api")}) */ /** @type {import("../matrix/api")}) */
const api = sync.require("../matrix/api") const api = sync.require("../matrix/api")
/** @type {import("../discord/utils")} */ /** @type {import("../discord/utils")} */
const utils = sync.require("../discord/utils") const dUtils = sync.require("../discord/utils")
/** @type {import("../discord/discord-command-handler")}) */ /** @type {import("../discord/discord-command-handler")}) */
const discordCommandHandler = sync.require("../discord/discord-command-handler") const discordCommandHandler = sync.require("../discord/discord-command-handler")
/** @type {import("../m2d/converters/utils")} */
const mxUtils = require("../m2d/converters/utils")
/** @type {any} */ // @ts-ignore bad types from semaphore /** @type {any} */ // @ts-ignore bad types from semaphore
const Semaphore = require("@chriscdn/promise-semaphore") const Semaphore = require("@chriscdn/promise-semaphore")
@ -68,20 +70,17 @@ module.exports = {
stackLines = stackLines.slice(0, cloudstormLine - 2) stackLines = stackLines.slice(0, cloudstormLine - 2)
} }
} }
let formattedBody = "\u26a0 <strong>Bridged event from Discord not delivered</strong>"
+ `<br>Gateway event: ${gatewayMessage.t}` const builder = new mxUtils.MatrixStringBuilder()
+ `<br>${e.toString()}` builder.addLine("\u26a0 Bridged event from Discord not delivered", "\u26a0 <strong>Bridged event from Discord not delivered</strong>")
builder.addLine(`Gateway event: ${gatewayMessage.t}`)
builder.addLine(e.toString())
if (stackLines) { if (stackLines) {
formattedBody += `<br><details><summary>Error trace</summary>` builder.addLine(`Error trace:\n${stackLines.join("\n")}`, `<details><summary>Error trace</summary><pre>${stackLines.join("\n")}</pre></details>`)
+ `<pre>${stackLines.join("\n")}</pre></details>`
} }
formattedBody += `<details><summary>Original payload</summary>` builder.addLine("", `<details><summary>Original payload</summary><pre>${util.inspect(gatewayMessage.d, false, 4, false)}</pre></details>`)
+ `<pre>${util.inspect(gatewayMessage.d, false, 4, false)}</pre></details>`,
api.sendEvent(roomID, "m.room.message", { api.sendEvent(roomID, "m.room.message", {
msgtype: "m.text", ...builder.get(),
body: "\u26a0 Bridged event from Discord not delivered. See formatted content for full details.",
format: "org.matrix.custom.html",
formatted_body: formattedBody,
"moe.cadence.ooye.error": { "moe.cadence.ooye.error": {
source: "discord", source: "discord",
payload: gatewayMessage payload: gatewayMessage
@ -113,7 +112,7 @@ module.exports = {
const member = guild.members.find(m => m.user?.id === client.user.id) const member = guild.members.find(m => m.user?.id === client.user.id)
if (!member) return if (!member) return
if (!("permission_overwrites" in channel)) continue if (!("permission_overwrites" in channel)) continue
const permissions = utils.getPermissions(member.roles, guild.roles, client.user.id, channel.permission_overwrites) const permissions = dUtils.getPermissions(member.roles, guild.roles, client.user.id, channel.permission_overwrites)
const wants = BigInt(1 << 10) | BigInt(1 << 16) // VIEW_CHANNEL + READ_MESSAGE_HISTORY const wants = BigInt(1 << 10) | BigInt(1 << 16) // VIEW_CHANNEL + READ_MESSAGE_HISTORY
if ((permissions & wants) !== wants) continue // We don't have permission to look back in this channel if ((permissions & wants) !== wants) continue // We don't have permission to look back in this channel
@ -162,7 +161,7 @@ module.exports = {
const lastPin = updatePins.convertTimestamp(channel.last_pin_timestamp) const lastPin = updatePins.convertTimestamp(channel.last_pin_timestamp)
// Permissions check // Permissions check
const permissions = utils.getPermissions(member.roles, guild.roles, client.user.id, channel.permission_overwrites) const permissions = dUtils.getPermissions(member.roles, guild.roles, client.user.id, channel.permission_overwrites)
const wants = BigInt(1 << 10) | BigInt(1 << 16) // VIEW_CHANNEL + READ_MESSAGE_HISTORY const wants = BigInt(1 << 10) | BigInt(1 << 16) // VIEW_CHANNEL + READ_MESSAGE_HISTORY
if ((permissions & wants) !== wants) continue // We don't have permission to look up the pins in this channel if ((permissions & wants) !== wants) continue // We don't have permission to look up the pins in this channel

View File

@ -16,6 +16,7 @@ async function migrate(db) {
let migrationRan = false let migrationRan = false
for (const filename of files) { for (const filename of files) {
/* c8 ignore next - we can't unit test this, but it's run on every real world bridge startup */
if (progress >= filename) continue if (progress >= filename) continue
console.log(`Applying database migration ${filename}`) console.log(`Applying database migration ${filename}`)
if (filename.endsWith(".sql")) { if (filename.endsWith(".sql")) {

0
db/migrations/.baby Normal file
View File

View File

@ -32,13 +32,13 @@ What does it look like on Discord-side?
This is an API request to get the pinned messages. To update this, an API request will pin or unpin any specific message, adding or removing it from the list. This is an API request to get the pinned messages. To update this, an API request will pin or unpin any specific message, adding or removing it from the list.
## What will the converter look like? ## What will the converter do?
The converter will be very different in both directions. The converter will be very different in both directions.
For d2m, we will get the list of pinned messages, we will convert each message ID into the ID of an event we already have, and then we will set the entire `m.room.pinned_events` state to that list. **For d2m, we will get the list of pinned messages, we will convert each message ID into the ID of an event we already have, and then we will set the entire `m.room.pinned_events` state to that list.**
For m2d, we will have to diff the list of pinned messages against the previous version of the list, and for each event that was pinned or unpinned, we will send an API request to Discord to change its state. **For m2d, we will have to diff the list of pinned messages against the previous version of the list, and for each event that was pinned or unpinned, we will send an API request to Discord to change its st**ate.
## Missing messages ## Missing messages
@ -53,7 +53,7 @@ In this situation we need to stop and think about the possible paths forward we
The latter method would still make the message appear at the bottom of the timeline for most Matrix clients, since for most the timestamp doesn't determine the actual _order._ It would then be confusing why an odd message suddenly appeared, because a pins change isn't that noticable in the room. The latter method would still make the message appear at the bottom of the timeline for most Matrix clients, since for most the timestamp doesn't determine the actual _order._ It would then be confusing why an odd message suddenly appeared, because a pins change isn't that noticable in the room.
To avoid this problem, I'll just go with the former method and ignore the message, so Matrix will only have some of the pins that Discord has. We will need to watch out if a Matrix user edits this list of partial pins, because if we _only_ pinned things on Discord that were pinned on Matrix, those partial pins Discord would be lost from Discord side. To avoid this problem, I'll just go with the former method and ignore the message, so Matrix will only have some of the pins that Discord has. We will need to watch out if a Matrix user edits this list of partial pins, because if we _only_ pinned things on Discord that were pinned on Matrix, then pins Matrix doesn't know about would be lost from Discord side.
In this situation I will prefer to keep the pins list inconsistent between both sides and only bridge _changes_ to the list. In this situation I will prefer to keep the pins list inconsistent between both sides and only bridge _changes_ to the list.
@ -61,7 +61,9 @@ If you were implementing this for real, you might have made different decisions
## Test data for the d2m converter ## Test data for the d2m converter
Let's start writing the d2m converter. It's helpful to write unit tests for Out Of Your Element, since this lets you check if it worked without having to start up a local copy of the bridge or play around with the interface. Let's start writing the d2m converter. It's helpful to write automated tests for Out Of Your Element, since this lets you check if it worked without having to start up a local copy of the bridge or mess around with the interface.
To test the Discord-to-Matrix pin converter, we'll need some samples of Discord message objects. Then we can put these sample message objects through the converter and check what comes out the other side.
Normally for getting test data, I would `curl` the Discord API to grab some real data and put it into `data.js` (and possibly also `ooye-test-data.sql`. But this time, I'll fabricate some test data. Here it is: Normally for getting test data, I would `curl` the Discord API to grab some real data and put it into `data.js` (and possibly also `ooye-test-data.sql`. But this time, I'll fabricate some test data. Here it is:
@ -74,7 +76,7 @@ Normally for getting test data, I would `curl` the Discord API to grab some real
] ]
``` ```
"These aren't message objects!" I hear you cry. Correct. I already know that my implementation is not going to care about any properties on these message object other than the IDs, so I'm just making a list of IDs to save time. "These aren't message objects!" I hear you cry. Correct. I already know that my implementation is not going to care about any properties on these message object other than the IDs, so to save time, I'm just making a list of IDs.
These IDs were carefully chosen. The first three are already in `ooye-test-data.sql` and are associated with event IDs. This is great, because in our test case, the Discord IDs will be converted to those event IDs. The fourth ID doesn't exist on Matrix-side. This is to test that partial pins are handled as expected, like I wrote in the previous section. These IDs were carefully chosen. The first three are already in `ooye-test-data.sql` and are associated with event IDs. This is great, because in our test case, the Discord IDs will be converted to those event IDs. The fourth ID doesn't exist on Matrix-side. This is to test that partial pins are handled as expected, like I wrote in the previous section.
@ -104,7 +106,7 @@ index c36f252..4919beb 100644
## Writing the d2m converter ## Writing the d2m converter
We can write a function that operates on this data to convert it to events. This is a _converter,_ not an _action._ it won't _do_ anything by itself. So it goes in the converters folder. The actual function is pretty simple since I've already planned what to do: We can write a function that operates on this data to convert it to events. This is a _converter,_ not an _action._ It won't _do_ anything by itself. So it goes in the converters folder. I've already planned (in the "What will the converter do?" section) what to do, so writing the function is pretty simple:
```diff ```diff
diff --git a/d2m/converters/pins-to-list.js b/d2m/converters/pins-to-list.js diff --git a/d2m/converters/pins-to-list.js b/d2m/converters/pins-to-list.js
@ -133,9 +135,36 @@ index 0000000..e4107be
+module.exports.pinsToList = pinsToList +module.exports.pinsToList = pinsToList
``` ```
### Explaining the code
All converters have a `function` which does the work, and the function is added to `module.exports` so that other files can use it.
Importing `select` from `passthrough` lets us do database access. Calling the `select` function can select from OOYE's own SQLite database. If you want to see what's in the database, look at `ooye-test-data.sql` for test data, or open `ooye.db` for real data from your own bridge.
The comments `// @ts-check`, `/** @type ... */`, and `/** @param ... */` provide type-based autosuggestions when editing in Visual Studio Code.
Here's the code I haven't yet discussed:
```js
function pinsToList(pins) {
const result = []
for (const message of pins) {
const eventID = select("event_message", "event_id", {message_id: message.id}).pluck().get()
if (eventID) result.push(eventID)
}
return result
}
```
It will go through each `message` in `pins`. For each message, it will look up the corresponding Matrix event in the database, and if found, it will add it to `result`.
The `select` line will run this SQL: `SELECT event_id FROM event_message WHERE message_id = {the message ID}` and will return the event ID as a string or null.
For any database experts worried about an SQL query inside a loop, the N+1 problem does not apply to SQLite because the queries are executed in the same process rather than crossing a process (and network) boundary. https://www.sqlite.org/np1queryprob.html
## Test case for the d2m converter ## Test case for the d2m converter
There's not much room for bugs in this function. A single manual test that it works would be good enough for me. But since this is an example of how you can add your own, let's add a test case for this. We'll take the data we just prepared and process it through the function we just wrote: There's not much room for bugs in this function. A single manual test that it works would be good enough for me. But since this is an example of how you can add your own, let's add a test case for this. The testing code will take the data we just prepared and process it through the `pinsToList` function we just wrote. Then, it will check the result is what we expected.
```diff ```diff
diff --git a/d2m/converters/pins-to-list.test.js b/d2m/converters/pins-to-list.test.js diff --git a/d2m/converters/pins-to-list.test.js b/d2m/converters/pins-to-list.test.js
@ -177,6 +206,18 @@ index 5cc851e..280503d 100644
Good to go. Good to go.
### Explaining the code
`require("supertape")` is a library that helps with testing and printing test results. `data = require("../../test/data")` is the file we edited earlier in the "Test data for the d2m converter" section. `require("./pins-to-list")` is the function we want to test.
Here is how you declare a test: `test("pins2list: converts known IDs, ignores unknown IDs", t => {` The string describes what you are trying to test and it will be displayed if the test fails.
`result = pinsToList(data.pins.faked)` is calling the implementation function we wrote.
`t.deepEqual(actual, expected)` will check whether the `actual` result value is the same as our `expected` result value. If it's not, it'll mark that as a failed test.
### Run the test!
``` ```
><> $ npm t ><> $ npm t
@ -209,7 +250,11 @@ Oh no! (I promise I didn't make it fail for demonstration purposes, this was act
('$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCI', 'm.room.message', 'm.image', '1141501302736695316', 1, 1), ('$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCI', 'm.room.message', 'm.image', '1141501302736695316', 1, 1),
``` ```
Explanation: This Discord message `1141501302736695316` is actually part of 2 different Matrix events, `$mtR...` and `$51f...`. This often happens when a Discord user uploads an image with a caption. Matrix doesn't support combined image+text events, so the image and the text have to be bridged to separate events. We should consider the text to be the primary part, and pin that, and consider the image to be the secondary part, and not pin that. Explanation: This Discord message `1141501302736695316` is actually part of 2 different Matrix events, `$mtR...` and `$51f...`. This often happens when a Discord user uploads an image with a caption. Matrix doesn't support combined image+text events, so the image and the text have to be bridged to separate events.
In the current code, `pinsToList` is picking ALL the associated event IDs, and then `.get` is forcing it to limit that list to 1. It doesn't care which, so it's essentially random which event it wants to pin.
We should make a decision on which event is more important. You can make whatever decision you want - you could even make it pin every event associated with a message - but I've decided that the text should be the primary part and be pinned, and the image should be considered a secondary part and left unpinned.
We already have a column `part` in the `event_message` table for this reason! When `part = 0`, that's the primary part. I'll edit the converter to actually use that column: We already have a column `part` in the `event_message` table for this reason! When `part = 0`, that's the primary part. I'll edit the converter to actually use that column:
@ -229,6 +274,8 @@ index e4107be..f401de2 100644
return result return result
``` ```
As long as the database is consistent, this new `select` will return at most 1 event, always choosing the primary part.
``` ```
><> $ npm t ><> $ npm t
@ -341,6 +388,8 @@ I try to keep as much logic as possible out of the actions and in the converters
## See if it works ## See if it works
Since the automated tests pass, let's start up the bridge and run our nice new code:
``` ```
node start.js node start.js
``` ```
@ -359,7 +408,7 @@ I expected that to be the end of the guide, but after some time, I noticed a new
[After some investigation,](https://gitdab.com/cadence/out-of-your-element/issues/16) it turns out Discord puts the most recently pinned message at the start of the array and displays the array in forwards order, while Matrix puts the most recently pinned message at the end of the array and displays the array in reverse order. [After some investigation,](https://gitdab.com/cadence/out-of-your-element/issues/16) it turns out Discord puts the most recently pinned message at the start of the array and displays the array in forwards order, while Matrix puts the most recently pinned message at the end of the array and displays the array in reverse order.
We'll fix this by reversing the order of the list of pins before we store it. I'll do this in the converter. We can fix this by reversing the order of the list of pins before we store it. The converter can do this:
```diff ```diff
diff --git a/d2m/converters/pins-to-list.js b/d2m/converters/pins-to-list.js diff --git a/d2m/converters/pins-to-list.js b/d2m/converters/pins-to-list.js
@ -405,7 +454,7 @@ index c2e3774..92e5678 100644
Pass! Pass!
``` ```
Next time a message is pinned or unpinned on Discord, the order should be updated on Matrix. Next time a message is pinned or unpinned on Discord, OOYE should update the order of all the pins on Matrix.
## Notes on missed events ## Notes on missed events

View File

@ -32,6 +32,7 @@ async function compositeMatrixEmojis(mxcs) {
// @ts-ignore the signal is slightly different from the type it wants (still works fine) // @ts-ignore the signal is slightly different from the type it wants (still works fine)
const res = await fetch(url, {agent: false, signal: abortController.signal}) const res = await fetch(url, {agent: false, signal: abortController.signal})
const {stream, mime} = await streamMimeType.getMimeType(res.body) const {stream, mime} = await streamMimeType.getMimeType(res.body)
assert(["image/png", "image/jpeg", "image/webp", "image/gif"].includes(mime), `Mime type ${mime} is impossible for emojis`)
if (mime === "image/png" || mime === "image/jpeg" || mime === "image/webp") { if (mime === "image/png" || mime === "image/jpeg" || mime === "image/webp") {
/** @type {{info: sharp.OutputInfo, buffer: Buffer}} */ /** @type {{info: sharp.OutputInfo, buffer: Buffer}} */
@ -64,10 +65,6 @@ async function compositeMatrixEmojis(mxcs) {
.toBuffer({resolveWithObject: true}) .toBuffer({resolveWithObject: true})
return buffer.data return buffer.data
} else {
// unsupported mime type
console.error(`I don't know what a ${mime} emoji is.`)
return null
} }
} finally { } finally {
abortController.abort() abortController.abort()

View File

@ -301,8 +301,12 @@ async function handleRoomOrMessageLinks(input, di) {
result = MAKE_RESULT.MESSAGE_LINK[resultType](guildID, channelID, messageID) result = MAKE_RESULT.MESSAGE_LINK[resultType](guildID, channelID, messageID)
} else { } else {
// 3: Linking to an unknown event that OOYE didn't originally bridge - we can guess messageID from the timestamp // 3: Linking to an unknown event that OOYE didn't originally bridge - we can guess messageID from the timestamp
const originalEvent = await di.api.getEvent(roomID, eventID) let originalEvent
if (!originalEvent) continue try {
originalEvent = await di.api.getEvent(roomID, eventID)
} catch (e) {
continue // Our homeserver doesn't know about the event, so can't resolve it to a Discord link
}
const guessedMessageID = dUtils.timestampToSnowflakeInexact(originalEvent.origin_server_ts) const guessedMessageID = dUtils.timestampToSnowflakeInexact(originalEvent.origin_server_ts)
result = MAKE_RESULT.MESSAGE_LINK[resultType](guildID, channelID, guessedMessageID) result = MAKE_RESULT.MESSAGE_LINK[resultType](guildID, channelID, guessedMessageID)
} }
@ -318,7 +322,7 @@ async function handleRoomOrMessageLinks(input, di) {
/** /**
* @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker | Ty.Event.Outer_M_Room_Message_Encrypted_File} event * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker | Ty.Event.Outer_M_Room_Message_Encrypted_File} event
* @param {import("discord-api-types/v10").APIGuild} guild * @param {import("discord-api-types/v10").APIGuild} guild
* @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer}} di simple-as-nails dependency injection for the matrix API * @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, fetch: typeof fetch}} di simple-as-nails dependency injection for the matrix API
*/ */
async function eventToMessage(event, guild, di) { async function eventToMessage(event, guild, di) {
/** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]})[]} */ /** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]})[]} */
@ -393,8 +397,43 @@ async function eventToMessage(event, guild, di) {
await (async () => { await (async () => {
const repliedToEventId = event.content["m.relates_to"]?.["m.in_reply_to"]?.event_id const repliedToEventId = event.content["m.relates_to"]?.["m.in_reply_to"]?.event_id
if (!repliedToEventId) return if (!repliedToEventId) return
let repliedToEvent = await di.api.getEvent(event.room_id, repliedToEventId) let repliedToEvent
if (!repliedToEvent) return try {
repliedToEvent = await di.api.getEvent(event.room_id, repliedToEventId)
} catch (e) {
// Original event isn't on our homeserver, so we'll *partially* trust the client's reply fallback.
// We'll trust the fallback's quoted content and put it in the reply preview, but we won't trust the authorship info on it.
// (But if the fallback's quoted content doesn't exist, we give up. There's nothing for us to quote.)
if (event.content["format"] !== "org.matrix.custom.html" || typeof event.content["formatted_body"] !== "string") {
const lines = event.content.body.split("\n")
let stage = 0
for (let i = 0; i < lines.length; i++) {
if (stage >= 0 && lines[i][0] === ">") stage = 1
if (stage >= 1 && lines[i].trim() === "") stage = 2
if (stage === 2 && lines[i].trim() !== "") {
event.content.body = lines.slice(i).join("\n")
break
}
}
return
}
const mxReply = event.content["formatted_body"]
const quoted = mxReply.match(/^<mx-reply><blockquote>.*?In reply to.*?<br>(.*)<\/blockquote><\/mx-reply>/)?.[1]
if (!quoted) return
const contentPreviewChunks = chunk(
entities.decodeHTML5Strict( // Remove entities like &amp; &quot;
quoted.replace(/^\s*<blockquote>.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards
.replace(/(?:\n|<br>)+/g, " ") // Should all be on one line
.replace(/<span [^>]*data-mx-spoiler\b[^>]*>.*?<\/span>/g, "[spoiler]") // Good enough method of removing spoiler content. (I don't want to break out the HTML parser unless I have to.)
.replace(/<[^>]+>/g, "") // Completely strip all HTML tags and formatting.
), 50)
replyLine = "> " + contentPreviewChunks[0]
if (contentPreviewChunks.length > 1) replyLine = replyLine.replace(/[,.']$/, "") + "..."
replyLine += "\n"
return
}
// @ts-ignore // @ts-ignore
const autoEmoji = new Map(select("auto_emoji", ["name", "emoji_id"], {}, "WHERE name = 'L1' OR name = 'L2'").raw().all()) const autoEmoji = new Map(select("auto_emoji", ["name", "emoji_id"], {}, "WHERE name = 'L1' OR name = 'L2'").raw().all())
replyLine = `<:L1:${autoEmoji.get("L1")}><:L2:${autoEmoji.get("L2")}>` replyLine = `<:L1:${autoEmoji.get("L1")}><:L2:${autoEmoji.get("L2")}>`
@ -408,7 +447,11 @@ async function eventToMessage(event, guild, di) {
replyLine += `<@${authorID}>` replyLine += `<@${authorID}>`
} else { } else {
let senderName = select("member_cache", "displayname", {mxid: repliedToEvent.sender}).pluck().get() let senderName = select("member_cache", "displayname", {mxid: repliedToEvent.sender}).pluck().get()
if (!senderName) senderName = sender.match(/@([^:]*)/)?.[1] || sender if (!senderName) {
const match = sender.match(/@([^:]*)/)
assert(match)
senderName = match[1]
}
replyLine += `Ⓜ️**${senderName}**` replyLine += `Ⓜ️**${senderName}**`
} }
// If the event has been edited, the homeserver will include the relation in `unsigned`. // If the event has been edited, the homeserver will include the relation in `unsigned`.
@ -507,7 +550,7 @@ async function eventToMessage(event, guild, di) {
if (!match[0].includes("data-mx-emoticon")) break if (!match[0].includes("data-mx-emoticon")) break
const mxcUrl = match[0].match(/\bsrc="(mxc:\/\/[^"]+)"/) const mxcUrl = match[0].match(/\bsrc="(mxc:\/\/[^"]+)"/)
if (mxcUrl) endOfMessageEmojis.unshift(mxcUrl[1]) if (mxcUrl) endOfMessageEmojis.unshift(mxcUrl[1])
if (typeof match.index !== "number") break assert(typeof match.index === "number", "Your JavaScript implementation does not comply with TC39: https://tc39.es/ecma262/multipage/text-processing.html#sec-regexpbuiltinexec")
last = match.index last = match.index
} }
@ -563,8 +606,11 @@ async function eventToMessage(event, guild, di) {
if (event.content.info?.mimetype?.includes("/")) { if (event.content.info?.mimetype?.includes("/")) {
mimetype = event.content.info.mimetype mimetype = event.content.info.mimetype
} else { } else {
const res = await fetch(url, {method: "HEAD"}) const res = await di.fetch(url, {method: "HEAD"})
mimetype = res.headers.get("content-type") || "image/webp" if (res.status === 200) {
mimetype = res.headers.get("content-type")
}
if (!mimetype) throw new Error(`Server error ${res.status} or missing content-type while detecting sticker mimetype`)
} }
filename += "." + mimetype.split("/")[1] filename += "." + mimetype.split("/")[1]
} }

View File

@ -3,7 +3,7 @@ const {test} = require("supertape")
const {eventToMessage} = require("./event-to-message") const {eventToMessage} = require("./event-to-message")
const data = require("../../test/data") const data = require("../../test/data")
const {MatrixServerError} = require("../../matrix/mreq") const {MatrixServerError} = require("../../matrix/mreq")
const {db, select} = require("../../passthrough") const {db, select, discord} = require("../../passthrough")
/* c8 ignore next 7 */ /* c8 ignore next 7 */
function slow() { function slow() {
@ -543,10 +543,6 @@ test("event2message: lists have appropriate line breaks", async t => {
room_id: '!cBxtVRxDlZvSVhJXVK:cadence.moe', room_id: '!cBxtVRxDlZvSVhJXVK:cadence.moe',
sender: '@Milan:tchncs.de', sender: '@Milan:tchncs.de',
type: 'm.room.message', type: 'm.room.message',
}, {}, {
api: {
getStateEvent: async () => ({displayname: "Milan"})
}
}), }),
{ {
ensureJoined: [], ensureJoined: [],
@ -759,6 +755,7 @@ test("event2message: rich reply to a rich reply to a multi-line message should c
}, },
snow: { snow: {
guild: { guild: {
/* c8 ignore next 4 */
searchGuildMembers: (_, options) => { searchGuildMembers: (_, options) => {
t.fail(`should not search guild members, but actually searched for: ${options.query}`) t.fail(`should not search guild members, but actually searched for: ${options.query}`)
return [] return []
@ -858,6 +855,151 @@ test("event2message: rich reply to an already-edited message will quote the new
) )
}) })
test("event2message: rich reply to a missing event will quote from formatted_body without a link", async t => {
let called = 0
t.deepEqual(
await eventToMessage({
"type": "m.room.message",
"sender": "@cadence:cadence.moe",
"content": {
"msgtype": "m.text",
"body": "> <@_ooye_kyuugryphon:cadence.moe>\n> > She *sells* *sea*shells by the *sea*shore.\n> But who *sees* the *sea*shells she *sells* sitting sideways?\n\nWhat a tongue-bender...",
"format": "org.matrix.custom.html",
"formatted_body": "<mx-reply><blockquote><a href=\"https://matrix.to/#/!fGgIymcYWOqjbSRUdV:cadence.moe/$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cmadeup?via=cadence.moe&via=feather.onl\">In reply to</a> <a href=\"https://matrix.to/#/@_ooye_kyuugryphon:cadence.moe\">@_ooye_kyuugryphon:cadence.moe</a><br>"
+ "<blockquote>She <em>sells</em> <em>sea</em>shells by the <em>sea</em>shore.</blockquote>But who <em>sees</em> the <em>sea</em>shells she <em>sells</em> sitting sideways?"
+ "</blockquote></mx-reply>What a tongue-bender...",
"m.relates_to": {
"m.in_reply_to": {
"event_id": "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cmadeup"
}
}
},
"origin_server_ts": 1693029683016,
"unsigned": {
"age": 91,
"transaction_id": "m1693029682894.510"
},
"event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8",
"room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe"
}, data.guild.general, {
api: {
async getEvent(roomID, eventID) {
called++
t.equal(roomID, "!fGgIymcYWOqjbSRUdV:cadence.moe")
t.equal(eventID, "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cmadeup")
throw new Error("missing event or something")
}
}
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "cadence [they]",
content: "> But who sees the seashells she sells sitting..."
+ "\nWhat a tongue-bender...",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU"
}]
}
)
t.equal(called, 1, "getEvent should be called once")
})
test("event2message: rich reply to a missing event without formatted_body will use plaintext body and strip reply fallback", async t => {
let called = 0
t.deepEqual(
await eventToMessage({
"type": "m.room.message",
"sender": "@cadence:cadence.moe",
"content": {
"msgtype": "m.text",
"body": "> <@_ooye_kyuugryphon:cadence.moe> Slow news day.\n\nTesting this reply, ignore",
"m.relates_to": {
"m.in_reply_to": {
"event_id": "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cmadeup"
}
}
},
"origin_server_ts": 1693029683016,
"unsigned": {
"age": 91,
"transaction_id": "m1693029682894.510"
},
"event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8",
"room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe"
}, data.guild.general, {
api: {
async getEvent(roomID, eventID) {
called++
t.equal(roomID, "!fGgIymcYWOqjbSRUdV:cadence.moe")
t.equal(eventID, "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cmadeup")
throw new Error("missing event or something")
}
}
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "cadence [they]",
content: "Testing this reply, ignore",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU"
}]
}
)
t.equal(called, 1, "getEvent should be called once")
})
test("event2message: rich reply to a missing event and no reply fallback will not generate a reply", async t => {
let called = 0
t.deepEqual(
await eventToMessage({
"type": "m.room.message",
"sender": "@cadence:cadence.moe",
"content": {
"msgtype": "m.text",
"body": "Testing this reply, ignore.",
"format": "org.matrix.custom.html",
"formatted_body": "Testing this reply, ignore.",
"m.relates_to": {
"m.in_reply_to": {
"event_id": "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cmadeup"
}
}
},
"origin_server_ts": 1693029683016,
"unsigned": {
"age": 91,
"transaction_id": "m1693029682894.510"
},
"event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8",
"room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe"
}, data.guild.general, {
api: {
async getEvent(roomID, eventID) {
called++
t.equal(roomID, "!fGgIymcYWOqjbSRUdV:cadence.moe")
t.equal(eventID, "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cmadeup")
throw new Error("missing event or something")
}
}
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "cadence [they]",
content: "Testing this reply, ignore.",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU"
}]
}
)
t.equal(called, 1, "getEvent should be called once")
})
test("event2message: should avoid using blockquote contents as reply preview in rich reply to a sim user", async t => { test("event2message: should avoid using blockquote contents as reply preview in rich reply to a sim user", async t => {
t.deepEqual( t.deepEqual(
await eventToMessage({ await eventToMessage({
@ -1825,6 +1967,35 @@ test("event2message: mentioning bridged rooms works", async t => {
) )
}) })
test("event2message: mentioning bridged rooms works (plaintext body)", async t => {
t.deepEqual(
await eventToMessage({
content: {
msgtype: "m.text",
body: `I'm just https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe?via=cadence.moe testing channel mentions`
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
origin_server_ts: 1688301929913,
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
sender: "@cadence:cadence.moe",
type: "m.room.message",
unsigned: {
age: 405299
}
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "cadence [they]",
content: "I'm just <#1100319550446252084> testing channel mentions",
avatar_url: undefined
}]
}
)
})
test("event2message: mentioning known bridged events works (plaintext body)", async t => { test("event2message: mentioning known bridged events works (plaintext body)", async t => {
t.deepEqual( t.deepEqual(
await eventToMessage({ await eventToMessage({
@ -1916,7 +2087,7 @@ test("event2message: mentioning known bridged events works (formatted body)", as
) )
}) })
test("event2message: mentioning unknown bridged events works", async t => { test("event2message: mentioning unknown bridged events can approximate with timestamps", async t => {
let called = 0 let called = 0
t.deepEqual( t.deepEqual(
await eventToMessage({ await eventToMessage({
@ -1960,6 +2131,88 @@ test("event2message: mentioning unknown bridged events works", async t => {
t.equal(called, 1, "getEvent should be called once") t.equal(called, 1, "getEvent should be called once")
}) })
test("event2message: mentioning events falls back to original link when server doesn't know about it", async t => {
let called = 0
t.deepEqual(
await eventToMessage({
content: {
msgtype: "m.text",
body: "wrong body",
format: "org.matrix.custom.html",
formatted_body: `it was uploaded years ago in <a href="https://matrix.to/#/!CzvdIdUQXgUjDVKxeU:cadence.moe/$zpzx6ABetMl8BrpsFbdZ7AefVU1Y_-t97bJRJM2JyW1?via=cadence.moe">amanda-spam</a>`
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOV",
origin_server_ts: 1688301929913,
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
sender: "@cadence:cadence.moe",
type: "m.room.message",
unsigned: {
age: 405299
}
}, {}, {
api: {
async getEvent(roomID, eventID) {
called++
t.equal(roomID, "!CzvdIdUQXgUjDVKxeU:cadence.moe")
t.equal(eventID, "$zpzx6ABetMl8BrpsFbdZ7AefVU1Y_-t97bJRJM2JyW1")
throw new Error("missing event or something")
}
}
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "cadence [they]",
content: "it was uploaded years ago in [amanda-spam](<https://matrix.to/#/!CzvdIdUQXgUjDVKxeU:cadence.moe/$zpzx6ABetMl8BrpsFbdZ7AefVU1Y_-t97bJRJM2JyW1?via=cadence.moe>)",
avatar_url: undefined
}]
}
)
t.equal(called, 1, "getEvent should be called once")
})
test("event2message: mentioning events falls back to original link when the channel-guild isn't in cache", async t => {
t.equal(select("channel_room", "channel_id", {room_id: "!tnedrGVYKFNUdnegvf:tchncs.de"}).pluck().get(), "489237891895768942", "consistency check: this channel-room needs to be in the database for the test to make sense")
t.equal(discord.channels.get("489237891895768942"), undefined, "consistency check: this channel needs to not be in client cache for the test to make sense")
t.deepEqual(
await eventToMessage({
content: {
msgtype: "m.text",
body: "wrong body",
format: "org.matrix.custom.html",
formatted_body: `it was uploaded years ago in <a href="https://matrix.to/#/!tnedrGVYKFNUdnegvf:tchncs.de/$zpzx6ABetMl8BrpsFbdZ7AefVU1Y_-t97bJRJM2JyW2?via=tchncs.de">ex-room-doesnt-exist-any-more</a>`
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOX",
origin_server_ts: 1688301929913,
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
sender: "@cadence:cadence.moe",
type: "m.room.message",
unsigned: {
age: 405299
}
}, {}, {
api: {
/* c8 skip next 3 */
async getEvent() {
t.fail("getEvent should not be called because it should quit early due to no channel-guild")
}
}
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "cadence [they]",
content: "it was uploaded years ago in [ex-room-doesnt-exist-any-more](<https://matrix.to/#/!tnedrGVYKFNUdnegvf:tchncs.de/$zpzx6ABetMl8BrpsFbdZ7AefVU1Y_-t97bJRJM2JyW2?via=tchncs.de>)",
avatar_url: undefined
}]
}
)
})
test("event2message: link to event in an unknown room", async t => { test("event2message: link to event in an unknown room", async t => {
t.deepEqual( t.deepEqual(
await eventToMessage({ await eventToMessage({
@ -2385,6 +2638,79 @@ test("event2message: stickers work", async t => {
) )
}) })
test("event2message: stickers fetch mimetype from server when mimetype not provided", async t => {
let called = 0
t.deepEqual(
await eventToMessage({
type: "m.sticker",
sender: "@cadence:cadence.moe",
content: {
body: "YESYESYES",
url: "mxc://cadence.moe/ybOWQCaXysnyUGuUCaQlTGJf"
},
event_id: "$mL-eEVWCwOvFtoOiivDP7gepvf-fTYH6_ioK82bWDI0",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}, {}, {
async fetch(url, options) {
called++
t.equal(url, "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/ybOWQCaXysnyUGuUCaQlTGJf")
t.equal(options.method, "HEAD")
return {
status: 200,
headers: new Map([
["content-type", "image/gif"]
])
}
}
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "cadence [they]",
content: "",
avatar_url: undefined,
attachments: [{id: "0", filename: "YESYESYES.gif"}],
pendingFiles: [{name: "YESYESYES.gif", url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/ybOWQCaXysnyUGuUCaQlTGJf"}]
}]
}
)
t.equal(called, 1, "sticker headers should be fetched")
})
test("event2message: stickers with unknown mimetype are not allowed", async t => {
let called = 0
try {
await eventToMessage({
type: "m.sticker",
sender: "@cadence:cadence.moe",
content: {
body: "something",
url: "mxc://cadence.moe/ybOWQCaXysnyUGuUCaQlTGJe"
},
event_id: "$mL-eEVWCwOvFtoOiivDP7gepvf-fTYH6_ioK82bWDI0",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}, {}, {
async fetch(url, options) {
called++
t.equal(url, "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/ybOWQCaXysnyUGuUCaQlTGJe")
t.equal(options.method, "HEAD")
return {
status: 404,
headers: new Map([
["content-type", "application/json"]
])
}
}
})
/* c8 ignore next */
t.fail("should throw an error")
} catch (e) {
t.match(e.toString(), "mimetype")
}
})
test("event2message: static emojis work", async t => { test("event2message: static emojis work", async t => {
t.deepEqual( t.deepEqual(
await eventToMessage({ await eventToMessage({

View File

@ -72,7 +72,7 @@ class MatrixStringBuilder {
/** /**
* @param {string} body * @param {string} body
* @param {string} formattedBody * @param {string} [formattedBody]
* @param {any} [condition] * @param {any} [condition]
*/ */
add(body, formattedBody, condition = true) { add(body, formattedBody, condition = true) {

View File

@ -1,7 +1,10 @@
// @ts-check // @ts-check
const e = new Error("Custom error")
const {test} = require("supertape") const {test} = require("supertape")
const {eventSenderIsFromDiscord, getEventIDHash} = require("./utils") const {eventSenderIsFromDiscord, getEventIDHash, MatrixStringBuilder} = require("./utils")
const util = require("util")
test("sender type: matrix user", t => { test("sender type: matrix user", t => {
t.notOk(eventSenderIsFromDiscord("@cadence:cadence.moe")) t.notOk(eventSenderIsFromDiscord("@cadence:cadence.moe"))
@ -23,3 +26,51 @@ test("event hash: hash is the same each time", t => {
test("event hash: hash is different for different inputs", t => { test("event hash: hash is different for different inputs", t => {
t.notEqual(getEventIDHash("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe1"), getEventIDHash("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe2")) t.notEqual(getEventIDHash("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe1"), getEventIDHash("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe2"))
}) })
test("MatrixStringBuilder: add, addLine, add same text", t => {
const gatewayMessage = {t: "MY_MESSAGE", d: {display: "Custom message data"}}
let stackLines = e.stack?.split("\n")
const builder = new MatrixStringBuilder()
builder.addLine("\u26a0 Bridged event from Discord not delivered", "\u26a0 <strong>Bridged event from Discord not delivered</strong>")
builder.addLine(`Gateway event: ${gatewayMessage.t}`)
builder.addLine(e.toString())
if (stackLines) {
stackLines = stackLines.slice(0, 2)
stackLines[1] = stackLines[1].replace(/\\/g, "/").replace(/(\s*at ).*(\/m2d\/)/, "$1.$2")
builder.addLine(`Error trace:`, `<details><summary>Error trace</summary>`)
builder.add(`\n${stackLines.join("\n")}`, `<pre>${stackLines.join("\n")}</pre></details>`)
}
builder.addLine("", `<details><summary>Original payload</summary><pre>${util.inspect(gatewayMessage.d, false, 4, false)}</pre></details>`)
t.deepEqual(builder.get(), {
msgtype: "m.text",
body: "\u26a0 Bridged event from Discord not delivered"
+ "\nGateway event: MY_MESSAGE"
+ "\nError: Custom error"
+ "\nError trace:"
+ "\nError: Custom error"
+ "\n at ./m2d/converters/utils.test.js:3:11)\n",
format: "org.matrix.custom.html",
formatted_body: "\u26a0 <strong>Bridged event from Discord not delivered</strong>"
+ "<br>Gateway event: MY_MESSAGE"
+ "<br>Error: Custom error"
+ "<br><details><summary>Error trace</summary><pre>Error: Custom error\n at ./m2d/converters/utils.test.js:3:11)</pre></details>"
+ `<details><summary>Original payload</summary><pre>{ display: 'Custom message data' }</pre></details>`
})
})
test("MatrixStringBuilder: complete code coverage", t => {
const builder = new MatrixStringBuilder()
builder.add("Line 1")
builder.addParagraph("Line 2")
builder.add("Line 3")
builder.addParagraph("Line 4")
t.deepEqual(builder.get(), {
msgtype: "m.text",
body: "Line 1\n\nLine 2Line 3\n\nLine 4",
format: "org.matrix.custom.html",
formatted_body: "Line 1<p>Line 2</p>Line 3<p>Line 4</p>"
})
})

View File

@ -495,6 +495,47 @@ module.exports = {
attachments: [], attachments: [],
guild_id: "112760669178241024" guild_id: "112760669178241024"
}, },
unknown_room_mention: {
type: 0,
tts: false,
timestamp: "2023-07-10T20:04:25.939000+00:00",
referenced_message: null,
pinned: false,
nonce: "1128054139385806848",
mentions: [],
mention_roles: [],
mention_everyone: false,
member: {
roles: [],
premium_since: null,
pending: false,
nick: null,
mute: false,
joined_at: "2015-11-11T09:55:40.321000+00:00",
flags: 0,
deaf: false,
communication_disabled_until: null,
avatar: null
},
id: "1128054143064494233",
flags: 0,
embeds: [],
edited_timestamp: null,
content: "<#555>",
components: [],
channel_id: "266767590641238027",
author: {
username: "kumaccino",
public_flags: 128,
id: "113340068197859328",
global_name: "kumaccino",
discriminator: "0",
avatar_decoration: null,
avatar: "b48302623a12bc7c59a71328f72ccb39"
},
attachments: [],
guild_id: "112760669178241024"
},
simple_role_mentions: { simple_role_mentions: {
id: "1162374402785153106", id: "1162374402785153106",
type: 0, type: 0,
@ -1204,6 +1245,101 @@ module.exports = {
attachments: [], attachments: [],
guild_id: "112760669178241024" guild_id: "112760669178241024"
}, },
reply_with_video: {
id: "1197621094983676007",
type: 19,
content: "",
channel_id: "112760669178241024",
author: {
id: "772659086046658620",
username: "cadence.worm",
avatar: "4b5c4b28051144e4c111f0113a0f1cf1",
discriminator: "0",
public_flags: 0,
premium_type: 2,
flags: 0,
banner: null,
accent_color: null,
global_name: "cadence",
avatar_decoration_data: null,
banner_color: null
},
attachments: [
{
id: "1197621094786531358",
filename: "Ins_1960637570.mp4",
size: 860559,
url: "https://cdn.discordapp.com/attachments/112760669178241024/1197621094786531358/Ins_1960637570.mp4?ex=65bbee8f&is=65a9798f&hm=ae14f7824c3d526c5e11c162e012e1ee405fd5776e1e9302ed80ccd86503cfda&",
proxy_url: "https://media.discordapp.net/attachments/112760669178241024/1197621094786531358/Ins_1960637570.mp4?ex=65bbee8f&is=65a9798f&hm=ae14f7824c3d526c5e11c162e012e1ee405fd5776e1e9302ed80ccd86503cfda&",
width: 480,
height: 854,
content_type: "video/mp4",
placeholder: "wvcFBABod4gIl3enl6iqfM+s+A==",
placeholder_version: 1
}
],
embeds: [],
mentions: [
{
id: "114147806469554185",
username: "extremity",
avatar: "e0394d500407a8fa93774e1835b8b03a",
discriminator: "0",
public_flags: 768,
premium_type: 2,
flags: 768,
banner: null,
accent_color: null,
global_name: "Extremity",
avatar_decoration_data: null,
banner_color: null
}
],
mention_roles: [],
pinned: false,
mention_everyone: false,
tts: false,
timestamp: "2024-01-18T19:18:39.768000+00:00",
edited_timestamp: null,
flags: 0,
components: [],
message_reference: {
channel_id: "112760669178241024",
message_id: "1197612733600895076",
guild_id: "112760669178241024"
},
referenced_message: {
id: "1197612733600895076",
type: 0,
content: 'deadpicord "extremity you wake up at 4am"',
channel_id: "112760669178241024",
author: {
id: "114147806469554185",
username: "extremity",
avatar: "e0394d500407a8fa93774e1835b8b03a",
discriminator: "0",
public_flags: 768,
premium_type: 2,
flags: 768,
banner: null,
accent_color: null,
global_name: "Extremity",
avatar_decoration_data: null,
banner_color: null
},
attachments: [],
embeds: [],
mentions: [],
mention_roles: [],
pinned: false,
mention_everyone: false,
tts: false,
timestamp: "2024-01-18T18:45:26.259000+00:00",
edited_timestamp: null,
flags: 0,
components: []
}
},
simple_reply_to_reply_in_thread: { simple_reply_to_reply_in_thread: {
type: 19, type: 19,
tts: false, tts: false,

View File

@ -11,7 +11,8 @@ INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom
('1162005314908999790', '!FuDZhlOAtqswlyxzeR:cadence.moe', 'Hey.', NULL, '1100319550446252084', NULL), ('1162005314908999790', '!FuDZhlOAtqswlyxzeR:cadence.moe', 'Hey.', NULL, '1100319550446252084', NULL),
('297272183716052993', '!rEOspnYqdOalaIFniV:cadence.moe', 'general', NULL, NULL, NULL), ('297272183716052993', '!rEOspnYqdOalaIFniV:cadence.moe', 'general', NULL, NULL, NULL),
('122155380120748034', '!cqeGDbPiMFAhLsqqqq:cadence.moe', 'cadences-mind', 'coding', NULL, NULL), ('122155380120748034', '!cqeGDbPiMFAhLsqqqq:cadence.moe', 'cadences-mind', 'coding', NULL, NULL),
('176333891320283136', '!qzDBLKlildpzrrOnFZ:cadence.moe', '🌈丨davids-horse_she-took-the-kids', 'wonderland', NULL, 'mxc://cadence.moe/EVvrSkKIRONHjtRJsMLmHWLS'); ('176333891320283136', '!qzDBLKlildpzrrOnFZ:cadence.moe', '🌈丨davids-horse_she-took-the-kids', 'wonderland', NULL, 'mxc://cadence.moe/EVvrSkKIRONHjtRJsMLmHWLS'),
('489237891895768942', '!tnedrGVYKFNUdnegvf:tchncs.de', 'ex-room-doesnt-exist-any-more', NULL, NULL, NULL);
INSERT INTO sim (user_id, sim_name, localpart, mxid) VALUES INSERT INTO sim (user_id, sim_name, localpart, mxid) VALUES
('0', 'bot', '_ooye_bot', '@_ooye_bot:cadence.moe'), ('0', 'bot', '_ooye_bot', '@_ooye_bot:cadence.moe'),
@ -42,7 +43,8 @@ INSERT INTO message_channel (message_id, channel_id) VALUES
('1145688633186193481', '1100319550446252084'), ('1145688633186193481', '1100319550446252084'),
('1162005526675193909', '1162005314908999790'), ('1162005526675193909', '1162005314908999790'),
('1162625810109317170', '497161350934560778'), ('1162625810109317170', '497161350934560778'),
('1158842413025071135', '176333891320283136'); ('1158842413025071135', '176333891320283136'),
('1197612733600895076', '112760669178241024');
INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES
('$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg', 'm.room.message', 'm.text', '1126786462646550579', 0, 0, 1), ('$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg', 'm.room.message', 'm.text', '1126786462646550579', 0, 0, 1),
@ -67,7 +69,8 @@ INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part
('$nUM-ABBF8KdnvrhXwLlYAE9dgDl_tskOvvcNIBrtsVo', 'm.room.message', 'm.text', '1162005526675193909', 0, 0, 0), ('$nUM-ABBF8KdnvrhXwLlYAE9dgDl_tskOvvcNIBrtsVo', 'm.room.message', 'm.text', '1162005526675193909', 0, 0, 0),
('$0wEdIP8fhTq-P68xwo_gyUw-Zv0KA2aS2tfhdFSrLZc', 'm.room.message', 'm.text', '1162625810109317170', 1, 1, 1), ('$0wEdIP8fhTq-P68xwo_gyUw-Zv0KA2aS2tfhdFSrLZc', 'm.room.message', 'm.text', '1162625810109317170', 1, 1, 1),
('$zJFjTvNn1w_YqpR4o4ISKUFisNRgZcu1KSMI_LADPVQ', 'm.room.message', 'm.notice', '1162625810109317170', 1, 0, 1), ('$zJFjTvNn1w_YqpR4o4ISKUFisNRgZcu1KSMI_LADPVQ', 'm.room.message', 'm.notice', '1162625810109317170', 1, 0, 1),
('$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo', 'm.room.message', 'm.text', '1158842413025071135', 0, 0, 1); ('$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo', 'm.room.message', 'm.text', '1158842413025071135', 0, 0, 1),
('$7tJoMw1h44n2gxgLUE1T_YinGrLbK0x-TDY1z6M7GBw', 'm.room.message', 'm.text', '1197612733600895076', 0, 0, 1);
INSERT INTO file (discord_url, mxc_url) VALUES INSERT INTO file (discord_url, mxc_url) VALUES
('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'), ('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'),
@ -84,7 +87,8 @@ INSERT INTO file (discord_url, mxc_url) VALUES
('https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg', 'mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR'), ('https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg', 'mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR'),
('https://cdn.discordapp.com/emojis/1125827250609201255.png', 'mxc://cadence.moe/pgdGTxAyEltccRgZKxdqzHHP'), ('https://cdn.discordapp.com/emojis/1125827250609201255.png', 'mxc://cadence.moe/pgdGTxAyEltccRgZKxdqzHHP'),
('https://cdn.discordapp.com/avatars/320067006521147393/5fc4ad85c1ea876709e9a7d3374a78a1.png?size=1024', 'mxc://cadence.moe/JPzSmALLirnIprlSMKohSSoX'), ('https://cdn.discordapp.com/avatars/320067006521147393/5fc4ad85c1ea876709e9a7d3374a78a1.png?size=1024', 'mxc://cadence.moe/JPzSmALLirnIprlSMKohSSoX'),
('https://cdn.discordapp.com/emojis/288858540888686602.png', 'mxc://cadence.moe/mwZaCtRGAQQyOItagDeCocEO'); ('https://cdn.discordapp.com/emojis/288858540888686602.png', 'mxc://cadence.moe/mwZaCtRGAQQyOItagDeCocEO'),
('https://cdn.discordapp.com/attachments/112760669178241024/1197621094786531358/Ins_1960637570.mp4', 'mxc://cadence.moe/kMqLycqMURhVpwleWkmASpnU');
INSERT INTO emoji (emoji_id, name, animated, mxc_url) VALUES INSERT INTO emoji (emoji_id, name, animated, mxc_url) VALUES
('230201364309868544', 'hippo', 0, 'mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC'), ('230201364309868544', 'hippo', 0, 'mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC'),
@ -101,7 +105,8 @@ INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES
('!fGgIymcYWOqjbSRUdV:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU'), ('!fGgIymcYWOqjbSRUdV:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU'),
('!BnKuBPCvyfOkhcUjEu:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU'), ('!BnKuBPCvyfOkhcUjEu:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU'),
('!maggESguZBqGBZtSnr:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU'), ('!maggESguZBqGBZtSnr:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU'),
('!CzvdIdUQXgUjDVKxeU:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU'); ('!CzvdIdUQXgUjDVKxeU:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU'),
('!cBxtVRxDlZvSVhJXVK:cadence.moe', '@Milan:tchncs.de', 'Milan', NULL);
INSERT INTO lottie (sticker_id, mxc_url) VALUES INSERT INTO lottie (sticker_id, mxc_url) VALUES
('860171525772279849', 'mxc://cadence.moe/ZtvvVbwMIdUZeovWVyGVFCeR'); ('860171525772279849', 'mxc://cadence.moe/ZtvvVbwMIdUZeovWVyGVFCeR');