Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63e3a4c062 | ||
|
|
a503277673 | ||
|
|
b49c6e8fd4 | ||
|
|
c25b5a19c6 | ||
|
|
302427037b | ||
|
|
5794bee128 | ||
|
|
fab512a9ba |
@@ -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
|
# Dependency justification
|
||||||
|
|
||||||
Total transitive production dependencies: 112
|
Total transitive production dependencies: 114
|
||||||
|
|
||||||
### <font size="+2">🦕</font>
|
### <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/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/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/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.
|
* (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.
|
* (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.
|
* (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.
|
* (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) 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) 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) 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) 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.
|
* (0) xxhash-wasm: Used where cryptographically secure hashing is not required.
|
||||||
|
|||||||
Generated
+40
-7
@@ -10,7 +10,7 @@
|
|||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chriscdn/promise-semaphore": "^3.0.1",
|
"@chriscdn/promise-semaphore": "^3.0.1",
|
||||||
"@cloudrac3r/discord-markdown": "^2.8.0",
|
"@cloudrac3r/discord-markdown": "^2.9.0",
|
||||||
"@cloudrac3r/giframe": "^0.4.3",
|
"@cloudrac3r/giframe": "^0.4.3",
|
||||||
"@cloudrac3r/html-template-tag": "^5.0.1",
|
"@cloudrac3r/html-template-tag": "^5.0.1",
|
||||||
"@cloudrac3r/in-your-element": "^1.1.1",
|
"@cloudrac3r/in-your-element": "^1.1.1",
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"@cloudrac3r/pngjs": "^7.0.3",
|
"@cloudrac3r/pngjs": "^7.0.3",
|
||||||
"@cloudrac3r/pug": "^4.0.4",
|
"@cloudrac3r/pug": "^4.0.4",
|
||||||
"@cloudrac3r/stream-type": "^1.0.0",
|
"@cloudrac3r/stream-type": "^1.0.0",
|
||||||
|
"@cloudrac3r/tell-me-when": "^2.0.0",
|
||||||
"@cloudrac3r/turndown": "^7.1.5",
|
"@cloudrac3r/turndown": "^7.1.5",
|
||||||
"@stackoverflow/stacks": "^2.5.4",
|
"@stackoverflow/stacks": "^2.5.4",
|
||||||
"@stackoverflow/stacks-icons": "^6.0.2",
|
"@stackoverflow/stacks-icons": "^6.0.2",
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
"prettier-bytes": "^1.0.4",
|
"prettier-bytes": "^1.0.4",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
"snowtransfer": "^0.19.0",
|
"snowtransfer": "^0.19.0",
|
||||||
|
"temporal-polyfill-lite": "^0.4.3",
|
||||||
"try-to-catch": "^4.0.5",
|
"try-to-catch": "^4.0.5",
|
||||||
"uqr": "^0.1.2",
|
"uqr": "^0.1.2",
|
||||||
"xxhash-wasm": "^1.0.2",
|
"xxhash-wasm": "^1.0.2",
|
||||||
@@ -55,6 +57,19 @@
|
|||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"../discord-markdown": {
|
||||||
|
"name": "@cloudrac3r/discord-markdown",
|
||||||
|
"version": "2.9.0",
|
||||||
|
"extraneous": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"simple-markdown": "^0.7.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudrac3r/tap-dot": "^2.0.2",
|
||||||
|
"supertape": "^13.6.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/helper-string-parser": {
|
"node_modules/@babel/helper-string-parser": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||||
@@ -131,9 +146,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cloudrac3r/discord-markdown": {
|
"node_modules/@cloudrac3r/discord-markdown": {
|
||||||
"version": "2.8.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.9.0.tgz",
|
||||||
"integrity": "sha512-ciLxS6iaMxuEkEln7Y9rTpUBFf+gij8djNKS1QGrTfKoHcKABmDvcXQlPntdTx7odHjtMqV2RfvR/rnmQ25YPQ==",
|
"integrity": "sha512-0S22mNlT9rPhYnxP+qzKWayVe+hFWcEqxVCviF/pyzWAb9nLaBZkIVk0jHTImoA1MY0mqx1uMKjIcxVHkEZe3A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"simple-markdown": "^0.7.3"
|
"simple-markdown": "^0.7.3"
|
||||||
@@ -267,6 +282,18 @@
|
|||||||
"node": ">=8.0.0"
|
"node": ">=8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@cloudrac3r/tell-me-when": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@cloudrac3r/tell-me-when/-/tell-me-when-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-U+TEOTe6f/vSIeD/QP0VPrkLrhnFtQs2euxBOAaqC8rzQKu8nNoKSFuhipT6DyuCZH48B58+qrPMmH2w2IgMhA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"tell-me-when": "dist/src/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@cloudrac3r/turndown": {
|
"node_modules/@cloudrac3r/turndown": {
|
||||||
"version": "7.1.5",
|
"version": "7.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@cloudrac3r/turndown/-/turndown-7.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@cloudrac3r/turndown/-/turndown-7.1.5.tgz",
|
||||||
@@ -1049,9 +1076,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.14",
|
"version": "19.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -2503,6 +2530,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/test-exclude": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz",
|
||||||
|
|||||||
+3
-1
@@ -19,7 +19,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chriscdn/promise-semaphore": "^3.0.1",
|
"@chriscdn/promise-semaphore": "^3.0.1",
|
||||||
"@cloudrac3r/discord-markdown": "^2.8.0",
|
"@cloudrac3r/discord-markdown": "^2.9.0",
|
||||||
"@cloudrac3r/giframe": "^0.4.3",
|
"@cloudrac3r/giframe": "^0.4.3",
|
||||||
"@cloudrac3r/html-template-tag": "^5.0.1",
|
"@cloudrac3r/html-template-tag": "^5.0.1",
|
||||||
"@cloudrac3r/in-your-element": "^1.1.1",
|
"@cloudrac3r/in-your-element": "^1.1.1",
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"@cloudrac3r/pngjs": "^7.0.3",
|
"@cloudrac3r/pngjs": "^7.0.3",
|
||||||
"@cloudrac3r/pug": "^4.0.4",
|
"@cloudrac3r/pug": "^4.0.4",
|
||||||
"@cloudrac3r/stream-type": "^1.0.0",
|
"@cloudrac3r/stream-type": "^1.0.0",
|
||||||
|
"@cloudrac3r/tell-me-when": "^2.0.0",
|
||||||
"@cloudrac3r/turndown": "^7.1.5",
|
"@cloudrac3r/turndown": "^7.1.5",
|
||||||
"@stackoverflow/stacks": "^2.5.4",
|
"@stackoverflow/stacks": "^2.5.4",
|
||||||
"@stackoverflow/stacks-icons": "^6.0.2",
|
"@stackoverflow/stacks-icons": "^6.0.2",
|
||||||
@@ -48,6 +49,7 @@
|
|||||||
"prettier-bytes": "^1.0.4",
|
"prettier-bytes": "^1.0.4",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
"snowtransfer": "^0.19.0",
|
"snowtransfer": "^0.19.0",
|
||||||
|
"temporal-polyfill-lite": "^0.4.3",
|
||||||
"try-to-catch": "^4.0.5",
|
"try-to-catch": "^4.0.5",
|
||||||
"uqr": "^0.1.2",
|
"uqr": "^0.1.2",
|
||||||
"xxhash-wasm": "^1.0.2",
|
"xxhash-wasm": "^1.0.2",
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ const lottie = sync.require("../actions/lottie")
|
|||||||
const mxUtils = sync.require("../../matrix/utils")
|
const mxUtils = sync.require("../../matrix/utils")
|
||||||
/** @type {import("../../discord/utils")} */
|
/** @type {import("../../discord/utils")} */
|
||||||
const dUtils = sync.require("../../discord/utils")
|
const dUtils = sync.require("../../discord/utils")
|
||||||
|
/** @type {import("../../discord/time")} */
|
||||||
|
const discordTime = sync.require("../../discord/time")
|
||||||
/** @type {import("./find-mentions")} */
|
/** @type {import("./find-mentions")} */
|
||||||
const findMentions = sync.require("./find-mentions")
|
const findMentions = sync.require("./find-mentions")
|
||||||
/** @type {import("../../discord/interactions/poll-responses")} */
|
/** @type {import("../../discord/interactions/poll-responses")} */
|
||||||
@@ -72,6 +74,17 @@ function getDiscordParseCallbacks(message, guild, useHTML, spoilers = []) {
|
|||||||
return `:${node.name}:`
|
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 => {
|
role: node => {
|
||||||
const role = guild.roles.find(r => r.id === node.id)
|
const role = guild.roles.find(r => r.id === node.id)
|
||||||
if (!role) {
|
if (!role) {
|
||||||
@@ -870,32 +883,47 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||||||
|
|
||||||
// Then scheduled events
|
// Then scheduled events
|
||||||
if (message.content && di?.snow) {
|
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
|
let invite
|
||||||
try {
|
|
||||||
invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]})
|
if (match[1]) {
|
||||||
} catch (e) {
|
try {
|
||||||
// Skip expired/invalid invites and events
|
invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]})
|
||||||
if (e.message === "Unknown Invite") {
|
event = invite.guild_scheduled_event
|
||||||
break
|
} catch (e) {
|
||||||
} else {
|
// 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
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = invite.guild_scheduled_event
|
|
||||||
if (!event) continue // the event ID provided was not valid
|
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 rep = new mxUtils.MatrixStringBuilder()
|
||||||
|
const unixSeconds = Math.floor(new Date(event.scheduled_start_time).getTime() / 1000)
|
||||||
|
|
||||||
// Add time
|
// Add time
|
||||||
if (event.scheduled_end_time) {
|
if (event.scheduled_end_time) {
|
||||||
// @ts-ignore - no definition available for formatRange
|
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
|
||||||
rep.addParagraph(`Scheduled Event - ${formatter.formatRange(new Date(event.scheduled_start_time), new Date(event.scheduled_end_time))}`)
|
var displayTime = formatter.formatRange(new Date(event.scheduled_start_time), new Date(event.scheduled_end_time))
|
||||||
} else {
|
} 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
|
// Add details
|
||||||
rep.addLine(`## ${event.name}`, tag`<strong>${event.name}</strong>`)
|
rep.addLine(`## ${event.name}`, tag`<strong>${event.name}</strong>`)
|
||||||
@@ -904,7 +932,7 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||||||
// Add location
|
// Add location
|
||||||
if (event.entity_metadata?.location) {
|
if (event.entity_metadata?.location) {
|
||||||
rep.addParagraph(`📍 ${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()
|
const roomID = select("channel_room", "room_id", {channel_id: invite.channel.id}).pluck().get()
|
||||||
if (roomID) {
|
if (roomID) {
|
||||||
const via = await getViaServersMemo(roomID)
|
const via = await getViaServersMemo(roomID)
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ test("message2event components: pk question mark output", async t => {
|
|||||||
+ "\n| Same hat"
|
+ "\n| Same hat"
|
||||||
+ "\n| 🖼️ Image: https://bridge.example.org/download/discordcdn/934955898965729280/1466556006527012987/image.png"
|
+ "\n| 🖼️ Image: https://bridge.example.org/download/discordcdn/934955898965729280/1466556006527012987/image.png"
|
||||||
+ "\n"
|
+ "\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",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: "<blockquote>"
|
formatted_body: "<blockquote>"
|
||||||
+ "<h3>Lillith (INX)</h3>"
|
+ "<h3>Lillith (INX)</h3>"
|
||||||
@@ -73,7 +73,7 @@ test("message2event components: pk question mark output", async t => {
|
|||||||
+ "<hr>"
|
+ "<hr>"
|
||||||
+ "<p>Same hat</p>"
|
+ "<p>Same hat</p>"
|
||||||
+ `🖼️ Image: <a href="https://bridge.example.org/download/discordcdn/934955898965729280/1466556006527012987/image.png">image.png</a></blockquote>`
|
+ `🖼️ Image: <a href="https://bridge.example.org/download/discordcdn/934955898965729280/1466556006527012987/image.png">image.png</a></blockquote>`
|
||||||
+ "<p><sub>Original Message ID: 1466556003645657118 · <t:1769724599:f></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": {},
|
"m.mentions": {},
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
}])
|
}])
|
||||||
|
|||||||
@@ -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 => {
|
test("message2event: simple message link", async t => {
|
||||||
let called = 0
|
let called = 0
|
||||||
const events = await messageToEvent(data.message.simple_message_link, data.guild.general, {}, {
|
const events = await messageToEvent(data.message.simple_message_link, data.guild.general, {}, {
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.getFormatOptions = getFormatOptions
|
||||||
|
module.exports.convertDiscordTime = convertDiscordTime
|
||||||
@@ -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)")
|
||||||
|
})
|
||||||
@@ -3,8 +3,9 @@
|
|||||||
const assert = require("assert").strict
|
const assert = require("assert").strict
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const Ty = require("../types")
|
const Ty = require("../types")
|
||||||
const {pipeline} = require("stream").promises
|
|
||||||
const sharp = require("sharp")
|
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")
|
const {discord, sync, db, select} = require("../passthrough")
|
||||||
/** @type {import("./api")}) */
|
/** @type {import("./api")}) */
|
||||||
@@ -13,8 +14,9 @@ const api = sync.require("./api")
|
|||||||
const mxUtils = sync.require("./utils")
|
const mxUtils = sync.require("./utils")
|
||||||
/** @type {import("../discord/utils")} */
|
/** @type {import("../discord/utils")} */
|
||||||
const dUtils = sync.require("../discord/utils")
|
const dUtils = sync.require("../discord/utils")
|
||||||
/** @type {import("./kstate")} */
|
/** @type {import("../d2m/actions/retrigger")} */
|
||||||
const ks = sync.require("./kstate")
|
const retrigger = sync.require("../d2m/actions/retrigger")
|
||||||
|
|
||||||
const {reg} = require("./read-registration")
|
const {reg} = require("./read-registration")
|
||||||
|
|
||||||
const PREFIXES = ["//", "/"]
|
const PREFIXES = ["//", "/"]
|
||||||
@@ -33,7 +35,17 @@ function getSlotCount(tier) {
|
|||||||
return TIER_EMOJI_SLOTS.get(tier) || 50
|
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
|
* @param {string} roomID where to add the button
|
||||||
@@ -51,22 +63,23 @@ async function addButton(roomID, eventID, key, mxid) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
return new Promise(resolve => {
|
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
|
// Clear out old buttons every so often to free memory
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const now = Date.now()
|
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()
|
}, 10*60*1000).unref()
|
||||||
|
|
||||||
/** @param {Ty.Event.Outer<Ty.Event.M_Reaction>} event */
|
/** @param {Ty.Event.Outer<Ty.Event.M_Reaction>} event */
|
||||||
function onReactionAdd(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) {
|
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)
|
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(() => {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
}]
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
//- 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.
|
||||||
|
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)
|
||||||
@@ -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})
|
||||||
|
}))
|
||||||
@@ -141,3 +141,4 @@ sync.require("./routes/oauth")
|
|||||||
sync.require("./routes/opt-out")
|
sync.require("./routes/opt-out")
|
||||||
sync.require("./routes/password")
|
sync.require("./routes/password")
|
||||||
sync.require("./routes/stats")
|
sync.require("./routes/stats")
|
||||||
|
sync.require("./routes/time")
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// @ts-check
|
// @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 fs = require("fs")
|
||||||
const sqlite = require("better-sqlite3")
|
const sqlite = require("better-sqlite3")
|
||||||
const migrate = require("./src/db/migrate")
|
const migrate = require("./src/db/migrate")
|
||||||
|
|||||||
@@ -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: {
|
unknown_role: {
|
||||||
id: "1162374402785153106",
|
id: "1162374402785153106",
|
||||||
type: 0,
|
type: 0,
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not
|
|||||||
require("../src/db/orm.test")
|
require("../src/db/orm.test")
|
||||||
require("../src/js/errors.test")
|
require("../src/js/errors.test")
|
||||||
require("../src/web/server.test")
|
require("../src/web/server.test")
|
||||||
|
require("../src/discord/time.test")
|
||||||
require("../src/discord/utils.test")
|
require("../src/discord/utils.test")
|
||||||
require("../src/matrix/kstate.test")
|
require("../src/matrix/kstate.test")
|
||||||
require("../src/matrix/api.test")
|
require("../src/matrix/api.test")
|
||||||
|
|||||||
Reference in New Issue
Block a user