diff --git a/src/d2m/converters/edit-to-changes.js b/src/d2m/converters/edit-to-changes.js
index c38c24e..c615a3f 100644
--- a/src/d2m/converters/edit-to-changes.js
+++ b/src/d2m/converters/edit-to-changes.js
@@ -22,6 +22,10 @@ function eventCanBeEdited(ev) {
return true
}
+function eventIsText(ev) {
+ return ev.old.event_type === "m.room.message" && (ev.old.event_subtype === "m.text" || ev.old.event_subtype === "m.notice")
+}
+
/**
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
* @param {import("discord-api-types/v10").APIGuild} guild
@@ -121,6 +125,20 @@ async function editToChanges(message, guild, api) {
unchangedEvents.push(...eventsToReplace.filter(ev => !eventCanBeEdited(ev))) // Move them from eventsToRedact to unchangedEvents.
eventsToReplace = eventsToReplace.filter(eventCanBeEdited)
+ // Now, everything in eventsToReplace has the potential to have changed, but did it actually?
+ // (Example: if a URL preview was generated or updated, the message text won't have changed.)
+ // Only way to detect this is by text content. So we'll remove text events from eventsToReplace that have the same new text as text currently in the event.
+ for (let i = eventsToReplace.length; i--;) { // move backwards through array
+ const event = eventsToReplace[i]
+ if (!eventIsText(event)) continue // not text, can't analyse
+ const oldEvent = await api.getEvent(roomID, eventsToReplace[i].old.event_id)
+ const oldEventBodyWithoutQuotedReply = oldEvent.content.body?.replace(/^(>.*\n)*\n*/sm, "")
+ if (oldEventBodyWithoutQuotedReply !== event.newInnerContent.body) continue // event changed, must replace it
+ // Move it from eventsToRedact to unchangedEvents.
+ unchangedEvents.push(...eventsToReplace.filter(ev => ev.old.event_id === event.old.event_id))
+ eventsToReplace = eventsToReplace.filter(ev => ev.old.event_id !== event.old.event_id)
+ }
+
// We want to maintain exactly one part = 0 and one reaction_part = 0 database row at all times.
// This would be disrupted if existing events that are (reaction_)part = 0 will be redacted.
// If that is the case, pick a different existing or newly sent event to be (reaction_)part = 0.
@@ -193,4 +211,3 @@ function makeReplacementEventContent(oldID, newFallbackContent, newInnerContent)
}
module.exports.editToChanges = editToChanges
-module.exports.makeReplacementEventContent = makeReplacementEventContent
diff --git a/src/d2m/converters/edit-to-changes.test.js b/src/d2m/converters/edit-to-changes.test.js
index 9721a85..30549c7 100644
--- a/src/d2m/converters/edit-to-changes.test.js
+++ b/src/d2m/converters/edit-to-changes.test.js
@@ -4,7 +4,14 @@ const data = require("../../../test/data")
const Ty = require("../../types")
test("edit2changes: edit by webhook", async t => {
- const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edit_by_webhook, data.guild.general, {})
+ let called = 0
+ const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edit_by_webhook, data.guild.general, {
+ getEvent(roomID, eventID) {
+ called++
+ t.equal(eventID, "$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10")
+ return {content: {body: "dummy"}}
+ }
+ })
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, [])
t.deepEqual(eventsToReplace, [{
@@ -28,10 +35,15 @@ test("edit2changes: edit by webhook", async t => {
}])
t.equal(senderMxid, null)
t.deepEqual(promotions, [])
+ t.equal(called, 1)
})
test("edit2changes: bot response", async t => {
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.bot_response, data.guild.general, {
+ getEvent(roomID, eventID) {
+ t.equal(eventID, "$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY")
+ return {content: {body: "dummy"}}
+ },
async getJoinedMembers(roomID) {
t.equal(roomID, "!hYnGGlPHlbujVVfktC:cadence.moe")
return new Promise(resolve => {
@@ -123,7 +135,14 @@ test("edit2changes: add caption back to that image (due to it having a reaction,
})
test("edit2changes: stickers and attachments are not changed, only the content can be edited", async t => {
- const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments, data.guild.general, {})
+ let called = 0
+ const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments, data.guild.general, {
+ getEvent(roomID, eventID) {
+ called++
+ t.equal(eventID, "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4")
+ return {content: {body: "dummy"}}
+ }
+ })
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, [])
t.deepEqual(eventsToReplace, [{
@@ -145,10 +164,16 @@ test("edit2changes: stickers and attachments are not changed, only the content c
}
}
}])
+ t.equal(called, 1)
})
test("edit2changes: edit of reply to skull webp attachment with content", async t => {
- const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edit_of_reply_to_skull_webp_attachment_with_content, data.guild.general, {})
+ const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edit_of_reply_to_skull_webp_attachment_with_content, data.guild.general, {
+ getEvent(roomID, eventID) {
+ t.equal(eventID, "$vgTKOR5ZTYNMKaS7XvgEIDaOWZtVCEyzLLi5Pc5Gz4M")
+ return {content: {body: "dummy"}}
+ }
+ })
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, [])
t.deepEqual(eventsToReplace, [{
@@ -177,7 +202,12 @@ 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, {})
+ const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments_but_all_parts_equal_0, data.guild.general, {
+ getEvent(roomID, eventID) {
+ t.equal(eventID, "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd999")
+ return {content: {body: "dummy"}}
+ }
+ })
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, [])
t.deepEqual(eventsToReplace, [{
@@ -202,7 +232,12 @@ test("edit2changes: edits the text event when multiple rows have part = 0 (shoul
})
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, {})
+ const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments_but_all_parts_equal_1, data.guild.general, {
+ getEvent(roomID, eventID) {
+ t.equal(eventID, "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd111")
+ return {content: {body: "dummy"}}
+ }
+ })
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, [])
t.deepEqual(eventsToReplace, [{
@@ -279,32 +314,31 @@ test("edit2changes: generated embed", async t => {
})
test("edit2changes: generated embed on a reply", async t => {
- const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_on_reply, data.guild.general, {})
+ let called = 0
+ const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_on_reply, data.guild.general, {
+ getEvent(roomID, eventID) {
+ called++
+ t.equal(eventID, "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF")
+ return {
+ type: "m.room.message",
+ content: {
+ // Unfortunately the edited message doesn't include the message_reference field. Fine. Whatever. It looks normal if you're using a good client.
+ body: "> a Discord user: [Replied-to message content wasn't provided by Discord]"
+ + "\n\nhttps://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM",
+ format: "org.matrix.custom.html",
+ formatted_body: "In reply to a Discord user
[Replied-to message content wasn't provided by Discord]
https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM",
+ "m.mentions": {},
+ "m.relates_to": {
+ event_id: "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF",
+ rel_type: "m.replace",
+ },
+ msgtype: "m.text",
+ }
+ }
+ }
+ })
t.deepEqual(eventsToRedact, [])
- t.deepEqual(eventsToReplace, [{
- oldID: "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF",
- newContent: {
- $type: "m.room.message",
- // Unfortunately the edited message doesn't include the message_reference field. Fine. Whatever. It looks normal if you're using a good client.
- body: "> a Discord user: [Replied-to message content wasn't provided by Discord]"
- + "\n\n* https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM",
- format: "org.matrix.custom.html",
- formatted_body: "In reply to a Discord user
[Replied-to message content wasn't provided by Discord]
* https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM",
- "m.mentions": {},
- "m.new_content": {
- body: "https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM",
- format: "org.matrix.custom.html",
- formatted_body: "https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM",
- "m.mentions": {},
- msgtype: "m.text",
- },
- "m.relates_to": {
- event_id: "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF",
- rel_type: "m.replace",
- },
- msgtype: "m.text",
- },
- }])
+ t.deepEqual(eventsToReplace, [])
t.deepEqual(eventsToSend, [{
$type: "m.room.message",
msgtype: "m.notice",
@@ -324,4 +358,5 @@ test("edit2changes: generated embed on a reply", async t => {
"nextEvent": true,
}])
t.equal(senderMxid, "@_ooye_cadence:cadence.moe")
+ t.equal(called, 1)
})
diff --git a/src/matrix/api.js b/src/matrix/api.js
index 41af63f..f9a85c0 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) {
+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/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/src/types.d.ts b/src/types.d.ts
index 37da633..aaeed60 100644
--- a/src/types.d.ts
+++ b/src/types.d.ts
@@ -148,6 +148,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
@@ -344,7 +352,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 c5f404e..d16ecea 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,10 +89,16 @@ 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)
+ 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}`})
}
@@ -134,19 +154,33 @@ 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 foundRoom = false
+ /** @type {string[]?} */
+ let foundVia = null
for await (const room of api.generateFullHierarchy(spaceID)) {
- if (room.room_id === parsedBody.matrix && !room.room_type) {
- 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
+ }
}
+
+ // 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)
+ 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: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`})
}
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
}
diff --git a/test/test.js b/test/test.js
index 3695a84..8d9ad16 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"