} channel id -> interval */
+const intervals = new Map()
+
+/** @param {string} channelID */
+function startTyping(channelID) {
+ isTyping.add(channelID)
+ // Start a new typing session if needed
+ if (!intervals.has(channelID)) {
+ intervals.set(channelID, setInterval(refreshTyping, 8e3, channelID))
+ discord.snow.channel.startChannelTyping(channelID).catch(() => {})
+ }
+}
+
+/** @param {string} channelID */
+function refreshTyping(channelID) {
+ if (isTyping.has(channelID)) {
+ // Continue typing session
+ discord.snow.channel.startChannelTyping(channelID).catch(() => {})
+ } else {
+ // End typing session
+ clearInterval(intervals.get(channelID))
+ intervals.delete(channelID)
+ }
+}
+
+/** @param {string} channelID */
+function stopTyping(channelID) {
+ // Schedule typing session to end
+ isTyping.delete(channelID)
+}
+
+module.exports.startTyping = startTyping
+module.exports.stopTyping = stopTyping
diff --git a/src/m2d/actions/update-pins.js b/src/m2d/actions/update-pins.js
index 1ff2bb9..1b3f1bd 100644
--- a/src/m2d/actions/update-pins.js
+++ b/src/m2d/actions/update-pins.js
@@ -16,9 +16,9 @@ async function updatePins(pins, prev) {
.select("reference_channel_id", "message_id").where({event_id}).and("ORDER BY part ASC").get()
if (!row) continue
if (added) {
- discord.snow.channel.addChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message pinned on Matrix")
+ discord.snow.channel.createChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message pinned on Matrix")
} else {
- discord.snow.channel.removeChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message unpinned on Matrix")
+ discord.snow.channel.deleteChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message unpinned on Matrix")
}
}
}
diff --git a/src/m2d/actions/vote.js b/src/m2d/actions/vote.js
index 926b957..84f8cc7 100644
--- a/src/m2d/actions/vote.js
+++ b/src/m2d/actions/vote.js
@@ -1,18 +1,12 @@
// @ts-check
const Ty = require("../../types")
-const DiscordTypes = require("discord-api-types/v10")
-const {Readable} = require("stream")
const assert = require("assert").strict
-const crypto = require("crypto")
const passthrough = require("../../passthrough")
-const {sync, discord, db, select} = passthrough
+const {sync, db, select} = passthrough
-const {reg} = require("../../matrix/read-registration")
-/** @type {import("../../matrix/api")} */
-const api = sync.require("../../matrix/api")
-/** @type {import("../../matrix/utils")} */
-const utils = sync.require("../../matrix/utils")
+/** @type {import("../../discord/utils")} */
+const dUtils = sync.require("../../discord/utils")
/** @type {import("../converters/poll-components")} */
const pollComponents = sync.require("../converters/poll-components")
/** @type {import("./channel-webhook")} */
@@ -33,10 +27,11 @@ async function updateVote(event) {
// If poll was started on Matrix, the Discord version is using components, so we can update that to the current status
if (messageRow.source === 0) {
- const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
- assert(channelID)
- await webhook.editMessageWithWebhook(channelID, messageID, pollComponents.getPollComponentsFromDatabase(messageID))
+ const row = select("channel_room", ["channel_id", "thread_parent"], {room_id: event.room_id}).get()
+ assert(row)
+ const {channelID, threadID} = dUtils.swapThreadID(row.channel_id, row.thread_parent)
+ await webhook.editMessageWithWebhook(channelID, messageID, pollComponents.getPollComponentsFromDatabase(messageID), threadID)
}
}
-module.exports.updateVote = updateVote
\ No newline at end of file
+module.exports.updateVote = updateVote
diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js
index 0a18a14..18b9848 100644
--- a/src/m2d/converters/event-to-message.js
+++ b/src/m2d/converters/event-to-message.js
@@ -10,6 +10,7 @@ const domino = require("domino")
const assert = require("assert").strict
const entities = require("entities")
const pb = require("prettier-bytes")
+const mimeTypes = require("mime-types")
const {tag} = require("@cloudrac3r/html-template-tag")
const passthrough = require("../../passthrough")
@@ -97,15 +98,6 @@ turndownService.addRule("underline", {
}
})
-turndownService.addRule("blockquote", {
- filter: "blockquote",
- replacement: function (content) {
- content = content.replace(/^\n+|\n+$/g, "")
- content = content.replace(/^/gm, "> ")
- return content
- }
-})
-
turndownService.addRule("spoiler", {
filter: function (node, options) {
return node.tagName === "SPAN" && node.hasAttribute("data-mx-spoiler")
@@ -622,13 +614,20 @@ async function eventToMessage(event, guild, channel, di) {
captionContent.addLine(`(Spoiler: ${fileSpoilerReason})`)
}
+ // If filename is not specified, create one from mimetype
+ let filename = event.content.filename || event.content.body
+ if (!filename) {
+ const extension = mimeTypes.extension(event.content.info?.mimetype)
+ if (!extension) throw new Error("Filename and mimetype missing. Please report this bug to your client.")
+ filename = `file.${extension}`
+ }
+
// File link as alternative to uploading
if (!("file" in event.content) && event.content.info?.size > getFileSizeForGuild(guild)) {
// Upload (unencrypted) file as link, because it's too large for Discord
// Do this by constructing a sample Matrix message with the link and then use the text processor to convert that + the original caption.
const url = mxUtils.getPublicUrlForMxc(event.content.url)
assert(url)
- const filename = event.content.filename || event.content.body
const emoji = attachmentEmojis.has(event.content.msgtype) ? attachmentEmojis.get(event.content.msgtype) + " " : ""
if (fileIsSpoiler) {
captionContent.addLine(`${emoji}Uploaded SPOILER file: <${url}> (${pb(event.content.info.size)})`, tag`${emoji}Uploaded SPOILER file: ${filename} (${pb(event.content.info.size)})`) // the space is necessary to work around a bug in Discord's URL previewer. the preview still gets blurred in the client.
@@ -637,7 +636,6 @@ async function eventToMessage(event, guild, channel, di) {
}
} else {
// Upload file as file
- let filename = event.content.filename || event.content.body
if (fileIsSpoiler) filename = "SPOILER_" + filename
if ("file" in event.content) {
// Encrypted
@@ -775,14 +773,14 @@ async function eventToMessage(event, guild, channel, di) {
repliedToEvent.content = repliedToEvent.content["m.new_content"]
}
/** @type {string} */
- let repliedToContent = repliedToEvent.content.formatted_body || repliedToEvent.content.body
+ let originalRepliedToContent = repliedToEvent.content.formatted_body || repliedToEvent.content.body
const fileReplyContentAlternative = attachmentEmojis.get(repliedToEvent.content.msgtype)
let contentPreview
if (fileReplyContentAlternative) {
contentPreview = " " + fileReplyContentAlternative
} else if (repliedToEvent.unsigned?.redacted_because) {
contentPreview = " (in reply to a deleted message)"
- } else if (typeof repliedToContent !== "string") {
+ } else if (typeof originalRepliedToContent !== "string") {
// in reply to a weird metadata event like m.room.name, m.room.member...
// I'm not implementing text fallbacks for arbitrary room events. this should cover most cases
// this has never ever happened in the wild anyway
@@ -790,6 +788,7 @@ async function eventToMessage(event, guild, channel, di) {
contentPreview = " (channel details edited)"
} else {
// Generate a reply preview for a standard message
+ let repliedToContent = originalRepliedToContent
repliedToContent = repliedToContent.replace(/.*<\/mx-reply>/s, "") // Remove everything before replies, so just use the actual message body
repliedToContent = repliedToContent.replace(/^\s*.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards
repliedToContent = repliedToContent.replace(/(?:\n|
)+/g, " ") // Should all be on one line
@@ -804,7 +803,18 @@ async function eventToMessage(event, guild, channel, di) {
const contentPreviewChunks = chunk(repliedToContent, 50)
if (contentPreviewChunks.length) {
contentPreview = ": " + contentPreviewChunks[0]
- contentPreview = contentPreview.replace(/\bhttps?:\/\/[^ )]*/g, "<$&>")
+ contentPreview = contentPreview.replace(/\bhttps?:\/\/[^ )]*/g, url => {
+ const originalUrlIndex = originalRepliedToContent.indexOf(url)
+ if (originalUrlIndex !== -1 && originalRepliedToContent[originalUrlIndex + url.length]?.match(/[^ )>"'`]/)) { // URL was truncated by chunking, replace it
+ try {
+ const u = new URL(url)
+ return ``
+ } catch (e) {
+ return ``
+ }
+ }
+ return `<${url}>` // Full URL is present, escape it
+ })
if (contentPreviewChunks.length > 1) contentPreview = contentPreview.replace(/[,.']$/, "") + "..."
} else {
contentPreview = ""
@@ -855,7 +865,7 @@ async function eventToMessage(event, guild, channel, di) {
input = input.replace(/("https:\/\/matrix.to.*?<\/a>):?/g, "$1")
// Element adds a bunch of
before
but doesn't render them. I can't figure out how this even works in the browser, so let's just delete those.
- input = input.replace(/(?:\n|
\s*)*<\/blockquote>/g, "")
+ input = input.replace(/(?:\n|
\s*)*<\/blockquote>(?:\n|
\s*)*/g, "")
// The matrix spec hasn't decided whether \n counts as a newline or not, but I'm going to count it, because if it's in the data it's there for a reason.
// But I should not count it if it's between block elements.
diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js
index 650e442..5dd0af7 100644
--- a/src/m2d/converters/event-to-message.test.js
+++ b/src/m2d/converters/event-to-message.test.js
@@ -855,6 +855,40 @@ test("event2message: html lines are bridged correctly", async t => {
)
})
+test("event2message: breaks between consecutive blockquotes are preserved", async t => {
+ t.deepEqual(
+ await eventToMessage({
+ type: "m.room.message",
+ sender: "@cadence:cadence.moe",
+ content: {
+ msgtype: "m.text",
+ body: "this was inspired by someone telling me\n\n>betrothed is an insane vocab pull\n\n> i think thats part of why im so scared of cadence\n\n> if an insult comes my way, not only do i not know when, but i'll have to humiliate myself and look it up",
+ format: "org.matrix.custom.html",
+ formatted_body: "this was inspired by someone telling me
\n\nbetrothed is an insane vocab pull
\n
\n\ni think thats part of why im so scared of cadence
\n
\n\nif an insult comes my way, not only do i not know when, but i'll have to humiliate myself and look it up
\n
"
+ },
+ origin_server_ts: 1783571303430,
+ event_id: "$cdltlkLTUp-ma2uYmLKy7o3RkDgpDbtdYfngST_igWE",
+ room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
+ }),
+ {
+ ensureJoined: [],
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ allowed_mentions: {
+ parse: ["users", "roles"]
+ },
+ username: "cadence [they]",
+ avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90",
+ content: "this was inspired by someone telling me"
+ + "\n\n> betrothed is an insane vocab pull"
+ + "\n\n> i think thats part of why im so scared of cadence"
+ + "\n\n> if an insult comes my way, not only do i not know when, but i'll have to humiliate myself and look it up"
+ }]
+ }
+ )
+})
+
/*test("event2message: whitespace is retained", async t => {
t.deepEqual(
await eventToMessage({
@@ -1294,7 +1328,7 @@ test("event2message: characters are encoded properly in code blocks", async t =>
)
})
-test("event2message: quotes have an appropriate amount of whitespace", async t => {
+test("event2message: quotes have an appropriate amount of whitespace (br following)", async t => {
t.deepEqual(
await eventToMessage({
content: {
@@ -1328,6 +1362,40 @@ test("event2message: quotes have an appropriate amount of whitespace", async t =
)
})
+test("event2message: quotes have an appropriate amount of whitespace (p following)", async t => {
+ t.deepEqual(
+ await eventToMessage({
+ content: {
+ msgtype: "m.text",
+ body: "wrong body",
+ format: "org.matrix.custom.html",
+ formatted_body: "\nshort story
\n
\nby whose standards? LOL
\nI basically went all the way back to childhood (which analysts always like you to do)
\n
\nlaughed out loud
"
+ },
+ event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
+ origin_server_ts: 1688301929913,
+ room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
+ sender: "@cadence:cadence.moe",
+ type: "m.room.message",
+ unsigned: {
+ age: 405299
+ }
+ }),
+ {
+ ensureJoined: [],
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ username: "cadence [they]",
+ content: "> short story\nby whose standards? LOL\n\n> I basically went all the way back to childhood (which analysts always like you to do)\nlaughed out loud",
+ avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90",
+ allowed_mentions: {
+ parse: ["users", "roles"]
+ }
+ }]
+ }
+ )
+})
+
test("event2message: lists have appropriate line breaks", async t => {
t.deepEqual(
await eventToMessage({
@@ -2049,6 +2117,57 @@ test("event2message: should suppress embeds for links in reply preview", async t
)
})
+test("event2message: truncated links in reply preview should be unlinked to prevent broken links", async t => {
+ t.deepEqual(
+ await eventToMessage({
+ type: "m.room.message",
+ sender: "@cadence:cadence.moe",
+ content: {
+ msgtype: "m.text",
+ body: `> <@_ooye_.wing.:cadence.moe> https://huggingface.co/blog/security-incident-july-2026\n\nthis is either written by claude or somebody who's too claude-brained and I just can't get through it`,
+ format: "org.matrix.custom.html",
+ formatted_body: `In reply to @_ooye_.wing.:cadence.moe
https://huggingface.co/blog/security-incident-july-2026
this is either written by claude or somebody who's too claude-brained and I just can't get through it`,
+ "m.relates_to": {
+ "m.in_reply_to": {
+ event_id: "$qmyjr-ISJtnOM5WTWLI0fT7uSlqRLgpyin2d2NCglCU"
+ }
+ }
+ },
+ event_id: "$0Bs3rbsXaeZmSztGMx1NIsqvOrkXOpIWebN-dqr09i4",
+ room_id: "!fGgIymcYWOqjbSRUdV:cadence.moe"
+ }, data.guild.general, data.channel.general, {
+ api: {
+ getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$qmyjr-ISJtnOM5WTWLI0fT7uSlqRLgpyin2d2NCglCU", {
+ "type": "m.room.message",
+ "sender": "@_ooye_.wing.:cadence.moe",
+ "content": {
+ "m.mentions": {},
+ "msgtype": "m.text",
+ "body": "",
+ "format": "org.matrix.custom.html",
+ "formatted_body": "https://huggingface.co/blog/security-incident-july-2026"
+ }
+ })
+ }
+ }),
+ {
+ ensureJoined: [],
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ username: "cadence [they]",
+ content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1273204543739396116 <@112890272819507200>:"
+ + " ..."
+ + `\nthis is either written by claude or somebody who's too claude-brained and I just can't get through it`,
+ avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar",
+ allowed_mentions: {
+ parse: ["users", "roles"]
+ }
+ }]
+ }
+ )
+})
+
test("event2message: should include a reply preview when message ends with a blockquote", async t => {
t.deepEqual(
await eventToMessage({
diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js
index 3580d1b..0560f46 100644
--- a/src/m2d/event-dispatcher.js
+++ b/src/m2d/event-dispatcher.js
@@ -24,6 +24,8 @@ const vote = sync.require("./actions/vote")
const matrixCommandHandler = sync.require("../matrix/matrix-command-handler")
/** @type {import("../matrix/utils")} */
const utils = sync.require("../matrix/utils")
+/** @type {import("../matrix/mreq")}) */
+const mreq = sync.require("../matrix/mreq")
/** @type {import("../matrix/api")}) */
const api = sync.require("../matrix/api")
/** @type {import("../d2m/actions/create-room")} */
@@ -32,6 +34,10 @@ const createRoom = sync.require("../d2m/actions/create-room")
const roomUpgrade = require("../matrix/room-upgrade")
/** @type {import("../d2m/actions/retrigger")} */
const retrigger = sync.require("../d2m/actions/retrigger")
+/** @type {import("../matrix/homeserver-status")} */
+const homeserverStatus = sync.require("../matrix/homeserver-status")
+/** @type {import("./actions/typing")} */
+const typing = sync.require("./actions/typing")
const {reg} = require("../matrix/read-registration")
let lastReportedEvent = 0
@@ -166,7 +172,15 @@ async function sendError(roomID, source, type, e, payload) {
key: "🔁"
}
})
- } catch (e) {}
+ } catch (e) {
+ if (
+ e.toString().includes("fetch failed")
+ || (e instanceof mreq.MatrixServerError && [502, 503].includes(e.httpStatus) && e.errcode === "CX_SERVER_ERROR")
+ ) {
+ // Matrix homeserver is down (reverse proxy indicated failure)
+ homeserverStatus.homeserverStatus.setErrorWithPacket(payload)
+ }
+ }
}
function guard(type, fn) {
@@ -515,6 +529,20 @@ async event => {
await createRoom.unbridgeChannel(channel, channel["guild_id"], "Encrypted rooms are not supported. This room was removed from the bridge.")
}))
+sync.addTemporaryListener(as, "ephemeral_type:m.typing", guard("m.typing",
+/**
+ * @param {Ty.Event.Outer_M_Typing} event
+ */
+async event => {
+ const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
+ if (!channelID) return
+ if (event.content.user_ids.some(mxid => !utils.eventSenderIsFromDiscord(mxid))) {
+ typing.startTyping(channelID)
+ } else {
+ typing.stopTyping(channelID)
+ }
+}))
+
module.exports.stringifyErrorStack = stringifyErrorStack
module.exports.cleanErrorStack = cleanErrorStack
module.exports.sendError = sendError
diff --git a/src/matrix/api.js b/src/matrix/api.js
index 9b7f280..47fc565 100644
--- a/src/matrix/api.js
+++ b/src/matrix/api.js
@@ -452,7 +452,8 @@ async function ping() {
headers: {
Authorization: `Bearer ${reg.as_token}`
},
- body: "{}"
+ body: "{}",
+ signal: AbortSignal.timeout(15e3)
})
const root = await res.json()
return {
@@ -474,7 +475,7 @@ async function ping() {
async function getMedia(mxc, init = {}) {
init = {...init}
- const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
+ const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/([a-zA-Z0-9_-]+)$/)
assert(mediaParts)
let route = "download"
diff --git a/src/matrix/homeserver-status.js b/src/matrix/homeserver-status.js
new file mode 100644
index 0000000..3ceac84
--- /dev/null
+++ b/src/matrix/homeserver-status.js
@@ -0,0 +1,134 @@
+// @ts-check
+
+const assert = require("assert")
+const Denque = require("denque")
+const StateMachine = require("snowtransfer").StateMachine
+
+const passthrough = require("../passthrough")
+const {sync} = passthrough
+/** @type {import("../d2m/discord-packets")} */
+const discordPackets = sync.require("../d2m/discord-packets")
+/** @type {import("../matrix/api")} */
+const api = sync.require("../matrix/api")
+
+const DEBUG_HOMESERVER_STATUS = false
+
+function debugHomeserverStatus(message) {
+ if (DEBUG_HOMESERVER_STATUS) {
+ console.log(message)
+ }
+}
+
+const homeserverStatus = new class HomeserverStatus {
+ constructor() {
+ /** @private */
+ this.queue = new Denque()
+
+ /** @private */
+ this.pingInterval = undefined
+
+ /** @private */
+ this.sm = new StateMachine("online")
+ .defineState("online")
+
+ .defineState("checking", {
+ onEnter: [async () => {
+ const pingResult = await api.ping().catch(e => ({ok: false}))
+ if (pingResult.ok) {
+ this.sm.doTransition("check ok")
+ } else {
+ this.sm.doTransition("check fail")
+ }
+ }],
+ onLeave: [],
+ transitions: new Map()
+ })
+
+ .defineState("offline", {
+ onEnter: [() => {
+ this.pingInterval = setInterval(async () => {
+ const pingResult = await api.ping().catch(e => ({ok: false, status: "net", root: e.message}))
+ if (pingResult.ok) {
+ this.sm.doTransition("ping ok")
+ }
+ }, 15e3)
+ }],
+ onLeave: [() => {
+ clearInterval(this.pingInterval)
+ }],
+ transitions: new Map()
+ })
+
+ .defineState("recovering", {
+ onEnter: [async () => { // Drain queue.
+ while (!this.queue.isEmpty()) {
+ const packet = this.queue.peekFront() // same position as .shift()
+ debugHomeserverStatus(`homeserver status: ${new Date().toISOString()} dq packet ${packet.t} ${packet.d?.content}`)
+ await discordPackets.dispatchPacketToBridge(passthrough.discord, packet)
+ if (this.sm.currentStateName !== "recovering") return // got kicked out due to another error
+ this.queue.shift()
+ }
+ this.sm.doTransition("recovered")
+ }],
+ onLeave: [],
+ transitions: new Map()
+ })
+
+ .defineUniversalTransition("error", "offline")
+ .defineTransition("offline", "ping ok", "recovering")
+ .defineTransition("recovering", "recovered", "online")
+ .defineTransition("online", "check", "checking")
+ .defineTransition("checking", "check ok", "recovering")
+ .defineTransition("checking", "check fail", "offline")
+
+ this.sm.on("enter", st => debugHomeserverStatus(`homeserver status: ${st}`))
+ this.sm.setMaxListeners(101)
+ this.sm.freeze()
+ }
+
+ isRealTime() {
+ return this.sm.currentStateName === "online"
+ }
+
+ /** @param {boolean} forceCheck */
+ waitForOnline(forceCheck) {
+ const onlinePromise = new Promise(resolve => {
+ // Already online? Start check or just done
+ if (this.sm.currentStateName === "online") {
+ if (forceCheck) {
+ this.sm.doTransition("check")
+ } else {
+ return resolve(null)
+ }
+ }
+
+ // Checking or not online. Wait for online.
+ const onlineListener = stateName => {
+ if (stateName === "online") {
+ this.sm.removeListener("enter", onlineListener)
+ resolve(null)
+ }
+ }
+ this.sm.on("enter", onlineListener)
+ })
+ return onlinePromise
+ }
+
+ /**
+ * When offline or recovering, call this for incoming packets to queue them to be sent in order later.
+ */
+ queuePacket(packet) {
+ assert(["offline", "recovering"].includes(this.sm.currentStateName))
+ this.queue.push(packet)
+ }
+
+ setErrorWithPacket(packet) {
+ const wasRecovering = this.sm.currentStateName === "recovering"
+ this.sm.doTransition("error")
+ if (!wasRecovering) { // if was recovering then packet is already in the right place in queue
+ this.queuePacket(packet)
+ }
+ }
+}
+
+module.exports.homeserverStatus = homeserverStatus
diff --git a/src/matrix/mreq.js b/src/matrix/mreq.js
index bb59506..b6bc5b9 100644
--- a/src/matrix/mreq.js
+++ b/src/matrix/mreq.js
@@ -9,9 +9,14 @@ const {reg} = require("./read-registration.js")
const baseUrl = `${reg.ooye.server_origin}/_matrix`
class MatrixServerError extends Error {
- constructor(data, opts) {
+ /**
+ * @param {number} httpStatus}
+ */
+ constructor(data, httpStatus, opts = {}) {
super(data.error || data.errcode)
this.data = data
+ /** @type {number} */
+ this.httpStatus = httpStatus
/** @type {string} */
this.errcode = data.errcode
this.opts = opts
@@ -44,11 +49,11 @@ async function _convertBody(body) {
async function makeMatrixServerError(res, opts = {}) {
delete opts.headers?.["Authorization"]
if (res.headers.get("content-type") === "application/json") {
- return new MatrixServerError(await res.json(), opts)
+ return new MatrixServerError(await res.json(), res.status, opts)
} else if (res.headers.get("content-type")?.startsWith("text/")) {
- return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, message: await res.text()}, opts)
+ return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, message: await res.text()}, res.status, opts)
} else {
- return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, content_type: res.headers.get("content-type")}, opts)
+ return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, content_type: res.headers.get("content-type")}, res.status, opts)
}
}
@@ -78,12 +83,12 @@ async function mreq(method, url, bodyIn, extra = {}) {
var root = JSON.parse(text)
} catch (e) {
delete opts.headers?.["Authorization"]
- throw new MatrixServerError(text, {baseUrl, url, ...opts})
+ throw new MatrixServerError(text, res.status, {baseUrl, url, ...opts})
}
if (!res.ok || root.errcode) {
delete opts.headers?.["Authorization"]
- throw new MatrixServerError(root, {baseUrl, url, ...opts})
+ throw new MatrixServerError(root, res.status, {baseUrl, url, ...opts})
}
return root
}
diff --git a/src/matrix/read-registration.js b/src/matrix/read-registration.js
index d1243a7..e6ca53f 100644
--- a/src/matrix/read-registration.js
+++ b/src/matrix/read-registration.js
@@ -17,6 +17,10 @@ function checkRegistration(reg) {
assert(reg.ooye?.server_origin.match(/^https?:\/\//), "server origin must start with http or https")
assert.notEqual(reg.ooye?.server_origin.slice(-1), "/", "server origin must not end in slash")
assert.match(reg.url, /^https?:/, "url must start with http:// or https://")
+ if (!reg.receive_ephemeral) {
+ reg.receive_ephemeral = true
+ writeRegistration(reg)
+ }
}
/* c8 ignore next 4 */
@@ -50,6 +54,7 @@ function getTemplateRegistration(serverName) {
],
sender_localpart: `${namespace_prefix}bot`,
rate_limited: false,
+ receive_ephemeral: true,
socket: 6693,
ooye: {
namespace_prefix,
diff --git a/src/matrix/utils.js b/src/matrix/utils.js
index eee635b..fed71c9 100644
--- a/src/matrix/utils.js
+++ b/src/matrix/utils.js
@@ -250,7 +250,7 @@ function getPublicUrlForMxc(mxc) {
*/
function makeMxcPublic(mxc) {
assert(hasher, "xxhash is not ready yet")
- const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
+ const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/([a-zA-Z0-9_-]+)$/)
if (!mediaParts) return undefined
const serverAndMediaID = `${mediaParts[1]}/${mediaParts[2]}`
diff --git a/src/types.d.ts b/src/types.d.ts
index be037ca..f65ce59 100644
--- a/src/types.d.ts
+++ b/src/types.d.ts
@@ -18,6 +18,7 @@ export type AppServiceRegistrationConfig = {
}
protocols: [string]
rate_limited: boolean
+ receive_ephemeral: boolean
socket?: string | number,
ooye: {
namespace_prefix: string
@@ -35,6 +36,7 @@ export type AppServiceRegistrationConfig = {
web_password: string
time_zone?: string
receive_presences: boolean
+ plu_ral_api_key?: string
}
old_bridge?: {
as_token: string
@@ -59,6 +61,7 @@ export type InitialAppServiceRegistrationConfig = {
}
protocols: [string]
rate_limited: boolean
+ receive_ephemeral: boolean
socket?: string | number,
ooye: {
namespace_prefix: string
@@ -122,6 +125,28 @@ export type PkMessage = {
sender: string
}
+export type PluRalWebhookMessage = {
+ original_id: string | null
+ proxy_id: string
+ author_id: string
+ channel_id: string
+ member_id: string
+ reason: string
+ webhook_id: string
+ member: PluRalMember
+}
+
+export type PluRalMember = {
+ id: string
+ name: string
+ pronouns: string
+ bio: string
+ birthday: string
+ color: number | null
+ avatar_url: string
+ private: boolean
+}
+
export namespace Event {
export type Outer = {
type: string
@@ -396,6 +421,14 @@ export namespace Event {
rotation_period_ms?: number
rotation_period_msgs?: number
}
+
+ export type Outer_M_Typing = {
+ content: {
+ user_ids: string[]
+ }
+ room_id: string
+ type: "m.typing"
+ }
}
export namespace R {
diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug
index 9791ae3..7411a1e 100644
--- a/src/web/pug/guild.pug
+++ b/src/web/pug/guild.pug
@@ -122,7 +122,7 @@ block body
#role-add.s-popover(popover style="display: revert").ws2.px0.py4.bs-lg.overflow-visible
.s-popover--arrow.s-popover--arrow__tc
+add-roles-menu(guild, guild_id)
- p.fc-medium.mb0.mt8 Matrix users will start with these roles. If your main channels are gated by a role, use this to let Matrix users skip the gate.
+ p.fc-light.mb0.mt8 Matrix users will start with these roles. If your main channels are gated by a role, use this to let Matrix users skip the gate.
h3.mt32.fs-category Features
.s-card.d-grid.px0.g16
@@ -191,14 +191,14 @@ block body
label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id)
+discord(channel, true, "Announcement")
else
- .s-empty-state.p8 All Discord channels are linked.
+ .s-empty-state.p8 No Discord channels available.
.fl-grow1.s-btn-group.fd-column.w30
each room in unlinkedRooms
input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id)
label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id)
+matrix(room, true)
else
- .s-empty-state.p8 All Matrix rooms are linked.
+ .s-empty-state.p8 No Matrix rooms available.
input(type="hidden" name="guild_id" value=guild_id)
div
button.s-btn.s-btn__icon.s-btn__filled#link-button
diff --git a/src/web/routes/download-matrix.js b/src/web/routes/download-matrix.js
index 6d2772b..2664520 100644
--- a/src/web/routes/download-matrix.js
+++ b/src/web/routes/download-matrix.js
@@ -98,6 +98,11 @@ as.router.get(`/download/matrix/:server_name/:media_id`, defineEventHandler(asyn
setResponseHeader(event, "Content-Type", contentType)
setResponseHeader(event, "Transfer-Encoding", "chunked")
+ const contentDisposition = res.headers.get("content-disposition")
+ if (contentDisposition) {
+ setResponseHeader(event, "Content-Disposition", contentDisposition)
+ }
+
if (res.ok && query.success) {
return MEDIA_THUMBNAIL_PRESETS[query.data.preset](res.body)
} else {
diff --git a/src/web/routes/download-matrix.test.js b/src/web/routes/download-matrix.test.js
index 610a62d..76e8152 100644
--- a/src/web/routes/download-matrix.test.js
+++ b/src/web/routes/download-matrix.test.js
@@ -30,12 +30,13 @@ test("web download matrix: works if a known attachment", async t => {
api: {
// @ts-ignore
async getMedia(mxc, init) {
- return new Response("", {status: 200, headers: {"content-type": "image/png"}})
+ return new Response("", {status: 200, headers: {"content-type": "image/png", "content-disposition": `attachment; filename="wa.png"`}})
}
}
})
t.equal(event.node.res.statusCode, 200)
t.equal(event.node.res.getHeader("content-type"), "image/png")
+ t.equal(event.node.res.getHeader("content-disposition"), `attachment; filename="wa.png"`)
})
/**
@@ -64,7 +65,7 @@ test("web sheet: single emoji", async t => {
mxcDownloader: mockGetAndConvertEmoji
})
t.equal(event.node.res.statusCode, 200)
- t.equal(sheet.subarray(0, 90).toString("base64"), "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAPoAAAD6AG1e1JrAAALoklEQVR4nM1ZaVBU2RU+LZSIGnAvFUtcRkSk6abpbkDH")
+ t.equal(sheet.subarray(0, 90).toString("base64"), "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAPoAAAD6AG1e1JrAAALoklEQVRogc1ZaVBU2RU+LZSIGnAvFUtcRkSk6abpbkDH")
})
test("web sheet: multiple sources", async t => {
diff --git a/src/web/routes/link.test.js b/src/web/routes/link.test.js
index 0182093..32f89bf 100644
--- a/src/web/routes/link.test.js
+++ b/src/web/routes/link.test.js
@@ -73,7 +73,7 @@ test("web link space: check that OOYE is joined", async t => {
api: {
async joinRoom(roomID) {
called++
- throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"})
+ throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}, 400)
}
}
}))
@@ -368,7 +368,7 @@ test("web link room: check that bridge can join room (notices lack of via and as
api: {
async joinRoom(roomID) {
called++
- throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"})
+ throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}, 400)
},
async *generateFullHierarchy(spaceID) {
called++
@@ -402,7 +402,7 @@ test("web link room: check that bridge can join room (uses via for join attempt)
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"})
+ throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}, 400)
},
async *generateFullHierarchy(spaceID) {
called++
@@ -710,7 +710,7 @@ test("web unlink room: checks that the channel is bridged", async t => {
}))
t.equal(error.data, "Channel ID 665310973967597573 is not currently bridged")
- db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar, last_bridged_pin_timestamp, speedbump_id, speedbump_checked, speedbump_webhook_id, guild_id, custom_topic) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(row.channel_id, row.room_id, row.name, row.nick, row.thread_parent, row.custom_avatar, row.last_bridged_pin_timestamp, row.speedbump_id, row.speedbump_checked, row.speedbump_webhook_id, row.guild_id, row.custom_topic)
+ db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar, last_bridged_pin_timestamp, speedbump_checked, guild_id, custom_topic) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(row.channel_id, row.room_id, row.name, row.nick, row.thread_parent, row.custom_avatar, row.last_bridged_pin_timestamp, row.speedbump_checked, row.guild_id, row.custom_topic)
const new_row = db.prepare("SELECT * FROM channel_room WHERE channel_id = '665310973967597573'").get()
t.deepEqual(row, new_row)
})
diff --git a/start.js b/start.js
index 39e8ea0..51942c6 100755
--- a/start.js
+++ b/start.js
@@ -31,8 +31,9 @@ sync.require("./src/m2d/event-dispatcher")
;(async () => {
await migrate.migrate(db)
+ process.stdout.write("Connecting to Discord... ")
await discord.cloud.connect()
- console.log("Discord gateway started")
+ console.log("ok.")
sync.require("./src/web/server")
await power.applyPower()
diff --git a/test/data.js b/test/data.js
index f3092bc..eab9a63 100644
--- a/test/data.js
+++ b/test/data.js
@@ -5473,6 +5473,189 @@ module.exports = {
content: '-# Original Message ID: 1466556003645657118 · '
}
]
+ },
+ pk_ping_components_v1: {
+ type: 23,
+ content: "Psst, **Red** (<@772659086046658620>), you have been pinged by <@772659086046658620>.",
+ mentions: [
+ {
+ id: "772659086046658620",
+ username: "cadence.worm",
+ avatar: "466df0c98b1af1e1388f595b4c1ad1b9",
+ discriminator: "0",
+ public_flags: 0,
+ flags: 0,
+ banner: null,
+ accent_color: null,
+ global_name: "cadence",
+ avatar_decoration_data: null,
+ collectibles: null,
+ display_name_styles: null,
+ banner_color: null,
+ clan: {
+ identity_guild_id: "532245108070809601",
+ identity_enabled: true,
+ tag: "doll",
+ badge: "dba08126b4e810a0e096cc7cd5bc37f0"
+ },
+ primary_guild: {
+ identity_guild_id: "532245108070809601",
+ identity_enabled: true,
+ tag: "doll",
+ badge: "dba08126b4e810a0e096cc7cd5bc37f0"
+ }
+ }
+ ],
+ mention_roles: [],
+ attachments: [],
+ embeds: [],
+ timestamp: "2026-03-25T07:07:02.626000+00:00",
+ edited_timestamp: null,
+ flags: 0,
+ components: [
+ {
+ type: 1,
+ id: 1,
+ components: [
+ {
+ type: 2,
+ id: 2,
+ style: 5,
+ label: "Jump",
+ url: "https://discord.com/channels/1160893336324931584/1160894080998461480/1440549403667468320"
+ }
+ ]
+ }
+ ],
+ id: "1486260105908457653",
+ channel_id: "1160894080998461480",
+ author: {
+ id: "466378653216014359",
+ username: "PluralKit",
+ avatar: "b78ef67a081737a830b60aa47d9ebcd9",
+ discriminator: "4020",
+ public_flags: 65536,
+ flags: 65536,
+ bot: true,
+ banner: null,
+ accent_color: null,
+ global_name: null,
+ avatar_decoration_data: null,
+ collectibles: null,
+ display_name_styles: null,
+ banner_color: null,
+ clan: null,
+ primary_guild: null
+ },
+ pinned: false,
+ mention_everyone: false,
+ tts: false,
+ application_id: "466378653216014359",
+ interaction: {
+ id: "1486260103928614932",
+ type: 2,
+ name: "🔔 Ping author",
+ user: {
+ id: "772659086046658620",
+ username: "cadence.worm",
+ avatar: "466df0c98b1af1e1388f595b4c1ad1b9",
+ discriminator: "0",
+ public_flags: 0,
+ flags: 0,
+ banner: null,
+ accent_color: null,
+ global_name: "cadence",
+ avatar_decoration_data: null,
+ collectibles: null,
+ display_name_styles: null,
+ banner_color: null,
+ clan: {
+ identity_guild_id: "532245108070809601",
+ identity_enabled: true,
+ tag: "doll",
+ badge: "dba08126b4e810a0e096cc7cd5bc37f0"
+ },
+ primary_guild: {
+ identity_guild_id: "532245108070809601",
+ identity_enabled: true,
+ tag: "doll",
+ badge: "dba08126b4e810a0e096cc7cd5bc37f0"
+ }
+ }
+ },
+ webhook_id: "466378653216014359",
+ message_reference: {
+ type: 0,
+ channel_id: "1160894080998461480",
+ message_id: "1440549403667468320",
+ guild_id: "1160893336324931584"
+ },
+ interaction_metadata: {
+ id: "1486260103928614932",
+ type: 2,
+ user: {
+ id: "772659086046658620",
+ username: "cadence.worm",
+ avatar: "466df0c98b1af1e1388f595b4c1ad1b9",
+ discriminator: "0",
+ public_flags: 0,
+ flags: 0,
+ banner: null,
+ accent_color: null,
+ global_name: "cadence",
+ avatar_decoration_data: null,
+ collectibles: null,
+ display_name_styles: null,
+ banner_color: null,
+ clan: {
+ identity_guild_id: "532245108070809601",
+ identity_enabled: true,
+ tag: "doll",
+ badge: "dba08126b4e810a0e096cc7cd5bc37f0"
+ },
+ primary_guild: {
+ identity_guild_id: "532245108070809601",
+ identity_enabled: true,
+ tag: "doll",
+ badge: "dba08126b4e810a0e096cc7cd5bc37f0"
+ }
+ },
+ authorizing_integration_owners: { "0": "1160893336324931584" },
+ name: "🔔 Ping author",
+ command_type: 3,
+ target_message_id: "1440549403667468320"
+ },
+ referenced_message: {
+ type: 0,
+ content: "test",
+ mentions: [],
+ mention_roles: [],
+ attachments: [],
+ embeds: [],
+ timestamp: "2025-11-19T03:49:01.948000+00:00",
+ edited_timestamp: null,
+ flags: 0,
+ components: [],
+ id: "1440549403667468320",
+ channel_id: "1160894080998461480",
+ author: {
+ id: "1195662438662680720",
+ username: "special name",
+ avatar: "a82347890f2739e5880cd82b8c1a708e",
+ discriminator: "0000",
+ public_flags: 0,
+ flags: 0,
+ bot: true,
+ global_name: null,
+ clan: null,
+ primary_guild: null
+ },
+ pinned: false,
+ mention_everyone: false,
+ tts: false,
+ application_id: "466378653216014359",
+ webhook_id: "1195662438662680720"
+ }
}
},
message_update: {
diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql
index 8dd71cd..1662320 100644
--- a/test/ooye-test-data.sql
+++ b/test/ooye-test-data.sql
@@ -95,7 +95,8 @@ WITH a (message_id, channel_id) AS (VALUES
('1381212840957972480', '112760669178241024'),
('1401760355339862066', '112760669178241024'),
('1439351590262800565', '1438284564815548418'),
-('1404133238414376971', '112760669178241024'))
+('1404133238414376971', '112760669178241024'),
+('1440549403667468320', '1160894080998461480'))
SELECT message_id, max(historical_room_index) as historical_room_index FROM a INNER JOIN historical_channel_room ON historical_channel_room.reference_channel_id = a.channel_id GROUP BY message_id;
INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES
@@ -143,7 +144,8 @@ INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part
('$7P2O_VTQNHvavX5zNJ35DV-dbJB1Ag80tGQP_JzGdhk', 'm.room.message', 'm.text', '1401760355339862066', 0, 0, 0),
('$ielAnR6geu0P1Tl5UXfrbxlIf-SV9jrNprxrGXP3v7M', 'm.room.message', 'm.image', '1439351590262800565', 0, 0, 0),
('$uUKLcTQvik5tgtTGDKuzn0Ci4zcCvSoUcYn2X7mXm9I', 'm.room.message', 'm.text', '1404133238414376971', 0, 1, 1),
-('$LhmoWWvYyn5_AHkfb6FaXmLI6ZOC1kloql5P40YDmIk', 'm.room.message', 'm.notice', '1404133238414376971', 1, 0, 1);
+('$LhmoWWvYyn5_AHkfb6FaXmLI6ZOC1kloql5P40YDmIk', 'm.room.message', 'm.notice', '1404133238414376971', 1, 0, 1),
+('$l9FMmsEbh9K0NUReeEpWOMZYGRlUOE8yLcm6P-TYHSM', 'm.room.message', 'm.text', '1440549403667468320', 0, 0, 1);
INSERT INTO file (discord_url, mxc_url) VALUES
('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'),