diff --git a/jsconfig.json b/jsconfig.json index 4106061..65a9b50 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "target": "es2024", "module": "nodenext", + "lib": ["ESNext"], "strict": true, "noImplicitAny": false, "useUnknownInCatchVariables": false diff --git a/scripts/reset-web-password.js b/scripts/reset-web-password.js new file mode 100644 index 0000000..9131efb --- /dev/null +++ b/scripts/reset-web-password.js @@ -0,0 +1,17 @@ +// @ts-check + +const {reg, writeRegistration, registrationFilePath} = require("../src/matrix/read-registration") +const {prompt} = require("enquirer") + +;(async () => { + /** @type {{web_password: string}} */ + const passwordResponse = await prompt({ + type: "text", + name: "web_password", + message: "Choose a simple password (optional)" + }) + + reg.ooye.web_password = passwordResponse.web_password + writeRegistration(reg) + console.log("Saved. Restart Out Of Your Element to apply this change.") +})() diff --git a/src/d2m/actions/poll-vote.js b/src/d2m/actions/poll-vote.js index 85a223d..66918fe 100644 --- a/src/d2m/actions/poll-vote.js +++ b/src/d2m/actions/poll-vote.js @@ -70,13 +70,14 @@ async function sendVotes(userOrID, channelID, pollMessageID, pollEventID) { return } + let userID, senderMxid if (typeof userOrID === "string") { // just a string when double-checking a vote removal - good thing the unvoter is already here from having voted - var userID = userOrID - var senderMxid = from("sim").join("sim_member", "mxid").where({user_id: userOrID, room_id: matchingRoomID}).pluck("mxid").get() + userID = userOrID + senderMxid = from("sim").join("sim_member", "mxid").where({user_id: userOrID, room_id: matchingRoomID}).pluck("mxid").get() if (!senderMxid) return } else { // sent in full when double-checking adding a vote, so we can properly ensure joined - var userID = userOrID.id - var senderMxid = await registerUser.ensureSimJoined(userOrID, matchingRoomID) + userID = userOrID.id + senderMxid = await registerUser.ensureSimJoined(userOrID, matchingRoomID) } const answersArray = select("poll_vote", "matrix_option", {discord_or_matrix_user_id: userID, message_id: pollMessageID}).pluck().all() diff --git a/src/d2m/actions/retrigger.js b/src/d2m/actions/retrigger.js index 7ff0426..66ef19e 100644 --- a/src/d2m/actions/retrigger.js +++ b/src/d2m/actions/retrigger.js @@ -19,7 +19,7 @@ const emitter = new EventEmitter() * Due to Eventual Consistency(TM) an update/delete may arrive before the original message arrives * (or before the it has finished being bridged to an event). * In this case, wait until the original message has finished bridging, then retrigger the passed function. - * @template {(...args: any[]) => Promise} T + * @template {(...args: any[]) => any} T * @param {string} inputID * @param {T} fn * @param {Parameters} rest diff --git a/src/d2m/actions/speedbump.js b/src/d2m/actions/speedbump.js index 1a6ef63..218f046 100644 --- a/src/d2m/actions/speedbump.js +++ b/src/d2m/actions/speedbump.js @@ -54,8 +54,8 @@ async function doSpeedbump(messageID) { debugSpeedbump(`[speedbump] DELETED ${messageID}`) return true } - value = bumping.get(messageID) - 1 - if (value === 0) { + value = (bumping.get(messageID) ?? 0) - 1 + if (value <= 0) { debugSpeedbump(`[speedbump] OK ${messageID}-- = ${value}`) bumping.delete(messageID) return false diff --git a/src/d2m/converters/find-mentions.js b/src/d2m/converters/find-mentions.js index 9db6355..8726830 100644 --- a/src/d2m/converters/find-mentions.js +++ b/src/d2m/converters/find-mentions.js @@ -9,7 +9,7 @@ const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) * @typedef {{text: string, index: number, end: number}} Token */ -/** @typedef {{mxids: {localpart: string, mxid: string, displayname?: string}[], names: {displaynameTokens: Token[], mxid: string}[]}} ProcessedJoined */ +/** @typedef {{mxids: {localpart: string, mxid: string, displayname?: string | null}[], names: {displaynameTokens: Token[], mxid: string}[]}} ProcessedJoined */ const lengthBonusLengthCap = 50 const lengthBonusValue = 0.5 @@ -18,7 +18,7 @@ const lengthBonusValue = 0.5 * 0 = no match * @param {string} localpart * @param {string} input - * @param {string} [displayname] only for the super tiebreaker + * @param {string | null} [displayname] only for the super tiebreaker * @returns {{score: number, matchedInputTokens: Token[]}} */ function scoreLocalpart(localpart, input, displayname) { @@ -103,7 +103,7 @@ function tokenise(name) { } /** - * @param {{mxid: string, displayname?: string}[]} joined + * @param {{mxid: string, displayname?: string | null}[]} joined * @returns {ProcessedJoined} */ function processJoined(joined) { @@ -120,6 +120,7 @@ function processJoined(joined) { }), names: joined.filter(j => j.displayname).map(j => { return { + // @ts-ignore displaynameTokens: tokenise(j.displayname), mxid: j.mxid } @@ -130,6 +131,8 @@ function processJoined(joined) { /** * @param {ProcessedJoined} pjr * @param {string} maximumWrittenSection lowercase please + * @param {number} baseOffset + * @param {string} prefix * @param {string} content */ function findMention(pjr, maximumWrittenSection, baseOffset, prefix, content) { @@ -142,7 +145,7 @@ function findMention(pjr, maximumWrittenSection, baseOffset, prefix, content) { if (best.scored.score > 4) { // requires in smallest case perfect match of 2 characters, or in largest case a partial middle match of 5+ characters in a row // Highlight the relevant part of the message const start = baseOffset + best.scored.matchedInputTokens[0].index - const end = baseOffset + prefix.length + best.scored.matchedInputTokens.at(-1).end + const end = baseOffset + prefix.length + best.scored.matchedInputTokens.slice(-1)[0].end const newContent = content.slice(0, start) + "[" + content.slice(start, end) + "](https://matrix.to/#/" + best.mxid + ")" + content.slice(end) return { mxid: best.mxid, diff --git a/src/d2m/converters/find-mentions.test.js b/src/d2m/converters/find-mentions.test.js index 0d02285..8f2be09 100644 --- a/src/d2m/converters/find-mentions.test.js +++ b/src/d2m/converters/find-mentions.test.js @@ -113,7 +113,7 @@ test("score name: finds match location", t => { const message = "evil lillith is an inspiration" const result = scoreName(tokenise("INX | Evil Lillith (she/her)"), tokenise(message)) const startLocation = result.matchedInputTokens[0].index - const endLocation = result.matchedInputTokens.at(-1).end + const endLocation = result.matchedInputTokens.slice(-1)[0].end t.equal(message.slice(startLocation, endLocation), "evil lillith") }) @@ -125,5 +125,5 @@ test("find mention: test various tiebreakers", t => { mxid: "@emma:rory.gay", displayname: "Emma [it/its]" }]), "emma ⚡ curious which one this prefers", 0, "@", "@emma ⚡ curious which one this prefers") - t.equal(found.mxid, "@emma:conduit.rory.gay") + t.equal(found?.mxid, "@emma:conduit.rory.gay") }) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 38d1601..b36bdf5 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -427,7 +427,7 @@ async function messageToEvent(message, guild, options = {}, di) { * @param {string} [timestampChannelID] */ async function getHistoricalEventRow(messageID, timestampChannelID) { - /** @type {{room_id: string} | {event_id: string, room_id: string, reference_channel_id: string, source: number} | null} */ + /** @type {{room_id: string} | {event_id: string, room_id: string, reference_channel_id: string, source: number} | null | undefined} */ let row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") .select("event_id", "room_id", "reference_channel_id", "source").where({message_id: messageID}).and("ORDER BY part ASC").get() if (!row && timestampChannelID) { @@ -574,6 +574,7 @@ async function messageToEvent(message, guild, options = {}, di) { if (repliedToEventInDifferentRoom || repliedToUnknownEvent) { let referenced = message.referenced_message if (!referenced) { // backend couldn't be bothered to dereference the message, have to do it ourselves + assert(message.message_reference?.message_id) referenced = await discord.snow.channel.getChannelMessage(message.message_reference.channel_id, message.message_reference.message_id) } @@ -661,14 +662,14 @@ async function messageToEvent(message, guild, options = {}, di) { } // Forwarded content appears first - if (message.message_reference?.type === DiscordTypes.MessageReferenceType.Forward && message.message_snapshots?.length) { + if (message.message_reference?.type === DiscordTypes.MessageReferenceType.Forward && message.message_reference.message_id && message.message_snapshots?.length) { // Forwarded notice const row = await getHistoricalEventRow(message.message_reference.message_id, message.message_reference.channel_id) const room = select("channel_room", ["room_id", "name", "nick"], {channel_id: message.message_reference.channel_id}).get() const forwardedNotice = new mxUtils.MatrixStringBuilder() if (room) { const roomName = room && (room.nick || room.name) - if ("event_id" in row) { + if (row && "event_id" in row) { const via = await getViaServersMemo(row.room_id) forwardedNotice.addLine( `[🔀 Forwarded from #${roomName}]`, @@ -802,20 +803,23 @@ async function messageToEvent(message, guild, options = {}, di) { // Then components if (message.components?.length) { - const stack = [new mxUtils.MatrixStringBuilder()] + const stack = new mxUtils.MatrixStringBuilderStack() /** @param {DiscordTypes.APIMessageComponent} component */ async function processComponent(component) { // Standalone components if (component.type === DiscordTypes.ComponentType.TextDisplay) { const {body, html} = await transformContent(component.content) - stack[0].addParagraph(body, html) + stack.msb.addParagraph(body, html) } else if (component.type === DiscordTypes.ComponentType.Separator) { - stack[0].addParagraph("----", "
") + stack.msb.addParagraph("----", "
") } else if (component.type === DiscordTypes.ComponentType.File) { - const ev = await attachmentToEvent({}, {...component.file, filename: component.name, size: component.size}, true) - stack[0].addLine(ev.body, ev.formatted_body) + /** @type {{[k in keyof DiscordTypes.APIUnfurledMediaItem]-?: NonNullable}} */ // @ts-ignore + const file = component.file + assert(component.name && component.size && file.content_type) + const ev = await attachmentToEvent({}, {...file, filename: component.name, size: component.size}, true) + stack.msb.addLine(ev.body, ev.formatted_body) } else if (component.type === DiscordTypes.ComponentType.MediaGallery) { const description = component.items.length === 1 ? component.items[0].description || "Image:" : "Image gallery:" @@ -826,43 +830,43 @@ async function messageToEvent(message, guild, options = {}, di) { estimatedName: item.media.url.match(/\/([^/?]+)(\?|$)/)?.[1] || publicURL } }) - stack[0].addLine(`🖼️ ${description} ${images.map(i => i.url).join(", ")}`, tag`🖼️ ${description} $${images.map(i => tag`${i.estimatedName}`).join(", ")}`) + stack.msb.addLine(`🖼️ ${description} ${images.map(i => i.url).join(", ")}`, tag`🖼️ ${description} $${images.map(i => tag`${i.estimatedName}`).join(", ")}`) } // string select, text input, user select, role select, mentionable select, channel select // Components that can have things nested else if (component.type === DiscordTypes.ComponentType.Container) { // May contain action row, text display, section, media gallery, separator, file - stack.unshift(new mxUtils.MatrixStringBuilder()) + stack.bump() for (const innerComponent of component.components) { await processComponent(innerComponent) } let {body, formatted_body} = stack.shift().get() body = body.split("\n").map(l => "| " + l).join("\n") formatted_body = `
${formatted_body}
` - if (stack[0].body) stack[0].body += "\n\n" - stack[0].add(body, formatted_body) + if (stack.msb.body) stack.msb.body += "\n\n" + stack.msb.add(body, formatted_body) } else if (component.type === DiscordTypes.ComponentType.Section) { // May contain text display, possibly more in the future // Accessory may be button or thumbnail - stack.unshift(new mxUtils.MatrixStringBuilder()) + stack.bump() for (const innerComponent of component.components) { await processComponent(innerComponent) } if (component.accessory) { - stack.unshift(new mxUtils.MatrixStringBuilder()) + stack.bump() await processComponent(component.accessory) const {body, formatted_body} = stack.shift().get() - stack[0].addLine(body, formatted_body) + stack.msb.addLine(body, formatted_body) } const {body, formatted_body} = stack.shift().get() - stack[0].addParagraph(body, formatted_body) + stack.msb.addParagraph(body, formatted_body) } else if (component.type === DiscordTypes.ComponentType.ActionRow) { const linkButtons = component.components.filter(c => c.type === DiscordTypes.ComponentType.Button && c.style === DiscordTypes.ButtonStyle.Link) if (linkButtons.length) { - stack[0].addLine("") + stack.msb.addLine("") for (const linkButton of linkButtons) { await processComponent(linkButton) } @@ -871,15 +875,15 @@ async function messageToEvent(message, guild, options = {}, di) { // Components that can only be inside things else if (component.type === DiscordTypes.ComponentType.Thumbnail) { // May only be a section accessory - stack[0].add(`🖼️ ${component.media.url}`, tag`🖼️ ${component.media.url}`) + stack.msb.add(`🖼️ ${component.media.url}`, tag`🖼️ ${component.media.url}`) } else if (component.type === DiscordTypes.ComponentType.Button) { // May only be a section accessory or in an action row (up to 5) if (component.style === DiscordTypes.ButtonStyle.Link) { if (component.label) { - stack[0].add(`[${component.label} ${component.url}] `, tag`${component.label} `) + stack.msb.add(`[${component.label} ${component.url}] `, tag`${component.label} `) } else { - stack[0].add(component.url) + stack.msb.add(component.url) } } } @@ -891,7 +895,7 @@ async function messageToEvent(message, guild, options = {}, di) { await processComponent(component) } - const {body, formatted_body} = stack[0].get() + const {body, formatted_body} = stack.msb.get() if (body.trim().length) { await addTextEvent(body, formatted_body, "m.text") } @@ -914,7 +918,7 @@ async function messageToEvent(message, guild, options = {}, di) { continue // Matrix's own URL previews are fine for images. } - if (embed.type === "video" && !embed.title && message.content.includes(embed.video?.url)) { + if (embed.type === "video" && embed.video?.url && !embed.title && message.content.includes(embed.video.url)) { continue // Doesn't add extra information and the direct video URL is already there. } @@ -937,6 +941,7 @@ async function messageToEvent(message, guild, options = {}, di) { const rep = new mxUtils.MatrixStringBuilder() if (isKlipyGIF) { + assert(embed.video?.url) rep.add("[GIF] ", "➿ ") if (embed.title) { rep.add(`${embed.title} ${embed.video.url}`, tag`${embed.title}`) diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index 2e005ad..af18669 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -308,7 +308,7 @@ module.exports = { async MESSAGE_REACTION_ADD(client, data) { if (data.user_id === client.user.id) return // m2d reactions are added by the discord bot user - do not reflect them back to matrix. if (data.emoji.name === "❓" && select("event_message", "message_id", {message_id: data.message_id, source: 0, part: 0}).get()) { // source 0 = matrix - const guild_id = data.guild_id ?? client.channels.get(data.channel_id)["guild_id"] + const guild_id = data.guild_id ?? client.channels.get(data.channel_id)?.["guild_id"] await Promise.all([ client.snow.channel.deleteReaction(data.channel_id, data.message_id, data.emoji.name).catch(() => {}), // @ts-ignore - this is all you need for it to do a matrix-side lookup diff --git a/src/discord/interactions/matrix-info.js b/src/discord/interactions/matrix-info.js index 79300a3..c85cec2 100644 --- a/src/discord/interactions/matrix-info.js +++ b/src/discord/interactions/matrix-info.js @@ -54,8 +54,11 @@ async function _interact({guild_id, data}, {api}) { // from Matrix const event = await api.getEvent(message.room_id, message.event_id) const via = await utils.getViaServersQuery(message.room_id, api) - const inChannels = discord.guildChannelMap.get(guild_id) - .map(cid => discord.channels.get(cid)) + const channelsInGuild = discord.guildChannelMap.get(guild_id) + assert(channelsInGuild) + const inChannels = channelsInGuild + // @ts-ignore + .map(/** @returns {DiscordTypes.APIGuildChannel} */ cid => discord.channels.get(cid)) .sort((a, b) => webGuild._getPosition(a, discord.channels) - webGuild._getPosition(b, discord.channels)) .filter(channel => from("channel_room").join("member_cache", "room_id").select("mxid").where({channel_id: channel.id, mxid: event.sender}).get()) const matrixMember = select("member_cache", ["displayname", "avatar_url"], {room_id: message.room_id, mxid: event.sender}).get() @@ -67,7 +70,7 @@ async function _interact({guild_id, data}, {api}) { author: { name, url: `https://matrix.to/#/${event.sender}`, - icon_url: utils.getPublicUrlForMxc(matrixMember.avatar_url) + icon_url: utils.getPublicUrlForMxc(matrixMember?.avatar_url) }, description: `This Matrix message was delivered to Discord by **Out Of Your Element**.\n[View on Matrix →]()\n\n**User ID**: [${event.sender}]()`, color: 0x0dbd8b, @@ -96,7 +99,7 @@ async function dm(interaction) { const channel = await discord.snow.user.createDirectMessageChannel(interaction.member.user.id) const response = await _interact(interaction, {api}) assert(response.type === DiscordTypes.InteractionResponseType.ChannelMessageWithSource) - response.data.flags &= 0 // not ephemeral + response.data.flags = 0 & 0 // not ephemeral await discord.snow.channel.createMessage(channel.id, response.data) } diff --git a/src/discord/interactions/ping.js b/src/discord/interactions/ping.js index 57b48b1..45824be 100644 --- a/src/discord/interactions/ping.js +++ b/src/discord/interactions/ping.js @@ -31,7 +31,7 @@ async function* _interactAutocomplete({data, channel}, {api}) { } // Check it was used in a bridged channel - const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() + const roomID = select("channel_room", "room_id", {channel_id: channel?.id}).pluck().get() if (!roomID) return yield exit() // Check we are in fact autocompleting the first option, the user @@ -58,9 +58,9 @@ async function* _interactAutocomplete({data, channel}, {api}) { const displaynameMatches = select("member_cache", ["mxid", "displayname"], {room_id: roomID}, "AND displayname IS NOT NULL AND displayname LIKE ? ESCAPE '$' LIMIT 25").all(query) // prioritise matches closer to the start displaynameMatches.sort((a, b) => { - let ai = a.displayname.toLowerCase().indexOf(input.toLowerCase()) + let ai = a.displayname?.toLowerCase().indexOf(input.toLowerCase()) ?? -1 if (ai === -1) ai = 999 - let bi = b.displayname.toLowerCase().indexOf(input.toLowerCase()) + let bi = b.displayname?.toLowerCase().indexOf(input.toLowerCase()) ?? -1 if (bi === -1) bi = 999 return ai - bi }) @@ -132,14 +132,18 @@ async function* _interactCommand({data, channel, guild_id}, {api}) { type: DiscordTypes.InteractionResponseType.DeferredChannelMessageWithSource }} + let member try { /** @type {Ty.Event.M_Room_Member} */ - var member = await api.getStateEvent(roomID, "m.room.member", mxid) + member = await api.getStateEvent(roomID, "m.room.member", mxid) } catch (e) {} if (!member || member.membership !== "join") { - const inChannels = discord.guildChannelMap.get(guild_id) - .map(cid => discord.channels.get(cid)) + const channelsInGuild = discord.guildChannelMap.get(guild_id) + assert(channelsInGuild) + const inChannels = channelsInGuild + // @ts-ignore + .map(/** @returns {DiscordTypes.APIGuildChannel} */ cid => discord.channels.get(cid)) .sort((a, b) => webGuild._getPosition(a, discord.channels) - webGuild._getPosition(b, discord.channels)) .filter(channel => from("channel_room").join("member_cache", "room_id").select("mxid").where({channel_id: channel.id, mxid}).get()) if (inChannels.length) { diff --git a/src/m2d/actions/add-reaction.js b/src/m2d/actions/add-reaction.js index 9ee9276..e4981fb 100644 --- a/src/m2d/actions/add-reaction.js +++ b/src/m2d/actions/add-reaction.js @@ -17,7 +17,7 @@ const retrigger = sync.require("../../d2m/actions/retrigger") */ async function addReaction(event) { // Wait until the corresponding channel and message have already been bridged - if (retrigger.eventNotFoundThenRetrigger(event.content["m.relates_to"].event_id, as.emit.bind(as, "type:m.reaction", event))) return + if (retrigger.eventNotFoundThenRetrigger(event.content["m.relates_to"].event_id, () => as.emit("type:m.reaction", event))) return // These will exist because it passed retrigger const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") diff --git a/src/m2d/actions/redact.js b/src/m2d/actions/redact.js index 022157d..3135d31 100644 --- a/src/m2d/actions/redact.js +++ b/src/m2d/actions/redact.js @@ -58,7 +58,7 @@ async function handle(event) { await removeReaction(event) // Or, it might be for removing a message or suppressing embeds. But to do that, the message needs to be bridged first. - if (retrigger.eventNotFoundThenRetrigger(event.redacts, as.emit.bind(as, "type:m.room.redaction", event))) return + if (retrigger.eventNotFoundThenRetrigger(event.redacts, () => as.emit("type:m.room.redaction", event))) return const row = select("event_message", ["event_type", "event_subtype", "part"], {event_id: event.redacts}).get() if (row && row.event_type === "m.room.message" && row.event_subtype === "m.notice" && row.part === 1) { diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index a99950b..f6baf55 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -1,4 +1,5 @@ // @ts-check +/// const Ty = require("../../types") const DiscordTypes = require("discord-api-types/v10") @@ -371,6 +372,7 @@ function linkEndOfMessageSpriteSheet(content) { for (const mxc of endOfMessageEmojis) { // We can do up to 2000 chars max. (In this maximal case it will get chunked to a separate message.) Ignore additional emojis. const withoutMxc = mxUtils.makeMxcPublic(mxc) + assert(withoutMxc) const emojisLength = params.toString().length + encodeURIComponent(withoutMxc).length + 2 if (content.length + emojisLength + afterLink.length > 2000) { break diff --git a/src/matrix/api.js b/src/matrix/api.js index 1cd05d3..70cb50b 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -196,9 +196,10 @@ async function getInviteState(roomID, event) { } // Try calling sliding sync API and extracting from stripped state + let root try { /** @type {Ty.R.SSS} */ - var root = await mreq.mreq("POST", path("/client/unstable/org.matrix.simplified_msc3575/sync", `@${reg.sender_localpart}:${reg.ooye.server_name}`, {timeout: "0"}), { + root = await mreq.mreq("POST", path("/client/unstable/org.matrix.simplified_msc3575/sync", `@${reg.sender_localpart}:${reg.ooye.server_name}`, {timeout: "0"}), { lists: { a: { ranges: [[0, 999]], @@ -239,7 +240,7 @@ async function getInviteState(roomID, event) { name: room.name ?? null, topic: room.topic ?? null, avatar: room.avatar_url ?? null, - type: room.room_type + type: room.room_type ?? null } } @@ -426,7 +427,7 @@ async function profileSetDisplayname(mxid, displayname, inhibitPropagate) { /** * @param {string} mxid - * @param {string} avatar_url + * @param {string | null | undefined} avatar_url * @param {boolean} [inhibitPropagate] */ async function profileSetAvatarUrl(mxid, avatar_url, inhibitPropagate) { diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index c1c69f1..e382a32 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -124,7 +124,7 @@ const commands = [{ if (matrixOnlyReason) { // If uploading to Matrix, check if we have permission const {powerLevels, powers: {[mxUtils.bot]: botPower}} = await mxUtils.getEffectivePower(event.room_id, [mxUtils.bot], api) - const requiredPower = powerLevels.events["im.ponies.room_emotes"] ?? powerLevels.state_default ?? 50 + const requiredPower = powerLevels.events?.["im.ponies.room_emotes"] ?? powerLevels.state_default ?? 50 if (botPower < requiredPower) { return api.sendEvent(event.room_id, "m.room.message", { ...ctx, diff --git a/src/matrix/room-upgrade.js b/src/matrix/room-upgrade.js index 6c344cf..5a2606e 100644 --- a/src/matrix/room-upgrade.js +++ b/src/matrix/room-upgrade.js @@ -57,12 +57,12 @@ async function onBotMembership(event, api, createRoom) { // 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 + if (!oldRoomID) return false 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 + if (event.content.membership !== "invite" && event.content.membership !== "join") return false return await roomUpgradeSema.request(async () => { // If invited, join diff --git a/src/matrix/utils.js b/src/matrix/utils.js index 9e447e7..9f5cb0f 100644 --- a/src/matrix/utils.js +++ b/src/matrix/utils.js @@ -60,6 +60,26 @@ function getEventIDHash(eventID) { return signedHash } +class MatrixStringBuilderStack { + constructor() { + this.stack = [new MatrixStringBuilder()] + } + + get msb() { + return this.stack[0] + } + + bump() { + this.stack.unshift(new MatrixStringBuilder()) + } + + shift() { + const msb = this.stack.shift() + assert(msb) + return msb + } +} + class MatrixStringBuilder { constructor() { this.body = "" @@ -228,7 +248,7 @@ function generatePermittedMediaHash(mxc) { * @see https://matrix.org/blog/2024/06/26/sunsetting-unauthenticated-media/ background * @see https://matrix.org/blog/2024/06/20/matrix-v1.11-release/ implementation details * @see https://www.sqlite.org/fileformat2.html#record_format SQLite integer field size - * @param {string} mxc + * @param {string | null | undefined} mxc * @returns {string | undefined} */ function getPublicUrlForMxc(mxc) { @@ -238,7 +258,7 @@ function getPublicUrlForMxc(mxc) { } /** - * @param {string} mxc + * @param {string | null | undefined} mxc * @returns {string | undefined} mxc URL with protocol stripped, e.g. "cadence.moe/abcdef1234" */ function makeMxcPublic(mxc) { @@ -289,7 +309,7 @@ function roomHasAtLeastVersion(roomVersionString, desiredVersion) { */ function removeCreatorsFromPowerLevels(roomCreateOuter, powerLevels) { assert(roomCreateOuter.sender) - if (roomHasAtLeastVersion(roomCreateOuter.content.room_version, 12)) { + if (roomHasAtLeastVersion(roomCreateOuter.content.room_version, 12) && powerLevels.users) { for (const creator of (roomCreateOuter.content.additional_creators ?? []).concat(roomCreateOuter.sender)) { delete powerLevels.users[creator] } @@ -385,6 +405,7 @@ module.exports.makeMxcPublic = makeMxcPublic module.exports.getPublicUrlForMxc = getPublicUrlForMxc module.exports.getEventIDHash = getEventIDHash module.exports.MatrixStringBuilder = MatrixStringBuilder +module.exports.MatrixStringBuilderStack = MatrixStringBuilderStack module.exports.getViaServers = getViaServers module.exports.getViaServersQuery = getViaServersQuery module.exports.roomHasAtLeastVersion = roomHasAtLeastVersion diff --git a/src/web/routes/download-discord.js b/src/web/routes/download-discord.js index 3c58a75..769fc9c 100644 --- a/src/web/routes/download-discord.js +++ b/src/web/routes/download-discord.js @@ -31,7 +31,7 @@ function getSnow(event) { /** @type {Map>} */ const cache = new Map() -/** @param {string | undefined} url */ +/** @param {string} url */ function timeUntilExpiry(url) { const params = new URL(url).searchParams const ex = params.get("ex") diff --git a/src/web/routes/download-matrix.test.js b/src/web/routes/download-matrix.test.js index 49a6349..ccbcfdd 100644 --- a/src/web/routes/download-matrix.test.js +++ b/src/web/routes/download-matrix.test.js @@ -5,6 +5,7 @@ const {convertImageStream} = require("../../m2d/converters/emoji-sheet") const tryToCatch = require("try-to-catch") const {test} = require("supertape") const {router} = require("../../../test/web") +const streamWeb = require("stream/web") test("web download matrix: access denied if not a known attachment", async t => { const [error] = await tryToCatch(() => @@ -27,6 +28,7 @@ test("web download matrix: works if a known attachment", async t => { }, event, api: { + // @ts-ignore async getMedia(mxc, init) { return new Response("", {status: 200, headers: {"content-type": "image/png"}}) } diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index 0af37e2..4f140a3 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -54,8 +54,8 @@ function getAPI(event) { const validNonce = new LRUCache({max: 200}) /** - * @param {{type: number, parent_id?: string, position?: number}} channel - * @param {Map} channels + * @param {{type: number, parent_id?: string | null, position?: number}} channel + * @param {Map} channels */ function getPosition(channel, channels) { let position = 0 @@ -65,9 +65,11 @@ function getPosition(channel, channels) { // Categories are size 2000. let foundCategory = channel while (foundCategory.parent_id) { - foundCategory = channels.get(foundCategory.parent_id) + const f = channels.get(foundCategory.parent_id) + assert(f) + foundCategory = f } - if (foundCategory.type === DiscordTypes.ChannelType.GuildCategory) position = (foundCategory.position + 1) * 2000 + if (foundCategory.type === DiscordTypes.ChannelType.GuildCategory) position = ((foundCategory.position || 0) + 1) * 2000 // Categories always appear above what they contain. if (channel.type === DiscordTypes.ChannelType.GuildCategory) position -= 0.5 @@ -81,7 +83,7 @@ function getPosition(channel, channels) { // 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) + let parent = channels.get(channel.parent_id || "") if (parent && parent["position"]) position += parent["position"] } @@ -98,7 +100,11 @@ function getChannelRoomsLinks(guild, rooms, roles) { assert(channelIDs) let linkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {channel_id: channelIDs}).all() - let linkedChannelsWithDetails = linkedChannels.map(c => ({channel: discord.channels.get(c.channel_id), ...c})) + let linkedChannelsWithDetails = linkedChannels.map(c => ({ + // @ts-ignore + /** @type {DiscordTypes.APIGuildChannel} */ channel: discord.channels.get(c.channel_id), + ...c + })) let removedUncachedChannels = dUtils.filterTo(linkedChannelsWithDetails, c => c.channel) let linkedChannelIDs = linkedChannelsWithDetails.map(c => c.channel_id) linkedChannelsWithDetails.sort((a, b) => getPosition(a.channel, discord.channels) - getPosition(b.channel, discord.channels)) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 8649348..10596f2 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -1,5 +1,6 @@ // @ts-check +const assert = require("assert").strict const {z} = require("zod") const {defineEventHandler, createError, readValidatedBody, setResponseHeader, H3Event} = require("h3") const Ty = require("../../types") @@ -77,7 +78,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 via = [inviteRow.mxid.match(/:(.*)/)[1]] + const inviteServer = inviteRow.mxid.match(/:(.*)/)?.[1] + assert(inviteServer) + const via = [inviteServer] // Check space exists and bridge is joined try {