From 09ea94230769c078a54f556655db9486ff6b7233 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sat, 14 Feb 2026 22:47:38 +1300 Subject: [PATCH 01/12] Remove deprecated db management --- scripts/setup.js | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/scripts/setup.js b/scripts/setup.js index 696eec9..ecc57fd 100644 --- a/scripts/setup.js +++ b/scripts/setup.js @@ -17,22 +17,6 @@ const {SnowTransfer} = require("snowtransfer") const DiscordTypes = require("discord-api-types/v10") const {createApp, defineEventHandler, toNodeListener} = require("h3") -// Move database file if it's still in the old location -if (fs.existsSync("db")) { - if (fs.existsSync("db/ooye.db")) { - fs.renameSync("db/ooye.db", "ooye.db") - } - const files = fs.readdirSync("db") - if (files.length) { - console.error("The db folder is deprecated and must be removed. Your ooye.db database file has already been moved to the root of the repo. You must manually move or delete the remaining files:") - for (const file of files) { - console.error(file) - } - process.exit(1) - } - fs.rmSync("db", {recursive: true}) -} - const passthrough = require("../src/passthrough") const db = new sqlite("ooye.db") const migrate = require("../src/db/migrate") From e779b4107285f8c6af8a9ac1918a264db74a3137 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 15 Feb 2026 12:34:08 +1300 Subject: [PATCH 02/12] Fix possible undefined property access --- src/d2m/converters/edit-to-changes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/d2m/converters/edit-to-changes.js b/src/d2m/converters/edit-to-changes.js index cfbd1e2..4f743eb 100644 --- a/src/d2m/converters/edit-to-changes.js +++ b/src/d2m/converters/edit-to-changes.js @@ -153,7 +153,7 @@ async function editToChanges(message, guild, api) { const embedsEnabled = select("guild_space", "url_preview", {guild_id: guild?.id}).pluck().get() ?? 1 if (messageReallyOld) { eventsToSend = [] // Only allow edits to change and delete, but not send new. - } else if ((messageQuiteOld || !embedsEnabled) && !message.author.bot) { + } else if ((messageQuiteOld || !embedsEnabled) && !message.author?.bot) { eventsToSend = eventsToSend.filter(e => e.msgtype !== "m.notice") // Only send events that aren't embeds. } From 0cd7e1c336ae12ea39431926913c2d5f11728839 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 17 Feb 2026 12:54:50 +1300 Subject: [PATCH 03/12] Allow for custom additions to webroot --- .gitignore | 1 + docs/developer-orientation.md | 4 +- package-lock.json | 2 + package.json | 1 + src/web/pug-sync.js | 9 +++ src/web/pug/home.pug | 22 +++---- src/web/pug/includes/template.pug | 30 ++++++++-- src/web/server.js | 98 ++++++++++++++++++++++++------- 8 files changed, 127 insertions(+), 40 deletions(-) diff --git a/.gitignore b/.gitignore index 1798643..c38dd88 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ registration.yaml ooye.db* events.db* backfill.db* +custom-webroot # Automatically generated node_modules diff --git a/docs/developer-orientation.md b/docs/developer-orientation.md index 056fe7e..dbb19f3 100644 --- a/docs/developer-orientation.md +++ b/docs/developer-orientation.md @@ -89,7 +89,7 @@ Whether you read those or not, I'm more than happy to help you 1-on-1 with codin # Dependency justification -Total transitive production dependencies: 137 +Total transitive production dependencies: 134 ### 🦕 @@ -119,8 +119,8 @@ Total transitive production dependencies: 137 * (0) entities: Looks fine. No dependencies. * (0) get-relative-path: Looks fine. No dependencies. * (1) heatsync: Module hot-reloader that I trust. -* (1) js-yaml: Will be removed in the future after registration.yaml is converted to JSON. * (0) lru-cache: For holding unused nonce in memory and letting them be overwritten later if never used. +* (0) mime-type: File extension to mime type mapping that's already pulled in by stream-mime-type. * (0) prettier-bytes: It does what I want and has no dependencies. * (0) snowtransfer: Discord API library with bring-your-own-caching that I trust. * (0) try-to-catch: Not strictly necessary, but it's already pulled in by supertape, so I may as well. diff --git a/package-lock.json b/package-lock.json index 5d183b7..9847400 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "heatsync": "^2.7.2", "htmx.org": "^2.0.4", "lru-cache": "^11.0.2", + "mime-types": "^2.1.35", "prettier-bytes": "^1.0.4", "sharp": "^0.34.5", "snowtransfer": "^0.17.1", @@ -2073,6 +2074,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", "dependencies": { "mime-db": "1.52.0" }, diff --git a/package.json b/package.json index 2ace5bf..afbb90a 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "heatsync": "^2.7.2", "htmx.org": "^2.0.4", "lru-cache": "^11.0.2", + "mime-types": "^2.1.35", "prettier-bytes": "^1.0.4", "sharp": "^0.34.5", "snowtransfer": "^0.17.1", diff --git a/src/web/pug-sync.js b/src/web/pug-sync.js index f49f5a2..f87550d 100644 --- a/src/web/pug-sync.js +++ b/src/web/pug-sync.js @@ -31,7 +31,15 @@ function addGlobals(obj) { */ function render(event, filename, locals) { const path = join(__dirname, "pug", filename) + return renderPath(event, path, locals) +} +/** + * @param {import("h3").H3Event} event + * @param {string} path + * @param {Record} locals + */ +function renderPath(event, path, locals) { function compile() { try { const template = compileFile(path, {pretty}) @@ -89,4 +97,5 @@ function createRoute(router, url, filename) { module.exports.addGlobals = addGlobals module.exports.render = render +module.exports.renderPath = renderPath module.exports.createRoute = createRoute diff --git a/src/web/pug/home.pug b/src/web/pug/home.pug index d562250..8b86533 100644 --- a/src/web/pug/home.pug +++ b/src/web/pug/home.pug @@ -41,16 +41,18 @@ block body = ` Set up self-service` .s-prose - h2 What is this? - p #[a(href="https://gitdab.com/cadence/out-of-your-element") Out Of Your Element] is a bridge between the Discord and Matrix chat apps. It lets people on both platforms chat with each other without needing to get everyone on the same app. - p Just chat like usual, and the bridge will forward messages back and forth between the two platforms, so everyone sees the whole conversation. - p All kinds of content are supported, including pictures, threads, emojis, and @mentions. - p It's really easy to set up, even if you only have Discord. Just add the bot to your server, and it'll make everything available on Matrix automatically. + block bridge-info + h2 What is this? + p #[a(href="https://gitdab.com/cadence/out-of-your-element") Out Of Your Element] is a bridge between the Discord and Matrix chat apps. It lets people on both platforms chat with each other without needing to get everyone on the same app. + p Just chat like usual, and the bridge will forward messages back and forth between the two platforms, so everyone sees the whole conversation. + p All kinds of content are supported, including pictures, threads, emojis, and @mentions. + p It's really easy to set up, even if you only have Discord. Just add the bot to your server, and it'll make everything available on Matrix automatically. if locked - h2 This is a private instance - p Anybody can run their own instance of the Out Of Your Element software. The person running this instance has made it private, so you can't add it to your server just yet. If you know who's in charge of #{reg.ooye.server_name}, ask them for the password. + block locked-info + h2 This is a private instance + p Anybody can run their own instance of the Out Of Your Element software. The person running this instance has made it private, so you can't add it to your server just yet. If you know who's in charge of #{reg.ooye.server_name}, ask them for the password. - h2 Run your own instance - p You can still use Out Of Your Element by running your own copy of the software, but this requires some technical skill. - p To get started, #[a(href="https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/get-started.md") check the installation instructions.] + h2 Run your own instance + p You can still use Out Of Your Element by running your own copy of the software, but this requires some technical skill. + p To get started, #[a(href="https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/get-started.md") check the installation instructions.] diff --git a/src/web/pug/includes/template.pug b/src/web/pug/includes/template.pug index 452f8d5..9fe80aa 100644 --- a/src/web/pug/includes/template.pug +++ b/src/web/pug/includes/template.pug @@ -1,4 +1,10 @@ -mixin guild(guild) +mixin guild-menuitem(guild) + - let bridgedRoomCount = from("channel_room").selectUnsafe("count(*) as count").where({guild_id: guild.id}).and("AND thread_parent IS NULL").get().count + li(role="menuitem") + a.s-topbar--item.s-user-card.d-flex.p4(href=rel(`/guild?guild_id=${guild.id}`) class={"bg-purple-200": bridgedRoomCount === 0, "h:bg-purple-300": bridgedRoomCount === 0}) + +guild(guild, bridgedRoomCount) + +mixin guild(guild, bridgedRoomCount) span.s-avatar.s-avatar__32.s-user-card--avatar if guild.icon img.s-avatar--image(src=`https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=32` alt="") @@ -6,8 +12,12 @@ mixin guild(guild) .s-avatar--letter.bg-silver-400.bar-md(aria-hidden="true")= guild.name[0] .s-user-card--info.ai-start strong= guild.name - ul.s-user-card--awards - li #{discord.guildChannelMap.get(guild.id).filter(c => [0, 5, 15, 16].includes(discord.channels.get(c).type)).length} channels + if bridgedRoomCount != null + ul.s-user-card--awards + if bridgedRoomCount + li #{bridgedRoomCount} bridged rooms + else + li.fc-purple Not yet linked mixin define-theme(name, h, s, l) style. @@ -58,6 +68,8 @@ html(lang="en") title Out Of Your Element link(rel="stylesheet" type="text/css" href=rel("/static/stacks.min.css")) + //- Please use responsibly!!!!! + link(rel="stylesheet" type="text/css" href=rel("/custom.css")) meta(name="htmx-config" content='{"requestClass":"is-loading"}') style. @@ -79,6 +91,14 @@ html(lang="en") .s-btn__dropdown:has(+ :popover-open) { background-color: var(--theme-topbar-item-background-hover, var(--black-200)) !important; } + @media (prefers-color-scheme: dark) { + body.theme-system .s-popover { + --_po-bg: var(--black-100); + --_po-bc: var(--bc-light); + --_po-bs: var(--bs-lg); + --_po-arrow-fc: var(--black-100); + } + } +define-themed-button("matrix", "black") body.themed.theme-system header.s-topbar @@ -114,9 +134,7 @@ html(lang="en") .s-popover--content.overflow-y-auto.overflow-x-hidden ul.s-menu(role="menu") each guild in [...managed].map(id => discord.guilds.get(id)).filter(g => g).sort((a, b) => a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1) - li(role="menuitem") - a.s-topbar--item.s-user-card.d-flex.p4(href=rel(`/guild?guild_id=${guild.id}`)) - +guild(guild) + +guild-menuitem(guild) //- Body .mx-auto.w100.wmx9.py24.px8.fs-body1#content block body diff --git a/src/web/server.js b/src/web/server.js index a214877..dc13cf0 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -4,13 +4,14 @@ const assert = require("assert") const fs = require("fs") const {join} = require("path") const h3 = require("h3") -const {defineEventHandler, defaultContentType, getRequestHeader, setResponseHeader, handleCacheHeaders} = h3 +const mimeTypes = require("mime-types") +const {defineEventHandler, defaultContentType, getRequestHeader, setResponseHeader, handleCacheHeaders, serveStatic} = h3 const icons = require("@stackoverflow/stacks-icons") const DiscordTypes = require("discord-api-types/v10") const dUtils = require("../discord/utils") const reg = require("../matrix/read-registration") -const {sync, discord, as, select} = require("../passthrough") +const {sync, discord, as, select, from} = require("../passthrough") /** @type {import("./pug-sync")} */ const pugSync = sync.require("./pug-sync") /** @type {import("../matrix/utils")} */ @@ -19,21 +20,7 @@ const {id} = require("../../addbot") // Pug -pugSync.addGlobals({id, h3, discord, select, DiscordTypes, dUtils, mUtils, icons, reg: reg.reg}) -pugSync.createRoute(as.router, "/", "home.pug") -pugSync.createRoute(as.router, "/ok", "ok.pug") - -// Routes - -sync.require("./routes/download-matrix") -sync.require("./routes/download-discord") -sync.require("./routes/guild-settings") -sync.require("./routes/guild") -sync.require("./routes/info") -sync.require("./routes/link") -sync.require("./routes/log-in-with-matrix") -sync.require("./routes/oauth") -sync.require("./routes/password") +pugSync.addGlobals({id, h3, discord, select, from, DiscordTypes, dUtils, mUtils, icons, reg: reg.reg}) // Files @@ -65,12 +52,79 @@ as.router.get("/static/htmx.js", defineEventHandler({ } })) -as.router.get("/icon.png", defineEventHandler(event => { - handleCacheHeaders(event, {maxAge: 86400}) - return fs.promises.readFile(join(__dirname, "../../docs/img/icon.png")) -})) - as.router.get("/download/file/poll-star-avatar.png", defineEventHandler(event => { handleCacheHeaders(event, {maxAge: 86400}) return fs.promises.readFile(join(__dirname, "../../docs/img/poll-star-avatar.png")) })) + +// Custom files + +const publicDir = "custom-webroot" + +/** + * @param {h3.H3Event} event + * @param {boolean} fallthrough + */ +function tryStatic(event, fallthrough) { + return serveStatic(event, { + indexNames: ["/index.html", "/index.pug"], + fallthrough, + getMeta: async id => { + // Check + const stats = await fs.promises.stat(join(publicDir, id)).catch(() => {}); + if (!stats || !stats.isFile()) { + return + } + // Pug + if (id.match(/\.pug$/)) { + defaultContentType(event, "text/html; charset=utf-8") + return {} + } + // Everything else + else { + const mime = mimeTypes.lookup(id) + if (typeof mime === "string") defaultContentType(event, mime) + return { + size: stats.size + } + } + }, + getContents: id => { + if (id.match(/\.pug$/)) { + const path = join(publicDir, id) + return pugSync.renderPath(event, path, {}) + } else { + return fs.promises.readFile(join(publicDir, id)) + } + } + }) +} + +as.router.get("/**", defineEventHandler(event => { + return tryStatic(event, false) +})) + +as.router.get("/", defineEventHandler(async event => { + return (await tryStatic(event, true)) || pugSync.render(event, "home.pug", {}) +})) + +as.router.get("/icon.png", defineEventHandler(async event => { + const s = await tryStatic(event, true) + if (s) return s + handleCacheHeaders(event, {maxAge: 86400}) + return fs.promises.readFile(join(__dirname, "../../docs/img/icon.png")) +})) + +// Routes + +pugSync.createRoute(as.router, "/ok", "ok.pug") + +sync.require("./routes/download-matrix") +sync.require("./routes/download-discord") +sync.require("./routes/guild-settings") +sync.require("./routes/guild") +sync.require("./routes/info") +sync.require("./routes/link") +sync.require("./routes/log-in-with-matrix") +sync.require("./routes/oauth") +sync.require("./routes/password") From ee583fddbdaf55a68041040541426ae32cabfcd3 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 17 Feb 2026 12:56:18 +1300 Subject: [PATCH 04/12] Fix server names with numbers in them --- scripts/setup.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/setup.js b/scripts/setup.js index ecc57fd..4e6de0a 100644 --- a/scripts/setup.js +++ b/scripts/setup.js @@ -86,7 +86,7 @@ function defineEchoHandler() { type: "input", name: "server_name", message: "Homeserver name", - validate: serverName => !!serverName.match(/[a-z][a-z.]+[a-z]/) + validate: serverName => !!serverName.match(/[a-z0-9][.a-z0-9-]+[a-z]/) }) console.log("What is the URL of your homeserver?") From 9f9cfdb53493eefbed4c7023d6ad937046ac9f1a Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 17 Feb 2026 14:03:57 +1300 Subject: [PATCH 05/12] Allow namespace prefix to be empty string --- src/matrix/read-registration.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/read-registration.js b/src/matrix/read-registration.js index 6dc64dd..114bf75 100644 --- a/src/matrix/read-registration.js +++ b/src/matrix/read-registration.js @@ -11,7 +11,7 @@ const registrationFilePath = path.join(process.cwd(), "registration.yaml") function checkRegistration(reg) { reg["ooye"].invite = reg.ooye.invite.filter(mxid => mxid.endsWith(`:${reg.ooye.server_name}`)) // one day I will understand why typescript disagrees with dot notation on this line assert(reg.ooye?.max_file_size) - assert(reg.ooye?.namespace_prefix) + assert(reg.ooye?.namespace_prefix != null) assert(reg.ooye?.server_name) assert(reg.sender_localpart?.startsWith(reg.ooye.namespace_prefix), "appservice's localpart must be in the namespace it controls") assert(reg.ooye?.server_origin.match(/^https?:\/\//), "server origin must start with http or https") From 411491b405c92c7817ee552745429f6e59d16958 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sat, 21 Feb 2026 12:04:42 +1300 Subject: [PATCH 06/12] Remove live dependency on cadence.moe --- scripts/setup.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/scripts/setup.js b/scripts/setup.js index 4e6de0a..69b62a2 100644 --- a/scripts/setup.js +++ b/scripts/setup.js @@ -1,6 +1,7 @@ #!/usr/bin/env node // @ts-check +const Ty = require("../src/types") const assert = require("assert").strict const fs = require("fs") const sqlite = require("better-sqlite3") @@ -285,8 +286,8 @@ function defineEchoHandler() { console.log() // Done with user prompts, reg is now guaranteed to be valid + const mreq = require("../src/matrix/mreq") const api = require("../src/matrix/api") - const file = require("../src/matrix/file") const DiscordClient = require("../src/d2m/discord-client") const discord = new DiscordClient(reg.ooye.discord_token, "no") passthrough.discord = discord @@ -343,7 +344,13 @@ function defineEchoHandler() { await api.register(reg.sender_localpart) // upload initial images... - const avatarUrl = await file.uploadDiscordFileToMxc("https://cadence.moe/friends/out_of_your_element.png") + const avatarBuffer = await fs.promises.readFile(join(__dirname, "..", "docs", "img", "icon.png"), null) + /** @type {Ty.R.FileUploaded} */ + const root = await mreq.mreq("POST", "/media/v3/upload", avatarBuffer, { + headers: {"Content-Type": "image/png"} + }) + const avatarUrl = root.content_uri + assert(avatarUrl) console.log("✅ Matrix appservice login works...") @@ -352,8 +359,7 @@ function defineEchoHandler() { console.log("✅ Emojis are ready...") // set profile data on discord... - const avatarImageBuffer = await fetch("https://cadence.moe/friends/out_of_your_element.png").then(res => res.arrayBuffer()) - await discord.snow.user.updateSelf({avatar: "data:image/png;base64," + Buffer.from(avatarImageBuffer).toString("base64")}) + await discord.snow.user.updateSelf({avatar: "data:image/png;base64," + avatarBuffer.toString("base64")}) console.log("✅ Discord profile updated...") // set profile data on homeserver... From 9b3707baa1dc7f9308f3152068d69f0678d16d30 Mon Sep 17 00:00:00 2001 From: Abdul <32655037-CamperThumper@users.noreply.gitlab.com> Date: Wed, 25 Feb 2026 01:03:30 +0300 Subject: [PATCH 07/12] Link sticker instead of file upload --- src/m2d/actions/sticker.js | 53 ++++++++++++++++++++++++++ src/m2d/converters/event-to-message.js | 25 +++++------- src/web/routes/download-matrix.js | 19 +++++++++ 3 files changed, 81 insertions(+), 16 deletions(-) create mode 100644 src/m2d/actions/sticker.js diff --git a/src/m2d/actions/sticker.js b/src/m2d/actions/sticker.js new file mode 100644 index 0000000..f9c06bf --- /dev/null +++ b/src/m2d/actions/sticker.js @@ -0,0 +1,53 @@ +// @ts-check + +const streamr = require("stream") +const {pipeline} = require("stream").promises + +const {sync} = require("../../passthrough") +const sharp = require("sharp") +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") +/** @type {import("../../matrix/mreq")} */ +const mreq = sync.require("../../matrix/mreq") +const streamMimeType = require("stream-mime-type") + +const WIDTH = 160 +const HEIGHT = 160 +/** + * Downloads the sticker from the web and converts to webp data. + * @param {string} mxc a single mxc:// URL + * @returns {Promise} sticker webp data, or undefined if the downloaded sticker is not valid + */ +async function getAndResizeSticker(mxc) { + const res = await api.getMedia(mxc) + if (res.status !== 200) { + const root = await res.json() + throw new mreq.MatrixServerError(root, {mxc}) + } + const streamIn = streamr.Readable.fromWeb(res.body) + + const { stream, mime } = await streamMimeType.getMimeType(streamIn) + let animated = false + if (mime === "image/gif" || mime === "image/webp") { + animated = true + } + + const result = await new Promise((resolve, reject) => { + const transformer = sharp({animated: animated}) + .resize(WIDTH, HEIGHT, {fit: "inside", background: {r: 0, g: 0, b: 0, alpha: 0}}) + .webp() + .toBuffer((err, buffer, info) => { + /* c8 ignore next */ + if (err) return reject(err) + resolve({info, buffer}) + }) + pipeline( + stream, + transformer + ) + }) + return result.buffer +} + + +module.exports.getAndResizeSticker = getAndResizeSticker diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 2add279..91c2400 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -632,23 +632,16 @@ async function eventToMessage(event, guild, channel, di) { if (event.type === "m.sticker") { content = "" - let filename = event.content.body - if (event.type === "m.sticker") { - let mimetype - if (event.content.info?.mimetype?.includes("/")) { - mimetype = event.content.info.mimetype - } else { - const res = await di.api.getMedia(event.content.url, {method: "HEAD"}) - if (res.status === 200) { - mimetype = res.headers.get("content-type") - } - if (!mimetype) throw new Error(`Server error ${res.status} or missing content-type while detecting sticker mimetype`) - } - filename += "." + mimetype.split("/")[1] - } - attachments.push({id: "0", filename}) - pendingFiles.push({name: filename, mxc: event.content.url}) + content += `[${event.content.body}](` // sticker title for fallback if the url preview fails + const afterLink = ")" + // Make sticker URL params + const params = new URLSearchParams() + const withoutMxc = mxUtils.makeMxcPublic(event.content.url) + assert(withoutMxc) + params.append("mxc", withoutMxc) + const url = `${reg.ooye.bridge_origin}/download/sticker.webp?${params.toString()}` + content += url + afterLink } else if (event.type === "org.matrix.msc3381.poll.start") { const pollContent = event.content["org.matrix.msc3381.poll.start"] // just for convenience const isClosed = false; diff --git a/src/web/routes/download-matrix.js b/src/web/routes/download-matrix.js index bb6b850..1fbc1d6 100644 --- a/src/web/routes/download-matrix.js +++ b/src/web/routes/download-matrix.js @@ -16,6 +16,9 @@ const emojiSheet = sync.require("../../m2d/actions/emoji-sheet") /** @type {import("../../m2d/converters/emoji-sheet")} */ const emojiSheetConverter = sync.require("../../m2d/converters/emoji-sheet") +/** @type {import("../../m2d/actions/sticker")} */ +const sticker = sync.require("../../m2d/actions/sticker") + const schema = { params: z.object({ server_name: z.string(), @@ -23,6 +26,9 @@ const schema = { }), sheet: z.object({ e: z.array(z.string()).or(z.string()) + }), + sticker: z.object({ + mxc: z.string() }) } @@ -90,3 +96,16 @@ as.router.get(`/download/sheet`, defineEventHandler(async event => { setResponseHeader(event, "Content-Type", "image/png") return buffer })) + +as.router.get(`/download/sticker.webp`, defineEventHandler(async event => { + const query = await getValidatedQuery(event, schema.sticker.parse) + + /** remember that these have no mxc:// protocol in the string */ + verifyMediaHash(query.mxc) + const mxc = `mxc://${query.mxc}` + + setResponseHeader(event, "Content-Type", 'image/webp') + const buffer = await sticker.getAndResizeSticker(mxc) + return buffer +})) + From d1aa8f01e70e78d7ae438059872d4f7ef52bff1a Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 25 Feb 2026 18:21:35 +1300 Subject: [PATCH 08/12] Change sticker URL and stream response --- src/m2d/actions/sticker.js | 33 ++++++++------------------ src/m2d/converters/event-to-message.js | 12 +++------- src/web/routes/download-matrix.js | 21 ++++++++-------- 3 files changed, 23 insertions(+), 43 deletions(-) diff --git a/src/m2d/actions/sticker.js b/src/m2d/actions/sticker.js index f9c06bf..341d8b0 100644 --- a/src/m2d/actions/sticker.js +++ b/src/m2d/actions/sticker.js @@ -1,7 +1,7 @@ // @ts-check -const streamr = require("stream") -const {pipeline} = require("stream").promises +const {Readable} = require("stream") +const {ReadableStream} = require("stream/web") const {sync} = require("../../passthrough") const sharp = require("sharp") @@ -16,7 +16,7 @@ const HEIGHT = 160 /** * Downloads the sticker from the web and converts to webp data. * @param {string} mxc a single mxc:// URL - * @returns {Promise} sticker webp data, or undefined if the downloaded sticker is not valid + * @returns {Promise} sticker webp data, or undefined if the downloaded sticker is not valid */ async function getAndResizeSticker(mxc) { const res = await api.getMedia(mxc) @@ -24,29 +24,16 @@ async function getAndResizeSticker(mxc) { const root = await res.json() throw new mreq.MatrixServerError(root, {mxc}) } - const streamIn = streamr.Readable.fromWeb(res.body) + const streamIn = Readable.fromWeb(res.body) const { stream, mime } = await streamMimeType.getMimeType(streamIn) - let animated = false - if (mime === "image/gif" || mime === "image/webp") { - animated = true - } + const animated = ["image/gif", "image/webp"].includes(mime) - const result = await new Promise((resolve, reject) => { - const transformer = sharp({animated: animated}) - .resize(WIDTH, HEIGHT, {fit: "inside", background: {r: 0, g: 0, b: 0, alpha: 0}}) - .webp() - .toBuffer((err, buffer, info) => { - /* c8 ignore next */ - if (err) return reject(err) - resolve({info, buffer}) - }) - pipeline( - stream, - transformer - ) - }) - return result.buffer + const transformer = sharp({animated: animated}) + .resize(WIDTH, HEIGHT, {fit: "inside", background: {r: 0, g: 0, b: 0, alpha: 0}}) + .webp() + stream.pipe(transformer) + return Readable.toWeb(transformer) } diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 91c2400..81ad48c 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -631,17 +631,11 @@ async function eventToMessage(event, guild, channel, di) { } if (event.type === "m.sticker") { - content = "" - content += `[${event.content.body}](` // sticker title for fallback if the url preview fails - const afterLink = ")" - - // Make sticker URL params - const params = new URLSearchParams() const withoutMxc = mxUtils.makeMxcPublic(event.content.url) assert(withoutMxc) - params.append("mxc", withoutMxc) - const url = `${reg.ooye.bridge_origin}/download/sticker.webp?${params.toString()}` - content += url + afterLink + const url = `${reg.ooye.bridge_origin}/download/sticker/${withoutMxc}/_.webp` + content = `[${event.content.body || "\u2800"}](${url})` + } else if (event.type === "org.matrix.msc3381.poll.start") { const pollContent = event.content["org.matrix.msc3381.poll.start"] // just for convenience const isClosed = false; diff --git a/src/web/routes/download-matrix.js b/src/web/routes/download-matrix.js index 1fbc1d6..82e2f7e 100644 --- a/src/web/routes/download-matrix.js +++ b/src/web/routes/download-matrix.js @@ -28,7 +28,8 @@ const schema = { e: z.array(z.string()).or(z.string()) }), sticker: z.object({ - mxc: z.string() + server_name: z.string().regex(/^[^/]+$/), + media_id: z.string().regex(/^[A-Za-z0-9_-]+$/) }) } @@ -97,15 +98,13 @@ as.router.get(`/download/sheet`, defineEventHandler(async event => { return buffer })) -as.router.get(`/download/sticker.webp`, defineEventHandler(async event => { - const query = await getValidatedQuery(event, schema.sticker.parse) +as.router.get(`/download/sticker/:server_name/:media_id/_.webp`, defineEventHandler(async event => { + const {server_name, media_id} = await getValidatedRouterParams(event, schema.sticker.parse) + /** remember that this has no mxc:// protocol in the string */ + const mxc = server_name + "/" + media_id + verifyMediaHash(mxc) - /** remember that these have no mxc:// protocol in the string */ - verifyMediaHash(query.mxc) - const mxc = `mxc://${query.mxc}` - - setResponseHeader(event, "Content-Type", 'image/webp') - const buffer = await sticker.getAndResizeSticker(mxc) - return buffer + const stream = await sticker.getAndResizeSticker(`mxc://${mxc}`) + setResponseHeader(event, "Content-Type", "image/webp") + return stream })) - From ea261e825b8b9a80ebae44d3048042af44df8e55 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 27 Feb 2026 18:33:29 +1300 Subject: [PATCH 09/12] Slashes not allowed in MXID --- src/d2m/converters/user-to-mxid.js | 6 +++--- src/d2m/converters/user-to-mxid.test.js | 8 ++++++-- src/db/migrations/0034-slash-not-allowed-in-mxid.sql | 5 +++++ 3 files changed, 14 insertions(+), 5 deletions(-) create mode 100644 src/db/migrations/0034-slash-not-allowed-in-mxid.sql diff --git a/src/d2m/converters/user-to-mxid.js b/src/d2m/converters/user-to-mxid.js index 12891c0..7705aff 100644 --- a/src/d2m/converters/user-to-mxid.js +++ b/src/d2m/converters/user-to-mxid.js @@ -20,10 +20,10 @@ const SPECIAL_USER_MAPPINGS = new Map([ function downcaseUsername(user) { // First, try to convert the username to the set of allowed characters let downcased = user.username.toLowerCase() - // spaces to underscores... - .replace(/ /g, "_") + // spaces and slashes to underscores... + .replace(/[ /]/g, "_") // remove disallowed characters... - .replace(/[^a-z0-9._=/-]*/g, "") + .replace(/[^a-z0-9._=-]*/g, "") // remove leading and trailing dashes and underscores... .replace(/(?:^[_-]*|[_-]*$)/g, "") // If requested, also make the Discord user ID part of the username diff --git a/src/d2m/converters/user-to-mxid.test.js b/src/d2m/converters/user-to-mxid.test.js index 387d472..f8cf16a 100644 --- a/src/d2m/converters/user-to-mxid.test.js +++ b/src/d2m/converters/user-to-mxid.test.js @@ -21,8 +21,12 @@ test("user2name: works on single emoji at the end", t => { t.equal(userToSimName({username: "Melody 🎵", discriminator: "2192"}), "melody") }) -test("user2name: works on crazy name", t => { - t.equal(userToSimName({username: "*** D3 &W (89) _7//-", discriminator: "0001"}), "d3_w_89__7//") +test("user2name: works on really weird name", t => { + t.equal(userToSimName({username: "*** D3 &W (89) _7//-", discriminator: "0001"}), "d3_w_89__7") +}) + +test("user2name: treats slashes", t => { + t.equal(userToSimName({username: "Evil Lillith (she/her)", discriminator: "5892"}), "evil_lillith_she_her") }) test("user2name: adds discriminator if name is unavailable (old tag format)", t => { diff --git a/src/db/migrations/0034-slash-not-allowed-in-mxid.sql b/src/db/migrations/0034-slash-not-allowed-in-mxid.sql new file mode 100644 index 0000000..ea2d031 --- /dev/null +++ b/src/db/migrations/0034-slash-not-allowed-in-mxid.sql @@ -0,0 +1,5 @@ +BEGIN TRANSACTION; + +DELETE FROM sim WHERE sim_name like '%/%'; + +COMMIT; From 780154fd09beda223a21bf5d17d407d07b2c7192 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 27 Feb 2026 18:34:30 +1300 Subject: [PATCH 10/12] Bots with Administrator may access all channels --- src/web/routes/guild.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index dfb393b..a5508c4 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -115,7 +115,7 @@ function getChannelRoomsLinks(guild, rooms, roles) { let removedWrongTypeChannels = dUtils.filterTo(unlinkedChannels, c => c && [0, 5].includes(c.type)) let removedPrivateChannels = dUtils.filterTo(unlinkedChannels, c => { const permissions = dUtils.getPermissions(guild.id, roles, guild.roles, botID, c["permission_overwrites"]) - return dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel) + return dUtils.hasSomePermissions(permissions, ["Administrator", "ViewChannel"]) }) unlinkedChannels.sort((a, b) => getPosition(a, discord.channels) - getPosition(b, discord.channels)) From e275d4c928b9bcbc7d3c855b7ce6900dae1e8686 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 27 Feb 2026 18:35:48 +1300 Subject: [PATCH 11/12] Add script to estimate total channel file size --- scripts/estimate-size.js | 65 ++++++++++++++++++++++++++++++++++++++++ src/matrix/api.js | 2 +- src/types.d.ts | 8 ++++- 3 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 scripts/estimate-size.js diff --git a/scripts/estimate-size.js b/scripts/estimate-size.js new file mode 100644 index 0000000..341abc0 --- /dev/null +++ b/scripts/estimate-size.js @@ -0,0 +1,65 @@ +// @ts-check + +const pb = require("prettier-bytes") +const sqlite = require("better-sqlite3") +const HeatSync = require("heatsync") + +const {reg} = require("../src/matrix/read-registration") +const passthrough = require("../src/passthrough") + +const sync = new HeatSync({watchFS: false}) +Object.assign(passthrough, {reg, sync}) + +const DiscordClient = require("../src/d2m/discord-client") + +const discord = new DiscordClient(reg.ooye.discord_token, "no") +passthrough.discord = discord + +const db = new sqlite("ooye.db") +passthrough.db = db + +const api = require("../src/matrix/api") + +const {room: roomID} = require("minimist")(process.argv.slice(2), {string: ["room"]}) +if (!roomID) { + console.error("Usage: ./scripts/estimate-size.js --room=") + process.exit(1) +} + +const {channel_id, guild_id} = db.prepare("SELECT channel_id, guild_id FROM channel_room WHERE room_id = ?").get(roomID) + +const max = 1000 + +;(async () => { + let total = 0 + let size = 0 + let from + + while (total < max) { + const events = await api.getEvents(roomID, "b", {limit: 1000, from}) + total += events.chunk.length + from = events.end + console.log(`Fetched ${total} events so far`) + + for (const e of events.chunk) { + if (e.content?.info?.size) { + size += e.content.info.size + } + } + + if (events.chunk.length === 0 || !events.end) break + } + + console.log(`Total size of uploads: ${pb(size)}`) + + const searchResults = await discord.snow.requestHandler.request(`/guilds/${guild_id}/messages/search`, { + channel_id, + offset: "0", + limit: "1" + }, "get", "json") + + const totalAllTime = searchResults.total_results + const fractionCounted = total / totalAllTime + console.log(`That counts for ${(fractionCounted*100).toFixed(2)}% of the history on Discord (${totalAllTime.toLocaleString()} messages)`) + console.log(`The size of uploads for the whole history would be approx: ${pb(Math.floor(size/total*totalAllTime))}`) +})() diff --git a/src/matrix/api.js b/src/matrix/api.js index 70cb50b..87bbf0c 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -136,7 +136,7 @@ async function getEventForTimestamp(roomID, ts) { */ async function getEvents(roomID, dir, pagination = {}, filter) { filter = filter && JSON.stringify(filter) - /** @type {Ty.Pagination>} */ + /** @type {Ty.MessagesPagination>} */ const root = await mreq.mreq("GET", path(`/client/v3/rooms/${roomID}/messages`, null, {...pagination, dir, filter})) return root } diff --git a/src/types.d.ts b/src/types.d.ts index 6ee2eb1..a85907d 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -498,7 +498,13 @@ export type Membership = "invite" | "knock" | "join" | "leave" | "ban" export type Pagination = { chunk: T[] next_batch?: string - prev_match?: string + prev_batch?: string +} + +export type MessagesPagination = { + chunk: T[] + start: string + end?: string } export type HierarchyPagination = { From c68bac5476dd6809dffacfd09d467db1f257ba83 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 1 Mar 2026 22:05:46 +1300 Subject: [PATCH 12/12] Document encryption as unsupported --- readme.md | 1 + 1 file changed, 1 insertion(+) diff --git a/readme.md b/readme.md index 8c9fc90..e8a8e7e 100644 --- a/readme.md +++ b/readme.md @@ -38,6 +38,7 @@ For more information about features, [see the user guide.](https://gitdab.com/ca * This bridge is not designed for puppetting. * Direct Messaging is not supported until I figure out a good way of doing it. +* Encrypted messages are not supported. Decryption is often unreliable on Matrix, and your messages end up in plaintext on Discord anyway, so there's not much advantage. ## Get started!