From dc7b44408684379a2c41916c2efb849fc96d93f0 Mon Sep 17 00:00:00 2001 From: Elliu Date: Thu, 21 Aug 2025 21:38:34 +0900 Subject: [PATCH 1/9] Fix matrix api joinRoom() for remote rooms When using self-service mode and trying to link with a remote matrix room (room not in the same HS as the bridge user), then we need to add the "via" HSs to join the room with, or else it fails. We get it from the "m.space.child" in the "children_state" of the space hierarchy. It seems like the "via" information can also be stored in the "m.space.parent" in the states of the room, but hopefully this shouldn't be needed in sane implementations --- src/matrix/api.js | 4 ++-- src/web/routes/link.js | 22 +++++++++++++++++----- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/matrix/api.js b/src/matrix/api.js index 709d70c..9daf65a 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -64,9 +64,9 @@ async function createRoom(content) { /** * @returns {Promise} room ID */ -async function joinRoom(roomIDOrAlias, mxid) { +async function joinRoom(roomIDOrAlias, mxid, via) { /** @type {Ty.R.RoomJoined} */ - const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid), {}) + const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid, via), {}) return root.room_id } diff --git a/src/web/routes/link.js b/src/web/routes/link.js index c5f404e..7654adf 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -77,7 +77,7 @@ as.router.post("/api/link-space", defineEventHandler(async event => { // Check space exists and bridge is joined try { - await api.joinRoom(parsedBody.space_id) + await api.joinRoom(parsedBody.space_id, null, via) } catch (e) { throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`}) } @@ -135,19 +135,31 @@ as.router.post("/api/link", defineEventHandler(async event => { // Check room is part of the guild's space let found = false + let via = undefined for await (const room of api.generateFullHierarchy(spaceID)) { - if (room.room_id === parsedBody.matrix && !room.room_type) { + if (via === undefined && room.room_type === "m.space") { + for (state of room.children_state) { + if (state.state_key === parsedBody.matrix){ + via = {via: state.content.via} + if (found === true) + break + } + } + } + + if (!found && room.room_id === parsedBody.matrix && !room.room_type) { found = true - break + if (via !== undefined) + break } } if (!found) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged space"}) // Check room exists and bridge is joined try { - await api.joinRoom(parsedBody.matrix) + await api.joinRoom(parsedBody.matrix, null, via ?? {}) } catch (e) { - throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`}) + throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}${via === null ? " (hint: couln't find a \"via\" in the space children_state for this room in order to help joining this room)" : ""}`}) } // Check bridge has PL 100 From 1efd301e1d21764b96c0b57a680d74c962ab197f Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 22 Aug 2025 21:25:05 +1200 Subject: [PATCH 2/9] Cleanup --- src/matrix/api.js | 11 +++++++++-- src/types.d.ts | 10 +++++++++- src/web/routes/link.js | 34 ++++++++++++++++++---------------- 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src/matrix/api.js b/src/matrix/api.js index 9daf65a..edffc45 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -22,7 +22,11 @@ function path(p, mxid, otherParams = {}) { const u = new URL(p, "http://localhost") if (mxid) u.searchParams.set("user_id", mxid) for (const entry of Object.entries(otherParams)) { - if (entry[1] != undefined) { + if (Array.isArray(entry[1])) { + for (const element of entry[1]) { + u.searchParams.append(entry[0], element) + } + } else if (entry[1] != undefined) { u.searchParams.set(entry[0], entry[1]) } } @@ -62,11 +66,14 @@ async function createRoom(content) { } /** + * @param {string} roomIDOrAlias + * @param {string?} [mxid] + * @param {string[]?} [via] * @returns {Promise} room ID */ async function joinRoom(roomIDOrAlias, mxid, via) { /** @type {Ty.R.RoomJoined} */ - const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid, via), {}) + const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid, {via}), {}) return root.room_id } diff --git a/src/types.d.ts b/src/types.d.ts index 27dfddf..a34095d 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -149,6 +149,14 @@ export namespace Event { prev_content?: any } + export type Outer_StrippedChildStateEvent = { + type: string + state_key: string + sender: string + origin_server_ts: number + content: any + } + export type M_Room_Message = { msgtype: "m.text" | "m.emote" body: string @@ -345,7 +353,7 @@ export namespace R { export type Hierarchy = { avatar_url?: string canonical_alias?: string - children_state: {} + children_state: Event.Outer_StrippedChildStateEvent[] guest_can_join: boolean join_rule?: string name?: string diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 7654adf..f85e66d 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -134,32 +134,34 @@ as.router.post("/api/link", defineEventHandler(async event => { if (row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${row.channel_id} or room ID ${parsedBody.matrix} are already bridged and cannot be reused`}) // Check room is part of the guild's space - let found = false - let via = undefined + let foundRoom = false + /** @type {string[]?} */ + let foundVia = null for await (const room of api.generateFullHierarchy(spaceID)) { - if (via === undefined && room.room_type === "m.space") { - for (state of room.children_state) { - if (state.state_key === parsedBody.matrix){ - via = {via: state.content.via} - if (found === true) - break - } + // When finding a space during iteration, look at space's children state, because we need a `via` to join the room (when we find it later) + for (const state of room.children_state) { + if (state.type === "m.space.child" && state.state_key === parsedBody.matrix) { + foundVia = state.content.via } } - if (!found && room.room_id === parsedBody.matrix && !room.room_type) { - found = true - if (via !== undefined) - break + // When finding a room during iteration, see if it was the requested room (to confirm that the room is in the space) + if (room.room_id === parsedBody.matrix && !room.room_type) { + foundRoom = true } + + if (foundRoom && foundVia) break } - if (!found) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged space"}) + if (!foundRoom) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged space"}) // Check room exists and bridge is joined try { - await api.joinRoom(parsedBody.matrix, null, via ?? {}) + await api.joinRoom(parsedBody.matrix, null, foundVia) } catch (e) { - throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}${via === null ? " (hint: couln't find a \"via\" in the space children_state for this room in order to help joining this room)" : ""}`}) + if (!foundVia) { + throw createError({status: 403, message: "Unable To Join", data: `Unable to join the requested Matrix room. Please invite the bridge to the room and try again. (Server said: ${e.errcode} - ${e.message})`}) + } + throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`}) } // Check bridge has PL 100 From ea08e16963d012943e631506be28e407f6737ebb Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 22 Aug 2025 21:42:13 +1200 Subject: [PATCH 3/9] Update tests for new types and code path --- src/web/routes/link.test.js | 58 +++++++++++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/src/web/routes/link.test.js b/src/web/routes/link.test.js index 0d8d366..068bc9b 100644 --- a/src/web/routes/link.test.js +++ b/src/web/routes/link.test.js @@ -360,7 +360,7 @@ test("web link room: check that room is part of space (not in hierarchy)", async t.equal(called, 1) }) -test("web link room: check that bridge can join room", async t => { +test("web link room: check that bridge can join room (notices lack of via and asks for invite instead)", async t => { let called = 0 const [error] = await tryToCatch(() => router.test("post", "/api/link", { sessionData: { @@ -381,7 +381,55 @@ test("web link room: check that bridge can join room", async t => { t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") yield { room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - children_state: {}, + children_state: [], + guest_can_join: false, + num_joined_members: 2 + } + /* c8 ignore next */ + } + } + })) + t.equal(error.data, "Unable to join the requested Matrix room. Please invite the bridge to the room and try again. (Server said: M_FORBIDDEN - not allowed to join I guess)") + t.equal(called, 2) +}) + +test("web link room: check that bridge can join room (uses via for join attempt)", async t => { + let called = 0 + const [error] = await tryToCatch(() => router.test("post", "/api/link", { + sessionData: { + managedGuilds: ["665289423482519565"] + }, + body: { + discord: "665310973967597573", + matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe", + guild_id: "665289423482519565" + }, + api: { + async joinRoom(roomID, _, via) { + called++ + t.deepEqual(via, ["cadence.moe", "hashi.re"]) + throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}) + }, + async *generateFullHierarchy(spaceID) { + called++ + t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") + yield { + room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", + children_state: [], + guest_can_join: false, + num_joined_members: 2 + } + yield { + room_id: "!zTMspHVUBhFLLSdmnS:cadence.moe", + children_state: [{ + type: "m.space.child", + state_key: "!NDbIqNpJyPvfKRnNcr:cadence.moe", + sender: "@elliu:hashi.re", + content: { + via: ["cadence.moe", "hashi.re"] + }, + origin_server_ts: 0 + }], guest_can_join: false, num_joined_members: 2 } @@ -414,7 +462,7 @@ test("web link room: check that bridge has PL 100 in target room (event missing) t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") yield { room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - children_state: {}, + children_state: [], guest_can_join: false, num_joined_members: 2 } @@ -454,7 +502,7 @@ test("web link room: check that bridge has PL 100 in target room (users default) t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") yield { room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - children_state: {}, + children_state: [], guest_can_join: false, num_joined_members: 2 } @@ -494,7 +542,7 @@ test("web link room: successfully calls createRoom", async t => { t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") yield { room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - children_state: {}, + children_state: [], guest_can_join: false, num_joined_members: 2 } From e7b4dfea9cb48598e9102764c6077797f222c930 Mon Sep 17 00:00:00 2001 From: Elliu Date: Sat, 23 Aug 2025 00:01:30 +0900 Subject: [PATCH 4/9] Fix /api/link-space joinRoom() for remote spaces --- src/web/routes/link.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index f85e66d..b46b78f 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -12,6 +12,20 @@ const auth = sync.require("../auth") const mreq = sync.require("../../matrix/mreq") const {reg} = require("../../matrix/read-registration") +/** + * @param {string} UserID + * @returns {string} the HS of the user, or "" if the user ID is malformed + */ +function getHSOfUser(user) { + domainStartIndex = user.indexOf(":"); + if (domainStartIndex >= 1) { + return user.slice(domainStartIndex + 1) + } + + return "" +} + + /** * @param {H3Event} event * @returns {import("../../matrix/api")} @@ -75,6 +89,9 @@ as.router.post("/api/link-space", defineEventHandler(async event => { 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() + via = [ getHSOfUser(inviteSender) ] + // Check space exists and bridge is joined try { await api.joinRoom(parsedBody.space_id, null, via) From 0776cc6ccdddef5da82caaa090121bf3809bf0c0 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sat, 23 Aug 2025 23:46:51 +1200 Subject: [PATCH 5/9] Fill in more of reg for other people to test with --- src/matrix/api.test.js | 4 ++++ test/test.js | 2 ++ 2 files changed, 6 insertions(+) diff --git a/src/matrix/api.test.js b/src/matrix/api.test.js index 82565eb..da92385 100644 --- a/src/matrix/api.test.js +++ b/src/matrix/api.test.js @@ -24,3 +24,7 @@ test("api path: real world mxid", t => { test("api path: extras number works", t => { t.equal(path(`/client/v3/rooms/!example/timestamp_to_event`, null, {ts: 1687324651120}), "/client/v3/rooms/!example/timestamp_to_event?ts=1687324651120") }) + +test("api path: multiple via params", t => { + t.equal(path(`/client/v3/rooms/!example/join`, null, {via: ["cadence.moe", "matrix.org"], ts: 1687324651120}), "/client/v3/rooms/!example/join?via=cadence.moe&via=matrix.org&ts=1687324651120") +}) diff --git a/test/test.js b/test/test.js index 233fd94..b01f0ce 100644 --- a/test/test.js +++ b/test/test.js @@ -17,6 +17,8 @@ const {reg} = require("../src/matrix/read-registration") reg.ooye.discord_token = "Njg0MjgwMTkyNTUzODQ0NzQ3.Xl3zlw.baby" reg.ooye.server_origin = "https://matrix.cadence.moe" // so that tests will pass even when hard-coded reg.ooye.server_name = "cadence.moe" +reg.ooye.namespace_prefix = "_ooye_" +reg.sender_localpart = "_ooye_bot" reg.id = "baby" reg.as_token = "don't actually take authenticated actions on the server" reg.hs_token = "don't actually take authenticated actions on the server" From 7932f8af851148bb7a10a4e5ac8f9305f15d2be1 Mon Sep 17 00:00:00 2001 From: Elliu Date: Sun, 31 Aug 2025 20:11:07 +0900 Subject: [PATCH 6/9] Add "please try invite" message when joinRoom in /api/link-space fails --- src/web/routes/link.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index b46b78f..d16ecea 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -96,6 +96,9 @@ as.router.post("/api/link-space", defineEventHandler(async event => { try { await api.joinRoom(parsedBody.space_id, null, via) } catch (e) { + if (via.join("") == "") { + throw createError({status: 403, message: "Unable To Join", data: `Unable to join the requested Matrix space. Please invite the bridge to the space and try again. (Server said: ${e.errcode} - ${e.message})`}) + } throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`}) } From 717dc185e5b68def578b1d31de5a158b386e0ab6 Mon Sep 17 00:00:00 2001 From: Elliu Date: Thu, 9 Oct 2025 21:54:15 +0900 Subject: [PATCH 7/9] Misc. fixes for remote join --- src/web/routes/link.js | 23 +++-------------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index d16ecea..a7ae8ec 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -12,20 +12,6 @@ const auth = sync.require("../auth") const mreq = sync.require("../../matrix/mreq") const {reg} = require("../../matrix/read-registration") -/** - * @param {string} UserID - * @returns {string} the HS of the user, or "" if the user ID is malformed - */ -function getHSOfUser(user) { - domainStartIndex = user.indexOf(":"); - if (domainStartIndex >= 1) { - return user.slice(domainStartIndex + 1) - } - - return "" -} - - /** * @param {H3Event} event * @returns {import("../../matrix/api")} @@ -90,16 +76,13 @@ as.router.post("/api/link-space", defineEventHandler(async event => { 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() - via = [ getHSOfUser(inviteSender) ] + const via = [ inviteSender?.match(/:(.*)/)?.[1] ?? "" ] // Check space exists and bridge is joined try { await api.joinRoom(parsedBody.space_id, null, via) } catch (e) { - if (via.join("") == "") { - throw createError({status: 403, message: "Unable To Join", data: `Unable to join the requested Matrix space. Please invite the bridge to the space and try again. (Server said: ${e.errcode} - ${e.message})`}) - } - throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`}) + throw createError({status: 400, message: "Unable To Join", data: `Unable to join the requested Matrix space. Please invite the bridge to the space and try again. (Server said: ${e.errcode} - ${e.message})`}) } // Check bridge has PL 100 @@ -179,7 +162,7 @@ as.router.post("/api/link", defineEventHandler(async event => { await api.joinRoom(parsedBody.matrix, null, foundVia) } catch (e) { if (!foundVia) { - throw createError({status: 403, message: "Unable To Join", data: `Unable to join the requested Matrix room. Please invite the bridge to the room and try again. (Server said: ${e.errcode} - ${e.message})`}) + throw createError({status: 400, message: "Unable To Join", data: `Unable to join the requested Matrix room. Please invite the bridge to the room and try again. (Server said: ${e.errcode} - ${e.message})`}) } throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`}) } From ab69eab8a492e99d7e30f4e3196c5a6d09debe0b Mon Sep 17 00:00:00 2001 From: Elliu Date: Sat, 1 Nov 2025 21:01:15 +0900 Subject: [PATCH 8/9] Fix tests for new link space error message --- src/web/routes/link.js | 4 ++-- src/web/routes/link.test.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index a7ae8ec..d4d13da 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -76,13 +76,13 @@ as.router.post("/api/link-space", defineEventHandler(async event => { 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 via = [ inviteSender?.match(/:(.*)/)?.[1] ?? "" ] + const via = [ inviteSender?.match(/:(.*)/)?.[1] ?? "" ] // Check space exists and bridge is joined try { await api.joinRoom(parsedBody.space_id, null, via) } catch (e) { - throw createError({status: 400, message: "Unable To Join", data: `Unable to join the requested Matrix space. Please invite the bridge to the space and try again. (Server said: ${e.errcode} - ${e.message})`}) + throw createError({status: 400, message: "Unable To Join", data: `Unable to join the requested Matrix space. Please invite the bridge to the space and try again. (Server said: ${e.errcode} - ${e.message})`}) } // Check bridge has PL 100 diff --git a/src/web/routes/link.test.js b/src/web/routes/link.test.js index 068bc9b..ffe4e5e 100644 --- a/src/web/routes/link.test.js +++ b/src/web/routes/link.test.js @@ -77,7 +77,7 @@ test("web link space: check that OOYE is joined", async t => { } } })) - t.equal(error.data, "M_FORBIDDEN - not allowed to join I guess") + t.equal(error.data, "Unable to join the requested Matrix space. Please invite the bridge to the space and try again. (Server said: M_FORBIDDEN - not allowed to join I guess)") t.equal(called, 1) }) From a55d7a16325c7783223bdcc54358c46d02aa57db Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 2 Nov 2025 20:46:31 +1300 Subject: [PATCH 9/9] Style match --- jsconfig.json | 9 +++++++++ src/d2m/converters/message-to-event.js | 1 + src/types.d.ts | 4 ++-- src/web/routes/link.js | 3 ++- 4 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 jsconfig.json diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..4106061 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "es2024", + "module": "nodenext", + "strict": true, + "noImplicitAny": false, + "useUnknownInCatchVariables": false + } +} diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 30a20fe..93e120e 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -207,6 +207,7 @@ async function attachmentToEvent(mentions, attachment) { * - alwaysReturnFormattedBody: false - formatted_body will be skipped if it is the same as body because the message is plaintext. if you want the formatted_body to be returned anyway, for example to merge it with another message, then set this to true. * - scanTextForMentions: true - needs to be set to false when converting forwarded messages etc which may be from a different channel that can't be scanned. * @param {{api: import("../../matrix/api"), snow?: import("snowtransfer").SnowTransfer}} di simple-as-nails dependency injection for the matrix API + * @returns {Promise<{$type: string, $sender?: string, [x: string]: any}[]>} */ async function messageToEvent(message, guild, options = {}, di) { const events = [] diff --git a/src/types.d.ts b/src/types.d.ts index a34095d..c7cb006 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -149,7 +149,7 @@ export namespace Event { prev_content?: any } - export type Outer_StrippedChildStateEvent = { + export type StrippedChildStateEvent = { type: string state_key: string sender: string @@ -353,7 +353,7 @@ export namespace R { export type Hierarchy = { avatar_url?: string canonical_alias?: string - children_state: Event.Outer_StrippedChildStateEvent[] + children_state: Event.StrippedChildStateEvent[] guest_can_join: boolean join_rule?: string name?: string diff --git a/src/web/routes/link.js b/src/web/routes/link.js index d4d13da..0afbc49 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -76,7 +76,8 @@ as.router.post("/api/link-space", defineEventHandler(async event => { 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 via = [ inviteSender?.match(/:(.*)/)?.[1] ?? "" ] + const inviteSenderServer = inviteSender?.match(/:(.*)/)?.[1] + const via = [inviteSenderServer || ""] // Check space exists and bridge is joined try {