diff --git a/package.json b/package.json
index 4a80f22..05a4220 100644
--- a/package.json
+++ b/package.json
@@ -67,6 +67,6 @@
"addbot": "node addbot.js",
"test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker 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 -o test/coverage --skip-full -x db/migrations -x src/matrix/file.js -x src/matrix/api.js -x src/d2m/converters/rlottie-wasm.js -r html -r text supertape --no-check-assertions-count --format fail --no-worker test/test.js -- --slow"
+ "cover": "c8 -o test/coverage --skip-full -x db/migrations -x src/m2d/event-dispatcher.js -x src/matrix/file.js -x src/matrix/api.js -x src/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/src/d2m/converters/edit-to-changes.test.js b/src/d2m/converters/edit-to-changes.test.js
index dfb286c..b252175 100644
--- a/src/d2m/converters/edit-to-changes.test.js
+++ b/src/d2m/converters/edit-to-changes.test.js
@@ -271,6 +271,61 @@ test("edit2changes: promotes the text event when multiple rows have part = 1 (sh
])
})
+test("edit2changes: promotes newly sent event", async t => {
+ const {eventsToReplace, eventsToRedact, eventsToSend, promotions} = await editToChanges({
+ channel_id: "1160894080998461480",
+ id: "1404133238414376971",
+ content: "hi",
+ attachments: [{
+ id: "1157854643037163610",
+ filename: "Screenshot_20231001_034036.jpg",
+ size: 51981,
+ url: "https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg?ex=651a1faa&is=6518ce2a&hm=eb5ca80a3fa7add8765bf404aea2028a28a2341e4a62435986bcdcf058da82f3&",
+ proxy_url: "https://media.discordapp.net/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg?ex=651a1faa&is=6518ce2a&hm=eb5ca80a3fa7add8765bf404aea2028a28a2341e4a62435986bcdcf058da82f3&",
+ width: 1080,
+ height: 1170,
+ content_type: "image/jpeg"
+ }],
+ author: {
+ username: "cadence.worm",
+ global_name: "Cadence"
+ }
+ }, data.guild.general, {
+ async getEvent(roomID, eventID) {
+ t.equal(eventID, "$uUKLcTQvik5tgtTGDKuzn0Ci4zcCvSoUcYn2X7mXm9I")
+ return {
+ type: "m.room.message",
+ sender: "@_ooye_cadence.worm:cadence.moe",
+ content: {
+ msgtype: "m.text",
+ body: "hi"
+ }
+ }
+ }
+ })
+ t.deepEqual(eventsToRedact, ["$LhmoWWvYyn5_AHkfb6FaXmLI6ZOC1kloql5P40YDmIk"])
+ t.deepEqual(eventsToReplace, [])
+ t.deepEqual(eventsToSend, [{
+ $type: "m.room.message",
+ body: "Screenshot_20231001_034036.jpg",
+ external_url: "https://bridge.example.org/download/discordcdn/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg",
+ filename: "Screenshot_20231001_034036.jpg",
+ info: {
+ mimetype: "image/jpeg",
+ size: 51981,
+ w: 1080,
+ h: 1170
+ },
+ url: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR",
+ "m.mentions": {},
+ msgtype: "m.image"
+ }])
+ t.deepEqual(promotions, [
+ {column: "reaction_part", nextEvent: true}
+ ])
+ // assert that the event parts will be consistent in database after this
+})
+
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, {
diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js
index 66717ad..4ff1f4d 100644
--- a/src/d2m/converters/message-to-event.js
+++ b/src/d2m/converters/message-to-event.js
@@ -178,7 +178,7 @@ async function attachmentToEvent(mentions, attachment) {
info: {
mimetype: attachment.content_type,
size: attachment.size,
- duration: attachment.duration_secs ? Math.round(attachment.duration_secs * 1000) : undefined
+ duration: attachment.duration_secs && Math.round(attachment.duration_secs * 1000)
}
}
} else {
diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js
index 05ec5be..3303b27 100644
--- a/src/d2m/converters/message-to-event.test.js
+++ b/src/d2m/converters/message-to-event.test.js
@@ -1356,3 +1356,83 @@ test("message2event: channel links are converted even inside lists (parser post-
])
t.equal(called, 1)
})
+
+test("message2event: emoji added special message", async t => {
+ const events = await messageToEvent(data.special_message.emoji_added)
+ t.deepEqual(events, [
+ {
+ $type: "m.room.message",
+ msgtype: "m.emote",
+ body: "added a new emoji, :cx_marvelous: :cx_marvelous:",
+ format: "org.matrix.custom.html",
+ formatted_body: `added a new emoji,
:cx_marvelous:`,
+ "m.mentions": {}
+ }
+ ])
+})
+
+test("message2event: cross-room reply", async t => {
+ let called = 0
+ const events = await messageToEvent({
+ type: 19,
+ message_reference: {
+ channel_id: "1161864271370666075",
+ guild_id: "1160893336324931584",
+ message_id: "1458091145136443547"
+ },
+ referenced_message: {
+ channel_id: "1161864271370666075",
+ id: "1458091145136443547",
+ content: "",
+ attachments: [{
+ filename: "image.png",
+ id: "1456813607693193478",
+ size: 104006,
+ content_type: "image/png",
+ url: "https://cdn.discordapp.com/attachments/1160893337029586956/1458790740338409605/image.png?ex=696194ff&is=6960437f&hm=923d0ef7d1b249470be49edbc37628cc4ff8a438f0ab12f54c045578135f7050"
+ }],
+ author: {
+ username: "Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆"
+ }
+ },
+ content: "cross-room reply"
+ }, {}, {}, {api: {
+ async getEvent(roomID, eventID) {
+ called++
+ t.equal(roomID, "!mHmhQQPwXNananaOLD:cadence.moe")
+ t.equal(eventID, "$pgzCQjq_y5sy8RvWOUuoF3obNHjs8iNvt9c-odrOCPY")
+ return {
+ type: "m.room.message",
+ sender: "@cadence:cadence.moe",
+ content: {
+ "body": "image.png",
+ "info": {
+ "h": 738,
+ "mimetype": "image/png",
+ "org.matrix.msc4230.is_animated": false,
+ "size": 111189,
+ "w": 772,
+ "xyz.amorgan.blurhash": "L255Oa~qRPD$-pxuoJoLIUM{xuxu"
+ },
+ "m.mentions": {},
+ "msgtype": "m.image",
+ "url": "mxc://matrix.org/QbSujQjRLekzPknKlPsXbGDS"
+ }
+ }
+ }
+ }})
+ t.deepEqual(events, [
+ {
+ $type: "m.room.message",
+ msgtype: "m.text",
+ body: "> Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆: [Media]\n\ncross-room reply",
+ format: "org.matrix.custom.html",
+ formatted_body: `
In reply to Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆
[Media]
cross-room reply`,
+ "m.mentions": {
+ user_ids: [
+ "@cadence:cadence.moe"
+ ]
+ }
+ }
+ ])
+})
diff --git a/src/d2m/converters/user-to-mxid.js b/src/d2m/converters/user-to-mxid.js
index f7a49b1..12891c0 100644
--- a/src/d2m/converters/user-to-mxid.js
+++ b/src/d2m/converters/user-to-mxid.js
@@ -108,12 +108,7 @@ function isWebhookUserID(userID) {
* @returns {string}
*/
function webhookAuthorToSimName(author) {
- if (SPECIAL_USER_MAPPINGS.has(author.id)) {
- const error = new Error("Special users should have followed the other code path.")
- // @ts-ignore
- error.author = author
- throw error
- }
+ assert(!SPECIAL_USER_MAPPINGS.has(author.id), "Special users should have followed the other code path.")
// 1. Is sim user already registered?
const fakeUserID = webhookAuthorToFakeUserID(author)
diff --git a/src/d2m/converters/user-to-mxid.test.js b/src/d2m/converters/user-to-mxid.test.js
index 86f151b..2217a93 100644
--- a/src/d2m/converters/user-to-mxid.test.js
+++ b/src/d2m/converters/user-to-mxid.test.js
@@ -2,7 +2,7 @@ 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")
+const {userToSimName, webhookAuthorToSimName} = require("./user-to-mxid")
test("user2name: cannot create user for a webhook", async t => {
const [error] = await tryToCatch(() => userToSimName({discriminator: "0000"}))
@@ -52,3 +52,11 @@ test("user2name: includes ID if requested in config", t => {
t.equal(userToSimName({username: "f***", discriminator: "0001", id: "123456"}), "123456_f")
reg.ooye.include_user_id_in_mxid = false
})
+
+test("webhook author: can generate sim names", t => {
+ t.equal(webhookAuthorToSimName({
+ username: "Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆",
+ avatar: null,
+ id: "123"
+ }), "webhook_cadence_maid_of_creation_eye_of_clarity_empress_of_hope")
+})
diff --git a/src/discord/utils.test.js b/src/discord/utils.test.js
index 7900440..516bd2f 100644
--- a/src/discord/utils.test.js
+++ b/src/discord/utils.test.js
@@ -180,3 +180,15 @@ test("isEphemeralMessage: doesn't detect normal message", t => {
test("getPublicUrlForCdn: no-op on non-discord URL", t => {
t.equal(utils.getPublicUrlForCdn("https://cadence.moe"), "https://cadence.moe")
})
+
+test("how old: now", t => {
+ t.equal(utils.howOldUnbridgedMessage(new Date().toISOString(), new Date().toISOString()), "an unbridged message")
+})
+
+test("how old: hours", t => {
+ t.equal(utils.howOldUnbridgedMessage("2026-01-01T00:00:00", "2026-01-01T03:10:00"), "a 3-hour-old unbridged message")
+})
+
+test("how old: days", t => {
+ t.equal(utils.howOldUnbridgedMessage("2024-01-01", "2025-01-01"), "a 366-day-old unbridged message")
+})
diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js
index 13c0af1..d910852 100644
--- a/src/m2d/event-dispatcher.js
+++ b/src/m2d/event-dispatcher.js
@@ -332,7 +332,7 @@ async event => {
const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}`
if (event.state_key === bot) {
- const upgraded = await roomUpgrade.onBotMembership(event)
+ const upgraded = await roomUpgrade.onBotMembership(event, api, createRoom)
if (upgraded) return
}
@@ -406,7 +406,9 @@ sync.addTemporaryListener(as, "type:m.room.tombstone", guard("m.room.tombstone",
* @param {Ty.Event.StateOuter} event
*/
async event => {
- await roomUpgrade.onTombstone(event)
+ if (event.state_key !== "") return
+ if (!event.content.replacement_room) return
+ await roomUpgrade.onTombstone(event, api)
}))
module.exports.stringifyErrorStack = stringifyErrorStack
diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js
index e30ae6f..721c3bd 100644
--- a/src/matrix/matrix-command-handler.js
+++ b/src/matrix/matrix-command-handler.js
@@ -58,7 +58,7 @@ async function addButton(roomID, eventID, key, mxid) {
setInterval(() => {
const now = Date.now()
buttons = buttons.filter(b => now - b.created < 2*60*60*1000)
-}, 10*60*1000)
+}, 10*60*1000).unref()
/** @param {Ty.Event.Outer} event */
function onReactionAdd(event) {
diff --git a/src/matrix/room-upgrade.js b/src/matrix/room-upgrade.js
index 7f0d4e6..6c344cf 100644
--- a/src/matrix/room-upgrade.js
+++ b/src/matrix/room-upgrade.js
@@ -4,12 +4,8 @@ const assert = require("assert/strict")
const Ty = require("../types")
const {Semaphore} = require("@chriscdn/promise-semaphore")
const {tag} = require("@cloudrac3r/html-template-tag")
-const {discord, db, sync, as, select, from} = require("../passthrough")
+const {db, sync, select, from} = require("../passthrough")
-/** @type {import("./api")}) */
-const api = sync.require("./api")
-/** @type {import("../d2m/actions/create-room")}) */
-const createRoom = sync.require("../d2m/actions/create-room")
/** @type {import("./utils")}) */
const utils = sync.require("./utils")
@@ -17,11 +13,12 @@ const roomUpgradeSema = new Semaphore()
/**
* @param {Ty.Event.StateOuter} event
+ * @param {import("./api")} api
*/
-async function onTombstone(event) {
- // Validate
- if (event.state_key !== "") return
- if (!event.content.replacement_room) return
+async function onTombstone(event, api) {
+ // Preconditions (checked by event-dispatcher, enforced here)
+ assert.equal(event.state_key, "")
+ assert.ok(event.content.replacement_room)
// Set up
const oldRoomID = event.room_id
@@ -48,13 +45,21 @@ async function onTombstone(event) {
/**
* @param {Ty.Event.StateOuter} event
+ * @param {import("./api")} api
+ * @param {import("../d2m/actions/create-room")} createRoom
* @returns {Promise} whether to cancel other membership actions
*/
-async function onBotMembership(event) {
+async function onBotMembership(event, api, createRoom) {
+ // Preconditions (checked by event-dispatcher, enforced here)
+ assert.equal(event.type, "m.room.member")
+ assert.equal(event.state_key, utils.bot)
+
// Check if an upgrade is pending for this room
const newRoomID = event.room_id
const oldRoomID = select("room_upgrade_pending", "old_room_id", {new_room_id: newRoomID}).pluck().get()
if (!oldRoomID) return
+ const channelRow = from("channel_room").join("guild_space", "guild_id").where({room_id: oldRoomID}).select("space_id", "guild_id", "channel_id").get()
+ assert(channelRow) // this could only fail if the channel was unbridged or something between upgrade and joining
// Check if is join/invite
if (event.content.membership !== "invite" && event.content.membership !== "join") return
@@ -65,9 +70,6 @@ async function onBotMembership(event) {
await api.joinRoom(newRoomID)
}
- const channelRow = from("channel_room").join("guild_space", "guild_id").where({room_id: oldRoomID}).select("space_id", "guild_id", "channel_id").get()
- assert(channelRow)
-
// Remove old room from space
await api.sendState(channelRow.space_id, "m.space.child", oldRoomID, {})
// await api.sendState(oldRoomID, "m.space.parent", spaceID, {}) // keep this - the room isn't advertised but should still be grouped if opened
@@ -75,7 +77,7 @@ async function onBotMembership(event) {
// Remove declaration that old room is bridged (if able)
try {
await api.sendState(oldRoomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${channelRow.guild_id}/${channelRow.channel_id}`, {})
- } catch (e) {}
+ } catch (e) { /* c8 ignore next */ }
// Update database
db.transaction(() => {
diff --git a/src/matrix/room-upgrade.test.js b/src/matrix/room-upgrade.test.js
new file mode 100644
index 0000000..3de1a8f
--- /dev/null
+++ b/src/matrix/room-upgrade.test.js
@@ -0,0 +1,169 @@
+const {test} = require("supertape")
+const {select} = require("../passthrough")
+const {onTombstone, onBotMembership} = require("./room-upgrade")
+
+test("join upgraded room: only cares about upgrades in progress", async t => {
+ let called = 0
+ await onBotMembership({
+ type: "m.room.member",
+ state_key: "@_ooye_bot:cadence.moe",
+ room_id: "!JBxeGYnzQwLnaooOLD:cadence.moe",
+ content: {
+ membership: "invite"
+ }
+ }, {
+ /* c8 ignore next 4 */
+ async joinRoom(roomID) {
+ called++
+ throw new Error("should not join this room")
+ }
+ })
+ t.equal(called, 0)
+})
+
+test("tombstone: only cares about bridged rooms", async t => {
+ let called = 0
+ await onTombstone({
+ event_id: "$tombstone",
+ type: "m.room.tombstone",
+ state_key: "",
+ sender: "@cadence:cadence.moe",
+ origin_server_ts: 0,
+ room_id: "!imaginary:cadence.moe",
+ content: {
+ body: "This room has been replaced",
+ replacement_room: "!JBxeGYnzQwLnaooNEW:cadence.moe"
+ }
+ }, {
+ /* c8 ignore next 4 */
+ async joinRoom(roomID) {
+ called++
+ throw new Error("should not join this room")
+ }
+ })
+ t.equal(called, 0)
+})
+
+test("tombstone: joins new room and stores upgrade in database", async t => {
+ let called = 0
+ await onTombstone({
+ event_id: "$tombstone",
+ type: "m.room.tombstone",
+ state_key: "",
+ sender: "@cadence:cadence.moe",
+ origin_server_ts: 0,
+ room_id: "!JBxeGYnzQwLnaooOLD:cadence.moe",
+ content: {
+ body: "This room has been replaced",
+ replacement_room: "!JBxeGYnzQwLnaooNEW:cadence.moe"
+ }
+ }, {
+ async joinRoom(roomID) {
+ called++
+ t.equal(roomID, "!JBxeGYnzQwLnaooNEW:cadence.moe")
+ return roomID
+ }
+ })
+ t.equal(called, 1)
+ t.ok(select("room_upgrade_pending", ["old_room_id", "new_room_id"], {new_room_id: "!JBxeGYnzQwLnaooNEW:cadence.moe", old_room_id: "!JBxeGYnzQwLnaooOLD:cadence.moe"}).get())
+})
+
+test("tombstone: requests invite from upgrader if can't join room", async t => {
+ let called = 0
+ await onTombstone({
+ event_id: "$tombstone",
+ type: "m.room.tombstone",
+ state_key: "",
+ sender: "@cadence:cadence.moe",
+ origin_server_ts: 0,
+ room_id: "!JBxeGYnzQwLnaooOLD:cadence.moe",
+ content: {
+ body: "This room has been replaced",
+ replacement_room: "!JBxeGYnzQwLnaooNEW:cadence.moe"
+ }
+ }, {
+ async joinRoom(roomID) {
+ called++
+ t.equal(roomID, "!JBxeGYnzQwLnaooNEW:cadence.moe")
+ throw new Error("access denied or something")
+ },
+ async usePrivateChat(sender) {
+ called++
+ t.equal(sender, "@cadence:cadence.moe")
+ return "!private"
+ },
+ async sendEvent(roomID, type, content) {
+ called++
+ t.equal(roomID, "!private")
+ t.equal(type, "m.room.message")
+ t.deepEqual(content, {
+ msgtype: "m.text",
+ body: "You upgraded the bridged room winners. To keep bridging, I need you to invite me to the new room: https://matrix.to/#/!JBxeGYnzQwLnaooNEW:cadence.moe",
+ format: "org.matrix.custom.html",
+ formatted_body: `You upgraded the bridged room winners. To keep bridging, I need you to invite me to the new room: https://matrix.to/#/!JBxeGYnzQwLnaooNEW:cadence.moe`
+ })
+ }
+ })
+ t.equal(called, 3)
+})
+
+test("join upgraded room: only cares about invites/joins", async t => {
+ let called = 0
+ await onBotMembership({
+ type: "m.room.member",
+ state_key: "@_ooye_bot:cadence.moe",
+ room_id: "!JBxeGYnzQwLnaooNEW:cadence.moe",
+ content: {
+ membership: "leave"
+ }
+ }, {
+ /* c8 ignore next 4 */
+ async joinRoom(roomID) {
+ called++
+ throw new Error("should not join this room")
+ }
+ })
+ t.equal(called, 0)
+})
+
+test("join upgraded room: joins invited room, updates database", async t => {
+ let called = 0
+ await onBotMembership({
+ type: "m.room.member",
+ state_key: "@_ooye_bot:cadence.moe",
+ room_id: "!JBxeGYnzQwLnaooNEW:cadence.moe",
+ content: {
+ membership: "invite"
+ }
+ }, {
+ async joinRoom(roomID) {
+ called++
+ t.equal(roomID, "!JBxeGYnzQwLnaooNEW:cadence.moe")
+ return roomID
+ },
+ async sendState(roomID, type, key, content) {
+ called++
+ if (type === "m.space.child") {
+ t.equal(roomID, "!CvQMeeqXIkgedUpkzv:cadence.moe") // space
+ t.equal(key, "!JBxeGYnzQwLnaooOLD:cadence.moe")
+ t.deepEqual(content, {})
+ return "$child"
+ } else if (type === "uk.half-shot.bridge") {
+ t.equal(roomID, "!JBxeGYnzQwLnaooOLD:cadence.moe")
+ t.equal(key, "moe.cadence.ooye://discord/1345641201902288987/598707048112193536")
+ t.deepEqual(content, {})
+ return "$bridge"
+ }
+ /* c8 ignore next */
+ throw new Error(`unexpected sendState: ${roomID} - ${type}/${key}`)
+ }
+ }, {
+ async syncRoom(channelID) {
+ called++
+ t.equal(channelID, "598707048112193536")
+ }
+ })
+ t.equal(called, 4)
+ t.equal(select("channel_room", "room_id", {channel_id: "598707048112193536"}).pluck().get(), "!JBxeGYnzQwLnaooNEW:cadence.moe")
+ t.equal(select("historical_channel_room", "historical_room_index", {reference_channel_id: "598707048112193536"}).pluck().all().length, 2)
+})
diff --git a/src/web/routes/download-discord.js b/src/web/routes/download-discord.js
index 7c8c8e7..3c58a75 100644
--- a/src/web/routes/download-discord.js
+++ b/src/web/routes/download-discord.js
@@ -83,3 +83,5 @@ function defineMediaProxyHandler(domain) {
as.router.get(`/download/discordcdn/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("cdn.discordapp.com"))
as.router.get(`/download/discordmedia/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("media.discordapp.net"))
+
+module.exports._cache = cache
diff --git a/src/web/routes/download-discord.test.js b/src/web/routes/download-discord.test.js
index a801c2c..e4f4ab4 100644
--- a/src/web/routes/download-discord.test.js
+++ b/src/web/routes/download-discord.test.js
@@ -1,9 +1,10 @@
// @ts-check
+const assert = require("assert").strict
const tryToCatch = require("try-to-catch")
const {test} = require("supertape")
const {router} = require("../../../test/web")
-const {MatrixServerError} = require("../../matrix/mreq")
+const {_cache} = require("./download-discord")
test("web download discord: access denied if not a known attachment", async t => {
const [error] = await tryToCatch(() =>
@@ -12,19 +13,6 @@ test("web download discord: access denied if not a known attachment", async t =>
channel_id: "1",
attachment_id: "2",
file_name: "image.png"
- },
- snow: {
- channel: {
- async refreshAttachmentURLs(attachments) {
- if (typeof attachments === "string") attachments = [attachments]
- return {
- refreshed_urls: attachments.map(a => ({
- original: a,
- refreshed: a + `?ex=${Math.floor(Date.now() / 1000 + 3600).toString(16)}`
- }))
- }
- }
- }
}
})
)
@@ -43,7 +31,7 @@ test("web download discord: works if a known attachment", async t => {
snow: {
channel: {
async refreshAttachmentURLs(attachments) {
- if (typeof attachments === "string") attachments = [attachments]
+ assert(Array.isArray(attachments))
return {
refreshed_urls: attachments.map(a => ({
original: a,
@@ -70,7 +58,7 @@ test("web download discord: uses cache", async t => {
event,
snow: {
channel: {
- // @ts-ignore
+ /* c8 ignore next 4 */
async refreshAttachmentURLs(attachments) {
notCalled = false
throw new Error("tried to refresh when it should be in cache")
@@ -80,3 +68,30 @@ test("web download discord: uses cache", async t => {
})
t.ok(notCalled)
})
+
+test("web download discord: refreshes when cache has expired", async t => {
+ _cache.set(`https://cdn.discordapp.com/attachments/655216173696286746/1314358913482621010/image.png`, Promise.resolve(`https://cdn.discordapp.com/blah?ex=${Math.floor(new Date("2026-01-01").getTime() / 1000 + 3600).toString(16)}`))
+ let called = 0
+ await router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name", {
+ params: {
+ channel_id: "655216173696286746",
+ attachment_id: "1314358913482621010",
+ file_name: "image.png"
+ },
+ snow: {
+ channel: {
+ async refreshAttachmentURLs(attachments) {
+ called++
+ assert(Array.isArray(attachments))
+ return {
+ refreshed_urls: attachments.map(a => ({
+ original: a,
+ refreshed: a + `?ex=${Math.floor(Date.now() / 1000 + 3600).toString(16)}`
+ }))
+ }
+ }
+ }
+ }
+ })
+ t.equal(called, 1)
+})
diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js
index 6b80e9d..9037d40 100644
--- a/src/web/routes/guild.js
+++ b/src/web/routes/guild.js
@@ -73,23 +73,47 @@ function filterTo(xs, fn) {
return filtered
}
+/**
+ * @param {{type: number, parent_id?: string, position?: number}} channel
+ * @param {Map} channels
+ */
+function getPosition(channel, channels) {
+ let position = 0
+
+ // Categories always appear below un-categorised channels. Their contents can be ordered.
+ // So categories, and things in them, will have their position multiplied by a big number. The category's big number. The regular position small number sorts within the category.
+ // Categories are size 2000.
+ let foundCategory = channel
+ while (foundCategory.parent_id) {
+ foundCategory = channels.get(foundCategory.parent_id)
+ }
+ if (foundCategory.type === DiscordTypes.ChannelType.GuildCategory) position = (foundCategory.position + 1) * 2000
+
+ // Categories always appear above what they contain.
+ if (channel.type === DiscordTypes.ChannelType.GuildCategory) position -= 0.5
+
+ // Within a category, voice channels are always sorted to the bottom. The text/voice split is size 1000 each.
+ if ([DiscordTypes.ChannelType.GuildVoice, DiscordTypes.ChannelType.GuildStageVoice].includes(channel.type)) position += 1000
+
+ // Channels are manually ordered within the text/voice split.
+ if (typeof channel.position === "number") position += channel.position
+
+ // Threads appear below their channel.
+ if ([DiscordTypes.ChannelType.PublicThread, DiscordTypes.ChannelType.PrivateThread, DiscordTypes.ChannelType.AnnouncementThread].includes(channel.type)) {
+ position += 0.5
+ let parent = channels.get(channel.parent_id)
+ if (parent && parent["position"]) position += parent["position"]
+ }
+
+ return position
+}
+
/**
* @param {DiscordTypes.APIGuild} guild
* @param {Ty.R.Hierarchy[]} rooms
* @param {string[]} roles
*/
function getChannelRoomsLinks(guild, rooms, roles) {
- function getPosition(channel) {
- let position = 0
- let looking = channel
- while (looking.parent_id) {
- looking = discord.channels.get(looking.parent_id)
- position = looking.position * 1000
- }
- if (channel.position) position += channel.position
- return position
- }
-
let channelIDs = discord.guildChannelMap.get(guild.id)
assert(channelIDs)
@@ -97,7 +121,7 @@ function getChannelRoomsLinks(guild, rooms, roles) {
let linkedChannelsWithDetails = linkedChannels.map(c => ({channel: discord.channels.get(c.channel_id), ...c}))
let removedUncachedChannels = filterTo(linkedChannelsWithDetails, c => c.channel)
let linkedChannelIDs = linkedChannelsWithDetails.map(c => c.channel_id)
- linkedChannelsWithDetails.sort((a, b) => getPosition(a.channel) - getPosition(b.channel))
+ linkedChannelsWithDetails.sort((a, b) => getPosition(a.channel, discord.channels) - getPosition(b.channel, discord.channels))
let unlinkedChannelIDs = channelIDs.filter(c => !linkedChannelIDs.includes(c))
/** @type {DiscordTypes.APIGuildChannel[]} */ // @ts-ignore
@@ -107,7 +131,7 @@ function getChannelRoomsLinks(guild, rooms, roles) {
const permissions = dUtils.getPermissions(roles, guild.roles, botID, c["permission_overwrites"])
return dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel)
})
- unlinkedChannels.sort((a, b) => getPosition(a) - getPosition(b))
+ unlinkedChannels.sort((a, b) => getPosition(a, discord.channels) - getPosition(b, discord.channels))
let linkedRoomIDs = linkedChannels.map(c => c.room_id)
let unlinkedRooms = [...rooms]
@@ -239,3 +263,6 @@ as.router.post("/api/invite", defineEventHandler(async event => {
return sendRedirect(event, "/ok?msg=User has been invited.", 302)
}
}))
+
+module.exports._getPosition = getPosition
+module.exports._filterTo = filterTo
diff --git a/src/web/routes/guild.test.js b/src/web/routes/guild.test.js
index 45869b3..e0b26df 100644
--- a/src/web/routes/guild.test.js
+++ b/src/web/routes/guild.test.js
@@ -1,8 +1,10 @@
// @ts-check
+const DiscordTypes = require("discord-api-types/v10")
const tryToCatch = require("try-to-catch")
const {router, test} = require("../../../test/web")
const {MatrixServerError} = require("../../matrix/mreq")
+const {_getPosition, _filterTo} = require("./guild")
let nonce
@@ -101,12 +103,6 @@ test("web guild: can view bridged guild when logged in with discord", async t =>
managedGuilds: ["112760669178241024"]
},
api: {
- async getStateEvent(roomID, type, key) {
- return {}
- },
- async getMembers(roomID, membership) {
- return {chunk: []}
- },
async getFullHierarchy(roomID) {
return []
}
@@ -121,12 +117,6 @@ test("web guild: can view bridged guild when logged in with matrix", async t =>
mxid: "@cadence:cadence.moe"
},
api: {
- async getStateEvent(roomID, type, key) {
- return {}
- },
- async getMembers(roomID, membership) {
- return {chunk: []}
- },
async getFullHierarchy(roomID) {
return []
}
@@ -191,12 +181,12 @@ test("api invite: can invite with valid nonce", async t => {
async getStateEvent(roomID, type, key) {
called++
if (type === "m.room.member" && key === "@cadence:cadence.moe") {
- return {membership: "leave"}
+ throw new Error("event not found")
} else if (type === "m.room.power_levels" && key === "") {
return {}
- } else {
- t.fail(`unexpected getStateEvent call. roomID: ${roomID}, type: ${type}, key: ${key}`)
}
+ /* c8 ignore next */
+ t.fail(`unexpected getStateEvent call. roomID: ${roomID}, type: ${type}, key: ${key}`)
},
async getStateEventOuter(roomID, type, key) {
called++
@@ -284,9 +274,9 @@ test("api invite: can invite to a moderated guild", async t => {
return {membership: "leave"}
} else if (type === "m.room.power_levels" && key === "") {
return {}
- } else {
- t.fail(`unexpected getStateEvent call. roomID: ${roomID}, type: ${type}, key: ${key}`)
}
+ /* c8 ignore next */
+ t.fail(`unexpected getStateEvent call. roomID: ${roomID}, type: ${type}, key: ${key}`)
},
async getStateEventOuter(roomID, type, key) {
called++
@@ -362,3 +352,52 @@ test("api invite: does not reinvite joined users", async t => {
t.notOk(error)
t.equal(called, 1)
})
+
+
+test("position sorting: sorts like discord does", t => {
+ const channelsList = [{
+ type: DiscordTypes.ChannelType.GuildText,
+ id: "first",
+ position: 0
+ }, {
+ type: DiscordTypes.ChannelType.PublicThread,
+ id: "thread",
+ parent_id: "first",
+ }, {
+ type: DiscordTypes.ChannelType.GuildText,
+ id: "second",
+ position: 1
+ }, {
+ type: DiscordTypes.ChannelType.GuildVoice,
+ id: "voice",
+ position: 0
+ }, {
+ type: DiscordTypes.ChannelType.GuildCategory,
+ id: "category",
+ position: 0
+ }, {
+ type: DiscordTypes.ChannelType.GuildText,
+ id: "category-first",
+ parent_id: "category",
+ position: 0
+ }, {
+ type: DiscordTypes.ChannelType.GuildText,
+ id: "category-second",
+ parent_id: "category",
+ position: 1
+ }, {
+ type: DiscordTypes.ChannelType.PublicThread,
+ id: "category-second-thread",
+ parent_id: "category-second",
+ }].reverse()
+ const channels = new Map(channelsList.map(c => [c.id, c]))
+ const sortedChannelIDs = [...channels.values()].sort((a, b) => _getPosition(a, channels) - _getPosition(b, channels)).map(c => c.id)
+ t.deepEqual(sortedChannelIDs, ["first", "thread", "second", "voice", "category", "category-first", "category-second", "category-second-thread"])
+})
+
+test("filterTo: works", t => {
+ const fruit = ["apple", "banana", "apricot"]
+ const rest = _filterTo(fruit, f => f[0] === "b")
+ t.deepEqual(fruit, ["banana"])
+ t.deepEqual(rest, ["apple", "apricot"])
+})
diff --git a/src/web/routes/link.js b/src/web/routes/link.js
index 5193ded..ce80fd4 100644
--- a/src/web/routes/link.js
+++ b/src/web/routes/link.js
@@ -70,16 +70,14 @@ as.router.post("/api/link-space", defineEventHandler(async event => {
// Check space ID
if (!session.data.mxid) throw createError({status: 403, message: "Forbidden", data: "Can't link with your Matrix space if you aren't logged in to Matrix"})
const spaceID = parsedBody.space_id
- const inviteType = select("invite", "type", {mxid: session.data.mxid, room_id: spaceID}).pluck().get()
- if (inviteType !== "m.space") throw createError({status: 403, message: "Forbidden", data: "You personally must invite OOYE to that space on Matrix"})
+ const inviteRow = select("invite", ["mxid", "type"], {mxid: session.data.mxid, room_id: spaceID}).get()
+ if (!inviteRow || inviteRow.type !== "m.space") throw createError({status: 403, message: "Forbidden", data: "You personally must invite OOYE to that space on Matrix"})
// Check they are not already bridged
const existing = select("guild_space", "guild_id", {}, "WHERE guild_id = ? OR space_id = ?").get(guildID, spaceID)
if (existing) throw createError({status: 400, message: "Bad Request", data: `Guild ID ${guildID} or space ID ${spaceID} are already bridged and cannot be reused`})
- const inviteSender = select("invite", "mxid", {mxid: session.data.mxid, room_id: spaceID}).pluck().get()
- const inviteSenderServer = inviteSender?.match(/:(.*)/)?.[1]
- const via = [inviteSenderServer || ""]
+ const via = [inviteRow.mxid.match(/:(.*)/)[1]]
// Check space exists and bridge is joined
try {
diff --git a/src/web/routes/log-in-with-matrix.test.js b/src/web/routes/log-in-with-matrix.test.js
index a403055..830556e 100644
--- a/src/web/routes/log-in-with-matrix.test.js
+++ b/src/web/routes/log-in-with-matrix.test.js
@@ -39,7 +39,8 @@ test("log in with matrix: sends message to log in", async t => {
let called = 0
await router.test("post", "/api/log-in-with-matrix", {
body: {
- mxid: "@cadence:cadence.moe"
+ mxid: "@cadence:cadence.moe",
+ next: "https://bridge.cadence.moe/guild?guild_id=123"
},
api: {
async usePrivateChat(mxid) {
@@ -51,7 +52,7 @@ test("log in with matrix: sends message to log in", async t => {
called++
t.equal(roomID, "!created:cadence.moe")
t.equal(type, "m.room.message")
- token = content.body.match(/log-in-with-matrix\?token=([a-f0-9-]+)/)[1]
+ token = content.body.match(/log-in-with-matrix\?token=([a-f0-9-]+)&next=/)[1]
t.ok(token, "log in token not issued")
return ""
}
diff --git a/src/web/routes/oauth.js b/src/web/routes/oauth.js
index fe35230..f4bb61f 100644
--- a/src/web/routes/oauth.js
+++ b/src/web/routes/oauth.js
@@ -2,12 +2,12 @@
const {z} = require("zod")
const {randomUUID} = require("crypto")
-const {defineEventHandler, getValidatedQuery, sendRedirect, createError} = require("h3")
+const {defineEventHandler, getValidatedQuery, sendRedirect, createError, H3Event} = require("h3")
const {SnowTransfer, tokenless} = require("snowtransfer")
const DiscordTypes = require("discord-api-types/v10")
const getRelativePath = require("get-relative-path")
-const {discord, as, db, sync} = require("../../passthrough")
+const {as, db, sync} = require("../../passthrough")
const {id, permissions} = require("../../../addbot")
/** @type {import("../auth")} */
const auth = sync.require("../auth")
@@ -33,6 +33,24 @@ const schema = {
})
}
+/**
+ * @param {H3Event} event
+ * @returns {(string) => {user: {getGuilds: () => Promise}}}
+ */
+function getClient(event) {
+ /* c8 ignore next */
+ return event.context.getClient || (accessToken => new SnowTransfer(`Bearer ${accessToken}`))
+}
+
+/**
+ * @param {H3Event} event
+ * @returns {typeof tokenless.getOauth2Token}
+ */
+function getOauth2Token(event) {
+ /* c8 ignore next */
+ return event.context.getOauth2Token || tokenless.getOauth2Token
+}
+
as.router.get("/oauth", defineEventHandler(async event => {
const session = await auth.useSession(event)
let scope = "guilds"
@@ -61,21 +79,15 @@ as.router.get("/oauth", defineEventHandler(async event => {
if (!savedState) throw createError({status: 400, message: "Missing state", data: "Missing saved state parameter. Please try again, and make sure you have cookies enabled."})
if (savedState != parsedQuery.data.state) return tryAgain()
- const oauthResult = await tokenless.getOauth2Token(id, redirect_uri, reg.ooye.discord_client_secret, parsedQuery.data.code)
- const parsedToken = schema.token.safeParse(oauthResult)
- if (!parsedToken.success) {
- throw createError({status: 502, message: "Invalid token response", data: `Discord completed OAuth, but returned this instead of an OAuth access token: ${JSON.stringify(oauthResult)}`})
- }
+ const oauthResult = await getOauth2Token(event)(id, redirect_uri, reg.ooye.discord_client_secret, parsedQuery.data.code)
+ const parsedToken = schema.token.parse(oauthResult)
- const userID = Buffer.from(parsedToken.data.access_token.split(".")[0], "base64").toString()
- const client = new SnowTransfer(`Bearer ${parsedToken.data.access_token}`)
- try {
- const guilds = await client.user.getGuilds()
- var managedGuilds = guilds.filter(g => BigInt(g.permissions) & DiscordTypes.PermissionFlagsBits.ManageGuild).map(g => g.id)
- await session.update({managedGuilds, userID, state: undefined})
- } catch (e) {
- throw createError({status: 502, message: "API call failed", data: e.message})
- }
+ const userID = Buffer.from(parsedToken.access_token.split(".")[0], "base64").toString()
+ const client = getClient(event)(parsedToken.access_token)
+
+ const guilds = await client.user.getGuilds()
+ var managedGuilds = guilds.filter(g => BigInt(g.permissions) & DiscordTypes.PermissionFlagsBits.ManageGuild).map(g => g.id)
+ await session.update({managedGuilds, userID, state: undefined})
// Set auto-create for the guild
// @ts-ignore
diff --git a/src/web/routes/oauth.test.js b/src/web/routes/oauth.test.js
new file mode 100644
index 0000000..2f3a791
--- /dev/null
+++ b/src/web/routes/oauth.test.js
@@ -0,0 +1,121 @@
+// @ts-check
+
+const DiscordTypes = require("discord-api-types/v10")
+const tryToCatch = require("try-to-catch")
+const assert = require("assert/strict")
+const {router, test} = require("../../../test/web")
+
+test("web oauth: redirects to Discord on first visit (add easy)", async t => {
+ let event = {}
+ await router.test("get", "/oauth?action=add", {
+ event,
+ sessionData: {
+ password: "password123"
+ }
+ })
+ t.equal(event.node.res.statusCode, 302)
+ t.match(event.node.res.getHeader("location"), /^https:\/\/discord.com\/oauth2\/authorize\?client_id=684280192553844747&scope=bot\+guilds&permissions=2251801424568320&response_type=code&redirect_uri=https:\/\/bridge\.example\.org\/oauth&state=/)
+})
+
+test("web oauth: redirects to Discord on first visit (add self service)", async t => {
+ let event = {}
+ await router.test("get", "/oauth?action=add-self-service", {
+ event,
+ sessionData: {
+ password: "password123"
+ }
+ })
+ t.equal(event.node.res.statusCode, 302)
+ t.match(event.node.res.getHeader("location"), /^https:\/\/discord.com\/oauth2\/authorize\?client_id=684280192553844747&scope=bot\+guilds&permissions=2251801424568320&response_type=code&redirect_uri=https:\/\/bridge\.example\.org\/oauth&state=/)
+})
+
+test("web oauth: advises user about cookies if state is missing", async t => {
+ let event = {}
+ const [e] = await tryToCatch(() => router.test("get", "/oauth?state=693551d5-47c5-49e2-a433-3600abe3c15c&code=DISCORD_CODE&guild_id=9", {
+ event
+ }))
+ t.equal(e.message, "Missing state")
+})
+
+test("web oauth: redirects to Discord again if state doesn't match", async t => {
+ let event = {}
+ await router.test("get", "/oauth?state=693551d5-47c5-49e2-a433-3600abe3c15c&code=DISCORD_CODE", {
+ event,
+ sessionData: {
+ state: "438aa253-1311-4483-9aa2-c251e29e72c9",
+ password: "password123"
+ }
+ })
+ t.equal(event.node.res.statusCode, 302)
+ t.match(event.node.res.getHeader("location"), /^https:\/\/discord\.com\/oauth2\/authorize/)
+})
+
+test("web oauth: uses returned state, logs in", async t => {
+ let event = {}
+ await router.test("get", "/oauth?state=693551d5-47c5-49e2-a433-3600abe3c15c&code=DISCORD_CODE", {
+ event,
+ sessionData: {
+ state: "693551d5-47c5-49e2-a433-3600abe3c15c",
+ selfService: false,
+ password: "password123"
+ },
+ getOauth2Token() {
+ return {
+ token_type: "Bearer",
+ access_token: "6qrZcUqja7812RVdnEKjpzOL4CvHBFG",
+ expires_in: 604800,
+ refresh_token: "D43f5y0ahjqew82jZ4NViEr2YafMKhue",
+ scope: "bot+guilds"
+ }
+ },
+ getClient(accessToken) {
+ return {
+ user: {
+ async getGuilds() {
+ return [{
+ id: "9",
+ permissions: DiscordTypes.PermissionFlagsBits.ManageGuild
+ }]
+ }
+ }
+ }
+ }
+ })
+ t.equal(event.node.res.statusCode, 302)
+ t.equal(event.node.res.getHeader("location"), "./")
+})
+
+test("web oauth: uses returned state, adds managed guild", async t => {
+ let event = {}
+ await router.test("get", "/oauth?state=693551d5-47c5-49e2-a433-3600abe3c15c&code=DISCORD_CODE&guild_id=9", {
+ event,
+ sessionData: {
+ state: "693551d5-47c5-49e2-a433-3600abe3c15c",
+ selfService: false,
+ password: "password123"
+ },
+ getOauth2Token() {
+ return {
+ token_type: "Bearer",
+ access_token: "6qrZcUqja7812RVdnEKjpzOL4CvHBFG",
+ expires_in: 604800,
+ refresh_token: "D43f5y0ahjqew82jZ4NViEr2YafMKhue",
+ scope: "bot+guilds"
+ }
+ },
+ getClient(accessToken) {
+ return {
+ user: {
+ async getGuilds() {
+ return [{
+ id: "9",
+ permissions: DiscordTypes.PermissionFlagsBits.ManageGuild
+ }]
+ }
+ }
+ }
+ }
+ })
+ t.equal(event.node.res.statusCode, 302)
+ t.equal(event.node.res.getHeader("location"), "guild?guild_id=9")
+})
diff --git a/test/data.js b/test/data.js
index 387ad6a..e80b436 100644
--- a/test/data.js
+++ b/test/data.js
@@ -180,6 +180,39 @@ module.exports = {
afk_timeout: 300,
id: "112760669178241024",
icon: "a_f83622e09ead74f0c5c527fe241f8f8c",
+ /** @type {DiscordTypes.APIGuildMember[]} */ // @ts-ignore
+ members: [{
+ user: {
+ username: 'Matrix Bridge',
+ public_flags: 0,
+ primary_guild: null,
+ id: '684280192553844747',
+ global_name: null,
+ display_name_styles: null,
+ display_name: null,
+ discriminator: '5728',
+ collectibles: null,
+ bot: true,
+ avatar_decoration_data: null,
+ avatar: '48ae3c24f2a6ec5c60c41bdabd904018'
+ },
+ roles: [
+ '703457691342995528',
+ '289671295359254529',
+ '1040735082610167858',
+ '114526764860047367'
+ ],
+ premium_since: null,
+ pending: false,
+ nick: 'Mother',
+ mute: false,
+ joined_at: '2020-04-25T04:09:43.253000+00:00',
+ flags: 0,
+ deaf: false,
+ communication_disabled_until: null,
+ banner: null,
+ avatar: null
+ }],
emojis: [
{
roles: [],
@@ -5433,6 +5466,50 @@ module.exports = {
}
},
special_message: {
+ emoji_added: {
+ type: 63,
+ content: '<:cx_marvelous:1437322787994992650>',
+ mentions: [],
+ mention_roles: [],
+ attachments: [],
+ embeds: [],
+ timestamp: '2025-11-10T06:07:36.930000+00:00',
+ edited_timestamp: null,
+ flags: 0,
+ components: [],
+ id: '1437322788439457794',
+ channel_id: '1100319550446252084',
+ author: {
+ id: '772659086046658620',
+ username: 'cadence.worm',
+ avatar: '466df0c98b1af1e1388f595b4c1ad1b9',
+ discriminator: '0',
+ public_flags: 0,
+ flags: 0,
+ banner: null,
+ accent_color: null,
+ global_name: 'cadence',
+ avatar_decoration_data: null,
+ collectibles: null,
+ display_name_styles: null,
+ banner_color: null,
+ clan: {
+ identity_guild_id: '532245108070809601',
+ identity_enabled: true,
+ tag: 'doll',
+ badge: 'dba08126b4e810a0e096cc7cd5bc37f0'
+ },
+ primary_guild: {
+ identity_guild_id: '532245108070809601',
+ identity_enabled: true,
+ tag: 'doll',
+ badge: 'dba08126b4e810a0e096cc7cd5bc37f0'
+ }
+ },
+ pinned: false,
+ mention_everyone: false,
+ tts: false
+ },
thread_name_change: {
id: "1142391602799710298",
type: 4,
diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql
index 76c822f..36ec0b6 100644
--- a/test/ooye-test-data.sql
+++ b/test/ooye-test-data.sql
@@ -3,10 +3,12 @@ BEGIN TRANSACTION;
INSERT INTO guild_active (guild_id, autocreate) VALUES
('112760669178241024', 1),
('66192955777486848', 1),
-('665289423482519565', 0);
+('665289423482519565', 0),
+('1345641201902288987', 1);
INSERT INTO guild_space (guild_id, space_id, privacy_level) VALUES
-('112760669178241024', '!jjmvBegULiLucuWEHU:cadence.moe', 0);
+('112760669178241024', '!jjmvBegULiLucuWEHU:cadence.moe', 0),
+('1345641201902288987', '!CvQMeeqXIkgedUpkzv:cadence.moe', 0);
INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar, guild_id) VALUES
('112760669178241024', '!kLRqKKUQXcibIMtOpl:cadence.moe', 'heave', 'main', NULL, NULL, '112760669178241024'),
@@ -21,7 +23,8 @@ INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom
('489237891895768942', '!tnedrGVYKFNUdnegvf:tchncs.de', 'ex-room-doesnt-exist-any-more', NULL, NULL, NULL, '66192955777486848'),
('1160894080998461480', '!TqlyQmifxGUggEmdBN:cadence.moe', 'ooyexperiment', NULL, NULL, NULL, '66192955777486848'),
('1161864271370666075', '!mHmhQQPwXNananMUqq:cadence.moe', 'updates', NULL, NULL, NULL, '665289423482519565'),
-('1438284564815548418', '!MHxNpwtgVqWOrmyoTn:cadence.moe', 'sin-cave', NULL, NULL, NULL, '665289423482519565');
+('1438284564815548418', '!MHxNpwtgVqWOrmyoTn:cadence.moe', 'sin-cave', NULL, NULL, NULL, '665289423482519565'),
+('598707048112193536', '!JBxeGYnzQwLnaooOLD:cadence.moe', 'winners', NULL, NULL, NULL, '1345641201902288987');
INSERT INTO historical_channel_room (reference_channel_id, room_id, upgraded_timestamp) SELECT channel_id, room_id, 0 FROM channel_room;
@@ -78,7 +81,8 @@ WITH a (message_id, channel_id) AS (VALUES
('1339000288144658482', '176333891320283136'),
('1381212840957972480', '112760669178241024'),
('1401760355339862066', '112760669178241024'),
-('1439351590262800565', '1438284564815548418'))
+('1439351590262800565', '1438284564815548418'),
+('1404133238414376971', '112760669178241024'))
SELECT message_id, max(historical_room_index) as historical_room_index FROM a INNER JOIN historical_channel_room ON historical_channel_room.reference_channel_id = a.channel_id GROUP BY message_id;
INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES
@@ -124,7 +128,9 @@ INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part
('$AfrB8hzXkDMvuoWjSZkDdFYomjInWH7jMBPkwQMN8AI', 'm.room.message', 'm.text', '1381212840957972480', 0, 1, 1),
('$43baKEhJfD-RlsFQi0LB16Zxd8yMqp0HSVL00TDQOqM', 'm.room.message', 'm.image', '1381212840957972480', 1, 0, 1),
('$7P2O_VTQNHvavX5zNJ35DV-dbJB1Ag80tGQP_JzGdhk', 'm.room.message', 'm.text', '1401760355339862066', 0, 0, 0),
-('$ielAnR6geu0P1Tl5UXfrbxlIf-SV9jrNprxrGXP3v7M', 'm.room.message', 'm.image', '1439351590262800565', 0, 0, 0);
+('$ielAnR6geu0P1Tl5UXfrbxlIf-SV9jrNprxrGXP3v7M', 'm.room.message', 'm.image', '1439351590262800565', 0, 0, 0),
+('$uUKLcTQvik5tgtTGDKuzn0Ci4zcCvSoUcYn2X7mXm9I', 'm.room.message', 'm.text', '1404133238414376971', 0, 1, 1),
+('$LhmoWWvYyn5_AHkfb6FaXmLI6ZOC1kloql5P40YDmIk', 'm.room.message', 'm.notice', '1404133238414376971', 1, 0, 1);
INSERT INTO file (discord_url, mxc_url) VALUES
('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'),
@@ -155,7 +161,8 @@ INSERT INTO emoji (emoji_id, name, animated, mxc_url) VALUES
('551636841284108289', 'ae_botrac4r', 0, 'mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs'),
('975572106295259148', 'brillillillilliant_move', 0, 'mxc://cadence.moe/scfRIDOGKWFDEBjVXocWYQHik'),
('606664341298872324', 'online', 0, 'mxc://cadence.moe/LCEqjStXCxvRQccEkuslXEyZ'),
-('288858540888686602', 'upstinky', 0, 'mxc://cadence.moe/mwZaCtRGAQQyOItagDeCocEO');
+('288858540888686602', 'upstinky', 0, 'mxc://cadence.moe/mwZaCtRGAQQyOItagDeCocEO'),
+('1437322787994992650', 'cx_marvelous', 0, 'mxc://cadence.moe/TPZdosVUjTIopsLijkygIbti');
INSERT INTO member_cache (room_id, mxid, displayname, avatar_url, power_level) VALUES
('!jjmvBegULiLucuWEHU:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL, 50),
@@ -200,4 +207,10 @@ INSERT INTO direct (mxid, room_id) VALUES
('@user1:example.org', '!existing:cadence.moe'),
('@user2:example.org', '!existing:cadence.moe');
+-- for cross-room reply test, in 'updates' room
+UPDATE historical_channel_room SET room_id = '!mHmhQQPwXNananaOLD:cadence.moe' WHERE room_id = '!mHmhQQPwXNananMUqq:cadence.moe';
+INSERT INTO historical_channel_room (reference_channel_id, room_id, upgraded_timestamp) VALUES ('1161864271370666075', '!mHmhQQPwXNananMUqq:cadence.moe', 1767922455991);
+INSERT INTO message_room (message_id, historical_room_index) SELECT '1458091145136443547', historical_room_index FROM historical_channel_room WHERE room_id = '!mHmhQQPwXNananaOLD:cadence.moe';
+INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES ('$pgzCQjq_y5sy8RvWOUuoF3obNHjs8iNvt9c-odrOCPY', 'm.room.message', 'm.image', '1458091145136443547', 0, 0, 0);
+
COMMIT;
diff --git a/test/test.js b/test/test.js
index 6470aae..be7febf 100644
--- a/test/test.js
+++ b/test/test.js
@@ -29,6 +29,7 @@ reg.namespaces = {
reg.ooye.bridge_origin = "https://bridge.example.org"
reg.ooye.time_zone = "Pacific/Auckland"
reg.ooye.max_file_size = 5000000
+reg.ooye.web_password = "password123"
const sync = new HeatSync({watchFS: false})
@@ -140,6 +141,7 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not
require("../src/web/routes/info.test")
require("../src/web/routes/link.test")
require("../src/web/routes/log-in-with-matrix.test")
+ require("../src/web/routes/oauth.test")
require("../src/web/routes/password.test")
require("../src/discord/utils.test")
require("../src/matrix/kstate.test")
@@ -147,6 +149,7 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not
require("../src/matrix/file.test")
require("../src/matrix/mreq.test")
require("../src/matrix/read-registration.test")
+ require("../src/matrix/room-upgrade.test")
require("../src/matrix/txnid.test")
require("../src/matrix/utils.test")
require("../src/d2m/actions/create-room.test")
diff --git a/test/web.js b/test/web.js
index 09af95b..463c6b1 100644
--- a/test/web.js
+++ b/test/web.js
@@ -51,7 +51,7 @@ class Router {
/**
* @param {string} method
* @param {string} inputUrl
- * @param {{event?: any, params?: any, body?: any, sessionData?: any, api?: Partial, snow?: {[k in keyof SnowTransfer]?: Partial}, createRoom?: Partial, createSpace?: Partial, headers?: any}} [options]
+ * @param {{event?: any, params?: any, body?: any, sessionData?: any, getOauth2Token?: any, getClient?: (string) => {user: {getGuilds: () => Promise}}, api?: Partial, snow?: {[k in keyof SnowTransfer]?: Partial}, createRoom?: Partial, createSpace?: Partial, headers?: any}} [options]
*/
async test(method, inputUrl, options = {}) {
const url = new URL(inputUrl, "http://a")
@@ -87,6 +87,8 @@ class Router {
snow: options.snow,
createRoom: options.createRoom,
createSpace: options.createSpace,
+ getOauth2Token: options.getOauth2Token,
+ getClient: options.getClient,
sessions: {
h3: {
id: "h3",