diff --git a/.c8rc.json b/.c8rc.json new file mode 100644 index 0000000..5847f1a --- /dev/null +++ b/.c8rc.json @@ -0,0 +1,8 @@ +{ + "watermarks": { + "statements": [60, 100], + "lines": [60, 100], + "functions": [60, 100], + "branches": [60, 100] + } +} diff --git a/d2m/converters/edit-to-changes.js b/d2m/converters/edit-to-changes.js index f86921c..08f787c 100644 --- a/d2m/converters/edit-to-changes.js +++ b/d2m/converters/edit-to-changes.js @@ -93,7 +93,11 @@ async function editToChanges(message, guild, api) { // We can choose an existing event to promote. Bigger order is better. const order = e => 2*+(e.event_type === "m.room.message") + 1*+(e.event_subtype === "m.text") eventsToReplace.sort((a, b) => order(b) - order(a)) - promotions.push({column, eventID: eventsToReplace[0].old.event_id}) + if (column === "part") { + promotions.push({column, eventID: eventsToReplace[0].old.event_id}) // part should be the first one + } else { + promotions.push({column, eventID: eventsToReplace[eventsToReplace.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}) diff --git a/d2m/converters/edit-to-changes.test.js b/d2m/converters/edit-to-changes.test.js index 7c29787..04f5568 100644 --- a/d2m/converters/edit-to-changes.test.js +++ b/d2m/converters/edit-to-changes.test.js @@ -175,3 +175,63 @@ test("edit2changes: edit of reply to skull webp attachment with content", async } }]) }) + +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" + } + ]) +}) diff --git a/d2m/converters/lottie.js b/d2m/converters/lottie.js index f802e35..6d13f70 100644 --- a/d2m/converters/lottie.js +++ b/d2m/converters/lottie.js @@ -1,5 +1,6 @@ // @ts-check +const assert = require("assert") const stream = require("stream") const {PNG} = require("pngjs") @@ -27,7 +28,7 @@ async function convert(text) { /** @type RlottieWasm */ const rh = new r.RlottieWasm() const status = rh.load(text) - if (!status) throw new Error(`Rlottie unable to load ${text.length} byte data file.`) + assert(status, `Rlottie unable to load ${text.length} byte data file.`) const rendered = rh.render(0, SIZE, SIZE) let png = new PNG({ width: SIZE, @@ -38,11 +39,9 @@ async function convert(text) { inputHasAlpha: true, }) png.data = Buffer.from(rendered) - // The transform stream is necessary because PNG requires me to pipe it somewhere before this event loop ends - const resultStream = png.pack() - const p = new stream.PassThrough() - resultStream.pipe(p) - return p + // 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 diff --git a/d2m/converters/message-to-event.js b/d2m/converters/message-to-event.js index 3e121bd..89edacc 100644 --- a/d2m/converters/message-to-event.js +++ b/d2m/converters/message-to-event.js @@ -78,10 +78,14 @@ function getDiscordParseCallbacks(message, guild, useHTML) { return `@${role.name}:` } }, - everyone: node => - "@room", - here: node => - "@here" + everyone: () => { + if (message.mention_everyone) return "@room" + return "@everyone" + }, + here: () => { + if (message.mention_everyone) return "@room" + return "@here" + } } } @@ -199,6 +203,7 @@ async function attachmentToEvent(mentions, attachment) { 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: diff --git a/d2m/converters/user-to-mxid.js b/d2m/converters/user-to-mxid.js index 4d53a9c..6b3bbbd 100644 --- a/d2m/converters/user-to-mxid.js +++ b/d2m/converters/user-to-mxid.js @@ -60,7 +60,7 @@ function userToSimName(user) { // 1. Is sim user already registered? const existing = select("sim", "sim_name", {user_id: user.id}).pluck().get() - if (existing) return existing + 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.) diff --git a/m2d/actions/emoji-sheet.js b/m2d/actions/emoji-sheet.js new file mode 100644 index 0000000..5f96297 --- /dev/null +++ b/m2d/actions/emoji-sheet.js @@ -0,0 +1,36 @@ +// @ts-check + +const assert = require("assert") +const fetch = require("node-fetch").default + +const utils = require("../converters/utils") +const {sync} = require("../../passthrough") + +/** @type {import("../converters/emoji-sheet")} */ +const emojiSheetConverter = sync.require("../converters/emoji-sheet") + +/** + * Downloads the emoji from the web and converts to uncompressed PNG data. + * @param {string} mxc a single mxc:// URL + * @returns {Promise} uncompressed PNG data, or undefined if the downloaded emoji is not valid + */ +async function getAndConvertEmoji(mxc) { + const abortController = new AbortController() + + const url = utils.getPublicUrlForMxc(mxc) + assert(url) + + /** @type {import("node-fetch").Response} */ + // If it turns out to be a GIF, we want to abandon the connection without downloading the whole thing. + // If we were using connection pooling, we would be forced to download the entire GIF. + // So we set no agent to ensure we are not connection pooling. + // @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}) + return emojiSheetConverter.convertImageStream(res.body, () => { + abortController.abort() + res.body.pause() + res.body.emit("end") + }) +} + +module.exports.getAndConvertEmoji = getAndConvertEmoji diff --git a/m2d/actions/send-event.js b/m2d/actions/send-event.js index 9067f9b..6c0448f 100644 --- a/m2d/actions/send-event.js +++ b/m2d/actions/send-event.js @@ -17,8 +17,8 @@ const eventToMessage = sync.require("../converters/event-to-message") const api = sync.require("../../matrix/api") /** @type {import("../../d2m/actions/register-user")} */ const registerUser = sync.require("../../d2m/actions/register-user") -/** @type {import("../converters/emoji-sheet")} */ -const emojiSheet = sync.require("../converters/emoji-sheet") +/** @type {import("../actions/emoji-sheet")} */ +const emojiSheet = sync.require("../actions/emoji-sheet") /** * @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[], pendingFiles?: ({name: string, url: string} | {name: string, url: string, key: string, iv: string} | {name: string, buffer: Buffer | Readable})[]}} message diff --git a/m2d/converters/emoji-sheet.js b/m2d/converters/emoji-sheet.js index 4b8c321..73c7e34 100644 --- a/m2d/converters/emoji-sheet.js +++ b/m2d/converters/emoji-sheet.js @@ -1,13 +1,10 @@ // @ts-check const assert = require("assert").strict -const fs = require("fs") const {pipeline} = require("stream").promises const sharp = require("sharp") const {GIFrame} = require("giframe") const {PNG} = require("pngjs") -const utils = require("./utils") -const fetch = require("node-fetch").default const streamMimeType = require("stream-mime-type") const SIZE = 48 @@ -50,49 +47,6 @@ async function compositeMatrixEmojis(mxcs, mxcDownloader) { return output.data } -/** - * Downloads the emoji from the web and converts to uncompressed PNG data. - * @param {string} mxc a single mxc:// URL - * @returns {Promise} uncompressed PNG data, or undefined if the downloaded emoji is not valid - */ -async function getAndConvertEmoji(mxc) { - const abortController = new AbortController() - - const url = utils.getPublicUrlForMxc(mxc) - assert(url) - - /** @type {import("node-fetch").Response} */ - // If it turns out to be a GIF, we want to abandon the connection without downloading the whole thing. - // If we were using connection pooling, we would be forced to download the entire GIF. - // So we set no agent to ensure we are not connection pooling. - // @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}) - return convertImageStream(res.body, () => { - abortController.abort() - res.body.pause() - res.body.emit("end") - }) -} - -/** - * MOCK: Gets the emoji from the filesystem and converts to uncompressed PNG data. - * @param {string} mxc a single mxc:// URL - * @returns {Promise} uncompressed PNG data, or undefined if the downloaded emoji is not valid -*/ -async function _mockGetAndConvertEmoji(mxc) { - const id = mxc.match(/\/([^./]*)$/)?.[1] - let s - if (fs.existsSync(`test/res/${id}.png`)) { - s = fs.createReadStream(`test/res/${id}.png`) - } else { - s = fs.createReadStream(`test/res/${id}.gif`) - } - return convertImageStream(s, () => { - s.pause() - s.emit("end") - }) -} - /** * @param {import("node-fetch").Response["body"]} streamIn * @param {() => any} stopStream @@ -156,6 +110,4 @@ async function convertImageStream(streamIn, stopStream) { } module.exports.compositeMatrixEmojis = compositeMatrixEmojis -module.exports.getAndConvertEmoji = getAndConvertEmoji -module.exports._mockGetAndConvertEmoji = _mockGetAndConvertEmoji -module.exports._convertImageStream = convertImageStream +module.exports.convertImageStream = convertImageStream diff --git a/m2d/converters/emoji-sheet.test.js b/m2d/converters/emoji-sheet.test.js index 6096d0c..de0aeef 100644 --- a/m2d/converters/emoji-sheet.test.js +++ b/m2d/converters/emoji-sheet.test.js @@ -1,6 +1,5 @@ -const assert = require("assert").strict const {test} = require("supertape") -const {_convertImageStream} = require("./emoji-sheet") +const {convertImageStream} = require("./emoji-sheet") const fs = require("fs") const {Transform} = require("stream").Transform @@ -33,7 +32,7 @@ async function runSingleTest(t, path, totalSize, sizeCheck) { const file = fs.createReadStream(path) const meter = new Meter() const p = file.pipe(meter) - const result = await _convertImageStream(p, () => { + const result = await convertImageStream(p, () => { file.pause() file.emit("end") }) diff --git a/m2d/converters/event-to-message.test.js b/m2d/converters/event-to-message.test.js index 2a88734..7471f6a 100644 --- a/m2d/converters/event-to-message.test.js +++ b/m2d/converters/event-to-message.test.js @@ -1,10 +1,11 @@ const assert = require("assert").strict +const fs = require("fs") const {test} = require("supertape") const {eventToMessage} = require("./event-to-message") -const {_mockGetAndConvertEmoji} = require("./emoji-sheet") +const {convertImageStream} = require("./emoji-sheet") const data = require("../../test/data") const {MatrixServerError} = require("../../matrix/mreq") -const {db, select, discord} = require("../../passthrough") +const {select, discord} = require("../../passthrough") /* c8 ignore next 7 */ function slow() { @@ -47,6 +48,25 @@ function sameFirstContentAndWhitespace(t, a, b) { t.equal(a2, b2) } +/** + * MOCK: Gets the emoji from the filesystem and converts to uncompressed PNG data. + * @param {string} mxc a single mxc:// URL + * @returns {Promise} uncompressed PNG data, or undefined if the downloaded emoji is not valid +*/ +async function mockGetAndConvertEmoji(mxc) { + const id = mxc.match(/\/([^./]*)$/)?.[1] + let s + if (fs.existsSync(`test/res/${id}.png`)) { + s = fs.createReadStream(`test/res/${id}.png`) + } else { + s = fs.createReadStream(`test/res/${id}.gif`) + } + return convertImageStream(s, () => { + s.pause() + s.emit("end") + }) +} + test("event2message: body is used when there is no formatted_body", async t => { t.deepEqual( await eventToMessage({ @@ -3535,7 +3555,7 @@ slow()("event2message: unknown emoji at the end is reuploaded as a sprite sheet" }, event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" - }, {}, {mxcDownloader: _mockGetAndConvertEmoji}) + }, {}, {mxcDownloader: mockGetAndConvertEmoji}) const testResult = { content: messages.messagesToSend[0].content, fileName: messages.messagesToSend[0].pendingFiles[0].name, @@ -3560,7 +3580,7 @@ slow()("event2message: known emoji from an unreachable server at the end is reup }, event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" - }, {}, {mxcDownloader: _mockGetAndConvertEmoji}) + }, {}, {mxcDownloader: mockGetAndConvertEmoji}) const testResult = { content: messages.messagesToSend[0].content, fileName: messages.messagesToSend[0].pendingFiles[0].name, @@ -3585,7 +3605,7 @@ slow()("event2message: known and unknown emojis in the end are reuploaded as a s }, event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" - }, {}, {mxcDownloader: _mockGetAndConvertEmoji}) + }, {}, {mxcDownloader: mockGetAndConvertEmoji}) const testResult = { content: messages.messagesToSend[0].content, fileName: messages.messagesToSend[0].pendingFiles[0].name, @@ -3610,7 +3630,7 @@ slow()("event2message: all unknown chess emojis are reuploaded as a sprite sheet }, event_id: "$Me6iE8C8CZyrDEOYYrXKSYRuuh_25Jj9kZaNrf7LKr4", room_id: "!maggESguZBqGBZtSnr:cadence.moe" - }, {}, {mxcDownloader: _mockGetAndConvertEmoji}) + }, {}, {mxcDownloader: mockGetAndConvertEmoji}) const testResult = { content: messages.messagesToSend[0].content, fileName: messages.messagesToSend[0].pendingFiles[0].name, diff --git a/matrix/kstate.test.js b/matrix/kstate.test.js index 7ab52db..239de75 100644 --- a/matrix/kstate.test.js +++ b/matrix/kstate.test.js @@ -1,3 +1,4 @@ +const assert = require("assert") const {kstateToState, stateToKState, diffKState, kstateStripConditionals} = require("./kstate") const {test} = require("supertape") @@ -162,3 +163,29 @@ test("diffKState: power levels are mixed together", t => { }) t.notDeepEqual(original, result) }) + +test("diffKState: cannot merge power levels if original power levels are missing", t => { + const original = {} + assert.throws(() => + diffKState(original, { + "m.room.power_levels/": { + "events": { + "m.room.avatar": 0 + } + } + }) + , /original power level data is missing/) + t.pass() +}) + +test("diffKState: kstate keys must contain a slash separator", t => { + assert.throws(() => + diffKState({ + "m.room.name/": {name: "test name"}, + }, { + "m.room.name/": {name: "test name"}, + "new": {a: 2} + }) + , /does not contain a slash separator/) + t.pass() +}) diff --git a/package.json b/package.json index 40b81ae..3a9a12c 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,6 @@ "addbot": "node addbot.js", "test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap test/test.js | tap-dot", "test-slow": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js -- --slow | tap-dot", - "cover": "c8 --skip-full -x db/migrations -x matrix/file.js -x matrix/api.js -x matrix/mreq.js -r html -r text supertape --no-check-assertions-count --format fail test/test.js -- --slow" + "cover": "c8 --skip-full -x db/migrations -x matrix/file.js -x matrix/api.js -x matrix/mreq.js -x d2m/converters/rlottie-wasm.js -r html -r text supertape --no-check-assertions-count --format fail --no-worker test/test.js -- --slow" } } diff --git a/test/data.js b/test/data.js index 8991e7b..2d0f795 100644 --- a/test/data.js +++ b/test/data.js @@ -2662,6 +2662,88 @@ module.exports = { name: "pomu puff" }] }, + edited_content_with_sticker_and_attachments_but_all_parts_equal_0: { + id: "1106366167788044451", + type: 0, + content: "only the content can be edited", + channel_id: "122155380120748034", + author: { + id: "113340068197859328", + username: "Cookie 🍪", + global_name: null, + display_name: null, + avatar: "b48302623a12bc7c59a71328f72ccb39", + discriminator: "7766", + public_flags: 128, + avatar_decoration: null + }, + attachments: [{ + id: "1106366167486038016", + filename: "image.png", + size: 127373, + url: "https://cdn.discordapp.com/attachments/122155380120748034/1106366167486038016/image.png", + proxy_url: "https://media.discordapp.net/attachments/122155380120748034/1106366167486038016/image.png", + width: 333, + height: 287, + content_type: "image/png" + }], + embeds: [], + mentions: [], + mention_roles: [], + pinned: false, + mention_everyone: false, + tts: false, + timestamp: "2023-05-11T23:44:09.690000+00:00", + edited_timestamp: "2023-05-11T23:44:19.690000+00:00", + flags: 0, + components: [], + sticker_items: [{ + id: "1106323941183717586", + format_type: 1, + name: "pomu puff" + }] + }, + edited_content_with_sticker_and_attachments_but_all_parts_equal_1: { + id: "1106366167788044452", + type: 0, + content: "only the content can be edited", + channel_id: "122155380120748034", + author: { + id: "113340068197859328", + username: "Cookie 🍪", + global_name: null, + display_name: null, + avatar: "b48302623a12bc7c59a71328f72ccb39", + discriminator: "7766", + public_flags: 128, + avatar_decoration: null + }, + attachments: [{ + id: "1106366167486038016", + filename: "image.png", + size: 127373, + url: "https://cdn.discordapp.com/attachments/122155380120748034/1106366167486038016/image.png", + proxy_url: "https://media.discordapp.net/attachments/122155380120748034/1106366167486038016/image.png", + width: 333, + height: 287, + content_type: "image/png" + }], + embeds: [], + mentions: [], + mention_roles: [], + pinned: false, + mention_everyone: false, + tts: false, + timestamp: "2023-05-11T23:44:09.690000+00:00", + edited_timestamp: "2023-05-11T23:44:19.690000+00:00", + flags: 0, + components: [], + sticker_items: [{ + id: "1106323941183717586", + format_type: 1, + name: "pomu puff" + }] + }, edit_of_reply_to_skull_webp_attachment_with_content: { type: 19, tts: false, diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql index d7dfd21..573d7a4 100644 --- a/test/ooye-test-data.sql +++ b/test/ooye-test-data.sql @@ -33,6 +33,8 @@ INSERT INTO sim_member (mxid, room_id, hashed_profile_content) VALUES INSERT INTO message_channel (message_id, channel_id) VALUES ('1106366167788044450', '122155380120748034'), +('1106366167788044451', '122155380120748034'), +('1106366167788044452', '122155380120748034'), ('1126786462646550579', '112760669178241024'), ('1128084748338741392', '112760669178241024'), ('1128084851279536279', '112760669178241024'), @@ -68,6 +70,12 @@ INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part ('$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4', 'm.room.message', 'm.text', '1106366167788044450', 0, 1, 1), ('$Ijf1MFCD39ktrNHxrA-i2aKoRWNYdAV2ZXYQeiZIgEU', 'm.room.message', 'm.image', '1106366167788044450', 1, 1, 0), ('$f9cjKiacXI9qPF_nUAckzbiKnJEi0LM399kOkhdd8f8', 'm.sticker', NULL, '1106366167788044450', 1, 0, 0), +('$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd999', 'm.room.message', 'm.text', '1106366167788044451', 0, 0, 1), +('$Ijf1MFCD39ktrNHxrA-i2aKoRWNYdAV2ZXYQeiZI999', 'm.room.message', 'm.image', '1106366167788044451', 0, 0, 1), +('$f9cjKiacXI9qPF_nUAckzbiKnJEi0LM399kOkhdd999', 'm.sticker', NULL, '1106366167788044451', 0, 0, 1), +('$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd111', 'm.room.message', 'm.text', '1106366167788044452', 1, 1, 1), +('$Ijf1MFCD39ktrNHxrA-i2aKoRWNYdAV2ZXYQeiZI111', 'm.room.message', 'm.image', '1106366167788044452', 1, 1, 1), +('$f9cjKiacXI9qPF_nUAckzbiKnJEi0LM399kOkhdd111', 'm.sticker', NULL, '1106366167788044452', 1, 1, 1), ('$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04', 'm.room.message', 'm.text', '1144865310588014633', 0, 0, 1), ('$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8', 'm.room.message', 'm.text', '1144874214311067708', 0, 0, 0), ('$7LIdiJCEqjcWUrpzWzS8TELOlFfBEe4ytgS7zn2lbSs', 'm.room.message', 'm.text', '1145688633186193479', 0, 0, 0), diff --git a/test/test.js b/test/test.js index 1e742a0..58459f6 100644 --- a/test/test.js +++ b/test/test.js @@ -49,6 +49,7 @@ passthrough.from = orm.from passthrough.select = orm.select const file = sync.require("../matrix/file") +/* c8 ignore next */ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not allowed to upload files during testing.\nURL: ${url}`) } ;(async () => { @@ -88,7 +89,7 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not {url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/AYPpqXzVJvZdzMQJGjioIQBZ", to: "test/res/AYPpqXzVJvZdzMQJGjioIQBZ.png"}, {url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/UVuzvpVUhqjiueMxYXJiFEAj", to: "test/res/UVuzvpVUhqjiueMxYXJiFEAj.png"}, {url: "https://ezgif.com/images/format-demo/butterfly.gif", to: "test/res/butterfly.gif"}, - {url: "https://ezgif.com/images/format-demo/butterfly.gif", to: "test/res/butterfly.png"}, + {url: "https://ezgif.com/images/format-demo/butterfly.png", to: "test/res/butterfly.png"}, ]) }, {timeout: 60000}) }