",
format: "org.matrix.custom.html",
formatted_body: ""
+ "Lillith (INX)
"
@@ -73,7 +73,7 @@ test("message2event components: pk question mark output", async t => {
+ "
"
+ "Same hat
"
+ `🖼️ Image: image.png
`
- + "Original Message ID: 1466556003645657118 · 29 January 2026 at 10:09 pm (UTC)
",
+ + "Original Message ID: 1466556003645657118 · <t:1769724599:f>
",
"m.mentions": {},
msgtype: "m.text",
}])
diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js
index 397be39..0742c7b 100644
--- a/src/d2m/converters/message-to-event.test.js
+++ b/src/d2m/converters/message-to-event.test.js
@@ -192,38 +192,6 @@ test("message2event: manually constructed unknown roles should use fallback", as
}])
})
-test("message2event: simple game mentions", async t => {
- const events = await messageToEvent(data.message.simple_game_mention, data.guild.general, {})
- t.deepEqual(events, [{
- $type: "m.room.message",
- "m.mentions": {},
- msgtype: "m.text",
- body: "do you play Minecraft"
- }])
-})
-
-test("message2event: timestamps", async t => {
- const events = await messageToEvent(data.message.timestamps, data.guild.general, {})
- t.deepEqual(events, [{
- $type: "m.room.message",
- msgtype: "m.text",
- body: "4 Oct 2026, 9:33:33 am (UTC)"
- + "\n4 October 2026 at 9:33 am (UTC)"
- + "\nSunday, 4 October 2026 at 9:34 am (UTC)"
- + "\n9:34:08 am (UTC)"
- + "\n3 October 2026 at 9:30 am (UTC)"
- + "\n24 December 2026 at 11:00 pm (UTC)",
- format: "org.matrix.custom.html",
- formatted_body: `4 Oct 2026, 9:33:33 am (UTC)`
- + `
4 October 2026 at 9:33 am (UTC)`
- + `
Sunday, 4 October 2026 at 9:34 am (UTC)`
- + `
9:34:08 am (UTC)`
- + `
3 October 2026 at 9:30 am (UTC)`
- + `
24 December 2026 at 11:00 pm (UTC)`,
- "m.mentions": {}
- }])
-})
-
test("message2event: simple message link", async t => {
let called = 0
const events = await messageToEvent(data.message.simple_message_link, data.guild.general, {}, {
@@ -1135,45 +1103,6 @@ test("message2event: very large attachment is linked instead of being uploaded",
}])
})
-test("message2event: very large png is converted to jpeg for upload", async t => {
- const events = await messageToEvent({
- content: "",
- attachments: [{
- filename: "855064e9-bdae-487a-a497-c662bd510487.png",
- url: "https://cdn.discordapp.com/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png",
- proxy_url: "https://media.discordapp.net/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png",
- content_type: "image/png",
- width: 5472,
- height: 3648,
- size: 14967919
- }]
- }, data.guild.general, {}, {
- async fetch(url, init) {
- t.equal(url, "https://media.discordapp.net/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png?format=jpeg")
- t.equal(init.method, "HEAD")
- return new Response("wa", {headers: {
- "Content-Type": "image/jpeg",
- "Content-Length": 483085
- }})
- }
- })
- t.deepEqual(events, [{
- $type: "m.room.message",
- "m.mentions": {},
- msgtype: "m.image",
- body: "855064e9-bdae-487a-a497-c662bd510487.jpg",
- info: {
- w: 5472,
- h: 3648,
- mimetype: "image/jpeg",
- size: 483085
- },
- external_url: "https://bridge.example.org/download/discordcdn/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png",
- filename: "855064e9-bdae-487a-a497-c662bd510487.jpg",
- url: "mxc://cadence.moe/zXNXRvJwRDMIzZZVqHUXtGeD"
- }])
-})
-
test("message2event: multiple attachments are combined into the same event where possible", async t => {
const events = await messageToEvent({
content: "hey",
diff --git a/src/d2m/converters/user-to-mxid.js b/src/d2m/converters/user-to-mxid.js
index fb37aa1..7705aff 100644
--- a/src/d2m/converters/user-to-mxid.js
+++ b/src/d2m/converters/user-to-mxid.js
@@ -1,6 +1,6 @@
// @ts-check
-const assert = require("assert").strict
+const assert = require("assert")
const {reg} = require("../../matrix/read-registration")
const Ty = require("../../types")
@@ -66,7 +66,7 @@ function userToSimName(user) {
// 1. Is sim user already registered?
const existing = select("sim", "user_id", {user_id: user.id}).pluck().get()
- assert.equal(existing, undefined, "Shouldn't try to create a new name for an existing sim")
+ assert.equal(existing, null, "Shouldn't try to create a new name for an existing sim")
// 2. Register based on username (could be new or old format)
// (Unless it's a special user, in which case copy their provided mappings.)
@@ -113,7 +113,7 @@ function webhookAuthorToSimName(author) {
// 1. Is sim user already registered?
const fakeUserID = webhookAuthorToFakeUserID(author)
const existing = select("sim", "user_id", {user_id: fakeUserID}).pluck().get()
- assert.equal(existing, undefined, "Shouldn't try to create a new name for an existing sim")
+ assert.equal(existing, null, "Shouldn't try to create a new name for an existing sim")
// 2. Register based on username (could be new or old format)
const downcased = "webhook_" + downcaseUsername(author)
diff --git a/src/d2m/discord-client.js b/src/d2m/discord-client.js
index 408c21c..b9b45e2 100644
--- a/src/d2m/discord-client.js
+++ b/src/d2m/discord-client.js
@@ -55,7 +55,9 @@ class DiscordClient {
this.guildChannelMap = new Map()
if (listen !== "no") {
this.cloud.on("event", message => {
- discordPackets.onPacket(this, message, listen)
+ process.nextTick(() => {
+ discordPackets.onPacket(this, message, listen)
+ })
})
}
diff --git a/src/d2m/discord-packets.js b/src/d2m/discord-packets.js
index 9f3b291..988f981 100644
--- a/src/d2m/discord-packets.js
+++ b/src/d2m/discord-packets.js
@@ -1,6 +1,6 @@
// @ts-check
-const assert = require("assert").strict
+const assert = require("assert")
const {scheduler} = require("timers/promises")
const passthrough = require("../passthrough")
const {sync} = passthrough
@@ -8,32 +8,7 @@ const {sync} = passthrough
/** @type {import("../matrix/homeserver-status")} */
const homeserverStatus = sync.require("../matrix/homeserver-status")
-const guildReadyStatus = new class {
- /** @type {Set | null} */
- unavailableGuilds = null
- _allReady = Promise.withResolvers()
-
- /**
- * @param {string} guildID
- * @returns {boolean} true if it was the last one
- */
- makeReady(guildID) {
- assert(this.unavailableGuilds)
- if (this.unavailableGuilds.delete(guildID) && this.unavailableGuilds.size === 0) {
- this._allReady.resolve(null)
- return true
- }
- return false
- }
-
- allReady() {
- return this.unavailableGuilds && this.unavailableGuilds.size === 0
- }
-
- waitForAllReady() {
- return this._allReady.promise
- }
-}
+let checkedHomeserver = false
/**
* @param {import("./discord-client")} client
@@ -53,31 +28,12 @@ async function onPacket(client, message, listen) {
client.ready = true
client.user = message.d.user
client.application = message.d.application
- guildReadyStatus.unavailableGuilds = new Set(message.d.guilds.filter(g => g.unavailable).map(g => g.id))
console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`)
- process.stdout.write("Waiting for guilds to warm up... ")
interactions.registerInteractions()
} else if (message.t === "GUILD_CREATE") {
- message.d.members = message.d.members.filter(m => m.user.id === client.user.id) // only keep the bot account's member - it's needed for roles to determine private channels on web
+ message.d.members = message.d.members.filter(m => m.user.id === client.user.id) // only keep the bot's own member - it's needed to determine private channels on web
client.guilds.set(message.d.id, message.d)
-
- /*
- Info about guilds is populated one guild at a time.
- For m->d bridging to work, the guild needs to be populated, so we need to have GUILD_CREATE for the guild.
- If we ping the homeserver, it will send us any pending events, so we need to wait for all GUILD_CREATES before we ping.
- We must attempt a ping because we don't want to try sending missed d->m messages to an offline homeserver.
- The "all guilds ready" delay can be removed if ONE of the following is done:
- 1. m->d can queue incoming events until their guild exists in memory
- 2. d->m missed messages can have their errors handled and added to queue, rather than pinging first
- */
- const firstReady = guildReadyStatus.allReady()
- const lastGuildReady = guildReadyStatus.makeReady(message.d.id)
-
- if (lastGuildReady) {
- console.log(`ok, ${client.guilds.size} available.`)
- }
-
const arr = []
client.guildChannelMap.set(message.d.id, arr)
for (const channel of message.d.channels || []) {
@@ -95,14 +51,28 @@ async function onPacket(client, message, listen) {
if (listen === "full") {
try {
- // Wait for guilds to be connected and homeserver to be online. If this is the last guild, a different code path is used to trigger the homeserver check.
- if (lastGuildReady) {
- process.stdout.write(`Connecting to homeserver... `)
- // await guildReadyStatus.waitForAllReady() - no need, we already checked this is the last guild
+ /*
+ Info about guilds is populated one guild at a time.
+ For m->d bridging to work, the guild needs to be populated, so we need to have GUILD_CREATE for the guild.
+ If we ping the homeserver, it will send us any pending events, so we need to wait for all GUILD_CREATES before we ping.
+ We must attempt a ping because we don't want to try sending missed d->m messages to an offline homeserver.
+ This delay can be removed if ONE of the following is done:
+ 1. m->d can queue incoming events until their guild exists in memory
+ 2. d->m missed messages can have their errors handled and added to queue, rather than pinging first
+ */
+ let isMainCharacter = false
+ if (!checkedHomeserver) {
+ checkedHomeserver = true
+ isMainCharacter = true
+ console.log("Warming up guilds~")
+ }
+ await scheduler.wait(5000)
+ if (isMainCharacter) {
+ checkedHomeserver = true
+ process.stdout.write("Connecting to homeserver... ")
await homeserverStatus.homeserverStatus.waitForOnline(true)
console.log("ok.\nReplaying past events. Welcome to Out Of Your Element.")
} else {
- await guildReadyStatus.waitForAllReady()
await homeserverStatus.homeserverStatus.waitForOnline(false)
}
@@ -111,9 +81,7 @@ async function onPacket(client, message, listen) {
await eventDispatcher.checkMissedPins(client, message.d)
await eventDispatcher.checkMissedLeaves(client, message.d)
} catch (e) {
- if (firstReady) {
- console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:")
- }
+ console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:")
console.error(e)
}
}
@@ -257,4 +225,3 @@ async function dispatchPacketToBridge(client, message) {
module.exports.onPacket = onPacket
module.exports.dispatchPacketToBridge = dispatchPacketToBridge
-module.exports.guildReadyStatus = guildReadyStatus
diff --git a/src/discord/time.js b/src/discord/time.js
deleted file mode 100644
index d0f7a04..0000000
--- a/src/discord/time.js
+++ /dev/null
@@ -1,51 +0,0 @@
-// @ts-check
-
-const assert = require("assert").strict
-
-/*
- https://docs.discord.com/developers/reference#message-formatting
-
- Trying to make these formats fairly culture independent since the Matrix text will show up the same for everyone.
- That means no ambiguous short dates like 03/04/26. The shortest allowable date is "medium", which spells the month.
- It also means no 24-hour time, since this is ambiguous to 12-hour time users. Times must have am/pm.
- Relative time is not supported for text conversion and will fall back to the default, "f".
-
- See the test file for sample outputs.
-*/
-
-/** @type {Map} discord style letter abbreviation -> Intl.DateTimeFormat constructor options */
-const styles = new Map([
- ["t", {timeStyle: "short", timeZone: "UTC"}],
- ["T", {timeStyle: "medium", timeZone: "UTC"}],
- ["d", {dateStyle: "medium", timeZone: "UTC"}],
- ["D", {dateStyle: "long", timeZone: "UTC"}],
- ["f", {dateStyle: "long", timeStyle: "short", timeZone: "UTC"}],
- ["F", {dateStyle: "full", timeStyle: "short", timeZone: "UTC"}],
- ["s", {dateStyle: "medium", timeStyle: "short", timeZone: "UTC"}],
- ["S", {dateStyle: "medium", timeStyle: "medium", timeZone: "UTC"}]
-])
-const defaultStyle = "f"
-
-/**
- * @param {string | null | undefined} [style]
- * @returns {Intl.DateTimeFormatOptions} */
-function getFormatOptions(style) {
- if (!style || !styles.has(style)) style = defaultStyle
- const formatOptions = styles.get(style)
- assert(formatOptions)
- return formatOptions
-}
-
-/**
- * @param {number} timestamp unix time in seconds
- * @param {string | null | undefined} [style] discord style letter abbreviation, if present
- * @returns {string}
- */
-function convertDiscordTime(timestamp, style) {
- const formatter = new Intl.DateTimeFormat("en-NZ", getFormatOptions(style))
- const d = new Date(timestamp * 1000)
- return formatter.format(d) + " (UTC)"
-}
-
-module.exports.getFormatOptions = getFormatOptions
-module.exports.convertDiscordTime = convertDiscordTime
diff --git a/src/discord/time.test.js b/src/discord/time.test.js
deleted file mode 100644
index b889682..0000000
--- a/src/discord/time.test.js
+++ /dev/null
@@ -1,48 +0,0 @@
-// @ts-check
-
-const time = require("./time")
-const {test} = require("supertape")
-
-const d = new Date("2026-02-14T14:23:06.000Z")
-const seconds = d.getTime() / 1000
-
-test("time: default style", t => {
- t.equal(time.convertDiscordTime(seconds), "14 February 2026 at 2:23 pm (UTC)")
-})
-
-test("time: t style", t => {
- t.equal(time.convertDiscordTime(seconds, "t"), "2:23 pm (UTC)")
-})
-
-test("time: T style", t => {
- t.equal(time.convertDiscordTime(seconds, "T"), "2:23:06 pm (UTC)")
-})
-
-test("time: d style", t => {
- t.equal(time.convertDiscordTime(seconds, "d"), "14 Feb 2026 (UTC)")
-})
-
-test("time: D style", t => {
- t.equal(time.convertDiscordTime(seconds, "D"), "14 February 2026 (UTC)")
-})
-
-test("time: f style", t => {
- t.equal(time.convertDiscordTime(seconds, "f"), "14 February 2026 at 2:23 pm (UTC)")
-})
-
-test("time: F style", t => {
- t.equal(time.convertDiscordTime(seconds, "F"), "Saturday, 14 February 2026 at 2:23 pm (UTC)")
-})
-
-test("time: s style", t => {
- t.equal(time.convertDiscordTime(seconds, "s"), "14 Feb 2026, 2:23 pm (UTC)")
-})
-
-test("time: S style", t => {
- t.equal(time.convertDiscordTime(seconds, "S"), "14 Feb 2026, 2:23:06 pm (UTC)")
-})
-
-test("time: R style", t => {
- // Discord would display this as relative time, but I don't want to do that in a static message.
- t.equal(time.convertDiscordTime(seconds, "R"), "14 February 2026 at 2:23 pm (UTC)")
-})
diff --git a/src/js/errors.js b/src/js/errors.js
deleted file mode 100644
index 7770772..0000000
--- a/src/js/errors.js
+++ /dev/null
@@ -1,53 +0,0 @@
-/**
- * This function is adapted from Evan Kaufman's fantastic work.
- * The original function and my adapted function are both MIT licensed.
- * @url https://github.com/EvanK/npm-loggable-error/
- * @param {number} [depth]
- * @returns {string}
-*/
-function stringifyErrorStack(err, depth = 0) {
- let collapsed = " ".repeat(depth);
- if (!(err instanceof Error)) {
- return collapsed + err
- }
-
- // add full stack trace if one exists, otherwise convert to string
- let stackLines = String(err?.stack ?? err).replace(/^/gm, " ".repeat(depth)).trim().split("\n")
- let cloudstormLine = stackLines.findIndex(l => l.includes("/node_modules/cloudstorm/"))
- if (cloudstormLine !== -1) {
- stackLines = stackLines.slice(0, cloudstormLine - 2)
- }
- collapsed += stackLines.join("\n")
-
- const props = Object.getOwnPropertyNames(err).filter(p => !["message", "stack"].includes(p))
-
- // only break into object notation if we have additional props to dump
- if (props.length) {
- const dedent = " ".repeat(depth);
- const indent = " ".repeat(depth + 2);
-
- collapsed += " {\n";
-
- // loop and print each (indented) prop name
- for (let property of props) {
- collapsed += `${indent}[${property}]: `;
-
- // if another error object, stringify it too
- if (err[property] instanceof Error) {
- collapsed += stringifyErrorStack(err[property], depth + 2).trimStart();
- }
- // otherwise stringify as JSON
- else {
- collapsed += JSON.stringify(err[property]);
- }
-
- collapsed += "\n";
- }
-
- collapsed += `${dedent}}\n`;
- }
-
- return collapsed;
-}
-
-module.exports.stringifyErrorStack = stringifyErrorStack
diff --git a/src/js/errors.test.js b/src/js/errors.test.js
deleted file mode 100644
index ddff7d3..0000000
--- a/src/js/errors.test.js
+++ /dev/null
@@ -1,23 +0,0 @@
-// @ts-check
-
-const {test} = require("supertape")
-const {stringifyErrorStack} = require("./errors")
-
-test("stringify error stack: works", t => {
- function a() {
- const e = new Error("message", {cause: new Error("inner")})
- // @ts-ignore
- e.prop = 2.1
- throw e
- }
- try {
- a()
- t.fail("shouldn't get here")
- } catch (e) {
- const str = stringifyErrorStack(e)
- t.match(str, /^Error: message$/m)
- t.match(str, /^ at a \(.*errors\.test\.js/m)
- t.match(str, /^ \[cause\]: Error: inner$/m)
- t.match(str, /^ \[prop\]: 2.1$/m)
- }
-})
diff --git a/src/m2d/actions/send-event.js b/src/m2d/actions/send-event.js
index 2e6bf5c..00557a1 100644
--- a/src/m2d/actions/send-event.js
+++ b/src/m2d/actions/send-event.js
@@ -153,7 +153,7 @@ async function sendEvent(event) {
channel_id: messageResponse.channel_id,
guild_id: guild.id,
embeds: messageResponse.embeds
- }, guild)
+ }, guild, null)
)
}
}
diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js
index 5fe1109..ea53e0a 100644
--- a/src/m2d/converters/event-to-message.js
+++ b/src/m2d/converters/event-to-message.js
@@ -32,8 +32,6 @@ const setupEmojis = sync.require("../actions/setup-emojis")
const userToMxid = sync.require("../../d2m/converters/user-to-mxid")
/** @type {import("../../web/routes/letter-avatar")} */
const letterAvatar = sync.require("../../web/routes/letter-avatar")
-/** @type {import("../../d2m/actions/retrigger")} */
-const retrigger = sync.require("../../d2m/actions/retrigger")
/** @type {[RegExp, string][]} */
const markdownEscapes = [
@@ -620,7 +618,7 @@ async function eventToMessage(event, guild, channel, di) {
let filename = event.content.filename || event.content.body
if (!filename) {
const extension = mimeTypes.extension(event.content.info?.mimetype)
- assert(extension, "Filename and mimetype missing. Please report this bug to your client.")
+ if (!extension) throw new Error("Filename and mimetype missing. Please report this bug to your client.")
filename = `file.${extension}`
}
@@ -698,7 +696,6 @@ async function eventToMessage(event, guild, channel, di) {
// Check if we have a pointer to what was edited
const originalEventId = relatesTo.event_id
if (!originalEventId) return
- if (!await retrigger.waitForEvent(originalEventId)) return
messageIDsToEdit = select("event_message", "message_id", {event_id: originalEventId}, "ORDER BY part").pluck().all()
if (!messageIDsToEdit.length) return
@@ -757,7 +754,7 @@ async function eventToMessage(event, guild, channel, di) {
.replace(/(?:\n|
)+/g, " ") // Should all be on one line
.replace(/]*data-mx-spoiler\b[^>]*>.*?<\/span>/g, "[spoiler]") // Good enough method of removing spoiler content. (I don't want to break out the HTML parser unless I have to.)
.replace(/<[^>]+>/g, "") // Completely strip all HTML tags and formatting.
- ), 50)
+ ), 65)
replyLine = "-# > " + contentPreviewChunks[0]
if (contentPreviewChunks.length > 1) replyLine = replyLine.replace(/[,.']$/, "") + "..."
replyLine += "\n"
@@ -765,11 +762,6 @@ async function eventToMessage(event, guild, channel, di) {
}
replyLine = await getL1L2ReplyLine()
- const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
- .select("reference_channel_id", "message_id").where({event_id: repliedToEventId}).and("ORDER BY part").get()
- if (row) {
- replyLine += `https://discord.com/channels/${guild.id}/${row.reference_channel_id}/${row.message_id} `
- }
// If the event has been edited, the homeserver will include the relation in `unsigned`.
if (repliedToEvent.unsigned?.["m.relations"]?.["m.replace"]?.content?.["m.new_content"]) {
repliedToEvent = repliedToEvent.unsigned["m.relations"]["m.replace"] // Note: this changes which event_id is in repliedToEvent.
@@ -780,15 +772,15 @@ async function eventToMessage(event, guild, channel, di) {
const fileReplyContentAlternative = attachmentEmojis.get(repliedToEvent.content.msgtype)
let contentPreview
if (fileReplyContentAlternative) {
- contentPreview = " " + fileReplyContentAlternative
+ contentPreview = fileReplyContentAlternative
} else if (repliedToEvent.unsigned?.redacted_because) {
- contentPreview = " (in reply to a deleted message)"
+ contentPreview = "(in reply to a deleted message)"
} 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
repliedToEvent.sender = ""
- contentPreview = " (channel details edited)"
+ contentPreview = "(channel details edited)"
} else {
// Generate a reply preview for a standard message
let repliedToContent = originalRepliedToContent
@@ -805,7 +797,7 @@ async function eventToMessage(event, guild, channel, di) {
repliedToContent = entities.decodeHTML5Strict(repliedToContent) // Remove entities like & "
const contentPreviewChunks = chunk(repliedToContent, 50)
if (contentPreviewChunks.length) {
- contentPreview = ": " + contentPreviewChunks[0]
+ contentPreview = contentPreviewChunks[0]
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
@@ -832,7 +824,12 @@ async function eventToMessage(event, guild, channel, di) {
if (!senderName) senderName = sender.match(/@([^:]*)/)?.[1]
if (senderName) replyLine += `**Ⓜ${senderName}**`
}
- replyLine = `-# > ${replyLine}${contentPreview}\n`
+ const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
+ .select("reference_channel_id", "message_id").where({event_id: repliedToEventId}).and("ORDER BY part").get()
+ if (row) {
+ contentPreview = `[${contentPreview}]()`
+ }
+ replyLine = `-# > ${replyLine} ${contentPreview}\n`
})()
if (shouldProcessTextEvent) {
diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js
index b262a37..6b22773 100644
--- a/src/m2d/converters/event-to-message.test.js
+++ b/src/m2d/converters/event-to-message.test.js
@@ -4816,39 +4816,6 @@ test("event2message: image attachments can have a formatted caption", async t =>
)
})
-test("event2message: attachments have a generated filename if it was missing", async t => {
- t.deepEqual(
- await eventToMessage({
- type: "m.room.message",
- sender: "@cadence:cadence.moe",
- content: {
- body: "",
- filename: "",
- info: {
- mimetype: "image/png",
- size: 50832
- },
- msgtype: "m.image",
- url: "mxc://agiadn.org/3SvzETkab3XBDM6HCMJxpOryIDxMO31j"
- },
- event_id: "$CXQy3Wmg1A-gL_xAesC1HQcQTEXwICLdSwwUx55FBTI",
- room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe"
- }),
- {
- ensureJoined: [],
- messagesToDelete: [],
- messagesToEdit: [],
- messagesToSend: [{
- username: "cadence [they]",
- content: "",
- avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar",
- attachments: [{id: "0", filename: "file.png"}],
- pendingFiles: [{name: "file.png", mxc: "mxc://agiadn.org/3SvzETkab3XBDM6HCMJxpOryIDxMO31j"}]
- }]
- }
- )
-})
-
test("event2message: encrypted image attachments work", async t => {
t.deepEqual(
await eventToMessage({
diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js
index f6f414a..0560f46 100644
--- a/src/m2d/event-dispatcher.js
+++ b/src/m2d/event-dispatcher.js
@@ -38,12 +38,62 @@ const retrigger = sync.require("../d2m/actions/retrigger")
const homeserverStatus = sync.require("../matrix/homeserver-status")
/** @type {import("./actions/typing")} */
const typing = sync.require("./actions/typing")
-/** @type {import("../js/errors")} */
-const errors = sync.require("../js/errors")
const {reg} = require("../matrix/read-registration")
let lastReportedEvent = 0
+/**
+ * This function is adapted from Evan Kaufman's fantastic work.
+ * The original function and my adapted function are both MIT licensed.
+ * @url https://github.com/EvanK/npm-loggable-error/
+ * @param {number} [depth]
+ * @returns {string}
+*/
+function stringifyErrorStack(err, depth = 0) {
+ let collapsed = " ".repeat(depth);
+ if (!(err instanceof Error)) {
+ return collapsed + err
+ }
+
+ // add full stack trace if one exists, otherwise convert to string
+ let stackLines = String(err?.stack ?? err).replace(/^/gm, " ".repeat(depth)).trim().split("\n")
+ let cloudstormLine = stackLines.findIndex(l => l.includes("/node_modules/cloudstorm/"))
+ if (cloudstormLine !== -1) {
+ stackLines = stackLines.slice(0, cloudstormLine - 2)
+ }
+ collapsed += stackLines.join("\n")
+
+ const props = Object.getOwnPropertyNames(err).filter(p => !["message", "stack"].includes(p))
+
+ // only break into object notation if we have additional props to dump
+ if (props.length) {
+ const dedent = " ".repeat(depth);
+ const indent = " ".repeat(depth + 2);
+
+ collapsed += " {\n";
+
+ // loop and print each (indented) prop name
+ for (let property of props) {
+ collapsed += `${indent}[${property}]: `;
+
+ // if another error object, stringify it too
+ if (err[property] instanceof Error) {
+ collapsed += stringifyErrorStack(err[property], depth + 2).trimStart();
+ }
+ // otherwise stringify as JSON
+ else {
+ collapsed += JSON.stringify(err[property]);
+ }
+
+ collapsed += "\n";
+ }
+
+ collapsed += `${dedent}}\n`;
+ }
+
+ return collapsed;
+}
+
function printError(type, source, e, payload) {
console.error(`Error while processing a ${type} ${source} event:`)
console.error(e)
@@ -95,7 +145,7 @@ async function sendError(roomID, source, type, e, payload) {
builder.addLine(errorIntroLine)
// Where
- const stack = cleanErrorStack(errors.stringifyErrorStack(e))
+ const stack = cleanErrorStack(stringifyErrorStack(e))
builder.addLine(`Error trace:\n${stack}`, tag`Error trace
${stack} `)
// How
@@ -493,6 +543,7 @@ async event => {
}
}))
+module.exports.stringifyErrorStack = stringifyErrorStack
module.exports.cleanErrorStack = cleanErrorStack
module.exports.sendError = sendError
module.exports.printError = printError
diff --git a/src/m2d/event-dispatcher.test.js b/src/m2d/event-dispatcher.test.js
index 302b33b..2de6381 100644
--- a/src/m2d/event-dispatcher.test.js
+++ b/src/m2d/event-dispatcher.test.js
@@ -1,7 +1,26 @@
// @ts-check
const {test} = require("supertape")
-const {cleanErrorStack} = require("./event-dispatcher")
+const {stringifyErrorStack, cleanErrorStack} = require("./event-dispatcher")
+
+test("stringify error stack: works", t => {
+ function a() {
+ const e = new Error("message", {cause: new Error("inner")})
+ // @ts-ignore
+ e.prop = 2.1
+ throw e
+ }
+ try {
+ a()
+ t.fail("shouldn't get here")
+ } catch (e) {
+ const str = stringifyErrorStack(e)
+ t.match(str, /^Error: message$/m)
+ t.match(str, /^ at a \(.*event-dispatcher\.test\.js/m)
+ t.match(str, /^ \[cause\]: Error: inner$/m)
+ t.match(str, /^ \[prop\]: 2.1$/m)
+ }
+})
test("clean error stack: removes webhook token", t => {
t.notMatch(
diff --git a/src/matrix/file.js b/src/matrix/file.js
index b338c59..c469aea 100644
--- a/src/matrix/file.js
+++ b/src/matrix/file.js
@@ -15,15 +15,10 @@ const IMAGE_SIZE = 1024
const inflight = new Map()
/**
- * @param {string} urlString
+ * @param {string} url
*/
-function _removeExpiryParams(urlString) {
- const url = new URL(urlString)
- url.searchParams.delete("ex")
- url.searchParams.delete("is")
- url.searchParams.delete("sg")
- url.searchParams.delete("hm")
- return url.toString()
+function _removeExpiryParams(url) {
+ return url.replace(/\?(?:(?:ex|is|sg|hm)=[a-f0-9]+&?)*$/, "")
}
/**
diff --git a/src/matrix/file.test.js b/src/matrix/file.test.js
index 3a5c73b..2d59dbc 100644
--- a/src/matrix/file.test.js
+++ b/src/matrix/file.test.js
@@ -20,9 +20,3 @@ test("removeExpiryParams: rearranged params are removed", t => {
const result = file._removeExpiryParams(url)
t.equal(result, "https://cdn.discordapp.com/attachments/112760669178241024/1157363960518029322/image.png")
})
-
-test("removeExpiryParams: works on media proxy and keeps quality", t => {
- const url = "https://media.discordapp.net/attachments/1160894080998461480/1548665030898094240/855064e9-bdae-487a-a497-c662bd510487.png?ex=6aa7e234&is=6aa690b4&hm=7c153efcdd1feb1d36d0bb99955a2bfe12f26abea377fed02980521766f51620&format=jpeg"
- const result = file._removeExpiryParams(url)
- t.equal(result, "https://media.discordapp.net/attachments/1160894080998461480/1548665030898094240/855064e9-bdae-487a-a497-c662bd510487.png?format=jpeg")
-})
diff --git a/src/matrix/homeserver-status.js b/src/matrix/homeserver-status.js
index 6d0ea76..3ceac84 100644
--- a/src/matrix/homeserver-status.js
+++ b/src/matrix/homeserver-status.js
@@ -1,6 +1,6 @@
// @ts-check
-const assert = require("assert").strict
+const assert = require("assert")
const Denque = require("denque")
const StateMachine = require("snowtransfer").StateMachine
@@ -27,21 +27,13 @@ const homeserverStatus = new class HomeserverStatus {
/** @private */
this.pingInterval = undefined
- /** @private */
- this.checkingWhileOnline = true
-
- /** @private */
- this.lastCheck = 0
-
/** @private */
this.sm = new StateMachine("online")
.defineState("online")
.defineState("checking", {
- onEnter: [async (event) => {
- this.checkingWhileOnline = event === "check while online"
+ onEnter: [async () => {
const pingResult = await api.ping().catch(e => ({ok: false}))
- this.lastCheck = Date.now()
if (pingResult.ok) {
this.sm.doTransition("check ok")
} else {
@@ -56,7 +48,6 @@ const homeserverStatus = new class HomeserverStatus {
onEnter: [() => {
this.pingInterval = setInterval(async () => {
const pingResult = await api.ping().catch(e => ({ok: false, status: "net", root: e.message}))
- this.lastCheck = Date.now()
if (pingResult.ok) {
this.sm.doTransition("ping ok")
}
@@ -86,8 +77,7 @@ const homeserverStatus = new class HomeserverStatus {
.defineUniversalTransition("error", "offline")
.defineTransition("offline", "ping ok", "recovering")
.defineTransition("recovering", "recovered", "online")
- .defineTransition("online", "check assume online", "checking")
- .defineTransition("online", "check assume offline", "checking")
+ .defineTransition("online", "check", "checking")
.defineTransition("checking", "check ok", "recovering")
.defineTransition("checking", "check fail", "offline")
@@ -97,7 +87,7 @@ const homeserverStatus = new class HomeserverStatus {
}
isRealTime() {
- return this.sm.currentStateName === "online" || (this.sm.currentStateName === "checking" && this.checkingWhileOnline)
+ return this.sm.currentStateName === "online"
}
/** @param {boolean} forceCheck */
@@ -106,9 +96,7 @@ const homeserverStatus = new class HomeserverStatus {
// Already online? Start check or just done
if (this.sm.currentStateName === "online") {
if (forceCheck) {
- this.sm.doTransition("check assume offline")
- } else if (this.lastCheck < Date.now() - 15e3) {
- this.sm.doTransition("check assume online")
+ this.sm.doTransition("check")
} else {
return resolve(null)
}
diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js
index de81899..aa80b9c 100644
--- a/src/matrix/matrix-command-handler.js
+++ b/src/matrix/matrix-command-handler.js
@@ -3,9 +3,8 @@
const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10")
const Ty = require("../types")
+const {pipeline} = require("stream").promises
const sharp = require("sharp")
-const tw = require("@cloudrac3r/tell-me-when")
-const {tag} = require("@cloudrac3r/html-template-tag")
const {discord, sync, db, select} = require("../passthrough")
/** @type {import("./api")}) */
@@ -14,9 +13,8 @@ const api = sync.require("./api")
const mxUtils = sync.require("./utils")
/** @type {import("../discord/utils")} */
const dUtils = sync.require("../discord/utils")
-/** @type {import("../d2m/actions/retrigger")} */
-const retrigger = sync.require("../d2m/actions/retrigger")
-
+/** @type {import("./kstate")} */
+const ks = sync.require("./kstate")
const {reg} = require("./read-registration")
const PREFIXES = ["//", "/"]
@@ -35,17 +33,7 @@ function getSlotCount(tier) {
return TIER_EMOJI_SLOTS.get(tier) || 50
}
-/**
- * @typedef Button
- * @property {string} roomID
- * @property {string} eventID
- * @property {string} mxid
- * @property {string} key
- * @property {(any) => any} resolve
- * @property {number} created
- */
-
-const buttonStore = sync.remember(() => /** @type {{buttons: Button[]}} */ ({buttons: []}))
+let buttons = []
/**
* @param {string} roomID where to add the button
@@ -63,23 +51,22 @@ async function addButton(roomID, eventID, key, mxid) {
}
})
return new Promise(resolve => {
- buttonStore.buttons.push({roomID, eventID, mxid, key, resolve, created: Date.now()})
+ buttons.push({roomID, eventID, mxid, key, resolve, created: Date.now()})
})
}
// Clear out old buttons every so often to free memory
setInterval(() => {
const now = Date.now()
- buttonStore.buttons = buttonStore.buttons.filter(b => now - b.created < 2*60*60*1000)
+ buttons = buttons.filter(b => now - b.created < 2*60*60*1000)
}, 10*60*1000).unref()
/** @param {Ty.Event.Outer} event */
function onReactionAdd(event) {
- const button = buttonStore.buttons.find(b => b.roomID === event.room_id && b.mxid === event.sender && b.eventID === event.content["m.relates_to"]?.event_id && b.key === event.content["m.relates_to"]?.key)
+ const button = buttons.find(b => b.roomID === event.room_id && b.mxid === event.sender && b.eventID === event.content["m.relates_to"]?.event_id && b.key === event.content["m.relates_to"]?.key)
if (button) {
- buttonStore.buttons = buttonStore.buttons.filter(b => b !== button) // remove button data so it can't be clicked again
+ buttons = buttons.filter(b => b !== button) // remove button data so it can't be clicked again
button.resolve(event)
- return true
}
}
@@ -331,114 +318,6 @@ const commands = [{
})
}
)
-}, {
- aliases: ["time"],
- execute: replyctx(
- async (event, realBody, words, ctx) => {
- // Guard
- /** @type {string} */ // @ts-ignore
- const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
- const channel = discord.channels.get(channelID)
- const guildID = channel?.["guild_id"]
- if (!guildID) {
- return api.sendEvent(event.room_id, "m.room.message", {
- ...ctx,
- msgtype: "m.text",
- body: "This room isn't bridged to the other side."
- })
- }
-
- // Get user timezone
- const profile = await api.getProfile(event.sender)
- const senderTimeZone = profile?.["m.tz"]
- if (!senderTimeZone) {
- return api.sendEvent(event.room_id, "m.room.message", {
- ...ctx,
- msgtype: "m.text",
- body: "This command converts a time in your local time zone for everybody else. But I don't know your time zone. Please set your time zone (hopefully possible in your client's settings/account menu) and try again."
- })
- }
-
- try {
- var now = Temporal.Now.zonedDateTimeISO(senderTimeZone)
- } catch (e) {
- return api.sendEvent(event.room_id, "m.room.message", {
- ...ctx,
- ...new mxUtils.MatrixStringBuilder().add(
- `The time zone on your account, "${senderTimeZone}", is not valid. It needs to be an IANA time zone value, with a slash. See yours here: https://time.is/your_time_zone`,
- tag`The time zone on your account, ${senderTimeZone}, is not valid. It needs to be an IANA time zone value with a slash. See yours here.`
- ).get()
- })
- }
-
- const input = words.slice(1).join(" ").trim()
- if (!input) {
- return api.sendEvent(event.room_id, "m.room.message", {
- ...ctx,
- msgtype: "m.text",
- body: "This command converts a time in your local time zone for everybody else. Use this command with a time or date (English natural language)."
- })
- }
-
- try {
- var parsed = tw.parse(input)
- var when = tw.tellMeWhen(input, {now})
- } catch (e) {
- return api.sendEvent(event.room_id, "m.room.message", {
- ...ctx,
- msgtype: "m.text",
- body: "I didn't understand your input. If you think your input was reasonable, please report this as a bug."
- })
- }
-
- const messageID = select("event_message", "message_id", {event_id: event.event_id}).pluck().get()
-
- const hasDate = parsed.some(x => Array.isArray(x) && x[0].match(/year|month|week|date|day/i))
- const hasTime = parsed.some(x => Array.isArray(x) && x[0].match(/hour|minute|second/i))
- const colonStyle =
- ( hasDate && hasTime ? ":F"
- : hasDate ? ":D"
- : ":t")
-
- /** @param {Temporal.ZonedDateTime} z */
- function convertOne(z) {
- return ""
- }
-
- if (Array.isArray(when)) { // convert range
- var content = `From ${convertOne(when[0])} to ${convertOne(when[1])}`
- } else { // convert single time
- var content = `${convertOne(when)}`
- }
-
- /** @type {DiscordTypes.RESTPostAPIChannelMessageJSONBody} */
- const message = {
- flags: DiscordTypes.MessageFlags.IsComponentsV2,
- components: [{
- type: DiscordTypes.ComponentType.Container,
- components: [{
- type: DiscordTypes.ComponentType.TextDisplay,
- content: `## ${content}\n-# Wrong? 🗑️ to delete`
- }]
- }]
- }
- if (messageID) {
- Object.assign(message, {
- message_reference: {
- type: DiscordTypes.MessageReferenceType.Default,
- message_id: messageID,
- }
- })
- }
- const sent = await discord.snow.channel.createMessage(channelID, message)
- if (!await retrigger.waitForMessage(sent.id)) return
- const sentEventID = select("event_message", "event_id", {message_id: sent.id}, "ORDER BY reaction_part").pluck().get()
- assert(sentEventID)
- addButton(event.room_id, sentEventID, "🗑️", event.sender).then(() => {
- discord.snow.channel.deleteMessage(channelID, sent.id).catch(() => {})
- })
- }
- )
}]
diff --git a/src/stdin.js b/src/stdin.js
index 6e35e78..43f9607 100644
--- a/src/stdin.js
+++ b/src/stdin.js
@@ -7,7 +7,6 @@ const {addbot} = require("../addbot")
const passthrough = require("./passthrough")
const {discord, sync, db, select, from, as} = passthrough
-const reg = require("./matrix/read-registration")
const data = sync.require("../test/data")
const createSpace = sync.require("./d2m/actions/create-space")
const createRoom = sync.require("./d2m/actions/create-room")
diff --git a/src/web/pug-sync.js b/src/web/pug-sync.js
index cca90c2..e61c53b 100644
--- a/src/web/pug-sync.js
+++ b/src/web/pug-sync.js
@@ -2,24 +2,20 @@
const assert = require("assert/strict")
const fs = require("fs")
-const util = require("util")
const {join} = require("path")
const getRelativePath = require("get-relative-path")
const h3 = require("h3")
-const {defineEventHandler, defaultContentType, setResponseStatus, getQuery, getRequestURL} = h3
+const {defineEventHandler, defaultContentType, setResponseStatus, getQuery} = h3
const {compileFile} = require("@cloudrac3r/pug")
-const walkAST = require("pug-walk")
const pretty = process.argv.join(" ").includes("test")
const {sync} = require("../passthrough")
/** @type {import("./auth")} */
const auth = sync.require("./auth")
-/** @type {import("../js/errors")} */
-const errors = sync.require("../js/errors")
// Pug
-let globals = sync.remember(() => ({}))
+let globals = {}
/** @type {Map) => Promise>} */
const pugCache = new Map()
@@ -34,60 +30,21 @@ function addGlobals(obj) {
* @param {Record} locals
*/
function render(event, filename, locals) {
- return renderFragments(event, filename, null, locals)
-}
-
-/**
- * @param {import("h3").H3Event} event
- * @param {string} filename
- * @param {string[] | null} fragments
- * @param {Record} locals
- */
-function renderFragments(event, filename, fragments, locals) {
const path = join(__dirname, "pug", filename)
- return renderPath(event, path, fragments, locals)
-}
-
-/**
- * @param {string[] | null} fragments
- * @param {string[]} requiresOut
- */
-function fragmentSelectorPlugins(fragments, requiresOut) {
- if (!fragments) return []
- return [{
- parse: true,
- postParse(ast, options) {
- const found = []
- walkAST(ast, (node, replace) => {
- if (node.type === "NamedBlock") {
- const [name, requires] = node.name.split("requires").map(x => x.trim())
- // console.log(name, "|", requires)
- if (fragments?.includes(name)) {
- if (requires) requiresOut.push(...requires.split(",").map(x => x.trim()))
- found.push(node)
- }
- }
- })
- // console.log(util.inspect(found, false, 9, true))
- return {type: "Block", nodes: found, line: 0, filename: found[0].filename}
- }
- }]
+ return renderPath(event, path, locals)
}
/**
* @param {import("h3").H3Event} event
* @param {string} path
- * @param {string[] | null} fragments
* @param {Record} locals
*/
-function renderPath(event, path, fragments, locals) {
- const cacheKey = path + (fragments ? fragments.map(f => `#${f}`).join("") : "")
-
+function renderPath(event, path, locals) {
function compile() {
try {
- const requiredVars = []
- const template = compileFile(path, {pretty, plugins: fragmentSelectorPlugins(fragments, requiredVars)})
- pugCache.set(cacheKey, async (event, locals) => {
+ const template = compileFile(path, {pretty})
+ pugCache.set(path, async (event, locals) => {
+ defaultContentType(event, "text/html; charset=utf-8")
const session = await auth.useSession(event)
const managed = await auth.getManagedGuilds(event)
const rel = (to, paramsObject) => {
@@ -98,36 +55,16 @@ function renderPath(event, path, fragments, locals) {
}
return result
}
- const templateVars = Object.assign({},
+ return template(Object.assign({},
getQuery(event), // Query parameters can be easily accessed on the top level but don't allow them to overwrite anything
globals, // Globals
locals, // Explicit locals overwrite globals in case we need to DI something
{session, event, rel, managed} // These are assigned last so they overwrite everything else. It would be catastrophically bad if they can't be trusted.
- )
- // Verify all fragment block required variables are present
- for (const key of requiredVars) {
- if (templateVars[key] === undefined) {
- setResponseStatus(event, 500, "Internal Template Variables Error")
- defaultContentType(event, "text/plain")
- return `requested fragments ${JSON.stringify(fragments)}\nrequire variables ${JSON.stringify(requiredVars)}\nbut "${key}" is ${templateVars[key]} (present: ${key in templateVars})`
- }
- }
- try {
- const content = template(templateVars)
- defaultContentType(event, "text/html; charset=utf-8")
- return content
- } catch (e) {
- console.error(e)
- setResponseStatus(event, 500, "Internal Template Rendering Error")
- defaultContentType(event, "text/plain")
- e.url = getRequestURL(event).toString()
- return errors.stringifyErrorStack(e)
- }
+ ))
})
/* c8 ignore start */
} catch (e) {
- pugCache.set(cacheKey, async (event) => {
- console.error(e)
+ pugCache.set(path, async (event) => {
setResponseStatus(event, 500, "Internal Template Error")
defaultContentType(event, "text/plain")
return e.toString()
@@ -136,16 +73,14 @@ function renderPath(event, path, fragments, locals) {
/* c8 ignore stop */
}
- if (!pugCache.has(cacheKey)) {
+ if (!pugCache.has(path)) {
compile()
- const ac = new AbortController()
- const {signal} = ac
- fs.watch(path, {persistent: false, signal}, compile)
- fs.watch(join(__dirname, "pug", "includes"), {persistent: false, signal}, compile)
- sync.events.once(__filename, () => ac.abort())
+ fs.watch(path, {persistent: false}, compile)
+ fs.watch(join(__dirname, "pug", "includes"), {persistent: false}, compile)
+ fs.watch(join(__dirname, "pug", "fragments"), {persistent: false}, compile)
}
- const cb = pugCache.get(cacheKey)
+ const cb = pugCache.get(path)
assert(cb)
return cb(event, locals)
}
@@ -163,6 +98,5 @@ function createRoute(router, url, filename) {
module.exports.addGlobals = addGlobals
module.exports.render = render
-module.exports.renderFragments = renderFragments
module.exports.renderPath = renderPath
module.exports.createRoute = createRoute
diff --git a/src/web/pug/fragments/default-roles-list.pug b/src/web/pug/fragments/default-roles-list.pug
new file mode 100644
index 0000000..3b36549
--- /dev/null
+++ b/src/web/pug/fragments/default-roles-list.pug
@@ -0,0 +1,5 @@
+//- locals: guild, guild_id
+
+include ../includes/default-roles-list.pug
++default-roles-list(guild, guild_id)
++add-roles-menu(guild, guild_id)
diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug
index c464954..7411a1e 100644
--- a/src/web/pug/guild.pug
+++ b/src/web/pug/guild.pug
@@ -1,4 +1,5 @@
extends includes/template.pug
+include includes/default-roles-list.pug
mixin badge-readonly
.s-badge.s-badge__xs.s-badge__icon.s-badge__muted
@@ -112,33 +113,15 @@ block body
.d-flex.fw-wrap.g4
.s-tag.s-tag__md.fs-body1.s-tag__required @everyone
- block default-roles-list requires guild, guild_id
- #default-roles-list(style="display: contents")
- each roleID in select("role_default", "role_id", {guild_id}).pluck().all()
- - let r = guild.roles.find(r => r.id === roleID)
- if r
- .s-tag.s-tag__md.fs-body1= r.name
- span(id=`role-loading-${roleID}`)
- button(name="remove_role" value=roleID hx-post="api/default-roles" hx-trigger="click consume" hx-indicator=`#role-loading-${roleID}`).s-tag--dismiss
- != icons.Icons.IconClearSm
+ +default-roles-list(guild, guild_id)
button(type="button" popovertarget="role-add").s-btn__dropdown.s-tag.s-tag__md.fs-body1.p0
.s-tag--dismiss.m1
!= icons.Icons.IconPlusSm
- #role-add.s-popover(popover style="display: revert").ws2.px0.py4.bs-lg.overflow-visible.box-border.lh-sm
+ #role-add.s-popover(popover style="display: revert").ws2.px0.py4.bs-lg.overflow-visible
.s-popover--arrow.s-popover--arrow__tc
-
- block add-roles-menu requires guild, guild_id
- ul.s-menu(role="menu" hx-swap-oob="true").overflow-y-auto.overflow-x-hidden#add-roles-menu
- li.s-menu--title.d-flex(role="separator") Select role
- span#add-role-loading
- each r in guild.roles.sort((a, b) => b.position - a.position)
- if r.id !== guild_id && !r.managed
- - let selected = !!select("role_default", "role_id", {guild_id, role_id: r.id}).get()
- li(role="menuitem")
- button(name="toggle_role" value=r.id class={"is-selected": selected}).s-block-link.s-block-link__left= r.name
-
+ +add-roles-menu(guild, guild_id)
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
diff --git a/src/web/pug/includes/default-roles-list.pug b/src/web/pug/includes/default-roles-list.pug
new file mode 100644
index 0000000..8c0a4e0
--- /dev/null
+++ b/src/web/pug/includes/default-roles-list.pug
@@ -0,0 +1,19 @@
+mixin default-roles-list(guild, guild_id)
+ #default-roles-list(style="display: contents")
+ each roleID in select("role_default", "role_id", {guild_id}).pluck().all()
+ - let r = guild.roles.find(r => r.id === roleID)
+ if r
+ .s-tag.s-tag__md.fs-body1= r.name
+ span(id=`role-loading-${roleID}`)
+ button(name="remove_role" value=roleID hx-post="api/default-roles" hx-trigger="click consume" hx-indicator=`#role-loading-${roleID}`).s-tag--dismiss
+ != icons.Icons.IconClearSm
+
+mixin add-roles-menu(guild, guild_id)
+ ul.s-menu(role="menu" hx-swap-oob="true").overflow-y-auto.overflow-x-hidden#add-roles-menu
+ li.s-menu--title.d-flex(role="separator") Select role
+ span#add-role-loading
+ each r in guild.roles.sort((a, b) => b.position - a.position)
+ if r.id !== guild_id && !r.managed
+ - let selected = !!select("role_default", "role_id", {guild_id, role_id: r.id}).get()
+ li(role="menuitem")
+ button(name="toggle_role" value=r.id class={"is-selected": selected}).s-block-link.s-block-link__left= r.name
diff --git a/src/web/pug/invite.pug b/src/web/pug/invite.pug
index 0596d58..81a428d 100644
--- a/src/web/pug/invite.pug
+++ b/src/web/pug/invite.pug
@@ -17,10 +17,10 @@ block body
.fl-grow1
h2.fs-headline1 Invite a Matrix user
- form.d-flex.gy16.wmx3.fd-column(method="post" action=rel("/api/invite") hx-post=rel("/api/invite") hx-indicator="#invite-button" hx-select="#ok" hx-target="#form-container")
+ form.d-flex.gy16.fd-column(method="post" action=rel("/api/invite") hx-post=rel("/api/invite") hx-indicator="#invite-button" hx-select="#ok" hx-target="#form-container")
.d-flex.gy4.fd-column
label.s-label(for="mxid") Matrix ID
- input.fl-grow1.s-input#mxid(name="mxid" required placeholder="@user:example.org")
+ input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org")
.d-flex.gy4.fd-column
label.s-label(for="permissions") Permissions
.s-select
diff --git a/src/web/pug/time.pug b/src/web/pug/time.pug
deleted file mode 100644
index 1d71c22..0000000
--- a/src/web/pug/time.pug
+++ /dev/null
@@ -1,143 +0,0 @@
-//- requires timestamp, text, style
-
-- const WIDTH = 600
-
-doctype html
-html(lang="en")
- head
-
-
- title= `Converting time ${text}...`
- style.
- body {
- font-family: "Segoe UI", sans-serif;
- margin: 0 auto;
- padding: 4px;
- font-size: 18px;
- background-color: #f0f2f5; /* element compound tertiary off-white */
- max-width: !{WIDTH}px;
- }
- .only-script {
- display: none;
- }
- .yes-script .only-script {
- display: revert;
- }
- .local {
- line-height: 1;
- width: auto;
- height: auto;
- max-width: 100%;
- }
- .original {
- color: #004c84;
- }
- .remaining {
- font-weight: bold;
- }
- .primary-remaining {
- font-size: 25px;
- }
- @media (prefers-color-scheme: dark) {
- body {
- color: #e3e3e3;
- background-color: #131416; /* element compound bg but desaturated */
- }
- .original {
- color: #99c9ec;
- }
- .local {
- fill: #fff;
- }
- .remaining {
- color: #fff;
- }
- }
- body
- noscript
- | You must enable JavaScript to convert to your local time.
- .only-script
- p In your local time, #[span.original= text] is...
- if style === "R"
- p.primary-remaining #[span#remaining.remaining]#[span#direction] #[span#tick.tick]
- svg.local(viewBox=`0 -130 ${WIDTH} 253`)
- text(style="font-size: 160px; font-weight: bold;")#localTime
- text(y=70 style="font-size: 45px; font-weight: bold;")#localDate
- text(y=115 style="font-size: 22px;")#localTz
- if style !== "R"
- p #[span#remaining.remaining]#[span#direction] #[span#tick.tick]
- script.
- document.body.classList.add("yes-script")
- const WIDTH = !{WIDTH}
- const timestamp = !{timestamp}
- const style = "!{style}"
- const formatOptions = !{JSON.stringify(formatOptions)}
- const d = new Date(timestamp * 1000)
- const UNITS = [
- ["week", 7*24*60*60*1000],
- ["day", 24*60*60*1000],
- ["hour", 60*60*1000],
- ["minute", 60*1000],
- ]
- const showSeconds = formatOptions.timeStyle === "medium" || style === "R"
- if (showSeconds) UNITS.push(["second", 1000])
-
- if (formatOptions.timeStyle) {
- localTime.textContent = new Intl.DateTimeFormat(undefined, {timeStyle: formatOptions.timeStyle}).format(d)
- } else {
- localTime.textContent = "Any time on"
- localTime.style.fill = "#888"
- }
- if (formatOptions.dateStyle) {
- localDate.textContent = new Intl.DateTimeFormat(undefined, {dateStyle: formatOptions.dateStyle}).format(d)
- } else {
- localDate.textContent = "On any date"
- localDate.style.fill = "#888"
- }
- const localTzText = new Intl.DateTimeFormat(undefined, {timeZoneName: "long"}).formatToParts(d).find(p => p.type === "timeZoneName").value
- const localTitleText = new Intl.DateTimeFormat(undefined, {...formatOptions, timeZone: undefined}).format(d)
- document.title = localTitleText
- localTz.textContent = localTzText
- for (const st of [localTime, localDate, localTz]) {
- const l = st.getComputedTextLength()
- if (l > WIDTH) {
- st.style.fontSize = Math.floor(parseInt(st.style.fontSize) * WIDTH / l) + "px";
- }
- }
-
- let frame = 0
- function updateTimeRemaining() {
- const now = new Date()
- const timeForRemaining = new Date(d)
- if (!formatOptions.dateStyle) {
- timeForRemaining.setFullYear(now.getFullYear())
- timeForRemaining.setMonth(now.getMonth())
- timeForRemaining.setDate(now.getDate())
- }
- let difference = Math.abs(timeForRemaining.getTime() - now.getTime())
-
- let foundSignificantField = false
- let remainingText = UNITS.map(([unit, duration], index) => {
- const multiplier = Math.floor(difference / duration)
- difference -= multiplier * duration
- if (multiplier > 0 || foundSignificantField) {
- foundSignificantField = true
- return multiplier + "\u00a0" + unit + (multiplier !== 1 ? "s" : "")
- }
- }).filter(s => s).join(", ")
- if (remainingText) {
- remaining.textContent = remainingText
- direction.textContent =
- ( timeForRemaining.getTime() > now.getTime()
- ? "\u00a0from\u00a0now."
- : "\u00a0ago.")
- } else {
- remaining.textContent = "Right now!"
- direction.textContent = ""
- }
- if (!showSeconds) {
- tick.textContent = String.fromCodePoint(11028 + ++frame % 2)
- }
- }
- updateTimeRemaining()
- setInterval(updateTimeRemaining, 1000)
diff --git a/src/web/routes/guild-settings.js b/src/web/routes/guild-settings.js
index 9bfff3d..8119f93 100644
--- a/src/web/routes/guild-settings.js
+++ b/src/web/routes/guild-settings.js
@@ -135,7 +135,7 @@ as.router.post("/api/default-roles", defineEventHandler(async event => {
await createSpace.syncSpaceFully(guildID) // this is inefficient but OK to call infrequently on user request
if (getRequestHeader(event, "HX-Request")) {
- return pugSync.renderFragments(event, "guild.pug", ["default-roles-list", "add-roles-menu"], {guild, guild_id: guildID})
+ return pugSync.render(event, "fragments/default-roles-list.pug", {guild, guild_id: guildID})
} else {
return sendRedirect(event, `/guild?guild_id=${guildID}`, 302)
}
diff --git a/src/web/routes/time.js b/src/web/routes/time.js
deleted file mode 100644
index ad89b58..0000000
--- a/src/web/routes/time.js
+++ /dev/null
@@ -1,31 +0,0 @@
-// @ts-check
-
-const {defineEventHandler, getValidatedRouterParams, H3Event, setResponseHeader} = require("h3")
-const {as, db, sync} = require("../../passthrough")
-const {z} = require("zod")
-
-/** @type {import("../pug-sync")} */
-const pugSync = sync.require("../pug-sync")
-/** @type {import("../../discord/time")} */
-const discordTime = sync.require("../../discord/time")
-
-const schema = {
- time: z.object({
- timestamp: z.coerce.number(),
- style: z.string().optional()
- })
-}
-
-as.router.get("/time/:timestamp", defineEventHandler(async event => {
- const {timestamp} = await getValidatedRouterParams(event, schema.time.parse)
- const formatOptions = discordTime.getFormatOptions()
- const text = discordTime.convertDiscordTime(timestamp)
- return pugSync.render(event, "time.pug", {text, timestamp, formatOptions})
-}))
-
-as.router.get("/time/:timestamp/:style", defineEventHandler(async event => {
- const {timestamp, style} = await getValidatedRouterParams(event, schema.time.parse)
- const formatOptions = discordTime.getFormatOptions(style)
- const text = discordTime.convertDiscordTime(timestamp, style)
- return pugSync.render(event, "time.pug", {text, timestamp, style, formatOptions})
-}))
diff --git a/src/web/server.js b/src/web/server.js
index 83aa588..c8d363d 100644
--- a/src/web/server.js
+++ b/src/web/server.js
@@ -1,6 +1,6 @@
// @ts-check
-const assert = require("assert").strict
+const assert = require("assert")
const fs = require("fs")
const {join} = require("path")
const h3 = require("h3")
@@ -98,7 +98,7 @@ function tryStatic(event, fallthrough) {
getContents: id => {
if (id.match(/\.pug$/)) {
const path = join(publicDir, id)
- return pugSync.renderPath(event, path, null, {})
+ return pugSync.renderPath(event, path, {})
} else {
return fs.createReadStream(join(publicDir, id))
}
@@ -141,4 +141,3 @@ sync.require("./routes/oauth")
sync.require("./routes/opt-out")
sync.require("./routes/password")
sync.require("./routes/stats")
-sync.require("./routes/time")
diff --git a/start.js b/start.js
index 728c8af..2ab79cb 100755
--- a/start.js
+++ b/start.js
@@ -1,11 +1,6 @@
#!/usr/bin/env node
// @ts-check
-// Temporal API stabilised in Node.js 26. Previous versions can use `--harmony-temporal`, but that's jank, so have a polyfill.
-if (!("Temporal" in globalThis)) {
- require("temporal-polyfill-lite/global")
-}
-
const fs = require("fs")
const sqlite = require("better-sqlite3")
const migrate = require("./src/db/migrate")
diff --git a/test/data.js b/test/data.js
index bcc9fdb..60e9bd8 100644
--- a/test/data.js
+++ b/test/data.js
@@ -1680,108 +1680,6 @@ module.exports = {
flags: 0,
components: []
},
- simple_game_mention: {
- type: 0,
- content: "do you play <@$1402418491272986635>",
- mentions: [],
- mention_roles: [],
- attachments: [],
- embeds: [],
- timestamp: "2026-09-28T21:01:56.749000+00:00",
- edited_timestamp: null,
- flags: 0,
- components: [],
- id: "1554236746756268077",
- channel_id: "1160894080998461480",
- author: {
- id: "177438227563675648",
- username: "owlbot",
- avatar: "bafbcafcc0a6c2698d59a2cb567a9ecf",
- discriminator: "0",
- public_flags: 0,
- flags: 0,
- banner: null,
- accent_color: null,
- global_name: null,
- avatar_decoration_data: null,
- collectibles: null,
- display_name_styles: null,
- vad_colors: null,
- banner_color: null,
- clan: {
- identity_guild_id: "773319828295385108",
- identity_enabled: true,
- tag: "puzl",
- badge: "1935451f265a42d57241c7bcc9555abe"
- },
- primary_guild: {
- identity_guild_id: "773319828295385108",
- identity_enabled: true,
- tag: "puzl",
- badge: "1935451f265a42d57241c7bcc9555abe"
- }
- },
- pinned: false,
- mention_everyone: false,
- tts: false,
- mention_games: [
- {
- id: "1402418491272986635",
- name: "Minecraft",
- aliases: [ "Minecraft Windows 10 Edition", "Minecraft Launcher" ],
- }
- ]
- },
- timestamps: {
- type: 0,
- content: "\n" +
- "\n" +
- "\n" +
- "\n" +
- "\n" +
- "",
- mentions: [],
- mention_roles: [],
- attachments: [],
- embeds: [],
- timestamp: "2026-10-04T09:34:27.909000+00:00",
- edited_timestamp: null,
- flags: 0,
- components: [],
- id: "1556238063774990499",
- channel_id: "1160894080998461480",
- author: {
- 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,
- vad_colors: 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"
- }
- },
- pinned: false,
- mention_everyone: false,
- tts: false
- },
unknown_role: {
id: "1162374402785153106",
type: 0,
diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql
index 072397f..524e1fe 100644
--- a/test/ooye-test-data.sql
+++ b/test/ooye-test-data.sql
@@ -168,8 +168,7 @@ INSERT INTO file (discord_url, mxc_url) VALUES
('https://cdn.discordapp.com/attachments/122155380120748034/1174514575220158545/the.yml', 'mxc://cadence.moe/HnQIYQmmlIKwOQsbFsIGpzPP'),
('https://cdn.discordapp.com/attachments/112760669178241024/1296237494987133070/100km.gif', 'mxc://cadence.moe/qDAotmebTfEIfsAIVCEZptLh'),
('https://cdn.discordapp.com/attachments/123/456/my_enemies.txt', 'mxc://cadence.moe/y89EOTRp2lbeOkgdsEleGOge'),
-('https://cdn.discordapp.com/emojis/1254940125948022915.webp', 'mxc://cadence.moe/bvVJFgOIyNcAknKCbmaHDktG'),
-('https://media.discordapp.net/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png?format=jpeg', 'mxc://cadence.moe/zXNXRvJwRDMIzZZVqHUXtGeD');
+('https://cdn.discordapp.com/emojis/1254940125948022915.webp', 'mxc://cadence.moe/bvVJFgOIyNcAknKCbmaHDktG');
INSERT INTO emoji (emoji_id, name, animated, mxc_url) VALUES
('230201364309868544', 'hippo', 0, 'mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC'),
diff --git a/test/test.js b/test/test.js
index 74d99b1..4cd9627 100644
--- a/test/test.js
+++ b/test/test.js
@@ -130,9 +130,7 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not
require("./addbot.test")
require("../src/db/orm.test")
- require("../src/js/errors.test")
require("../src/web/server.test")
- require("../src/discord/time.test")
require("../src/discord/utils.test")
require("../src/matrix/kstate.test")
require("../src/matrix/api.test")