Compare commits
3 commits
main
...
move-reply
| Author | SHA1 | Date | |
|---|---|---|---|
| 9eb2ba1458 | |||
| 95572446e6 | |||
| 5bfd753af3 |
46 changed files with 870 additions and 1515 deletions
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
|
|
@ -1,11 +1,5 @@
|
|||
{
|
||||
"editor.insertSpaces": false,
|
||||
"editor.detectIndentation": false,
|
||||
"editor.tabSize": 3,
|
||||
"[jade]": {
|
||||
"editor.stickyScroll.enabled": true,
|
||||
"editor.stickyScroll.maxLineCount": 6,
|
||||
"editor.tabSize": 2,
|
||||
"editor.insertSpaces": true
|
||||
}
|
||||
"editor.tabSize": 3
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,14 +89,14 @@ 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: 114
|
||||
Total transitive production dependencies: 144
|
||||
|
||||
### <font size="+2">🦕</font>
|
||||
|
||||
* (35) better-sqlite3: SQLite is the best database, and this is the best library for it.
|
||||
* (29) sharp: Image resizing and compositing. OOYE needs this for the emoji sprite sheets. It has libvips prebuilts for each platform.
|
||||
* (26) @cloudrac3r/pug: Language for dynamic web pages. This is my fork. (I released code that hadn't made it to npm, and removed the heavy pug-filters feature.)
|
||||
* (9) h3: Web server. OOYE needs this for the web UI, appservice listener, authmedia proxy, and more.
|
||||
* (1) better-sqlite3: SQLite is the best database, and this is the best library for it.
|
||||
|
||||
### <font size="-1">🪱</font>
|
||||
|
||||
|
|
@ -108,7 +108,6 @@ Total transitive production dependencies: 114
|
|||
* (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.
|
||||
|
|
@ -121,28 +120,11 @@ Total transitive production dependencies: 114
|
|||
* (0) entities: Looks fine. No dependencies.
|
||||
* (0) get-relative-path: Looks fine. No dependencies.
|
||||
* (1) heatsync: Module hot-reloader that I trust.
|
||||
* (0) htmx.org: Declarative partial page reloads that do just enough. I'm not totally happy with this one, but not sure what I'd do instead.
|
||||
* (0) lru-cache: For holding unused nonce in memory and letting them be overwritten later if never used.
|
||||
* (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.
|
||||
* (0) zod: Input validation for the web server. It's popular and easy to use.
|
||||
|
||||
### Outdated versions
|
||||
|
||||
* @chriscdn/promise-semaphore
|
||||
* Highest usable version is 3.1.3 because the author **dropped CJS** in 4.0.0.
|
||||
* @stackoverflow/stacks
|
||||
* Highest usable version is 2.9.0 (and 6.9.0 for stacks-icons) because the company **ruined their design** in 3.0.0.
|
||||
* cross-env
|
||||
* Highest usable version is 7.0.3 because the author **dropped CJS** in 10.0.0.
|
||||
* entities
|
||||
* Using version 5.0.0. The author messed things up in 6.0.0, started using LLMs in 7.0.0, and **dropped CJS** in 8.0.0.
|
||||
* h3
|
||||
* Using major version 1. Version 2 has been stuck as **release candidates for months**, and all the code comments are Claude.
|
||||
* htmx.org
|
||||
* Using major version 2. Version 4 exists, and **I'd be okay updating to it**, but the author changed a lot and the entire site would need to be re-tested.
|
||||
|
|
|
|||
1045
package-lock.json
generated
1045
package-lock.json
generated
File diff suppressed because it is too large
Load diff
14
package.json
14
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "out-of-your-element",
|
||||
"version": "3.7.0",
|
||||
"version": "3.6.0",
|
||||
"description": "A bridge between Matrix and Discord",
|
||||
"main": "index.js",
|
||||
"repository": {
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@chriscdn/promise-semaphore": "^3.0.1",
|
||||
"@cloudrac3r/discord-markdown": "^2.9.0",
|
||||
"@cloudrac3r/discord-markdown": "^2.7.0",
|
||||
"@cloudrac3r/giframe": "^0.4.3",
|
||||
"@cloudrac3r/html-template-tag": "^5.0.1",
|
||||
"@cloudrac3r/in-your-element": "^1.1.1",
|
||||
|
|
@ -27,12 +27,11 @@
|
|||
"@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",
|
||||
"ansi-colors": "^4.1.3",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"chunk-text": "^2.0.1",
|
||||
"cloudstorm": "^0.19.0",
|
||||
"denque": "^2.1.0",
|
||||
|
|
@ -42,14 +41,13 @@
|
|||
"entities": "^5.0.0",
|
||||
"get-relative-path": "^1.0.2",
|
||||
"h3": "^1.15.10",
|
||||
"heatsync": "^3.3.0",
|
||||
"heatsync": "^2.7.2",
|
||||
"htmx.org": "^2.0.4",
|
||||
"lru-cache": "^11.0.2",
|
||||
"mime-types": "^3.0.2",
|
||||
"mime-types": "^2.1.35",
|
||||
"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",
|
||||
|
|
@ -61,7 +59,7 @@
|
|||
"devDependencies": {
|
||||
"@cloudrac3r/tap-dot": "^2.0.3",
|
||||
"@types/node": "^22.17.1",
|
||||
"c8": "^12.0.0",
|
||||
"c8": "^11.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"supertape": "^13.2.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,10 +8,6 @@ Modern Matrix-to-Discord appservice bridge, created by [@cadence:cadence.moe](ht
|
|||
|
||||

|
||||
|
||||
## AI policy
|
||||
|
||||
Out Of Your Element is 100% organically cultivated ethical hand-fed code. Let's work together to conserve that!
|
||||
|
||||
## Why a new bridge?
|
||||
|
||||
* Modern: Supports new Discord features like replies, threads and stickers, and new Matrix features like edits, spaces and space membership.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
#!/usr/bin/env node
|
||||
// @ts-check
|
||||
|
||||
const {createServer} = require("http")
|
||||
const EventEmitter = require("events")
|
||||
const {createApp, createRouter, toNodeListener} = require("h3")
|
||||
const sqlite = require("better-sqlite3")
|
||||
const migrate = require("../src/db/migrate")
|
||||
const HeatSync = require("heatsync")
|
||||
|
|
@ -13,10 +16,6 @@ const sync = new HeatSync()
|
|||
|
||||
Object.assign(passthrough, {sync, db})
|
||||
|
||||
const orm = sync.require("../src/db/orm")
|
||||
passthrough.from = orm.from
|
||||
passthrough.select = orm.select
|
||||
|
||||
const DiscordClient = require("../src/d2m/discord-client")
|
||||
|
||||
const discord = new DiscordClient(reg.ooye.discord_token, "half")
|
||||
|
|
@ -25,6 +24,10 @@ passthrough.discord = discord
|
|||
const {as} = require("../src/matrix/appservice")
|
||||
passthrough.as = as
|
||||
|
||||
const orm = sync.require("../src/db/orm")
|
||||
passthrough.from = orm.from
|
||||
passthrough.select = orm.select
|
||||
|
||||
;(async () => {
|
||||
await migrate.migrate(db)
|
||||
await discord.cloud.connect()
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
const assert = require("assert").strict
|
||||
const {reg} = require("../../matrix/read-registration")
|
||||
const Ty = require("../../types")
|
||||
const {scheduler} = require("timers/promises")
|
||||
|
||||
const passthrough = require("../../passthrough")
|
||||
const {sync, db, select, from} = passthrough
|
||||
|
|
@ -15,21 +14,14 @@ const file = sync.require("../../matrix/file")
|
|||
const registerUser = sync.require("./register-user")
|
||||
|
||||
/** @returns {Promise<Ty.PkMessage>} */
|
||||
async function fetchMessage(messageID, attempt = 1) {
|
||||
async function fetchMessage(messageID) {
|
||||
try {
|
||||
var res = await fetch(`https://api.pluralkit.me/v2/messages/${messageID}`)
|
||||
} catch (networkError) {
|
||||
// Network issue, raise a more readable message
|
||||
throw new Error(`Failed to connect to PK API: ${networkError.toString()}`)
|
||||
}
|
||||
if (!res.ok) {
|
||||
if (attempt < 2) {
|
||||
await scheduler.wait(5000)
|
||||
return fetchMessage(messageID, attempt + 1)
|
||||
} else {
|
||||
throw new Error(`PK API returned an error: ${await res.text()}`)
|
||||
}
|
||||
}
|
||||
if (!res.ok) throw new Error(`PK API returned an error: ${await res.text()}`)
|
||||
/** @type {any} */
|
||||
const root = await res.json()
|
||||
if (!root.member) throw new Error(`PK API didn't return member data: ${JSON.stringify(root)}`)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// @ts-check
|
||||
|
||||
const assert = require("assert").strict
|
||||
const assert = require("assert")
|
||||
const {reg} = require("../../matrix/read-registration")
|
||||
const Ty = require("../../types")
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ const storage = new class {
|
|||
this.timers.set(id, setTimeout(() => this.resolve(id, false), 60 * 1000).unref()) // 1 minute
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** @param {string} id */
|
||||
isNotPaused(id) {
|
||||
return !storage.paused.has(id)
|
||||
|
|
@ -98,7 +98,7 @@ function waitFor(id, resolve, existsInDatabase) {
|
|||
const GET_EVENT_PREPARED = from("event_message").select("event_id").and("WHERE event_id = ?").prepare().raw()
|
||||
/**
|
||||
* @param {string} eventID
|
||||
* @returns {Promise<boolean>} if false then the message did not arrive
|
||||
* @returns {Promise<boolean>} if true then the message did not arrive
|
||||
*/
|
||||
function waitForEvent(eventID) {
|
||||
const {promise, resolve} = Promise.withResolvers()
|
||||
|
|
@ -109,7 +109,7 @@ function waitForEvent(eventID) {
|
|||
const GET_MESSAGE_PREPARED = from("event_message").select("message_id").and("WHERE message_id = ?").prepare().raw()
|
||||
/**
|
||||
* @param {string} messageID
|
||||
* @returns {Promise<boolean>} if false then the message did not arrive
|
||||
* @returns {Promise<boolean>} if true then the message did not arrive
|
||||
*/
|
||||
function waitForMessage(messageID) {
|
||||
const {promise, resolve} = Promise.withResolvers()
|
||||
|
|
@ -120,7 +120,7 @@ function waitForMessage(messageID) {
|
|||
const GET_REACTION_EVENT_PREPARED = from("reaction").select("hashed_event_id").and("WHERE hashed_event_id = ?").prepare().raw()
|
||||
/**
|
||||
* @param {string} eventID
|
||||
* @returns {Promise<boolean>} if false then the message did not arrive
|
||||
* @returns {Promise<boolean>} if true then the message did not arrive
|
||||
*/
|
||||
function waitForReactionEvent(eventID) {
|
||||
const {promise, resolve} = Promise.withResolvers()
|
||||
|
|
@ -156,4 +156,4 @@ module.exports.waitForMessage = waitForMessage
|
|||
module.exports.waitForEvent = waitForEvent
|
||||
module.exports.waitForReactionEvent = waitForReactionEvent
|
||||
module.exports.pauseChanges = pauseChanges
|
||||
module.exports.finishedBridging = finishedBridging
|
||||
module.exports.finishedBridging = finishedBridging
|
||||
|
|
@ -55,7 +55,7 @@ async function sendMessage(message, channel, guild) {
|
|||
}
|
||||
}
|
||||
|
||||
const events = await messageToEvent.messageToEvent(message, guild, {}, {api, snow: discord.snow, fetch})
|
||||
const events = await messageToEvent.messageToEvent(message, guild, {}, {api, snow: discord.snow})
|
||||
const eventIDs = []
|
||||
if (events.length) {
|
||||
db.prepare("INSERT OR IGNORE INTO message_room (message_id, historical_room_index) VALUES (?, ?)").run(message.id, historicalRoomIndex)
|
||||
|
|
|
|||
|
|
@ -69,8 +69,8 @@ async function editToChanges(message, guild, api) {
|
|||
|
||||
// Figure out what we will be replacing them with
|
||||
|
||||
const newFallbackContent = await messageToEvent.messageToEvent(message, guild, {includeEditFallbackStar: true}, {api, fetch})
|
||||
const newInnerContent = await messageToEvent.messageToEvent(message, guild, {includeReplyFallback: false}, {api, fetch})
|
||||
const newFallbackContent = await messageToEvent.messageToEvent(message, guild, {includeEditFallbackStar: true}, {api})
|
||||
const newInnerContent = await messageToEvent.messageToEvent(message, guild, {includeReplyFallback: false}, {api})
|
||||
assert.ok(newFallbackContent.length === newInnerContent.length)
|
||||
|
||||
// Match the new events to the old events
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// @ts-check
|
||||
|
||||
const assert = require("assert").strict
|
||||
const assert = require("assert")
|
||||
|
||||
const {reg} = require("../../matrix/read-registration")
|
||||
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// @ts-check
|
||||
|
||||
const assert = require("assert").strict
|
||||
const assert = require("assert")
|
||||
const stream = require("stream")
|
||||
const {PNG} = require("@cloudrac3r/pngjs")
|
||||
|
||||
|
|
|
|||
|
|
@ -18,8 +18,6 @@ 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")} */
|
||||
|
|
@ -74,17 +72,6 @@ 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) {
|
||||
|
|
@ -99,14 +86,6 @@ function getDiscordParseCallbacks(message, guild, useHTML, spoilers = []) {
|
|||
return `@${role.name}:`
|
||||
}
|
||||
},
|
||||
game: node => {
|
||||
const game = message.mention_games?.find(g => g.id === node.id)
|
||||
if (!game?.name) {
|
||||
return "@$" + node.id
|
||||
} else {
|
||||
return game.name
|
||||
}
|
||||
},
|
||||
everyone: () => {
|
||||
if (message.mention_everyone) return "@room"
|
||||
return "@everyone"
|
||||
|
|
@ -130,11 +109,10 @@ const embedTitleParser = markdown.markdownEngine.parserFor({
|
|||
|
||||
/**
|
||||
* @param {{room?: boolean, user_ids?: string[]}} mentions
|
||||
* @param {Omit<DiscordTypes.APIAttachment, "id" | "proxy_url"> & {proxy_url?: string}} attachment
|
||||
* @param {Omit<DiscordTypes.APIAttachment, "id" | "proxy_url">} attachment
|
||||
* @param {boolean} [alwaysLink]
|
||||
* @param {{fetch?: typeof fetch}} [di]
|
||||
*/
|
||||
async function attachmentToEvent(mentions, attachment, alwaysLink, di) {
|
||||
async function attachmentToEvent(mentions, attachment, alwaysLink) {
|
||||
const external_url = dUtils.getPublicUrlForCdn(attachment.url)
|
||||
const emoji =
|
||||
attachment.content_type?.startsWith("image/jp") ? "📸"
|
||||
|
|
@ -154,37 +132,8 @@ async function attachmentToEvent(mentions, attachment, alwaysLink, di) {
|
|||
formatted_body: `<blockquote>${emoji} Uploaded SPOILER file: <a href="${external_url}">${external_url}</a> (${pb(attachment.size)})</blockquote>`
|
||||
}
|
||||
}
|
||||
// attempt to convert large PNG image files to JPEG, since it's almost certainly a photo that was forced into PNG by the user copy-pasting.
|
||||
if (
|
||||
attachment.content_type === "image/png" && attachment.size > reg.ooye.max_file_size
|
||||
&& !alwaysLink && attachment.proxy_url && attachment.width && attachment.height && di?.fetch
|
||||
) {
|
||||
const proxyUrl = new URL(attachment.proxy_url)
|
||||
proxyUrl.searchParams.set("format", "jpeg")
|
||||
const jpegUrl = proxyUrl.toString()
|
||||
const res = await di.fetch(jpegUrl, {method: "HEAD"})
|
||||
const newFilename = attachment.filename.replace(/\.png$/, ".jpg")
|
||||
const newSize = Number(res.headers.get("content-length"))
|
||||
if (res.ok && res.headers.get("content-type") === "image/jpeg" && !res.headers.has("content-encoding") && newSize <= reg.ooye.max_file_size) {
|
||||
return {
|
||||
$type: "m.room.message",
|
||||
"m.mentions": mentions,
|
||||
msgtype: "m.image",
|
||||
url: await file.uploadDiscordFileToMxc(jpegUrl),
|
||||
external_url,
|
||||
body: attachment.description || newFilename,
|
||||
filename: newFilename,
|
||||
info: {
|
||||
mimetype: "image/jpeg",
|
||||
w: attachment.width,
|
||||
h: attachment.height,
|
||||
size: newSize
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// for large files, always link them instead of uploading so I don't use up all the space in the content repo
|
||||
if (alwaysLink || attachment.size > reg.ooye.max_file_size) {
|
||||
else if (alwaysLink || attachment.size > reg.ooye.max_file_size) {
|
||||
return {
|
||||
$type: "m.room.message",
|
||||
"m.mentions": mentions,
|
||||
|
|
@ -193,8 +142,7 @@ async function attachmentToEvent(mentions, attachment, alwaysLink, di) {
|
|||
format: "org.matrix.custom.html",
|
||||
formatted_body: `${emoji} Uploaded file: <a href="${external_url}">${attachment.filename}</a> (${pb(attachment.size)})`
|
||||
}
|
||||
}
|
||||
if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) {
|
||||
} else if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) {
|
||||
return {
|
||||
$type: "m.room.message",
|
||||
"m.mentions": mentions,
|
||||
|
|
@ -210,8 +158,7 @@ async function attachmentToEvent(mentions, attachment, alwaysLink, di) {
|
|||
size: attachment.size
|
||||
}
|
||||
}
|
||||
}
|
||||
if (attachment.content_type?.startsWith("video/") && attachment.width && attachment.height) {
|
||||
} else if (attachment.content_type?.startsWith("video/") && attachment.width && attachment.height) {
|
||||
return {
|
||||
$type: "m.room.message",
|
||||
"m.mentions": mentions,
|
||||
|
|
@ -227,8 +174,7 @@ async function attachmentToEvent(mentions, attachment, alwaysLink, di) {
|
|||
size: attachment.size
|
||||
}
|
||||
}
|
||||
}
|
||||
if (attachment.content_type?.startsWith("audio/")) {
|
||||
} else if (attachment.content_type?.startsWith("audio/")) {
|
||||
return {
|
||||
$type: "m.room.message",
|
||||
"m.mentions": mentions,
|
||||
|
|
@ -243,19 +189,19 @@ async function attachmentToEvent(mentions, attachment, alwaysLink, di) {
|
|||
duration: attachment.duration_secs && Math.round(attachment.duration_secs * 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
// else
|
||||
return {
|
||||
$type: "m.room.message",
|
||||
"m.mentions": mentions,
|
||||
msgtype: "m.file",
|
||||
url: await file.uploadDiscordFileToMxc(attachment.url),
|
||||
external_url,
|
||||
body: attachment.description || attachment.filename,
|
||||
filename: attachment.filename,
|
||||
info: {
|
||||
mimetype: attachment.content_type,
|
||||
size: attachment.size
|
||||
} else {
|
||||
return {
|
||||
$type: "m.room.message",
|
||||
"m.mentions": mentions,
|
||||
msgtype: "m.file",
|
||||
url: await file.uploadDiscordFileToMxc(attachment.url),
|
||||
external_url,
|
||||
body: attachment.description || attachment.filename,
|
||||
filename: attachment.filename,
|
||||
info: {
|
||||
mimetype: attachment.content_type,
|
||||
size: attachment.size
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -349,7 +295,7 @@ function mergeTextEvents(newEvents, events, forceSameMsgtype, forceMerge = false
|
|||
* - includeEditFallbackStar: false
|
||||
* - alwaysReturnFormattedBody: false - formatted_body will be skipped if it is the same as body because the message is plaintext. if you want the formatted_body to be returned anyway, for example to merge it with another message, then set this to true.
|
||||
* - scanTextForMentions: true - needs to be set to false when converting forwarded messages etc which may be from a different channel that can't be scanned.
|
||||
* @param {{api: import("../../matrix/api"), snow?: import("snowtransfer").SnowTransfer, fetch?: typeof fetch}} di simple-as-nails dependency injection for the matrix API
|
||||
* @param {{api: import("../../matrix/api"), snow?: import("snowtransfer").SnowTransfer}} di simple-as-nails dependency injection for the matrix API
|
||||
* @returns {Promise<{$type: string, $sender?: string, [x: string]: any}[]>}
|
||||
*/
|
||||
async function messageToEvent(message, guild, options = {}, di) {
|
||||
|
|
@ -804,12 +750,11 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
}
|
||||
|
||||
// Send Klipy GIFs in customised form
|
||||
const urlPreviewEnabled = select("guild_space", "url_preview", {guild_id: guild?.id}).pluck().get() ?? 1
|
||||
let isKlipyGIF = false
|
||||
let isOnlyKlipyGIF = false
|
||||
if (message.embeds?.length === 1 && message.embeds[0].provider?.name === "Klipy" && message.embeds[0].video?.url && urlPreviewEnabled) {
|
||||
if (message.embeds?.length === 1 && message.embeds[0].provider?.name === "Klipy" && message.embeds[0].video?.url) {
|
||||
isKlipyGIF = true
|
||||
if (message.content?.match(/^https?:\/\/klipy\.com[^ \n]+$/)) {
|
||||
if (message.content.match(/^https?:\/\/klipy\.com[^ \n]+$/)) {
|
||||
isOnlyKlipyGIF = true
|
||||
}
|
||||
}
|
||||
|
|
@ -883,47 +828,32 @@ 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,})|\/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
|
||||
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
|
||||
let invite
|
||||
|
||||
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) {
|
||||
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 {
|
||||
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) {
|
||||
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
|
||||
var displayTime = formatter.formatRange(new Date(event.scheduled_start_time), new Date(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))}`)
|
||||
} else {
|
||||
var displayTime = discordTime.convertDiscordTime(unixSeconds, "F")
|
||||
rep.addParagraph(`Scheduled Event - ${formatter.format(new Date(event.scheduled_start_time))}`)
|
||||
}
|
||||
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>`)
|
||||
|
|
@ -932,7 +862,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)
|
||||
|
|
@ -952,7 +882,7 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
|
||||
// Then attachments
|
||||
if (message.attachments) {
|
||||
const attachmentEvents = await Promise.all(message.attachments.map(attachment => attachmentToEvent(mentions, attachment, false, {fetch: di?.fetch})))
|
||||
const attachmentEvents = await Promise.all(message.attachments.map(attachment => attachmentToEvent(mentions, attachment)))
|
||||
|
||||
// Try to merge attachment events with the previous event
|
||||
// This means that if the attachments ended up as a text link, and especially if there were many of them, the events will be joined together.
|
||||
|
|
@ -983,7 +913,7 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
url: file.url,
|
||||
height: file.height,
|
||||
width: file.width,
|
||||
}, true, {fetch: di?.fetch})
|
||||
}, true)
|
||||
stack.msb.addLine(ev.body, ev.formatted_body)
|
||||
}
|
||||
else if (component.type === DiscordTypes.ComponentType.MediaGallery) {
|
||||
|
|
@ -1083,6 +1013,7 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
}
|
||||
|
||||
// Then embeds
|
||||
const urlPreviewEnabled = select("guild_space", "url_preview", {guild_id: guild?.id}).pluck().get() ?? 1
|
||||
for (const embed of message.embeds || []) {
|
||||
if (!urlPreviewEnabled && !message.author?.bot) {
|
||||
continue // show embeds for everyone if enabled, or bot users only if disabled (bots often send content in embeds)
|
||||
|
|
@ -1104,10 +1035,6 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
continue // If discord creates an embed preview for a discord channel link, don't copy that embed
|
||||
}
|
||||
|
||||
if (embed.provider?.name === "GitHub") {
|
||||
continue // GitHub url previews are very large and fairly useless
|
||||
}
|
||||
|
||||
if (embed.url && spoilers.some(sp => sp.match(/\bhttps?:\/\/[a-z]/))) {
|
||||
// If the original message had spoilered URLs, don't generate any embeds for links.
|
||||
// This logic is the same as the Discord desktop client. It doesn't match specific embeds to specific spoilered text, it's all or nothing.
|
||||
|
|
|
|||
|
|
@ -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 · 29 January 2026 at 10:09 pm (UTC)",
|
||||
+ "\n-# Original Message ID: 1466556003645657118 · <t:1769724599:f>",
|
||||
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 · <a href=\"https://bridge.example.org/time/1769724599/f\">29 January 2026 at 10:09 pm (UTC)</a></sub></p>",
|
||||
+ "<p><sub>Original Message ID: 1466556003645657118 · <t:1769724599:f></sub></p>",
|
||||
"m.mentions": {},
|
||||
msgtype: "m.text",
|
||||
}])
|
||||
|
|
|
|||
|
|
@ -192,38 +192,6 @@ test("message2event: manually constructed unknown roles should use fallback", as
|
|||
}])
|
||||
})
|
||||
|
||||
test("message2event: simple game mentions", async t => {
|
||||
const events = await messageToEvent(data.message.simple_game_mention, data.guild.general, {})
|
||||
t.deepEqual(events, [{
|
||||
$type: "m.room.message",
|
||||
"m.mentions": {},
|
||||
msgtype: "m.text",
|
||||
body: "do you play Minecraft"
|
||||
}])
|
||||
})
|
||||
|
||||
test("message2event: timestamps", async t => {
|
||||
const events = await messageToEvent(data.message.timestamps, data.guild.general, {})
|
||||
t.deepEqual(events, [{
|
||||
$type: "m.room.message",
|
||||
msgtype: "m.text",
|
||||
body: "4 Oct 2026, 9:33:33 am (UTC)"
|
||||
+ "\n4 October 2026 at 9:33 am (UTC)"
|
||||
+ "\nSunday, 4 October 2026 at 9:34 am (UTC)"
|
||||
+ "\n9:34:08 am (UTC)"
|
||||
+ "\n3 October 2026 at 9:30 am (UTC)"
|
||||
+ "\n24 December 2026 at 11:00 pm (UTC)",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `<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, {}, {
|
||||
|
|
@ -1135,45 +1103,6 @@ test("message2event: very large attachment is linked instead of being uploaded",
|
|||
}])
|
||||
})
|
||||
|
||||
test("message2event: very large png is converted to jpeg for upload", async t => {
|
||||
const events = await messageToEvent({
|
||||
content: "",
|
||||
attachments: [{
|
||||
filename: "855064e9-bdae-487a-a497-c662bd510487.png",
|
||||
url: "https://cdn.discordapp.com/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png",
|
||||
proxy_url: "https://media.discordapp.net/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png",
|
||||
content_type: "image/png",
|
||||
width: 5472,
|
||||
height: 3648,
|
||||
size: 14967919
|
||||
}]
|
||||
}, data.guild.general, {}, {
|
||||
async fetch(url, init) {
|
||||
t.equal(url, "https://media.discordapp.net/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png?format=jpeg")
|
||||
t.equal(init.method, "HEAD")
|
||||
return new Response("wa", {headers: {
|
||||
"Content-Type": "image/jpeg",
|
||||
"Content-Length": 483085
|
||||
}})
|
||||
}
|
||||
})
|
||||
t.deepEqual(events, [{
|
||||
$type: "m.room.message",
|
||||
"m.mentions": {},
|
||||
msgtype: "m.image",
|
||||
body: "855064e9-bdae-487a-a497-c662bd510487.jpg",
|
||||
info: {
|
||||
w: 5472,
|
||||
h: 3648,
|
||||
mimetype: "image/jpeg",
|
||||
size: 483085
|
||||
},
|
||||
external_url: "https://bridge.example.org/download/discordcdn/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png",
|
||||
filename: "855064e9-bdae-487a-a497-c662bd510487.jpg",
|
||||
url: "mxc://cadence.moe/zXNXRvJwRDMIzZZVqHUXtGeD"
|
||||
}])
|
||||
})
|
||||
|
||||
test("message2event: multiple attachments are combined into the same event where possible", async t => {
|
||||
const events = await messageToEvent({
|
||||
content: "hey",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// @ts-check
|
||||
|
||||
const assert = require("assert").strict
|
||||
const assert = require("assert")
|
||||
const {reg} = require("../../matrix/read-registration")
|
||||
const Ty = require("../../types")
|
||||
|
||||
|
|
@ -66,7 +66,7 @@ function userToSimName(user) {
|
|||
|
||||
// 1. Is sim user already registered?
|
||||
const existing = select("sim", "user_id", {user_id: user.id}).pluck().get()
|
||||
assert.equal(existing, undefined, "Shouldn't try to create a new name for an existing sim")
|
||||
assert.equal(existing, null, "Shouldn't try to create a new name for an existing sim")
|
||||
|
||||
// 2. Register based on username (could be new or old format)
|
||||
// (Unless it's a special user, in which case copy their provided mappings.)
|
||||
|
|
@ -113,7 +113,7 @@ function webhookAuthorToSimName(author) {
|
|||
// 1. Is sim user already registered?
|
||||
const fakeUserID = webhookAuthorToFakeUserID(author)
|
||||
const existing = select("sim", "user_id", {user_id: fakeUserID}).pluck().get()
|
||||
assert.equal(existing, undefined, "Shouldn't try to create a new name for an existing sim")
|
||||
assert.equal(existing, null, "Shouldn't try to create a new name for an existing sim")
|
||||
|
||||
// 2. Register based on username (could be new or old format)
|
||||
const downcased = "webhook_" + downcaseUsername(author)
|
||||
|
|
|
|||
|
|
@ -55,7 +55,9 @@ class DiscordClient {
|
|||
this.guildChannelMap = new Map()
|
||||
if (listen !== "no") {
|
||||
this.cloud.on("event", message => {
|
||||
discordPackets.onPacket(this, message, listen)
|
||||
process.nextTick(() => {
|
||||
discordPackets.onPacket(this, message, listen)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// @ts-check
|
||||
|
||||
const assert = require("assert").strict
|
||||
const assert = require("assert")
|
||||
const {scheduler} = require("timers/promises")
|
||||
const passthrough = require("../passthrough")
|
||||
const {sync} = passthrough
|
||||
|
|
@ -8,32 +8,7 @@ const {sync} = passthrough
|
|||
/** @type {import("../matrix/homeserver-status")} */
|
||||
const homeserverStatus = sync.require("../matrix/homeserver-status")
|
||||
|
||||
const guildReadyStatus = new class {
|
||||
/** @type {Set<string> | null} */
|
||||
unavailableGuilds = null
|
||||
_allReady = Promise.withResolvers()
|
||||
|
||||
/**
|
||||
* @param {string} guildID
|
||||
* @returns {boolean} true if it was the last one
|
||||
*/
|
||||
makeReady(guildID) {
|
||||
assert(this.unavailableGuilds)
|
||||
if (this.unavailableGuilds.delete(guildID) && this.unavailableGuilds.size === 0) {
|
||||
this._allReady.resolve(null)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
allReady() {
|
||||
return this.unavailableGuilds && this.unavailableGuilds.size === 0
|
||||
}
|
||||
|
||||
waitForAllReady() {
|
||||
return this._allReady.promise
|
||||
}
|
||||
}
|
||||
let checkedHomeserver = false
|
||||
|
||||
/**
|
||||
* @param {import("./discord-client")} client
|
||||
|
|
@ -53,31 +28,12 @@ async function onPacket(client, message, listen) {
|
|||
client.ready = true
|
||||
client.user = message.d.user
|
||||
client.application = message.d.application
|
||||
guildReadyStatus.unavailableGuilds = new Set(message.d.guilds.filter(g => g.unavailable).map(g => g.id))
|
||||
console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`)
|
||||
process.stdout.write("Waiting for guilds to warm up... ")
|
||||
interactions.registerInteractions()
|
||||
|
||||
} else if (message.t === "GUILD_CREATE") {
|
||||
message.d.members = message.d.members.filter(m => m.user.id === client.user.id) // only keep the bot account's member - it's needed for roles to determine private channels on web
|
||||
message.d.members = message.d.members.filter(m => m.user.id === client.user.id) // only keep the bot's own member - it's needed to determine private channels on web
|
||||
client.guilds.set(message.d.id, message.d)
|
||||
|
||||
/*
|
||||
Info about guilds is populated one guild at a time.
|
||||
For m->d bridging to work, the guild needs to be populated, so we need to have GUILD_CREATE for the guild.
|
||||
If we ping the homeserver, it will send us any pending events, so we need to wait for all GUILD_CREATES before we ping.
|
||||
We must attempt a ping because we don't want to try sending missed d->m messages to an offline homeserver.
|
||||
The "all guilds ready" delay can be removed if ONE of the following is done:
|
||||
1. m->d can queue incoming events until their guild exists in memory
|
||||
2. d->m missed messages can have their errors handled and added to queue, rather than pinging first
|
||||
*/
|
||||
const firstReady = guildReadyStatus.allReady()
|
||||
const lastGuildReady = guildReadyStatus.makeReady(message.d.id)
|
||||
|
||||
if (lastGuildReady) {
|
||||
console.log(`ok, ${client.guilds.size} available.`)
|
||||
}
|
||||
|
||||
const arr = []
|
||||
client.guildChannelMap.set(message.d.id, arr)
|
||||
for (const channel of message.d.channels || []) {
|
||||
|
|
@ -95,14 +51,28 @@ async function onPacket(client, message, listen) {
|
|||
|
||||
if (listen === "full") {
|
||||
try {
|
||||
// Wait for guilds to be connected and homeserver to be online. If this is the last guild, a different code path is used to trigger the homeserver check.
|
||||
if (lastGuildReady) {
|
||||
process.stdout.write(`Connecting to homeserver... `)
|
||||
// await guildReadyStatus.waitForAllReady() - no need, we already checked this is the last guild
|
||||
/*
|
||||
Info about guilds is populated one guild at a time.
|
||||
For m->d bridging to work, the guild needs to be populated, so we need to have GUILD_CREATE for the guild.
|
||||
If we ping the homeserver, it will send us any pending events, so we need to wait for all GUILD_CREATES before we ping.
|
||||
We must attempt a ping because we don't want to try sending missed d->m messages to an offline homeserver.
|
||||
This delay can be removed if ONE of the following is done:
|
||||
1. m->d can queue incoming events until their guild exists in memory
|
||||
2. d->m missed messages can have their errors handled and added to queue, rather than pinging first
|
||||
*/
|
||||
let isMainCharacter = false
|
||||
if (!checkedHomeserver) {
|
||||
checkedHomeserver = true
|
||||
isMainCharacter = true
|
||||
console.log("Warming up guilds~")
|
||||
}
|
||||
await scheduler.wait(5000)
|
||||
if (isMainCharacter) {
|
||||
checkedHomeserver = true
|
||||
process.stdout.write("Connecting to homeserver... ")
|
||||
await homeserverStatus.homeserverStatus.waitForOnline(true)
|
||||
console.log("ok.\nReplaying past events. Welcome to Out Of Your Element.")
|
||||
} else {
|
||||
await guildReadyStatus.waitForAllReady()
|
||||
await homeserverStatus.homeserverStatus.waitForOnline(false)
|
||||
}
|
||||
|
||||
|
|
@ -111,9 +81,7 @@ async function onPacket(client, message, listen) {
|
|||
await eventDispatcher.checkMissedPins(client, message.d)
|
||||
await eventDispatcher.checkMissedLeaves(client, message.d)
|
||||
} catch (e) {
|
||||
if (firstReady) {
|
||||
console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:")
|
||||
}
|
||||
console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:")
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
|
@ -257,4 +225,3 @@ async function dispatchPacketToBridge(client, message) {
|
|||
|
||||
module.exports.onPacket = onPacket
|
||||
module.exports.dispatchPacketToBridge = dispatchPacketToBridge
|
||||
module.exports.guildReadyStatus = guildReadyStatus
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
// @ts-check
|
||||
|
||||
const assert = require("assert").strict
|
||||
|
||||
/*
|
||||
https://docs.discord.com/developers/reference#message-formatting
|
||||
|
||||
Trying to make these formats fairly culture independent since the Matrix text will show up the same for everyone.
|
||||
That means no ambiguous short dates like 03/04/26. The shortest allowable date is "medium", which spells the month.
|
||||
It also means no 24-hour time, since this is ambiguous to 12-hour time users. Times must have am/pm.
|
||||
Relative time is not supported for text conversion and will fall back to the default, "f".
|
||||
|
||||
See the test file for sample outputs.
|
||||
*/
|
||||
|
||||
/** @type {Map<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
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
// @ts-check
|
||||
|
||||
const time = require("./time")
|
||||
const {test} = require("supertape")
|
||||
|
||||
const d = new Date("2026-02-14T14:23:06.000Z")
|
||||
const seconds = d.getTime() / 1000
|
||||
|
||||
test("time: default style", t => {
|
||||
t.equal(time.convertDiscordTime(seconds), "14 February 2026 at 2:23 pm (UTC)")
|
||||
})
|
||||
|
||||
test("time: t style", t => {
|
||||
t.equal(time.convertDiscordTime(seconds, "t"), "2:23 pm (UTC)")
|
||||
})
|
||||
|
||||
test("time: T style", t => {
|
||||
t.equal(time.convertDiscordTime(seconds, "T"), "2:23:06 pm (UTC)")
|
||||
})
|
||||
|
||||
test("time: d style", t => {
|
||||
t.equal(time.convertDiscordTime(seconds, "d"), "14 Feb 2026 (UTC)")
|
||||
})
|
||||
|
||||
test("time: D style", t => {
|
||||
t.equal(time.convertDiscordTime(seconds, "D"), "14 February 2026 (UTC)")
|
||||
})
|
||||
|
||||
test("time: f style", t => {
|
||||
t.equal(time.convertDiscordTime(seconds, "f"), "14 February 2026 at 2:23 pm (UTC)")
|
||||
})
|
||||
|
||||
test("time: F style", t => {
|
||||
t.equal(time.convertDiscordTime(seconds, "F"), "Saturday, 14 February 2026 at 2:23 pm (UTC)")
|
||||
})
|
||||
|
||||
test("time: s style", t => {
|
||||
t.equal(time.convertDiscordTime(seconds, "s"), "14 Feb 2026, 2:23 pm (UTC)")
|
||||
})
|
||||
|
||||
test("time: S style", t => {
|
||||
t.equal(time.convertDiscordTime(seconds, "S"), "14 Feb 2026, 2:23:06 pm (UTC)")
|
||||
})
|
||||
|
||||
test("time: R style", t => {
|
||||
// Discord would display this as relative time, but I don't want to do that in a static message.
|
||||
t.equal(time.convertDiscordTime(seconds, "R"), "14 February 2026 at 2:23 pm (UTC)")
|
||||
})
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
/**
|
||||
* This function is adapted from Evan Kaufman's fantastic work.
|
||||
* The original function and my adapted function are both MIT licensed.
|
||||
* @url https://github.com/EvanK/npm-loggable-error/
|
||||
* @param {number} [depth]
|
||||
* @returns {string}
|
||||
*/
|
||||
function stringifyErrorStack(err, depth = 0) {
|
||||
let collapsed = " ".repeat(depth);
|
||||
if (!(err instanceof Error)) {
|
||||
return collapsed + err
|
||||
}
|
||||
|
||||
// add full stack trace if one exists, otherwise convert to string
|
||||
let stackLines = String(err?.stack ?? err).replace(/^/gm, " ".repeat(depth)).trim().split("\n")
|
||||
let cloudstormLine = stackLines.findIndex(l => l.includes("/node_modules/cloudstorm/"))
|
||||
if (cloudstormLine !== -1) {
|
||||
stackLines = stackLines.slice(0, cloudstormLine - 2)
|
||||
}
|
||||
collapsed += stackLines.join("\n")
|
||||
|
||||
const props = Object.getOwnPropertyNames(err).filter(p => !["message", "stack"].includes(p))
|
||||
|
||||
// only break into object notation if we have additional props to dump
|
||||
if (props.length) {
|
||||
const dedent = " ".repeat(depth);
|
||||
const indent = " ".repeat(depth + 2);
|
||||
|
||||
collapsed += " {\n";
|
||||
|
||||
// loop and print each (indented) prop name
|
||||
for (let property of props) {
|
||||
collapsed += `${indent}[${property}]: `;
|
||||
|
||||
// if another error object, stringify it too
|
||||
if (err[property] instanceof Error) {
|
||||
collapsed += stringifyErrorStack(err[property], depth + 2).trimStart();
|
||||
}
|
||||
// otherwise stringify as JSON
|
||||
else {
|
||||
collapsed += JSON.stringify(err[property]);
|
||||
}
|
||||
|
||||
collapsed += "\n";
|
||||
}
|
||||
|
||||
collapsed += `${dedent}}\n`;
|
||||
}
|
||||
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
module.exports.stringifyErrorStack = stringifyErrorStack
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
// @ts-check
|
||||
|
||||
const {test} = require("supertape")
|
||||
const {stringifyErrorStack} = require("./errors")
|
||||
|
||||
test("stringify error stack: works", t => {
|
||||
function a() {
|
||||
const e = new Error("message", {cause: new Error("inner")})
|
||||
// @ts-ignore
|
||||
e.prop = 2.1
|
||||
throw e
|
||||
}
|
||||
try {
|
||||
a()
|
||||
t.fail("shouldn't get here")
|
||||
} catch (e) {
|
||||
const str = stringifyErrorStack(e)
|
||||
t.match(str, /^Error: message$/m)
|
||||
t.match(str, /^ at a \(.*errors\.test\.js/m)
|
||||
t.match(str, /^ \[cause\]: Error: inner$/m)
|
||||
t.match(str, /^ \[prop\]: 2.1$/m)
|
||||
}
|
||||
})
|
||||
|
|
@ -153,7 +153,7 @@ async function sendEvent(event) {
|
|||
channel_id: messageResponse.channel_id,
|
||||
guild_id: guild.id,
|
||||
embeds: messageResponse.embeds
|
||||
}, guild)
|
||||
}, guild, null)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,8 +32,6 @@ const setupEmojis = sync.require("../actions/setup-emojis")
|
|||
const userToMxid = sync.require("../../d2m/converters/user-to-mxid")
|
||||
/** @type {import("../../web/routes/letter-avatar")} */
|
||||
const letterAvatar = sync.require("../../web/routes/letter-avatar")
|
||||
/** @type {import("../../d2m/actions/retrigger")} */
|
||||
const retrigger = sync.require("../../d2m/actions/retrigger")
|
||||
|
||||
/** @type {[RegExp, string][]} */
|
||||
const markdownEscapes = [
|
||||
|
|
@ -620,7 +618,7 @@ async function eventToMessage(event, guild, channel, di) {
|
|||
let filename = event.content.filename || event.content.body
|
||||
if (!filename) {
|
||||
const extension = mimeTypes.extension(event.content.info?.mimetype)
|
||||
assert(extension, "Filename and mimetype missing. Please report this bug to your client.")
|
||||
if (!extension) throw new Error("Filename and mimetype missing. Please report this bug to your client.")
|
||||
filename = `file.${extension}`
|
||||
}
|
||||
|
||||
|
|
@ -698,7 +696,6 @@ async function eventToMessage(event, guild, channel, di) {
|
|||
// Check if we have a pointer to what was edited
|
||||
const originalEventId = relatesTo.event_id
|
||||
if (!originalEventId) return
|
||||
if (!await retrigger.waitForEvent(originalEventId)) return
|
||||
messageIDsToEdit = select("event_message", "message_id", {event_id: originalEventId}, "ORDER BY part").pluck().all()
|
||||
if (!messageIDsToEdit.length) return
|
||||
|
||||
|
|
@ -757,7 +754,7 @@ async function eventToMessage(event, guild, channel, di) {
|
|||
.replace(/(?:\n|<br>)+/g, " ") // Should all be on one line
|
||||
.replace(/<span [^>]*data-mx-spoiler\b[^>]*>.*?<\/span>/g, "[spoiler]") // Good enough method of removing spoiler content. (I don't want to break out the HTML parser unless I have to.)
|
||||
.replace(/<[^>]+>/g, "") // Completely strip all HTML tags and formatting.
|
||||
), 50)
|
||||
), 65)
|
||||
replyLine = "-# > " + contentPreviewChunks[0]
|
||||
if (contentPreviewChunks.length > 1) replyLine = replyLine.replace(/[,.']$/, "") + "..."
|
||||
replyLine += "\n"
|
||||
|
|
@ -765,11 +762,6 @@ async function eventToMessage(event, guild, channel, di) {
|
|||
}
|
||||
|
||||
replyLine = await getL1L2ReplyLine()
|
||||
const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
|
||||
.select("reference_channel_id", "message_id").where({event_id: repliedToEventId}).and("ORDER BY part").get()
|
||||
if (row) {
|
||||
replyLine += `https://discord.com/channels/${guild.id}/${row.reference_channel_id}/${row.message_id} `
|
||||
}
|
||||
// If the event has been edited, the homeserver will include the relation in `unsigned`.
|
||||
if (repliedToEvent.unsigned?.["m.relations"]?.["m.replace"]?.content?.["m.new_content"]) {
|
||||
repliedToEvent = repliedToEvent.unsigned["m.relations"]["m.replace"] // Note: this changes which event_id is in repliedToEvent.
|
||||
|
|
@ -780,15 +772,15 @@ async function eventToMessage(event, guild, channel, di) {
|
|||
const fileReplyContentAlternative = attachmentEmojis.get(repliedToEvent.content.msgtype)
|
||||
let contentPreview
|
||||
if (fileReplyContentAlternative) {
|
||||
contentPreview = " " + fileReplyContentAlternative
|
||||
contentPreview = fileReplyContentAlternative
|
||||
} else if (repliedToEvent.unsigned?.redacted_because) {
|
||||
contentPreview = " (in reply to a deleted message)"
|
||||
contentPreview = "(in reply to a deleted message)"
|
||||
} else if (typeof originalRepliedToContent !== "string") {
|
||||
// in reply to a weird metadata event like m.room.name, m.room.member...
|
||||
// I'm not implementing text fallbacks for arbitrary room events. this should cover most cases
|
||||
// this has never ever happened in the wild anyway
|
||||
repliedToEvent.sender = ""
|
||||
contentPreview = " (channel details edited)"
|
||||
contentPreview = "(channel details edited)"
|
||||
} else {
|
||||
// Generate a reply preview for a standard message
|
||||
let repliedToContent = originalRepliedToContent
|
||||
|
|
@ -805,7 +797,7 @@ async function eventToMessage(event, guild, channel, di) {
|
|||
repliedToContent = entities.decodeHTML5Strict(repliedToContent) // Remove entities like & "
|
||||
const contentPreviewChunks = chunk(repliedToContent, 50)
|
||||
if (contentPreviewChunks.length) {
|
||||
contentPreview = ": " + contentPreviewChunks[0]
|
||||
contentPreview = contentPreviewChunks[0]
|
||||
contentPreview = contentPreview.replace(/\bhttps?:\/\/[^ )]*/g, url => {
|
||||
const originalUrlIndex = originalRepliedToContent.indexOf(url)
|
||||
if (originalUrlIndex !== -1 && originalRepliedToContent[originalUrlIndex + url.length]?.match(/[^ )>"'`]/)) { // URL was truncated by chunking, replace it
|
||||
|
|
@ -832,7 +824,12 @@ async function eventToMessage(event, guild, channel, di) {
|
|||
if (!senderName) senderName = sender.match(/@([^:]*)/)?.[1]
|
||||
if (senderName) replyLine += `**Ⓜ${senderName}**`
|
||||
}
|
||||
replyLine = `-# > ${replyLine}${contentPreview}\n`
|
||||
const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
|
||||
.select("reference_channel_id", "message_id").where({event_id: repliedToEventId}).and("ORDER BY part").get()
|
||||
if (row) {
|
||||
contentPreview = `[${contentPreview}](<https://discord.com/channels/${guild.id}/${row.reference_channel_id}/${row.message_id}>)`
|
||||
}
|
||||
replyLine = `-# > ${replyLine} ${contentPreview}\n`
|
||||
})()
|
||||
|
||||
if (shouldProcessTextEvent) {
|
||||
|
|
|
|||
|
|
@ -4816,39 +4816,6 @@ test("event2message: image attachments can have a formatted caption", async t =>
|
|||
)
|
||||
})
|
||||
|
||||
test("event2message: attachments have a generated filename if it was missing", async t => {
|
||||
t.deepEqual(
|
||||
await eventToMessage({
|
||||
type: "m.room.message",
|
||||
sender: "@cadence:cadence.moe",
|
||||
content: {
|
||||
body: "",
|
||||
filename: "",
|
||||
info: {
|
||||
mimetype: "image/png",
|
||||
size: 50832
|
||||
},
|
||||
msgtype: "m.image",
|
||||
url: "mxc://agiadn.org/3SvzETkab3XBDM6HCMJxpOryIDxMO31j"
|
||||
},
|
||||
event_id: "$CXQy3Wmg1A-gL_xAesC1HQcQTEXwICLdSwwUx55FBTI",
|
||||
room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe"
|
||||
}),
|
||||
{
|
||||
ensureJoined: [],
|
||||
messagesToDelete: [],
|
||||
messagesToEdit: [],
|
||||
messagesToSend: [{
|
||||
username: "cadence [they]",
|
||||
content: "",
|
||||
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar",
|
||||
attachments: [{id: "0", filename: "file.png"}],
|
||||
pendingFiles: [{name: "file.png", mxc: "mxc://agiadn.org/3SvzETkab3XBDM6HCMJxpOryIDxMO31j"}]
|
||||
}]
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test("event2message: encrypted image attachments work", async t => {
|
||||
t.deepEqual(
|
||||
await eventToMessage({
|
||||
|
|
|
|||
|
|
@ -38,12 +38,62 @@ const retrigger = sync.require("../d2m/actions/retrigger")
|
|||
const homeserverStatus = sync.require("../matrix/homeserver-status")
|
||||
/** @type {import("./actions/typing")} */
|
||||
const typing = sync.require("./actions/typing")
|
||||
/** @type {import("../js/errors")} */
|
||||
const errors = sync.require("../js/errors")
|
||||
const {reg} = require("../matrix/read-registration")
|
||||
|
||||
let lastReportedEvent = 0
|
||||
|
||||
/**
|
||||
* This function is adapted from Evan Kaufman's fantastic work.
|
||||
* The original function and my adapted function are both MIT licensed.
|
||||
* @url https://github.com/EvanK/npm-loggable-error/
|
||||
* @param {number} [depth]
|
||||
* @returns {string}
|
||||
*/
|
||||
function stringifyErrorStack(err, depth = 0) {
|
||||
let collapsed = " ".repeat(depth);
|
||||
if (!(err instanceof Error)) {
|
||||
return collapsed + err
|
||||
}
|
||||
|
||||
// add full stack trace if one exists, otherwise convert to string
|
||||
let stackLines = String(err?.stack ?? err).replace(/^/gm, " ".repeat(depth)).trim().split("\n")
|
||||
let cloudstormLine = stackLines.findIndex(l => l.includes("/node_modules/cloudstorm/"))
|
||||
if (cloudstormLine !== -1) {
|
||||
stackLines = stackLines.slice(0, cloudstormLine - 2)
|
||||
}
|
||||
collapsed += stackLines.join("\n")
|
||||
|
||||
const props = Object.getOwnPropertyNames(err).filter(p => !["message", "stack"].includes(p))
|
||||
|
||||
// only break into object notation if we have additional props to dump
|
||||
if (props.length) {
|
||||
const dedent = " ".repeat(depth);
|
||||
const indent = " ".repeat(depth + 2);
|
||||
|
||||
collapsed += " {\n";
|
||||
|
||||
// loop and print each (indented) prop name
|
||||
for (let property of props) {
|
||||
collapsed += `${indent}[${property}]: `;
|
||||
|
||||
// if another error object, stringify it too
|
||||
if (err[property] instanceof Error) {
|
||||
collapsed += stringifyErrorStack(err[property], depth + 2).trimStart();
|
||||
}
|
||||
// otherwise stringify as JSON
|
||||
else {
|
||||
collapsed += JSON.stringify(err[property]);
|
||||
}
|
||||
|
||||
collapsed += "\n";
|
||||
}
|
||||
|
||||
collapsed += `${dedent}}\n`;
|
||||
}
|
||||
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
function printError(type, source, e, payload) {
|
||||
console.error(`Error while processing a ${type} ${source} event:`)
|
||||
console.error(e)
|
||||
|
|
@ -95,7 +145,7 @@ async function sendError(roomID, source, type, e, payload) {
|
|||
builder.addLine(errorIntroLine)
|
||||
|
||||
// Where
|
||||
const stack = cleanErrorStack(errors.stringifyErrorStack(e))
|
||||
const stack = cleanErrorStack(stringifyErrorStack(e))
|
||||
builder.addLine(`Error trace:\n${stack}`, tag`<details><summary>Error trace</summary><pre>${stack}</pre></details>`)
|
||||
|
||||
// How
|
||||
|
|
@ -493,6 +543,7 @@ async event => {
|
|||
}
|
||||
}))
|
||||
|
||||
module.exports.stringifyErrorStack = stringifyErrorStack
|
||||
module.exports.cleanErrorStack = cleanErrorStack
|
||||
module.exports.sendError = sendError
|
||||
module.exports.printError = printError
|
||||
|
|
|
|||
|
|
@ -1,7 +1,26 @@
|
|||
// @ts-check
|
||||
|
||||
const {test} = require("supertape")
|
||||
const {cleanErrorStack} = require("./event-dispatcher")
|
||||
const {stringifyErrorStack, cleanErrorStack} = require("./event-dispatcher")
|
||||
|
||||
test("stringify error stack: works", t => {
|
||||
function a() {
|
||||
const e = new Error("message", {cause: new Error("inner")})
|
||||
// @ts-ignore
|
||||
e.prop = 2.1
|
||||
throw e
|
||||
}
|
||||
try {
|
||||
a()
|
||||
t.fail("shouldn't get here")
|
||||
} catch (e) {
|
||||
const str = stringifyErrorStack(e)
|
||||
t.match(str, /^Error: message$/m)
|
||||
t.match(str, /^ at a \(.*event-dispatcher\.test\.js/m)
|
||||
t.match(str, /^ \[cause\]: Error: inner$/m)
|
||||
t.match(str, /^ \[prop\]: 2.1$/m)
|
||||
}
|
||||
})
|
||||
|
||||
test("clean error stack: removes webhook token", t => {
|
||||
t.notMatch(
|
||||
|
|
|
|||
|
|
@ -15,15 +15,10 @@ const IMAGE_SIZE = 1024
|
|||
const inflight = new Map()
|
||||
|
||||
/**
|
||||
* @param {string} urlString
|
||||
* @param {string} url
|
||||
*/
|
||||
function _removeExpiryParams(urlString) {
|
||||
const url = new URL(urlString)
|
||||
url.searchParams.delete("ex")
|
||||
url.searchParams.delete("is")
|
||||
url.searchParams.delete("sg")
|
||||
url.searchParams.delete("hm")
|
||||
return url.toString()
|
||||
function _removeExpiryParams(url) {
|
||||
return url.replace(/\?(?:(?:ex|is|sg|hm)=[a-f0-9]+&?)*$/, "")
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -20,9 +20,3 @@ test("removeExpiryParams: rearranged params are removed", t => {
|
|||
const result = file._removeExpiryParams(url)
|
||||
t.equal(result, "https://cdn.discordapp.com/attachments/112760669178241024/1157363960518029322/image.png")
|
||||
})
|
||||
|
||||
test("removeExpiryParams: works on media proxy and keeps quality", t => {
|
||||
const url = "https://media.discordapp.net/attachments/1160894080998461480/1548665030898094240/855064e9-bdae-487a-a497-c662bd510487.png?ex=6aa7e234&is=6aa690b4&hm=7c153efcdd1feb1d36d0bb99955a2bfe12f26abea377fed02980521766f51620&format=jpeg"
|
||||
const result = file._removeExpiryParams(url)
|
||||
t.equal(result, "https://media.discordapp.net/attachments/1160894080998461480/1548665030898094240/855064e9-bdae-487a-a497-c662bd510487.png?format=jpeg")
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// @ts-check
|
||||
|
||||
const assert = require("assert").strict
|
||||
const assert = require("assert")
|
||||
const Denque = require("denque")
|
||||
const StateMachine = require("snowtransfer").StateMachine
|
||||
|
||||
|
|
@ -27,21 +27,13 @@ const homeserverStatus = new class HomeserverStatus {
|
|||
/** @private */
|
||||
this.pingInterval = undefined
|
||||
|
||||
/** @private */
|
||||
this.checkingWhileOnline = true
|
||||
|
||||
/** @private */
|
||||
this.lastCheck = 0
|
||||
|
||||
/** @private */
|
||||
this.sm = new StateMachine("online")
|
||||
.defineState("online")
|
||||
|
||||
.defineState("checking", {
|
||||
onEnter: [async (event) => {
|
||||
this.checkingWhileOnline = event === "check while online"
|
||||
onEnter: [async () => {
|
||||
const pingResult = await api.ping().catch(e => ({ok: false}))
|
||||
this.lastCheck = Date.now()
|
||||
if (pingResult.ok) {
|
||||
this.sm.doTransition("check ok")
|
||||
} else {
|
||||
|
|
@ -56,7 +48,6 @@ const homeserverStatus = new class HomeserverStatus {
|
|||
onEnter: [() => {
|
||||
this.pingInterval = setInterval(async () => {
|
||||
const pingResult = await api.ping().catch(e => ({ok: false, status: "net", root: e.message}))
|
||||
this.lastCheck = Date.now()
|
||||
if (pingResult.ok) {
|
||||
this.sm.doTransition("ping ok")
|
||||
}
|
||||
|
|
@ -86,8 +77,7 @@ const homeserverStatus = new class HomeserverStatus {
|
|||
.defineUniversalTransition("error", "offline")
|
||||
.defineTransition("offline", "ping ok", "recovering")
|
||||
.defineTransition("recovering", "recovered", "online")
|
||||
.defineTransition("online", "check assume online", "checking")
|
||||
.defineTransition("online", "check assume offline", "checking")
|
||||
.defineTransition("online", "check", "checking")
|
||||
.defineTransition("checking", "check ok", "recovering")
|
||||
.defineTransition("checking", "check fail", "offline")
|
||||
|
||||
|
|
@ -97,7 +87,7 @@ const homeserverStatus = new class HomeserverStatus {
|
|||
}
|
||||
|
||||
isRealTime() {
|
||||
return this.sm.currentStateName === "online" || (this.sm.currentStateName === "checking" && this.checkingWhileOnline)
|
||||
return this.sm.currentStateName === "online"
|
||||
}
|
||||
|
||||
/** @param {boolean} forceCheck */
|
||||
|
|
@ -106,9 +96,7 @@ const homeserverStatus = new class HomeserverStatus {
|
|||
// Already online? Start check or just done
|
||||
if (this.sm.currentStateName === "online") {
|
||||
if (forceCheck) {
|
||||
this.sm.doTransition("check assume offline")
|
||||
} else if (this.lastCheck < Date.now() - 15e3) {
|
||||
this.sm.doTransition("check assume online")
|
||||
this.sm.doTransition("check")
|
||||
} else {
|
||||
return resolve(null)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@
|
|||
const assert = require("assert").strict
|
||||
const DiscordTypes = require("discord-api-types/v10")
|
||||
const Ty = require("../types")
|
||||
const {pipeline} = require("stream").promises
|
||||
const sharp = require("sharp")
|
||||
const tw = require("@cloudrac3r/tell-me-when")
|
||||
const {tag} = require("@cloudrac3r/html-template-tag")
|
||||
|
||||
const {discord, sync, db, select} = require("../passthrough")
|
||||
/** @type {import("./api")}) */
|
||||
|
|
@ -14,9 +13,8 @@ const api = sync.require("./api")
|
|||
const mxUtils = sync.require("./utils")
|
||||
/** @type {import("../discord/utils")} */
|
||||
const dUtils = sync.require("../discord/utils")
|
||||
/** @type {import("../d2m/actions/retrigger")} */
|
||||
const retrigger = sync.require("../d2m/actions/retrigger")
|
||||
|
||||
/** @type {import("./kstate")} */
|
||||
const ks = sync.require("./kstate")
|
||||
const {reg} = require("./read-registration")
|
||||
|
||||
const PREFIXES = ["//", "/"]
|
||||
|
|
@ -35,17 +33,7 @@ function getSlotCount(tier) {
|
|||
return TIER_EMOJI_SLOTS.get(tier) || 50
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef Button
|
||||
* @property {string} roomID
|
||||
* @property {string} eventID
|
||||
* @property {string} mxid
|
||||
* @property {string} key
|
||||
* @property {(any) => any} resolve
|
||||
* @property {number} created
|
||||
*/
|
||||
|
||||
const buttonStore = sync.remember(() => /** @type {{buttons: Button[]}} */ ({buttons: []}))
|
||||
let buttons = []
|
||||
|
||||
/**
|
||||
* @param {string} roomID where to add the button
|
||||
|
|
@ -63,23 +51,22 @@ async function addButton(roomID, eventID, key, mxid) {
|
|||
}
|
||||
})
|
||||
return new Promise(resolve => {
|
||||
buttonStore.buttons.push({roomID, eventID, mxid, key, resolve, created: Date.now()})
|
||||
buttons.push({roomID, eventID, mxid, key, resolve, created: Date.now()})
|
||||
})
|
||||
}
|
||||
|
||||
// Clear out old buttons every so often to free memory
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
buttonStore.buttons = buttonStore.buttons.filter(b => now - b.created < 2*60*60*1000)
|
||||
buttons = buttons.filter(b => now - b.created < 2*60*60*1000)
|
||||
}, 10*60*1000).unref()
|
||||
|
||||
/** @param {Ty.Event.Outer<Ty.Event.M_Reaction>} event */
|
||||
function onReactionAdd(event) {
|
||||
const button = buttonStore.buttons.find(b => b.roomID === event.room_id && b.mxid === event.sender && b.eventID === event.content["m.relates_to"]?.event_id && b.key === event.content["m.relates_to"]?.key)
|
||||
const button = buttons.find(b => b.roomID === event.room_id && b.mxid === event.sender && b.eventID === event.content["m.relates_to"]?.event_id && b.key === event.content["m.relates_to"]?.key)
|
||||
if (button) {
|
||||
buttonStore.buttons = buttonStore.buttons.filter(b => b !== button) // remove button data so it can't be clicked again
|
||||
buttons = buttons.filter(b => b !== button) // remove button data so it can't be clicked again
|
||||
button.resolve(event)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -331,114 +318,6 @@ const commands = [{
|
|||
})
|
||||
}
|
||||
)
|
||||
}, {
|
||||
aliases: ["time"],
|
||||
execute: replyctx(
|
||||
async (event, realBody, words, ctx) => {
|
||||
// Guard
|
||||
/** @type {string} */ // @ts-ignore
|
||||
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
|
||||
const channel = discord.channels.get(channelID)
|
||||
const guildID = channel?.["guild_id"]
|
||||
if (!guildID) {
|
||||
return api.sendEvent(event.room_id, "m.room.message", {
|
||||
...ctx,
|
||||
msgtype: "m.text",
|
||||
body: "This room isn't bridged to the other side."
|
||||
})
|
||||
}
|
||||
|
||||
// Get user timezone
|
||||
const profile = await api.getProfile(event.sender)
|
||||
const senderTimeZone = profile?.["m.tz"]
|
||||
if (!senderTimeZone) {
|
||||
return api.sendEvent(event.room_id, "m.room.message", {
|
||||
...ctx,
|
||||
msgtype: "m.text",
|
||||
body: "This command converts a time in your local time zone for everybody else. But I don't know your time zone. Please set your time zone (hopefully possible in your client's settings/account menu) and try again."
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
var now = Temporal.Now.zonedDateTimeISO(senderTimeZone)
|
||||
} catch (e) {
|
||||
return api.sendEvent(event.room_id, "m.room.message", {
|
||||
...ctx,
|
||||
...new mxUtils.MatrixStringBuilder().add(
|
||||
`The time zone on your account, "${senderTimeZone}", is not valid. It needs to be an IANA time zone value, with a slash. See yours here: https://time.is/your_time_zone`,
|
||||
tag`The time zone on your account, <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(() => {})
|
||||
})
|
||||
}
|
||||
)
|
||||
}]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ const {addbot} = require("../addbot")
|
|||
const passthrough = require("./passthrough")
|
||||
const {discord, sync, db, select, from, as} = passthrough
|
||||
|
||||
const reg = require("./matrix/read-registration")
|
||||
const data = sync.require("../test/data")
|
||||
const createSpace = sync.require("./d2m/actions/create-space")
|
||||
const createRoom = sync.require("./d2m/actions/create-room")
|
||||
|
|
|
|||
|
|
@ -2,24 +2,20 @@
|
|||
|
||||
const assert = require("assert/strict")
|
||||
const fs = require("fs")
|
||||
const util = require("util")
|
||||
const {join} = require("path")
|
||||
const getRelativePath = require("get-relative-path")
|
||||
const h3 = require("h3")
|
||||
const {defineEventHandler, defaultContentType, setResponseStatus, getQuery, getRequestURL} = h3
|
||||
const {defineEventHandler, defaultContentType, setResponseStatus, getQuery} = h3
|
||||
const {compileFile} = require("@cloudrac3r/pug")
|
||||
const walkAST = require("pug-walk")
|
||||
const pretty = process.argv.join(" ").includes("test")
|
||||
|
||||
const {sync} = require("../passthrough")
|
||||
/** @type {import("./auth")} */
|
||||
const auth = sync.require("./auth")
|
||||
/** @type {import("../js/errors")} */
|
||||
const errors = sync.require("../js/errors")
|
||||
|
||||
// Pug
|
||||
|
||||
let globals = sync.remember(() => ({}))
|
||||
let globals = {}
|
||||
|
||||
/** @type {Map<string, (event: import("h3").H3Event, locals: Record<string, any>) => Promise<string>>} */
|
||||
const pugCache = new Map()
|
||||
|
|
@ -34,60 +30,21 @@ function addGlobals(obj) {
|
|||
* @param {Record<string, any>} locals
|
||||
*/
|
||||
function render(event, filename, locals) {
|
||||
return renderFragments(event, filename, null, locals)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("h3").H3Event} event
|
||||
* @param {string} filename
|
||||
* @param {string[] | null} fragments
|
||||
* @param {Record<string, any>} locals
|
||||
*/
|
||||
function renderFragments(event, filename, fragments, locals) {
|
||||
const path = join(__dirname, "pug", filename)
|
||||
return renderPath(event, path, fragments, locals)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[] | null} fragments
|
||||
* @param {string[]} requiresOut
|
||||
*/
|
||||
function fragmentSelectorPlugins(fragments, requiresOut) {
|
||||
if (!fragments) return []
|
||||
return [{
|
||||
parse: true,
|
||||
postParse(ast, options) {
|
||||
const found = []
|
||||
walkAST(ast, (node, replace) => {
|
||||
if (node.type === "NamedBlock") {
|
||||
const [name, requires] = node.name.split("requires").map(x => x.trim())
|
||||
// console.log(name, "|", requires)
|
||||
if (fragments?.includes(name)) {
|
||||
if (requires) requiresOut.push(...requires.split(",").map(x => x.trim()))
|
||||
found.push(node)
|
||||
}
|
||||
}
|
||||
})
|
||||
// console.log(util.inspect(found, false, 9, true))
|
||||
return {type: "Block", nodes: found, line: 0, filename: found[0].filename}
|
||||
}
|
||||
}]
|
||||
return renderPath(event, path, locals)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("h3").H3Event} event
|
||||
* @param {string} path
|
||||
* @param {string[] | null} fragments
|
||||
* @param {Record<string, any>} locals
|
||||
*/
|
||||
function renderPath(event, path, fragments, locals) {
|
||||
const cacheKey = path + (fragments ? fragments.map(f => `#${f}`).join("") : "")
|
||||
|
||||
function renderPath(event, path, locals) {
|
||||
function compile() {
|
||||
try {
|
||||
const requiredVars = []
|
||||
const template = compileFile(path, {pretty, plugins: fragmentSelectorPlugins(fragments, requiredVars)})
|
||||
pugCache.set(cacheKey, async (event, locals) => {
|
||||
const template = compileFile(path, {pretty})
|
||||
pugCache.set(path, async (event, locals) => {
|
||||
defaultContentType(event, "text/html; charset=utf-8")
|
||||
const session = await auth.useSession(event)
|
||||
const managed = await auth.getManagedGuilds(event)
|
||||
const rel = (to, paramsObject) => {
|
||||
|
|
@ -98,36 +55,16 @@ function renderPath(event, path, fragments, locals) {
|
|||
}
|
||||
return result
|
||||
}
|
||||
const templateVars = Object.assign({},
|
||||
return template(Object.assign({},
|
||||
getQuery(event), // Query parameters can be easily accessed on the top level but don't allow them to overwrite anything
|
||||
globals, // Globals
|
||||
locals, // Explicit locals overwrite globals in case we need to DI something
|
||||
{session, event, rel, managed} // These are assigned last so they overwrite everything else. It would be catastrophically bad if they can't be trusted.
|
||||
)
|
||||
// Verify all fragment block required variables are present
|
||||
for (const key of requiredVars) {
|
||||
if (templateVars[key] === undefined) {
|
||||
setResponseStatus(event, 500, "Internal Template Variables Error")
|
||||
defaultContentType(event, "text/plain")
|
||||
return `requested fragments ${JSON.stringify(fragments)}\nrequire variables ${JSON.stringify(requiredVars)}\nbut "${key}" is ${templateVars[key]} (present: ${key in templateVars})`
|
||||
}
|
||||
}
|
||||
try {
|
||||
const content = template(templateVars)
|
||||
defaultContentType(event, "text/html; charset=utf-8")
|
||||
return content
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setResponseStatus(event, 500, "Internal Template Rendering Error")
|
||||
defaultContentType(event, "text/plain")
|
||||
e.url = getRequestURL(event).toString()
|
||||
return errors.stringifyErrorStack(e)
|
||||
}
|
||||
))
|
||||
})
|
||||
/* c8 ignore start */
|
||||
} catch (e) {
|
||||
pugCache.set(cacheKey, async (event) => {
|
||||
console.error(e)
|
||||
pugCache.set(path, async (event) => {
|
||||
setResponseStatus(event, 500, "Internal Template Error")
|
||||
defaultContentType(event, "text/plain")
|
||||
return e.toString()
|
||||
|
|
@ -136,16 +73,14 @@ function renderPath(event, path, fragments, locals) {
|
|||
/* c8 ignore stop */
|
||||
}
|
||||
|
||||
if (!pugCache.has(cacheKey)) {
|
||||
if (!pugCache.has(path)) {
|
||||
compile()
|
||||
const ac = new AbortController()
|
||||
const {signal} = ac
|
||||
fs.watch(path, {persistent: false, signal}, compile)
|
||||
fs.watch(join(__dirname, "pug", "includes"), {persistent: false, signal}, compile)
|
||||
sync.events.once(__filename, () => ac.abort())
|
||||
fs.watch(path, {persistent: false}, compile)
|
||||
fs.watch(join(__dirname, "pug", "includes"), {persistent: false}, compile)
|
||||
fs.watch(join(__dirname, "pug", "fragments"), {persistent: false}, compile)
|
||||
}
|
||||
|
||||
const cb = pugCache.get(cacheKey)
|
||||
const cb = pugCache.get(path)
|
||||
assert(cb)
|
||||
return cb(event, locals)
|
||||
}
|
||||
|
|
@ -163,6 +98,5 @@ function createRoute(router, url, filename) {
|
|||
|
||||
module.exports.addGlobals = addGlobals
|
||||
module.exports.render = render
|
||||
module.exports.renderFragments = renderFragments
|
||||
module.exports.renderPath = renderPath
|
||||
module.exports.createRoute = createRoute
|
||||
|
|
|
|||
5
src/web/pug/fragments/default-roles-list.pug
Normal file
5
src/web/pug/fragments/default-roles-list.pug
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
//- locals: guild, guild_id
|
||||
|
||||
include ../includes/default-roles-list.pug
|
||||
+default-roles-list(guild, guild_id)
|
||||
+add-roles-menu(guild, guild_id)
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
extends includes/template.pug
|
||||
include includes/default-roles-list.pug
|
||||
|
||||
mixin badge-readonly
|
||||
.s-badge.s-badge__xs.s-badge__icon.s-badge__muted
|
||||
|
|
@ -112,33 +113,15 @@ block body
|
|||
.d-flex.fw-wrap.g4
|
||||
.s-tag.s-tag__md.fs-body1.s-tag__required @everyone
|
||||
|
||||
block default-roles-list requires guild, guild_id
|
||||
#default-roles-list(style="display: contents")
|
||||
each roleID in select("role_default", "role_id", {guild_id}).pluck().all()
|
||||
- let r = guild.roles.find(r => r.id === roleID)
|
||||
if r
|
||||
.s-tag.s-tag__md.fs-body1= r.name
|
||||
span(id=`role-loading-${roleID}`)
|
||||
button(name="remove_role" value=roleID hx-post="api/default-roles" hx-trigger="click consume" hx-indicator=`#role-loading-${roleID}`).s-tag--dismiss
|
||||
!= icons.Icons.IconClearSm
|
||||
+default-roles-list(guild, guild_id)
|
||||
|
||||
button(type="button" popovertarget="role-add").s-btn__dropdown.s-tag.s-tag__md.fs-body1.p0
|
||||
.s-tag--dismiss.m1
|
||||
!= icons.Icons.IconPlusSm
|
||||
|
||||
#role-add.s-popover(popover style="display: revert").ws2.px0.py4.bs-lg.overflow-visible.box-border.lh-sm
|
||||
#role-add.s-popover(popover style="display: revert").ws2.px0.py4.bs-lg.overflow-visible
|
||||
.s-popover--arrow.s-popover--arrow__tc
|
||||
|
||||
block add-roles-menu requires guild, guild_id
|
||||
ul.s-menu(role="menu" hx-swap-oob="true").overflow-y-auto.overflow-x-hidden#add-roles-menu
|
||||
li.s-menu--title.d-flex(role="separator") Select role
|
||||
span#add-role-loading
|
||||
each r in guild.roles.sort((a, b) => b.position - a.position)
|
||||
if r.id !== guild_id && !r.managed
|
||||
- let selected = !!select("role_default", "role_id", {guild_id, role_id: r.id}).get()
|
||||
li(role="menuitem")
|
||||
button(name="toggle_role" value=r.id class={"is-selected": selected}).s-block-link.s-block-link__left= r.name
|
||||
|
||||
+add-roles-menu(guild, guild_id)
|
||||
p.fc-light.mb0.mt8 Matrix users will start with these roles. If your main channels are gated by a role, use this to let Matrix users skip the gate.
|
||||
|
||||
h3.mt32.fs-category Features
|
||||
|
|
|
|||
19
src/web/pug/includes/default-roles-list.pug
Normal file
19
src/web/pug/includes/default-roles-list.pug
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
mixin default-roles-list(guild, guild_id)
|
||||
#default-roles-list(style="display: contents")
|
||||
each roleID in select("role_default", "role_id", {guild_id}).pluck().all()
|
||||
- let r = guild.roles.find(r => r.id === roleID)
|
||||
if r
|
||||
.s-tag.s-tag__md.fs-body1= r.name
|
||||
span(id=`role-loading-${roleID}`)
|
||||
button(name="remove_role" value=roleID hx-post="api/default-roles" hx-trigger="click consume" hx-indicator=`#role-loading-${roleID}`).s-tag--dismiss
|
||||
!= icons.Icons.IconClearSm
|
||||
|
||||
mixin add-roles-menu(guild, guild_id)
|
||||
ul.s-menu(role="menu" hx-swap-oob="true").overflow-y-auto.overflow-x-hidden#add-roles-menu
|
||||
li.s-menu--title.d-flex(role="separator") Select role
|
||||
span#add-role-loading
|
||||
each r in guild.roles.sort((a, b) => b.position - a.position)
|
||||
if r.id !== guild_id && !r.managed
|
||||
- let selected = !!select("role_default", "role_id", {guild_id, role_id: r.id}).get()
|
||||
li(role="menuitem")
|
||||
button(name="toggle_role" value=r.id class={"is-selected": selected}).s-block-link.s-block-link__left= r.name
|
||||
|
|
@ -17,10 +17,10 @@ block body
|
|||
.fl-grow1
|
||||
h2.fs-headline1 Invite a Matrix user
|
||||
|
||||
form.d-flex.gy16.wmx3.fd-column(method="post" action=rel("/api/invite") hx-post=rel("/api/invite") hx-indicator="#invite-button" hx-select="#ok" hx-target="#form-container")
|
||||
form.d-flex.gy16.fd-column(method="post" action=rel("/api/invite") hx-post=rel("/api/invite") hx-indicator="#invite-button" hx-select="#ok" hx-target="#form-container")
|
||||
.d-flex.gy4.fd-column
|
||||
label.s-label(for="mxid") Matrix ID
|
||||
input.fl-grow1.s-input#mxid(name="mxid" required placeholder="@user:example.org")
|
||||
input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org")
|
||||
.d-flex.gy4.fd-column
|
||||
label.s-label(for="permissions") Permissions
|
||||
.s-select
|
||||
|
|
|
|||
|
|
@ -1,143 +0,0 @@
|
|||
//- 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)
|
||||
|
|
@ -135,7 +135,7 @@ as.router.post("/api/default-roles", defineEventHandler(async event => {
|
|||
await createSpace.syncSpaceFully(guildID) // this is inefficient but OK to call infrequently on user request
|
||||
|
||||
if (getRequestHeader(event, "HX-Request")) {
|
||||
return pugSync.renderFragments(event, "guild.pug", ["default-roles-list", "add-roles-menu"], {guild, guild_id: guildID})
|
||||
return pugSync.render(event, "fragments/default-roles-list.pug", {guild, guild_id: guildID})
|
||||
} else {
|
||||
return sendRedirect(event, `/guild?guild_id=${guildID}`, 302)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
// @ts-check
|
||||
|
||||
const {defineEventHandler, getValidatedRouterParams, H3Event, setResponseHeader} = require("h3")
|
||||
const {as, db, sync} = require("../../passthrough")
|
||||
const {z} = require("zod")
|
||||
|
||||
/** @type {import("../pug-sync")} */
|
||||
const pugSync = sync.require("../pug-sync")
|
||||
/** @type {import("../../discord/time")} */
|
||||
const discordTime = sync.require("../../discord/time")
|
||||
|
||||
const schema = {
|
||||
time: z.object({
|
||||
timestamp: z.coerce.number(),
|
||||
style: z.string().optional()
|
||||
})
|
||||
}
|
||||
|
||||
as.router.get("/time/:timestamp", defineEventHandler(async event => {
|
||||
const {timestamp} = await getValidatedRouterParams(event, schema.time.parse)
|
||||
const formatOptions = discordTime.getFormatOptions()
|
||||
const text = discordTime.convertDiscordTime(timestamp)
|
||||
return pugSync.render(event, "time.pug", {text, timestamp, formatOptions})
|
||||
}))
|
||||
|
||||
as.router.get("/time/:timestamp/:style", defineEventHandler(async event => {
|
||||
const {timestamp, style} = await getValidatedRouterParams(event, schema.time.parse)
|
||||
const formatOptions = discordTime.getFormatOptions(style)
|
||||
const text = discordTime.convertDiscordTime(timestamp, style)
|
||||
return pugSync.render(event, "time.pug", {text, timestamp, style, formatOptions})
|
||||
}))
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// @ts-check
|
||||
|
||||
const assert = require("assert").strict
|
||||
const assert = require("assert")
|
||||
const fs = require("fs")
|
||||
const {join} = require("path")
|
||||
const h3 = require("h3")
|
||||
|
|
@ -98,7 +98,7 @@ function tryStatic(event, fallthrough) {
|
|||
getContents: id => {
|
||||
if (id.match(/\.pug$/)) {
|
||||
const path = join(publicDir, id)
|
||||
return pugSync.renderPath(event, path, null, {})
|
||||
return pugSync.renderPath(event, path, {})
|
||||
} else {
|
||||
return fs.createReadStream(join(publicDir, id))
|
||||
}
|
||||
|
|
@ -141,4 +141,3 @@ sync.require("./routes/oauth")
|
|||
sync.require("./routes/opt-out")
|
||||
sync.require("./routes/password")
|
||||
sync.require("./routes/stats")
|
||||
sync.require("./routes/time")
|
||||
|
|
|
|||
5
start.js
5
start.js
|
|
@ -1,11 +1,6 @@
|
|||
#!/usr/bin/env node
|
||||
// @ts-check
|
||||
|
||||
// Temporal API stabilised in Node.js 26. Previous versions can use `--harmony-temporal`, but that's jank, so have a polyfill.
|
||||
if (!("Temporal" in globalThis)) {
|
||||
require("temporal-polyfill-lite/global")
|
||||
}
|
||||
|
||||
const fs = require("fs")
|
||||
const sqlite = require("better-sqlite3")
|
||||
const migrate = require("./src/db/migrate")
|
||||
|
|
|
|||
102
test/data.js
102
test/data.js
|
|
@ -1680,108 +1680,6 @@ module.exports = {
|
|||
flags: 0,
|
||||
components: []
|
||||
},
|
||||
simple_game_mention: {
|
||||
type: 0,
|
||||
content: "do you play <@$1402418491272986635>",
|
||||
mentions: [],
|
||||
mention_roles: [],
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
timestamp: "2026-09-28T21:01:56.749000+00:00",
|
||||
edited_timestamp: null,
|
||||
flags: 0,
|
||||
components: [],
|
||||
id: "1554236746756268077",
|
||||
channel_id: "1160894080998461480",
|
||||
author: {
|
||||
id: "177438227563675648",
|
||||
username: "owlbot",
|
||||
avatar: "bafbcafcc0a6c2698d59a2cb567a9ecf",
|
||||
discriminator: "0",
|
||||
public_flags: 0,
|
||||
flags: 0,
|
||||
banner: null,
|
||||
accent_color: null,
|
||||
global_name: null,
|
||||
avatar_decoration_data: null,
|
||||
collectibles: null,
|
||||
display_name_styles: null,
|
||||
vad_colors: null,
|
||||
banner_color: null,
|
||||
clan: {
|
||||
identity_guild_id: "773319828295385108",
|
||||
identity_enabled: true,
|
||||
tag: "puzl",
|
||||
badge: "1935451f265a42d57241c7bcc9555abe"
|
||||
},
|
||||
primary_guild: {
|
||||
identity_guild_id: "773319828295385108",
|
||||
identity_enabled: true,
|
||||
tag: "puzl",
|
||||
badge: "1935451f265a42d57241c7bcc9555abe"
|
||||
}
|
||||
},
|
||||
pinned: false,
|
||||
mention_everyone: false,
|
||||
tts: false,
|
||||
mention_games: [
|
||||
{
|
||||
id: "1402418491272986635",
|
||||
name: "Minecraft",
|
||||
aliases: [ "Minecraft Windows 10 Edition", "Minecraft Launcher" ],
|
||||
}
|
||||
]
|
||||
},
|
||||
timestamps: {
|
||||
type: 0,
|
||||
content: "<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,
|
||||
|
|
|
|||
|
|
@ -168,8 +168,7 @@ INSERT INTO file (discord_url, mxc_url) VALUES
|
|||
('https://cdn.discordapp.com/attachments/122155380120748034/1174514575220158545/the.yml', 'mxc://cadence.moe/HnQIYQmmlIKwOQsbFsIGpzPP'),
|
||||
('https://cdn.discordapp.com/attachments/112760669178241024/1296237494987133070/100km.gif', 'mxc://cadence.moe/qDAotmebTfEIfsAIVCEZptLh'),
|
||||
('https://cdn.discordapp.com/attachments/123/456/my_enemies.txt', 'mxc://cadence.moe/y89EOTRp2lbeOkgdsEleGOge'),
|
||||
('https://cdn.discordapp.com/emojis/1254940125948022915.webp', 'mxc://cadence.moe/bvVJFgOIyNcAknKCbmaHDktG'),
|
||||
('https://media.discordapp.net/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png?format=jpeg', 'mxc://cadence.moe/zXNXRvJwRDMIzZZVqHUXtGeD');
|
||||
('https://cdn.discordapp.com/emojis/1254940125948022915.webp', 'mxc://cadence.moe/bvVJFgOIyNcAknKCbmaHDktG');
|
||||
|
||||
INSERT INTO emoji (emoji_id, name, animated, mxc_url) VALUES
|
||||
('230201364309868544', 'hippo', 0, 'mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC'),
|
||||
|
|
|
|||
|
|
@ -130,9 +130,7 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not
|
|||
|
||||
require("./addbot.test")
|
||||
require("../src/db/orm.test")
|
||||
require("../src/js/errors.test")
|
||||
require("../src/web/server.test")
|
||||
require("../src/discord/time.test")
|
||||
require("../src/discord/utils.test")
|
||||
require("../src/matrix/kstate.test")
|
||||
require("../src/matrix/api.test")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue