Compare commits

..

6 commits

Author SHA1 Message Date
10d14bbdaa add tests from /unlink-space endpoint 2025-11-01 22:36:42 +09:00
677d899a92 Fill in more of reg for other people to test with (cherry pick to run
tests, to delete after rebase main when fix-remote-join is merged)
2025-11-01 22:36:16 +09:00
9492137ca6 Add /api/unlink-space implementation 2025-11-01 22:36:03 +09:00
6aa2bfad13 Extract /api/unlink code to its own function 2025-11-01 22:36:00 +09:00
fc8d400b7d Factorize some of the space link/unlink sanity checks 2025-11-01 22:12:03 +09:00
f1313db028 Add button to unlink a space 2025-11-01 21:03:10 +09:00
6 changed files with 17 additions and 108 deletions

View file

@ -1,9 +0,0 @@
{
"compilerOptions": {
"target": "es2024",
"module": "nodenext",
"strict": true,
"noImplicitAny": false,
"useUnknownInCatchVariables": false
}
}

View file

@ -207,7 +207,6 @@ 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. * - 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. * - 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 * @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) { async function messageToEvent(message, guild, options = {}, di) {
const events = [] const events = []

View file

@ -22,11 +22,7 @@ function path(p, mxid, otherParams = {}) {
const u = new URL(p, "http://localhost") const u = new URL(p, "http://localhost")
if (mxid) u.searchParams.set("user_id", mxid) if (mxid) u.searchParams.set("user_id", mxid)
for (const entry of Object.entries(otherParams)) { for (const entry of Object.entries(otherParams)) {
if (Array.isArray(entry[1])) { if (entry[1] != undefined) {
for (const element of entry[1]) {
u.searchParams.append(entry[0], element)
}
} else if (entry[1] != undefined) {
u.searchParams.set(entry[0], entry[1]) u.searchParams.set(entry[0], entry[1])
} }
} }
@ -66,14 +62,11 @@ async function createRoom(content) {
} }
/** /**
* @param {string} roomIDOrAlias
* @param {string?} [mxid]
* @param {string[]?} [via]
* @returns {Promise<string>} room ID * @returns {Promise<string>} room ID
*/ */
async function joinRoom(roomIDOrAlias, mxid, via) { async function joinRoom(roomIDOrAlias, mxid) {
/** @type {Ty.R.RoomJoined} */ /** @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), {})
return root.room_id return root.room_id
} }

10
src/types.d.ts vendored
View file

@ -149,14 +149,6 @@ export namespace Event {
prev_content?: any prev_content?: any
} }
export type StrippedChildStateEvent = {
type: string
state_key: string
sender: string
origin_server_ts: number
content: any
}
export type M_Room_Message = { export type M_Room_Message = {
msgtype: "m.text" | "m.emote" msgtype: "m.text" | "m.emote"
body: string body: string
@ -353,7 +345,7 @@ export namespace R {
export type Hierarchy = { export type Hierarchy = {
avatar_url?: string avatar_url?: string
canonical_alias?: string canonical_alias?: string
children_state: Event.StrippedChildStateEvent[] children_state: {}
guest_can_join: boolean guest_can_join: boolean
join_rule?: string join_rule?: string
name?: string name?: string

View file

@ -133,15 +133,11 @@ 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) 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`}) 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 || ""]
// Check space exists and bridge is joined // Check space exists and bridge is joined
try { try {
await api.joinRoom(parsedBody.space_id, null, via) await api.joinRoom(parsedBody.space_id)
} catch (e) { } 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: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`})
} }
// Check bridge has PL 100 // Check bridge has PL 100
@ -189,33 +185,19 @@ 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`}) 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 // Check room is part of the guild's space
let foundRoom = false let found = false
/** @type {string[]?} */
let foundVia = null
for await (const room of api.generateFullHierarchy(spaceID)) { for await (const room of api.generateFullHierarchy(spaceID)) {
// 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
}
}
// 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) { if (room.room_id === parsedBody.matrix && !room.room_type) {
foundRoom = true found = true
break
} }
if (foundRoom && foundVia) break
} }
if (!foundRoom) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged space"}) 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 // Check room exists and bridge is joined
try { try {
await api.joinRoom(parsedBody.matrix, null, foundVia) await api.joinRoom(parsedBody.matrix)
} catch (e) { } catch (e) {
if (!foundVia) {
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}`}) throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`})
} }

View file

@ -77,7 +77,7 @@ test("web link space: check that OOYE is joined", async t => {
} }
} }
})) }))
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(error.data, "M_FORBIDDEN - not allowed to join I guess")
t.equal(called, 1) t.equal(called, 1)
}) })
@ -360,7 +360,7 @@ test("web link room: check that room is part of space (not in hierarchy)", async
t.equal(called, 1) t.equal(called, 1)
}) })
test("web link room: check that bridge can join room (notices lack of via and asks for invite instead)", async t => { test("web link room: check that bridge can join room", async t => {
let called = 0 let called = 0
const [error] = await tryToCatch(() => router.test("post", "/api/link", { const [error] = await tryToCatch(() => router.test("post", "/api/link", {
sessionData: { sessionData: {
@ -381,55 +381,7 @@ test("web link room: check that bridge can join room (notices lack of via and as
t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
yield { yield {
room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", 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, guest_can_join: false,
num_joined_members: 2 num_joined_members: 2
} }
@ -462,7 +414,7 @@ test("web link room: check that bridge has PL 100 in target room (event missing)
t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
yield { yield {
room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
children_state: [], children_state: {},
guest_can_join: false, guest_can_join: false,
num_joined_members: 2 num_joined_members: 2
} }
@ -502,7 +454,7 @@ test("web link room: check that bridge has PL 100 in target room (users default)
t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
yield { yield {
room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
children_state: [], children_state: {},
guest_can_join: false, guest_can_join: false,
num_joined_members: 2 num_joined_members: 2
} }
@ -542,7 +494,7 @@ test("web link room: successfully calls createRoom", async t => {
t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
yield { yield {
room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
children_state: [], children_state: {},
guest_can_join: false, guest_can_join: false,
num_joined_members: 2 num_joined_members: 2
} }