",
+ + "\n-# Original Message ID: 1466556003645657118 · 29 January 2026 at 10:09 pm (UTC)",
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 · <t:1769724599:f>
",
+ + "Original Message ID: 1466556003645657118 · 29 January 2026 at 10:09 pm (UTC)
",
"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 41f40ba..397be39 100644
--- a/src/d2m/converters/message-to-event.test.js
+++ b/src/d2m/converters/message-to-event.test.js
@@ -202,6 +202,28 @@ test("message2event: simple game mentions", async t => {
}])
})
+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, {}, {
diff --git a/src/discord/time.js b/src/discord/time.js
new file mode 100644
index 0000000..d0f7a04
--- /dev/null
+++ b/src/discord/time.js
@@ -0,0 +1,51 @@
+// @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
new file mode 100644
index 0000000..b889682
--- /dev/null
+++ b/src/discord/time.test.js
@@ -0,0 +1,48 @@
+// @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/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js
index aa80b9c..de81899 100644
--- a/src/matrix/matrix-command-handler.js
+++ b/src/matrix/matrix-command-handler.js
@@ -3,8 +3,9 @@
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")}) */
@@ -13,8 +14,9 @@ const api = sync.require("./api")
const mxUtils = sync.require("./utils")
/** @type {import("../discord/utils")} */
const dUtils = sync.require("../discord/utils")
-/** @type {import("./kstate")} */
-const ks = sync.require("./kstate")
+/** @type {import("../d2m/actions/retrigger")} */
+const retrigger = sync.require("../d2m/actions/retrigger")
+
const {reg} = require("./read-registration")
const PREFIXES = ["//", "/"]
@@ -33,7 +35,17 @@ function getSlotCount(tier) {
return TIER_EMOJI_SLOTS.get(tier) || 50
}
-let buttons = []
+/**
+ * @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: []}))
/**
* @param {string} roomID where to add the button
@@ -51,22 +63,23 @@ async function addButton(roomID, eventID, key, mxid) {
}
})
return new Promise(resolve => {
- buttons.push({roomID, eventID, mxid, key, resolve, created: Date.now()})
+ buttonStore.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()
- buttons = buttons.filter(b => now - b.created < 2*60*60*1000)
+ buttonStore.buttons = buttonStore.buttons.filter(b => now - b.created < 2*60*60*1000)
}, 10*60*1000).unref()
/** @param {Ty.Event.Outer} event */
function onReactionAdd(event) {
- 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)
+ 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)
if (button) {
- buttons = buttons.filter(b => b !== button) // remove button data so it can't be clicked again
+ buttonStore.buttons = buttonStore.buttons.filter(b => b !== button) // remove button data so it can't be clicked again
button.resolve(event)
+ return true
}
}
@@ -318,6 +331,114 @@ 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/web/pug/time.pug b/src/web/pug/time.pug
new file mode 100644
index 0000000..1d71c22
--- /dev/null
+++ b/src/web/pug/time.pug
@@ -0,0 +1,143 @@
+//- 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/time.js b/src/web/routes/time.js
new file mode 100644
index 0000000..ad89b58
--- /dev/null
+++ b/src/web/routes/time.js
@@ -0,0 +1,31 @@
+// @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 3c06ede..83aa588 100644
--- a/src/web/server.js
+++ b/src/web/server.js
@@ -141,3 +141,4 @@ 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 2ab79cb..728c8af 100755
--- a/start.js
+++ b/start.js
@@ -1,6 +1,11 @@
#!/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 f441778..bcc9fdb 100644
--- a/test/data.js
+++ b/test/data.js
@@ -1732,6 +1732,56 @@ module.exports = {
}
]
},
+ 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/test.js b/test/test.js
index 5079ed3..74d99b1 100644
--- a/test/test.js
+++ b/test/test.js
@@ -132,6 +132,7 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not
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")