Compare commits

..
13 Commits
24 changed files with 735 additions and 82 deletions
+3 -1
View File
@@ -89,7 +89,7 @@ Whether you read those or not, I'm more than happy to help you 1-on-1 with codin
# Dependency justification
Total transitive production dependencies: 112
Total transitive production dependencies: 114
### <font size="+2">🦕</font>
@@ -108,6 +108,7 @@ Total transitive production dependencies: 112
* (0) @cloudrac3r/mixin-deep: This is my fork. (It fixes a bug in regular mixin-deep.)
* (0) @cloudrac3r/pngjs: Lottie stickers are converted to bitmaps with the vendored Rlottie WASM build, then the bitmaps are converted to PNG with pngjs.
* (0) @cloudrac3r/stream-type: Determine type of Matrix files that don't specify it in info. Switched from stream-mime-type to this.
* (0) @cloudrac3r/tell-me-when: Parse English natural language dates and times for the m->d //time command.
* (0) @cloudrac3r/turndown: This HTML-to-Markdown converter looked the most suitable. I forked it to change the escaping logic to match the way Discord works.
* (3) @stackoverflow/stacks: Stack Overflow design language and icons.
* (0) ansi-colors: Helps with interactive prompting for the initial setup, and it's already pulled in by enquirer.
@@ -125,6 +126,7 @@ Total transitive production dependencies: 112
* (1) mime-types: List of mime type mappings. Needed to serve static files.
* (0) prettier-bytes: It does what I want and has no dependencies.
* (0) snowtransfer: Discord API library with bring-your-own-caching that I trust.
* (0) temporal-polyfill-lite: Needed for the //time command. The Temporal API has only been stabilised as of Node.js v26.
* (0) try-to-catch: Not strictly necessary, but it's already pulled in by supertape, so I may as well.
* (0) uqr: QR code SVG generator. Used on the website to scan in an invite link.
* (0) xxhash-wasm: Used where cryptographically secure hashing is not required.
+27 -8
View File
@@ -10,7 +10,7 @@
"license": "AGPL-3.0-or-later",
"dependencies": {
"@chriscdn/promise-semaphore": "^3.0.1",
"@cloudrac3r/discord-markdown": "^2.8.0",
"@cloudrac3r/discord-markdown": "^2.9.0",
"@cloudrac3r/giframe": "^0.4.3",
"@cloudrac3r/html-template-tag": "^5.0.1",
"@cloudrac3r/in-your-element": "^1.1.1",
@@ -18,6 +18,7 @@
"@cloudrac3r/pngjs": "^7.0.3",
"@cloudrac3r/pug": "^4.0.4",
"@cloudrac3r/stream-type": "^1.0.0",
"@cloudrac3r/tell-me-when": "^2.0.0",
"@cloudrac3r/turndown": "^7.1.5",
"@stackoverflow/stacks": "^2.5.4",
"@stackoverflow/stacks-icons": "^6.0.2",
@@ -39,6 +40,7 @@
"prettier-bytes": "^1.0.4",
"sharp": "^0.35.3",
"snowtransfer": "^0.19.0",
"temporal-polyfill-lite": "^0.4.3",
"try-to-catch": "^4.0.5",
"uqr": "^0.1.2",
"xxhash-wasm": "^1.0.2",
@@ -131,9 +133,9 @@
}
},
"node_modules/@cloudrac3r/discord-markdown": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.8.0.tgz",
"integrity": "sha512-ciLxS6iaMxuEkEln7Y9rTpUBFf+gij8djNKS1QGrTfKoHcKABmDvcXQlPntdTx7odHjtMqV2RfvR/rnmQ25YPQ==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.9.1.tgz",
"integrity": "sha512-sYaDfRBaS7uMVQx4wJwT68F6OzJZX84DF2XvHkQr8vdwTCvnpeaT26obqvzNawVwVoQ4xjIMZxafgMrAEK4Oxw==",
"license": "MIT",
"dependencies": {
"simple-markdown": "^0.7.3"
@@ -267,6 +269,18 @@
"node": ">=8.0.0"
}
},
"node_modules/@cloudrac3r/tell-me-when": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@cloudrac3r/tell-me-when/-/tell-me-when-2.0.1.tgz",
"integrity": "sha512-nm1Ao/vpHDn2YMivnc9EVr/QrcT5IG6G7o1Ffh31YafIo9Cc3bSN1jPLL9eOuKKLCYT1/DZP7Nh8PXAN371bAQ==",
"license": "MIT",
"bin": {
"tell-me-when": "dist/src/cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@cloudrac3r/turndown": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/@cloudrac3r/turndown/-/turndown-7.1.5.tgz",
@@ -1049,9 +1063,9 @@
}
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -1148,7 +1162,6 @@
"version": "13.0.3",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
"integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.0.0"
@@ -2503,6 +2516,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/temporal-polyfill-lite": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/temporal-polyfill-lite/-/temporal-polyfill-lite-0.4.3.tgz",
"integrity": "sha512-Lw/de9tydKmrU0icQy6Z+abX5mzDk4iqyt6Xp2iDDxY6SGISiuhuP3TcfWlYveM1FN1I7FjT5nG+Kh9eCiSnVg==",
"license": "MIT"
},
"node_modules/test-exclude": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz",
+6 -1
View File
@@ -19,7 +19,7 @@
},
"dependencies": {
"@chriscdn/promise-semaphore": "^3.0.1",
"@cloudrac3r/discord-markdown": "^2.8.0",
"@cloudrac3r/discord-markdown": "^2.9.0",
"@cloudrac3r/giframe": "^0.4.3",
"@cloudrac3r/html-template-tag": "^5.0.1",
"@cloudrac3r/in-your-element": "^1.1.1",
@@ -27,6 +27,7 @@
"@cloudrac3r/pngjs": "^7.0.3",
"@cloudrac3r/pug": "^4.0.4",
"@cloudrac3r/stream-type": "^1.0.0",
"@cloudrac3r/tell-me-when": "^2.0.0",
"@cloudrac3r/turndown": "^7.1.5",
"@stackoverflow/stacks": "^2.5.4",
"@stackoverflow/stacks-icons": "^6.0.2",
@@ -48,6 +49,7 @@
"prettier-bytes": "^1.0.4",
"sharp": "^0.35.3",
"snowtransfer": "^0.19.0",
"temporal-polyfill-lite": "^0.4.3",
"try-to-catch": "^4.0.5",
"uqr": "^0.1.2",
"xxhash-wasm": "^1.0.2",
@@ -69,5 +71,8 @@
"addbot": "node addbot.js",
"test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js | tap-dot",
"cover": "c8 -o test/coverage --skip-full -x db/migrations -x src/m2d/event-dispatcher.js -x src/matrix/file.js -x src/matrix/api.js -x src/d2m/converters/rlottie-wasm.js -r html -r text supertape --no-check-assertions-count --format fail --no-worker test/test.js -- --slow"
},
"allowScripts": {
"better-sqlite3": true
}
}
+42 -14
View File
@@ -18,6 +18,8 @@ const lottie = sync.require("../actions/lottie")
const mxUtils = sync.require("../../matrix/utils")
/** @type {import("../../discord/utils")} */
const dUtils = sync.require("../../discord/utils")
/** @type {import("../../discord/time")} */
const discordTime = sync.require("../../discord/time")
/** @type {import("./find-mentions")} */
const findMentions = sync.require("./find-mentions")
/** @type {import("../../discord/interactions/poll-responses")} */
@@ -72,6 +74,17 @@ function getDiscordParseCallbacks(message, guild, useHTML, spoilers = []) {
return `:${node.name}:`
}
},
/** @param {{timestamp: number, style: string | null}} node */
timestamp: node => {
const text = discordTime.convertDiscordTime(node.timestamp, node.style)
if (useHTML) {
let url = `${reg.ooye.bridge_origin}/time/${node.timestamp}`
if (node.style) url += `/${node.style}`
return tag`<a href="${url}">${text}</a>`
} else {
return text
}
},
role: node => {
const role = guild.roles.find(r => r.id === node.id)
if (!role) {
@@ -870,32 +883,47 @@ async function messageToEvent(message, guild, options = {}, di) {
// Then scheduled events
if (message.content && di?.snow) {
for (const match of [...message.content.matchAll(/discord\.gg\/([A-Za-z0-9]+)\?event=([0-9]{18,})/g)]) { // snowflake has minimum 18 because the events feature is at least that old
for (const match of [...message.content.matchAll(/discord\.gg\/([A-Za-z0-9]+)\?event=([0-9]{18,})|\/events\/([0-9]+)\/([0-9]{18,})/g)]) { // snowflake has minimum 18 because the events feature is at least that old
/** @type {DiscordTypes.APIGuildScheduledEvent | undefined} */
let event
let invite
try {
invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]})
} catch (e) {
// Skip expired/invalid invites and events
if (e.message === "Unknown Invite") {
break
} else {
if (match[1]) {
try {
invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]})
event = invite.guild_scheduled_event
} catch (e) {
// Skip expired/invalid invites and events
if (e.message === "Unknown Invite") {
break
} else {
throw e
}
}
} else if (match[3]) {
try {
event = await di.snow.guildScheduledEvent.getGuildScheduledEvent(match[3], match[4])
} catch (e) {
throw e
}
}
const event = invite.guild_scheduled_event
if (!event) continue // the event ID provided was not valid
const formatter = new Intl.DateTimeFormat("en-NZ", {month: "long", day: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "shortGeneric", timeZone: reg.ooye.time_zone}) // 9 June at 3:00 pm NZT
const rep = new mxUtils.MatrixStringBuilder()
const unixSeconds = Math.floor(new Date(event.scheduled_start_time).getTime() / 1000)
// Add time
if (event.scheduled_end_time) {
// @ts-ignore - no definition available for formatRange
rep.addParagraph(`Scheduled Event - ${formatter.formatRange(new Date(event.scheduled_start_time), new Date(event.scheduled_end_time))}`)
const formatter = new Intl.DateTimeFormat("en-NZ", {weekday: "long", month: "long", day: "numeric", hour: "numeric", minute: "2-digit", timeZone: "UTC"}) // 9 June at 3:00 pm NZT
var displayTime = (formatter.formatRange(new Date(event.scheduled_start_time), new Date(event.scheduled_end_time)) + " (UTC)").replaceAll(/\p{Zs}/gv, " ")
} else {
rep.addParagraph(`Scheduled Event - ${formatter.format(new Date(event.scheduled_start_time))}`)
var displayTime = discordTime.convertDiscordTime(unixSeconds, "F")
}
rep.addParagraph(
`Scheduled Event - ${displayTime}`,
tag`Scheduled Event - <a href="${reg.ooye.bridge_origin}/time/${unixSeconds.toString()}/F">${displayTime}</a>`
)
// Add details
rep.addLine(`## ${event.name}`, tag`<strong>${event.name}</strong>`)
@@ -904,7 +932,7 @@ async function messageToEvent(message, guild, options = {}, di) {
// Add location
if (event.entity_metadata?.location) {
rep.addParagraph(`📍 ${event.entity_metadata.location}`)
} else if (invite.channel?.name) {
} else if (invite?.channel?.name) {
const roomID = select("channel_room", "room_id", {channel_id: invite.channel.id}).pluck().get()
if (roomID) {
const via = await getViaServersMemo(roomID)
@@ -44,7 +44,7 @@ test("message2event components: pk question mark output", async t => {
+ "\n| Same hat"
+ "\n| 🖼️ Image: https://bridge.example.org/download/discordcdn/934955898965729280/1466556006527012987/image.png"
+ "\n"
+ "\n-# Original Message ID: 1466556003645657118 · <t:1769724599:f>",
+ "\n-# Original Message ID: 1466556003645657118 · 29 January 2026 at 10:09 pm (UTC)",
format: "org.matrix.custom.html",
formatted_body: "<blockquote>"
+ "<h3>Lillith (INX)</h3>"
@@ -73,7 +73,7 @@ test("message2event components: pk question mark output", async t => {
+ "<hr>"
+ "<p>Same hat</p>"
+ `🖼️ Image: <a href="https://bridge.example.org/download/discordcdn/934955898965729280/1466556006527012987/image.png">image.png</a></blockquote>`
+ "<p><sub>Original Message ID: 1466556003645657118 · &lt;t:1769724599:f&gt;</sub></p>",
+ "<p><sub>Original Message ID: 1466556003645657118 · <a href=\"https://bridge.example.org/time/1769724599/f\">29 January 2026 at 10:09 pm (UTC)</a></sub></p>",
"m.mentions": {},
msgtype: "m.text",
}])
+28 -6
View File
@@ -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: `<a href="https://bridge.example.org/time/1791106413/S">4 Oct 2026, 9:33:33 am (UTC)</a>`
+ `<br><a href="https://bridge.example.org/time/1791106436/f">4 October 2026 at 9:33 am (UTC)</a>`
+ `<br><a href="https://bridge.example.org/time/1791106440/F">Sunday, 4 October 2026 at 9:34 am (UTC)</a>`
+ `<br><a href="https://bridge.example.org/time/1791106448/T">9:34:08 am (UTC)</a>`
+ `<br><a href="https://bridge.example.org/time/1791019800/R">3 October 2026 at 9:30 am (UTC)</a>`
+ `<br><a href="https://bridge.example.org/time/1798153200/R">24 December 2026 at 11:00 pm (UTC)</a>`,
"m.mentions": {}
}])
})
test("message2event: simple message link", async t => {
let called = 0
const events = await messageToEvent(data.message.simple_message_link, data.guild.general, {}, {
@@ -1567,12 +1589,12 @@ test("message2event: irl invite event renders embed", async t => {
{
$type: "m.room.message",
msgtype: "m.notice",
body: `| Scheduled Event - 8 June at 10:00 pm NZT – 9 June at 12:00 am NZT`
body: `| Scheduled Event - Sunday, 8 June, 10:00 am – 12:00 pm (UTC)`
+ `\n| ## forest exploration`
+ `\n| `
+ `\n| 📍 the dark forest`,
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p>Scheduled Event - 8 June at 10:00 pm NZT – 9 June at 12:00 am NZT</p>`
formatted_body: `<blockquote><p>Scheduled Event - <a href=\"https://bridge.example.org/time/1749376800/F\">Sunday, 8 June, 10:00 am – 12:00 pm (UTC)</a></p>`
+ `<strong>forest exploration</strong>`
+ `<p>📍 the dark forest</p></blockquote>`,
"m.mentions": {}
@@ -1600,13 +1622,13 @@ test("message2event: vc invite event renders embed", async t => {
{
$type: "m.room.message",
msgtype: "m.notice",
body: `| Scheduled Event - 9 June at 3:00 pm NZT`
body: `| Scheduled Event - Monday, 9 June 2025 at 3:00 am (UTC)`
+ `\n| ## Cooking (Netrunners)`
+ `\n| Short circuited brain interfaces actually just means your brain is medium rare, yum.`
+ `\n| `
+ `\n| 🔊 Cooking`,
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p>Scheduled Event - 9 June at 3:00 pm NZT</p>`
formatted_body: `<blockquote><p>Scheduled Event - <a href=\"https://bridge.example.org/time/1749438000/F\">Monday, 9 June 2025 at 3:00 am (UTC)</a></p>`
+ `<strong>Cooking (Netrunners)</strong><br>Short circuited brain interfaces actually just means your brain is medium rare, yum.`
+ `<p>🔊 Cooking</p></blockquote>`,
"m.mentions": {}
@@ -1642,13 +1664,13 @@ test("message2event: vc invite event renders embed with room link", async t => {
{
$type: "m.room.message",
msgtype: "m.notice",
body: `| Scheduled Event - 9 June at 3:00 pm NZT`
body: `| Scheduled Event - Monday, 9 June 2025 at 3:00 am (UTC)`
+ `\n| ## Cooking (Netrunners)`
+ `\n| Short circuited brain interfaces actually just means your brain is medium rare, yum.`
+ `\n| `
+ `\n| 🔊 Hey. - https://matrix.to/#/!FuDZhlOAtqswlyxzeR:cadence.moe?via=cadence.moe`,
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p>Scheduled Event - 9 June at 3:00 pm NZT</p>`
formatted_body: `<blockquote><p>Scheduled Event - <a href=\"https://bridge.example.org/time/1749438000/F\">Monday, 9 June 2025 at 3:00 am (UTC)</a></p>`
+ `<strong>Cooking (Netrunners)</strong><br>Short circuited brain interfaces actually just means your brain is medium rare, yum.`
+ `<p>🔊 Hey. - <a href="https://matrix.to/#/!FuDZhlOAtqswlyxzeR:cadence.moe?via=cadence.moe">Hey.</a></p></blockquote>`,
"m.mentions": {}
@@ -0,0 +1,5 @@
BEGIN TRANSACTION;
ALTER TABLE channel_room ADD COLUMN room_mention_role TEXT;
COMMIT;
@@ -1,5 +0,0 @@
BEGIN TRANSACTION;
ALTER TABLE channel_room ADD COLUMN ping_role_id TEXT;
COMMIT;
+1 -1
View File
@@ -21,7 +21,7 @@ export type Models = {
speedbump_checked: number | null
guild_id: string | null
custom_topic: number
ping_role_id: string | null
room_mention_role: string | null
}
channel_speedbump: {
+51
View File
@@ -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<string, Intl.DateTimeFormatOptions>} 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)").replaceAll(/\p{Zs}/gv, " ")
}
module.exports.getFormatOptions = getFormatOptions
module.exports.convertDiscordTime = convertDiscordTime
+48
View File
@@ -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)")
})
+9 -7
View File
@@ -452,18 +452,18 @@ async function handleRoomOrMessageLinks(input, di) {
/**
* @param {string} content
* @param {string} senderMxid
* @param {string} roomID
* @param {{content: any, room_id: string, sender: string}} event
* @param {DiscordTypes.APIGuild} guild
* @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer}} di
*/
async function checkWrittenMentions(content, senderMxid, roomID, guild, di) {
async function checkWrittenMentions(content, event, guild, di) {
let writtenMentionMatch = content.match(/(?:^|[^"[<>/A-Za-z0-9])@([A-Za-z][A-Za-z0-9._\[\]\(\)-]+):?/d) // /d flag for indices requires node.js 16+
if (writtenMentionMatch) {
if (writtenMentionMatch[1] === "room") { // convert @room to discord
const {powers: {[senderMxid]: userPower}, powerLevels} = await mxUtils.getEffectivePower(roomID, [senderMxid], di.api)
if (("m.mentions" in event.content) && !event.content["m.mentions"].room) return // check it's allowed by m.mentions if present
const {powers: {[event.sender]: userPower}, powerLevels} = await mxUtils.getEffectivePower(event.room_id, [event.sender], di.api)
if (userPower >= (powerLevels.notifications?.room ?? 50)) {
const roleID = select("channel_room", "ping_role_id", {room_id: roomID}).pluck().get()
const roleID = select("channel_room", "room_mention_role", {room_id: event.room_id}).pluck().get()
return {
// @ts-ignore - typescript doesn't know about indices yet
content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + (roleID ? `<@&${roleID}>` : "@everyone") + content.slice(writtenMentionMatch.indices[1][1]),
@@ -861,6 +861,8 @@ async function eventToMessage(event, guild, channel, di) {
}
})
input = input.replace(/<a\s[^>]*href="https:\/\/matrix\.to\/#\/(?:!|%21|#|%23)[^"\/]*"[^>]*>@room<\/a>/g, "@room")
// Handling mentions of rooms and room-messages
input = await handleRoomOrMessageLinks(input, di)
@@ -912,7 +914,7 @@ async function eventToMessage(event, guild, channel, di) {
// Check written mentions
if (node.nodeType === 3 && node.nodeValue.includes("@") && !nodeIsChildOf(node, ["A", "CODE", "PRE"])) {
const result = await checkWrittenMentions(node.nodeValue, event.sender, event.room_id, guild, di)
const result = await checkWrittenMentions(node.nodeValue, event, guild, di)
if (result) {
node.nodeValue = result.content
ensureJoined.push(...result.ensureJoined)
@@ -1030,7 +1032,7 @@ async function eventToMessage(event, guild, channel, di) {
}
}
const result = await checkWrittenMentions(content, event.sender, event.room_id, guild, di)
const result = await checkWrittenMentions(content, event, guild, di)
if (result) {
content = result.content
ensureJoined.push(...result.ensureJoined)
+121 -1
View File
@@ -5521,6 +5521,9 @@ test("event2message: @room converts to @everyone and is allowed when the room do
type: "m.room.message",
sender: "@cadence:cadence.moe",
content: {
"m.mentions": {
room: true
},
msgtype: "m.text",
body: "@room dinner's ready",
},
@@ -5563,7 +5566,124 @@ test("event2message: @room converts to @everyone and is allowed when the room do
content: "@everyone dinner's ready",
avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90",
allowed_mentions: {
parse: ["users", "roles", "everyone"]
users: [],
parse: ["roles", "everyone"]
}
}],
ensureJoined: []
}
)
})
test("event2message: @room is converted to a role in configured rooms", async t => {
let called = 0
t.deepEqual(
await eventToMessage({
type: "m.room.message",
sender: "@cadence:cadence.moe",
content: {
msgtype: "m.text",
body: "@room dinner's ready",
},
room_id: "!fGgIymcYWOqjbSRUdV:cadence.moe",
event_id: "$SiXetU9h9Dg-M9Frcw_C6ahnoXZ3QPZe3MVJR5tcB9A"
}, data.guild.general, data.channel.general, {
api: {
async getStateEvent(roomID, type, key) {
called++
t.equal(roomID, "!fGgIymcYWOqjbSRUdV:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {
users: {},
notifications: {
room: 0
}
}
},
async getStateEventOuter(roomID, type, key) {
t.equal(roomID, "!fGgIymcYWOqjbSRUdV:cadence.moe")
t.equal(type, "m.room.create")
t.equal(key, "")
return {
type: "m.room.create",
state_key: "",
sender: "@_ooye_bot:cadence.moe",
content: {
room_version: "11"
}
}
}
}
}),
{
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "cadence [they]",
content: "<@&123456> dinner's ready",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar",
allowed_mentions: {
parse: ["users", "roles"]
}
}],
ensureJoined: []
}
)
})
test("event2message: @room is not allowed with mismatched m.mentions", async t => {
let called = 0
t.deepEqual(
await eventToMessage({
type: "m.room.message",
sender: "@cadence:cadence.moe",
content: {
"m.mentions": {},
msgtype: "m.text",
body: "@room dinner's ready",
},
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
event_id: "$SiXetU9h9Dg-M9Frcw_C6ahnoXZ3QPZe3MVJR5tcB9A"
}, data.guild.general, data.channel.general, {
api: {
async getStateEvent(roomID, type, key) {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {
users: {},
notifications: {
room: 0
}
}
},
async getStateEventOuter(roomID, type, key) {
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
t.equal(type, "m.room.create")
t.equal(key, "")
return {
type: "m.room.create",
state_key: "",
sender: "@_ooye_bot:cadence.moe",
content: {
room_version: "11"
}
}
}
}
}),
{
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "cadence [they]",
content: "@room dinner's ready",
avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90",
allowed_mentions: {
users: [],
parse: ["roles"]
}
}],
ensureJoined: []
+129 -8
View File
@@ -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<Ty.Event.M_Reaction>} 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, <code>${senderTimeZone}</code>, is not valid. It needs to be an IANA time zone value with a slash. <a href="https://time.is/your_time_zone">See yours here.</a>`
).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 "<t:" + Math.floor(z.toInstant().epochMilliseconds / 1000) + colonStyle + ">"
}
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(() => {})
})
}
)
}]
+23 -23
View File
@@ -189,13 +189,13 @@ block body
.s-empty-state No channels linked between Discord and Matrix yet...
if space_id && linkedChannelsWithDetails.length
- const customPingRooms = linkedChannelsWithDetails.filter(row => row.ping_role_id)
- const defaultPingRooms = linkedChannelsWithDetails.filter(row => !row.ping_role_id)
- const pingRoles = [...guild.roles].filter(role => role.id !== guild_id).sort((a, b) => b.position - a.position)
- const customMentionRooms = linkedChannelsWithDetails.filter(row => row.room_mention_role)
- const defaultMentionRooms = linkedChannelsWithDetails.filter(row => !row.room_mention_role)
- const mentionRoles = [...guild.roles].filter(role => role.id !== guild_id).sort((a, b) => b.position - a.position)
h3.mt32.fs-category#room-notifications Room notifications
.s-card.p0
p.s-description.m12 Choose which Discord role receives Matrix @room notifications
if customPingRooms.length
if customMentionRooms.length
.s-table-container(style="container-type: inline-size")
table.s-table.s-table__bx-simple(style="table-layout: fixed")
thead
@@ -203,51 +203,51 @@ block body
th(scope="col" style="width: calc(50cqw - 40px)") Channel
th.pr12(scope="col") Notify role
tbody
each row in customPingRooms
each row in customMentionRooms
tr
td.va-middle
+discord(row.channel)
td.pr12
.d-flex.ai-start.g8
form.fl-grow1.wmn0.d-flex.ai-stretch.g8(method="post" action=rel("/api/room-ping-role"))
form.fl-grow1.wmn0.d-flex.ai-stretch.g8(method="post" action=rel("/api/room-mention-role"))
input(type="hidden" name="guild_id" value=guild_id)
input(type="hidden" name="channel_id" value=row.channel.id)
.s-select.fl-grow1.wmn0
select.w100(name="ping_role_id" id="ping-role-" + row.channel.id aria-label="Notify role for #" + row.channel.name onchange="this.form.requestSubmit()" style="height: 32px")
if !pingRoles.some(role => role.id === row.ping_role_id)
option(value=row.ping_role_id selected disabled) Deleted role (choose a replacement)
each role in pingRoles
option(value=role.id selected=row.ping_role_id === role.id)= "@" + role.name
button.s-btn.s-btn__outlined.py0.box-border(type="submit" data-room-ping-save style="height: 32px; min-height: 0") Save
form.d-flex.ai-stretch(method="post" action=rel("/api/room-ping-role"))
select.w100(name="room_mention_role" id="mention-role-" + row.channel.id aria-label="Notify role for #" + row.channel.name onchange="this.form.requestSubmit()" style="height: 32px")
if !mentionRoles.some(role => role.id === row.room_mention_role)
option(value=row.room_mention_role selected disabled) Deleted role (choose a replacement)
each role in mentionRoles
option(value=role.id selected=row.room_mention_role === role.id)= "@" + role.name
button.s-btn.s-btn__outlined.py0.box-border(type="submit" data-room-mention-save style="height: 32px; min-height: 0") Save
form.d-flex.ai-stretch(method="post" action=rel("/api/room-mention-role"))
input(type="hidden" name="guild_id" value=guild_id)
input(type="hidden" name="channel_id" value=row.channel.id)
input(type="hidden" name="ping_role_id" value="")
input(type="hidden" name="room_mention_role" value="")
button.s-btn.s-btn__danger.s-btn__outlined.py0(type="submit" aria-label="Remove custom role for #" + row.channel.name style="height: 32px") Remove
script.
document.querySelectorAll('[data-room-ping-save]').forEach(button => button.remove())
document.querySelectorAll('[data-room-mention-save]').forEach(button => button.remove())
if defaultPingRooms.length && pingRoles.length
if defaultMentionRooms.length && mentionRoles.length
details.mt16.mx12.mb12
summary.s-label.cursor-pointer Add custom role
form.mt16(method="post" action=rel("/api/room-ping-role"))
form.mt16(method="post" action=rel("/api/room-mention-role"))
input(type="hidden" name="guild_id" value=guild_id)
.d-flex.fw-wrap.ai-end.g16
.fl-grow1.wmn0(style="flex-basis: 0; min-width: 180px")
label.s-label(for="ping-role-channel") Channel
label.s-label(for="mention-role-channel") Channel
.s-select
select.w100.box-border#ping-role-channel(name="channel_id" required style="height: 32px")
select.w100.box-border#mention-role-channel(name="channel_id" required style="height: 32px")
option(value="" selected disabled) Choose a channel
each row in defaultPingRooms
each row in defaultMentionRooms
- const parent = discord.channels.get(row.channel.parent_id)
option(value=row.channel.id)= "#" + row.channel.name + (parent ? " - " + parent.name : "")
.fl-grow1.wmn0(style="flex-basis: 0; min-width: 180px")
label.s-label(for="ping-role-new") Notify role
label.s-label(for="mention-role-new") Notify role
.s-select
select.w100.box-border#ping-role-new(name="ping_role_id" required style="height: 32px")
select.w100.box-border#mention-role-new(name="room_mention_role" required style="height: 32px")
option(value="" selected disabled) Choose a role
each role in pingRoles
each role in mentionRoles
option(value=role.id)= "@" + role.name
.d-flex.ai-stretch(style="height: 32px")
button.s-btn.s-btn__icon.s-btn__filled.d-inline-flex.ai-center.g4.py0.box-border(type="submit" style="min-height: 0")
+145
View File
@@ -0,0 +1,145 @@
//- requires timestamp, text, style
- const WIDTH = 600
doctype html
html(lang="en")
head
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 80%22><text y=%22.83em%22 font-size=%2283%22>⏰</text></svg>">
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.
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";
}
}
document.body.classList.add("yes-script")
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())
// Temporal.Duration isn't Baseline https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/Duration
let foundSignificantField = false
const remainingTextParts = 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)
const remainingText = remainingTextParts.length === 2 ? remainingTextParts.join(" and ") : remainingTextParts.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)
+4 -4
View File
@@ -142,20 +142,20 @@ as.router.post("/api/default-roles", defineEventHandler(async event => {
}))
// An empty role restores @everyone
as.router.post("/api/room-ping-role", defineEventHandler(async event => {
as.router.post("/api/room-mention-role", defineEventHandler(async event => {
const body = await readValidatedBody(event, z.object({
guild_id: z.string(),
channel_id: z.string(),
ping_role_id: z.string().regex(/^(?:[0-9]+)?$/)
room_mention_role: z.string().regex(/^(?:[0-9]+)?$/)
}).parse)
const managed = await auth.getManagedGuilds(event)
if (!managed.has(body.guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"})
const room = select("channel_room", "room_id", {channel_id: body.channel_id, guild_id: body.guild_id}).get()
if (!room) throw createError({status: 404, message: "Linked channel not found"})
const guild = discord.guilds.get(body.guild_id)
if (body.ping_role_id && (!guild?.roles.some(role => role.id === body.ping_role_id) || body.ping_role_id === body.guild_id)) {
if (body.room_mention_role && (!guild?.roles.some(role => role.id === body.room_mention_role) || body.room_mention_role === body.guild_id)) {
throw createError({status: 400, message: "Choose a role from this server"})
}
db.prepare("UPDATE channel_room SET ping_role_id = ? WHERE channel_id = ? AND guild_id = ?").run(body.ping_role_id || null, body.channel_id, body.guild_id)
db.prepare("UPDATE channel_room SET room_mention_role = ? WHERE channel_id = ? AND guild_id = ?").run(body.room_mention_role || null, body.channel_id, body.guild_id)
return sendRedirect(event, `/guild?guild_id=${body.guild_id}#room-notifications`, 302)
}))
+1 -1
View File
@@ -99,7 +99,7 @@ function getChannelRoomsLinks(guild, rooms, roles) {
let channelIDs = discord.guildChannelMap.get(guild.id)
assert(channelIDs)
let linkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick", "ping_role_id"], {channel_id: channelIDs}).all()
let linkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick", "room_mention_role"], {channel_id: channelIDs}).all()
let linkedChannelsWithDetails = linkedChannels.map(c => ({
// @ts-ignore
/** @type {DiscordTypes.APIGuildChannel} */ channel: discord.channels.get(c.channel_id),
+31
View File
@@ -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})
}))
+1
View File
@@ -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")
+3
View File
@@ -1,6 +1,9 @@
#!/usr/bin/env node
// @ts-check
// Ensure consistent output from the Intl and Temporal API across Node.js versions
require("temporal-polyfill-lite/global")
const fs = require("fs")
const sqlite = require("better-sqlite3")
const migrate = require("./src/db/migrate")
+50
View File
@@ -1732,6 +1732,56 @@ module.exports = {
}
]
},
timestamps: {
type: 0,
content: "<t:1791106413:S>\n" +
"<t:1791106436:f>\n" +
"<t:1791106440:F>\n" +
"<t:1791106448:T>\n" +
"<t:1791019800:R>\n" +
"<t:1798153200:R>",
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,
+1
View File
@@ -25,6 +25,7 @@ INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom
('1161864271370666075', '!mHmhQQPwXNananMUqq:cadence.moe', 'updates', NULL, NULL, NULL, '112760669178241024'),
('1438284564815548418', '!MHxNpwtgVqWOrmyoTn:cadence.moe', 'sin-cave', NULL, NULL, NULL, '665289423482519565'),
('598707048112193536', '!JBxeGYnzQwLnaooOLD:cadence.moe', 'winners', NULL, NULL, NULL, '1345641201902288987');
UPDATE channel_room SET room_mention_role = '123456' WHERE room_id = '!fGgIymcYWOqjbSRUdV:cadence.moe';
INSERT INTO historical_channel_room (reference_channel_id, room_id, upgraded_timestamp) SELECT channel_id, room_id, 0 FROM channel_room;
+4
View File
@@ -1,5 +1,8 @@
// @ts-check
// Ensure consistent output from the Intl and Temporal API across Node.js versions
require("temporal-polyfill-lite/global")
const fs = require("fs")
const {join} = require("path")
const sqlite = require("better-sqlite3")
@@ -132,6 +135,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")