diff --git a/m2d/converters/utils.js b/m2d/converters/utils.js index 8a83a07..5707aec 100644 --- a/m2d/converters/utils.js +++ b/m2d/converters/utils.js @@ -127,8 +127,74 @@ class MatrixStringBuilder { } } +/** + * Room IDs are not routable on their own. Room permalinks need a list of servers to try. The client is responsible for coming up with a list of servers. + * https://spec.matrix.org/v1.9/appendices/#routing + * https://gitdab.com/cadence/out-of-your-element/issues/11 + * @param {string} roomID + * @param {{[K in "getStateEvent" | "getJoinedMembers"]: import("../../matrix/api")[K]}} api + */ +async function getViaServers(roomID, api) { + const candidates = [] + const {joined} = await api.getJoinedMembers(roomID) + // Candidate 0: The bot's own server name + candidates.push(reg.ooye.server_name) + // Candidate 1: Highest joined non-sim non-bot power level user in the room + // https://github.com/matrix-org/matrix-react-sdk/blob/552c65db98b59406fb49562e537a2721c8505517/src/utils/permalinks/Permalinks.ts#L172 + try { + /** @type {{users?: {[mxid: string]: number}}} */ + const powerLevels = await api.getStateEvent(roomID, "m.room.power_levels", "") + if (powerLevels.users) { + const sorted = Object.entries(powerLevels.users).sort((a, b) => b[1] - a[1]) // Highest... + for (const power of sorted) { + const mxid = power[0] + if (!(mxid in joined)) continue // joined... + if (userRegex.some(r => mxid.match(r))) continue // non-sim non-bot... + const match = mxid.match(/:(.*)/) + assert(match) + if (!candidates.includes(match[1])) { + candidates.push(match[1]) + break + } + } + } + } catch (e) { + // power levels event not found + } + // Candidates 2-3: Most popular servers in the room + /** @type {Map} */ + const servers = new Map() + // We can get the most popular servers if we know the members, so let's process those... + Object.keys(joined) + .filter(mxid => !mxid.startsWith("@_")) // Quick check + .filter(mxid => !userRegex.some(r => mxid.match(r))) // Full check + .slice(0, 1000) // Just sample the first thousand real members + .map(mxid => { + const match = mxid.match(/:(.*)/) + assert(match) + return match[1] + }) + .filter(server => !server.match(/([a-f0-9:]+:+)+[a-f0-9]+/)) // No IPv6 servers + .filter(server => !server.match(/[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/)) // No IPv4 servers + // I don't care enough to check ACLs + .forEach(server => { + const existing = servers.get(server) + if (!existing) servers.set(server, 1) + else servers.set(server, existing + 1) + }) + const serverList = [...servers.entries()].sort((a, b) => b[1] - a[1]) + for (const server of serverList) { + if (!candidates.includes(server[0])) { + candidates.push(server[0]) + if (candidates.length >= 4) break // Can have at most 4 candidate via servers + } + } + return candidates +} + module.exports.BLOCK_ELEMENTS = BLOCK_ELEMENTS module.exports.eventSenderIsFromDiscord = eventSenderIsFromDiscord module.exports.getPublicUrlForMxc = getPublicUrlForMxc module.exports.getEventIDHash = getEventIDHash module.exports.MatrixStringBuilder = MatrixStringBuilder +module.exports.getViaServers = getViaServers diff --git a/m2d/converters/utils.test.js b/m2d/converters/utils.test.js index 76fd824..3689a87 100644 --- a/m2d/converters/utils.test.js +++ b/m2d/converters/utils.test.js @@ -3,9 +3,22 @@ const e = new Error("Custom error") const {test} = require("supertape") -const {eventSenderIsFromDiscord, getEventIDHash, MatrixStringBuilder} = require("./utils") +const {eventSenderIsFromDiscord, getEventIDHash, MatrixStringBuilder, getViaServers} = require("./utils") const util = require("util") +/** @param {string[]} mxids */ +function joinedList(mxids) { + /** @type {{[mxid: string]: {display_name: null, avatar_url: null}}} */ + const joined = {} + for (const mxid of mxids) { + joined[mxid] = { + display_name: null, + avatar_url: null + } + } + return {joined} +} + test("sender type: matrix user", t => { t.notOk(eventSenderIsFromDiscord("@cadence:cadence.moe")) }) @@ -74,3 +87,68 @@ test("MatrixStringBuilder: complete code coverage", t => { formatted_body: "Line 1

Line 2

Line 3

Line 4

" }) }) + +test("getViaServers: returns the server name if the room only has sim users", async t => { + const result = await getViaServers("!baby", { + getStateEvent: async () => ({}), + getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe"]) + }) + t.deepEqual(result, ["cadence.moe"]) +}) + +test("getViaServers: also returns the most popular servers in order", async t => { + const result = await getViaServers("!baby", { + getStateEvent: async () => ({}), + getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid"]) + }) + t.deepEqual(result, ["cadence.moe", "thecollective.invalid", "selfhosted.invalid"]) +}) + +test("getViaServers: does not return IP address servers", async t => { + const result = await getViaServers("!baby", { + getStateEvent: async () => ({}), + getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:45.77.232.172:8443", "@cadence:[::1]:8443", "@cadence:123example.456example.invalid"]) + }) + t.deepEqual(result, ["cadence.moe", "123example.456example.invalid"]) +}) + +test("getViaServers: also returns the highest power level user (100)", async t => { + const result = await getViaServers("!baby", { + getStateEvent: async () => ({ + users: { + "@moderator:tractor.invalid": 50, + "@singleuser:selfhosted.invalid": 100, + "@_ooye_bot:cadence.moe": 100 + } + }), + getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid", "@moderator:tractor.invalid"]) + }) + t.deepEqual(result, ["cadence.moe", "selfhosted.invalid", "thecollective.invalid", "tractor.invalid"]) +}) + +test("getViaServers: also returns the highest power level user (50)", async t => { + const result = await getViaServers("!baby", { + getStateEvent: async () => ({ + users: { + "@moderator:tractor.invalid": 50, + "@_ooye_bot:cadence.moe": 100 + } + }), + getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid", "@singleuser:selfhosted.invalid"]) + }) + t.deepEqual(result, ["cadence.moe", "tractor.invalid", "thecollective.invalid", "selfhosted.invalid"]) +}) + +test("getViaServers: returns at most 4 results", async t => { + const result = await getViaServers("!baby", { + getStateEvent: async () => ({ + users: { + "@moderator:tractor.invalid": 50, + "@singleuser:selfhosted.invalid": 100, + "@_ooye_bot:cadence.moe": 100 + } + }), + getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@cadence:123example.456example.invalid"]) + }) + t.deepEqual(result.length, 4) +}) diff --git a/matrix/api.js b/matrix/api.js index b59d6ef..baa5d96 100644 --- a/matrix/api.js +++ b/matrix/api.js @@ -115,7 +115,7 @@ function getStateEvent(roomID, type, key) { /** * "Any of the AS's users must be in the room. This API is primarily for Application Services and should be faster to respond than /members as it can be implemented more efficiently on the server." * @param {string} roomID - * @returns {Promise<{joined: {[mxid: string]: {avatar_url?: string, display_name?: string}}}>} + * @returns {Promise<{joined: {[mxid: string]: {avatar_url: string?, display_name: string?}}}>} */ function getJoinedMembers(roomID) { return mreq.mreq("GET", `/client/v3/rooms/${roomID}/joined_members`)