[WIP] threads. #74

Draft
Guzio wants to merge 147 commits from Guzio/out-of-your-element:mergable-fr-fr into main
17 changed files with 176 additions and 63 deletions
Showing only changes of commit cbe957f790 - Show all commits

Hello thread PR, my old friend...

I've come to fix conflicts, and then pretend you don't exist, again.
Guzio 2026-10-20 13:45:31 +02:00

View file

@ -0,0 +1,31 @@
// @ts-check
const crypto = require("crypto")
const fs = require("fs")
const assert = require("assert")
const stream = require("stream")
const {scheduler} = require("timers/promises")
;(async () => {
try {
const res = await fetch("http://localhost:6694", {
keepalive: true
})
assert(res.body)
// const data = await res.blob().then(blob => blob.bytes())
// console.log(data)
const c = crypto.createCipheriv("aes-256-ctr", Buffer.from("oKInhenybApUF-X4ahfQi0dIql8fyTXDVjfiEZvJquE", "base64url"), Buffer.from("Qt4ZM5g1zDQAAAAAAAAAAA", "base64url"))
// @ts-ignore - close enough
// const nodeBody = stream.Readable.fromWeb(res.body)
console.log(await new Response(res.body.pipeThrough(stream.Transform.toWeb(c))).bytes())
console.log("all done")
} catch (e) {
console.error(e)
}
console.log("didn't crash")
})()

View file

@ -0,0 +1,63 @@
// @ts-check
const net = require("net")
const stream = require("stream")
const {scheduler} = require("timers/promises")
const CHUNKED = true
const REPEAT_COUNT = 10
const b = new Uint8Array(1024).fill(0x65)
async function* createData() {
for (let i = 0; i < REPEAT_COUNT; i++) {
console.log(`generated chunk ${i}`)
if (CHUNKED) {
yield b.length.toString(16)
yield "\r\n"
}
yield b
if (CHUNKED) {
yield "\r\n"
}
await scheduler.wait(800)
}
if (CHUNKED) {
yield "0\r\n\r\n"
}
}
const server = net.createServer(socket => {
socket.write(`HTTP/1.1 200 OK\r\n`)
socket.write("Connection: keep-alive\r\n")
if (CHUNKED) {
socket.write("Transfer-Encoding: chunked\r\n")
} else {
socket.write(`Content-Length: ${b.length*REPEAT_COUNT}\r\n`)
}
socket.write(`\r\n`)
const dataStream = stream.Readable.from(createData())
dataStream.pipe(socket)
// In case of problem with dataStream
dataStream.on("error", () => {
console.error("aw dang it")
socket.end()
})
// In case of problem with recipient
socket.on("close", () => {
console.log("socket close")
dataStream.destroy()
})
socket.on("error", () => {
console.log("socket error")
})
socket.on("end", () => {
console.log("socket end")
})
})
server.listen(6694)

View file

@ -506,10 +506,19 @@ async function unbridgeChannel(channel, guildID, messageBeforeLeave = "This room
const powerLevelsUsers = (powerLevels.users ||= {}) const powerLevelsUsers = (powerLevels.users ||= {})
for (const mxid of Object.keys(powerLevelsUsers)) { for (const mxid of Object.keys(powerLevelsUsers)) {
if (powerLevelsUsers[mxid] >= (powerLevels.state_default ?? 50) && !allCreators.includes(mxid) && mUtils.eventSenderIsFromDiscord(mxid) && mxid !== mUtils.bot) { if (powerLevelsUsers[mxid] >= (powerLevels.state_default ?? 50) && !allCreators.includes(mxid) && mUtils.eventSenderIsFromDiscord(mxid) && mxid !== mUtils.bot) {
// can OOYE bot demote them?
if (allCreators.includes(mUtils.bot) || powerLevelsUsers[mxid] < powerLevelsUsers[mUtils.bot]) {
delete powerLevelsUsers[mxid]
await api.sendState(roomID, "m.room.power_levels", "", powerLevels) // bot can demote them
} else {
// check user is still in room and can send events
if (select("sim_member", "mxid", {mxid, room_id: roomID}).get()) {
delete powerLevelsUsers[mxid] delete powerLevelsUsers[mxid]
await api.sendState(roomID, "m.room.power_levels", "", powerLevels, mxid) // done individually because each user must demote themselves await api.sendState(roomID, "m.room.power_levels", "", powerLevels, mxid) // done individually because each user must demote themselves
} }
} }
}
}
// send a notification in the room // send a notification in the room
await api.sendEvent(roomID, "m.room.message", { await api.sendEvent(roomID, "m.room.message", {

View file

@ -79,8 +79,13 @@ function getDiscordParseCallbacks(message, guild, useHTML, spoilers = []) {
const text = discordTime.convertDiscordTime(node.timestamp, node.style) const text = discordTime.convertDiscordTime(node.timestamp, node.style)
if (useHTML) { if (useHTML) {
let url = `${reg.ooye.bridge_origin}/time/${node.timestamp}` let url = `${reg.ooye.bridge_origin}/time/${node.timestamp}`
if (node.style) url += `/${node.style}` let dataStyle = ""
return tag`<a href="${url}">${text}</a>` if (node.style) {
url += `/${node.style}`
dataStyle = tag`data-style="${node.style}" `
}
const iso = new Date(node.timestamp * 1000).toISOString()
return tag`<a href="${url}"><time $${dataStyle}datetime="${iso}">${text}</time></a>`
} else { } else {
return text return text
} }

View file

@ -96,7 +96,7 @@ test("message2event components: pk question mark output", async t => {
+ "<hr>" + "<hr>"
+ "<p>Same hat</p>" + "<p>Same hat</p>"
+ `🖼️ Image: <a href="https://bridge.example.org/download/discordcdn/934955898965729280/1466556006527012987/image.png">image.png</a></blockquote>` + `🖼️ Image: <a href="https://bridge.example.org/download/discordcdn/934955898965729280/1466556006527012987/image.png">image.png</a></blockquote>`
+ "<p><sub>Original Message ID: 1466556003645657118 · <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 · <a href=\"https://bridge.example.org/time/1769724599/f\"><time data-style=\"f\" datetime=\"2026-01-29T22:09:59.000Z\">29 January 2026 at 10:09 pm (UTC)</time></a></sub></p>",
"m.mentions": {}, "m.mentions": {},
msgtype: "m.text", msgtype: "m.text",
}]) }])

View file

@ -214,12 +214,12 @@ test("message2event: timestamps", async t => {
+ "\n3 October 2026 at 9:30 am (UTC)" + "\n3 October 2026 at 9:30 am (UTC)"
+ "\n24 December 2026 at 11:00 pm (UTC)", + "\n24 December 2026 at 11:00 pm (UTC)",
format: "org.matrix.custom.html", 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>` formatted_body: `<a href="https://bridge.example.org/time/1791106413/S"><time data-style="S" datetime="2026-10-04T09:33:33.000Z">4 Oct 2026, 9:33:33 am (UTC)</time></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/1791106436/f"><time data-style="f" datetime="2026-10-04T09:33:56.000Z">4 October 2026 at 9:33 am (UTC)</time></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/1791106440/F"><time data-style="F" datetime="2026-10-04T09:34:00.000Z">Sunday, 4 October 2026 at 9:34 am (UTC)</time></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/1791106448/T"><time data-style="T" datetime="2026-10-04T09:34:08.000Z">9:34:08 am (UTC)</time></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/1791019800/R"><time data-style="R" datetime="2026-10-03T09:30:00.000Z">3 October 2026 at 9:30 am (UTC)</time></a>`
+ `<br><a href="https://bridge.example.org/time/1798153200/R">24 December 2026 at 11:00 pm (UTC)</a>`, + `<br><a href="https://bridge.example.org/time/1798153200/R"><time data-style="R" datetime="2026-12-24T23:00:00.000Z">24 December 2026 at 11:00 pm (UTC)</time></a>`,
"m.mentions": {} "m.mentions": {}
}]) }])
}) })

View file

@ -221,7 +221,7 @@ async function onPacket(client, message, listen) {
// when not real time, skip optional events // when not real time, skip optional events
} else { } else {
// queue required events // queue required events
homeserverStatus.homeserverStatus.queuePacket(message) homeserverStatus.homeserverStatus.queuePacket(["Discord", message])
} }
} }
} }

View file

@ -57,7 +57,7 @@ async function withWebhook(channelID, callback) {
/** /**
* @param {string} channelID * @param {string} channelID
* @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]}} data * @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Blob}[]}} data
* @param {string} [threadID] * @param {string} [threadID]
*/ */
async function sendMessageWithWebhook(channelID, data, threadID) { async function sendMessageWithWebhook(channelID, data, threadID) {
@ -70,7 +70,7 @@ async function sendMessageWithWebhook(channelID, data, threadID) {
/** /**
* @param {string} channelID * @param {string} channelID
* @param {string} messageID * @param {string} messageID
* @param {DiscordTypes.RESTPatchAPIWebhookWithTokenMessageJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]}} data * @param {DiscordTypes.RESTPatchAPIWebhookWithTokenMessageJSONBody & {files?: {name: string, file: Buffer | Blob}[]}} data
* @param {string} [threadID] * @param {string} [threadID]
*/ */
async function editMessageWithWebhook(channelID, messageID, data, threadID) { async function editMessageWithWebhook(channelID, messageID, data, threadID) {

View file

@ -28,8 +28,8 @@ const emojiSheet = sync.require("../actions/emoji-sheet")
const latestSenderPerRoom = sync.remember(() => new Map()) const latestSenderPerRoom = sync.remember(() => new Map())
/** /**
* @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[], pendingFiles?: ({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer | stream.Readable})[]}} message * @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Blob}[], pendingFiles?: ({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer | Blob})[]}} message
* @returns {Promise<DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]}>} * @returns {Promise<DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Blob}[]}>}
*/ */
async function resolvePendingFiles(message) { async function resolvePendingFiles(message) {
if (!message.pendingFiles) return message if (!message.pendingFiles) return message
@ -42,24 +42,22 @@ async function resolvePendingFiles(message) {
} }
if ("key" in p) { if ("key" in p) {
// Encrypted file // Encrypted file
const res = await api.getMedia(p.mxc)
const d = crypto.createDecipheriv("aes-256-ctr", Buffer.from(p.key, "base64url"), Buffer.from(p.iv, "base64url")) const d = crypto.createDecipheriv("aes-256-ctr", Buffer.from(p.key, "base64url"), Buffer.from(p.iv, "base64url"))
await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb( /** @type {ReadableStream} */ // @ts-ignore - close enough
// @ts-ignore const piped = res.body.pipeThrough(stream.Transform.toWeb(d))
res.body const blob = await new Response(piped).blob()
).pipe(d))
return { return {
name: p.name, name: p.name,
file: d file: blob
} }
} else { } else {
// Unencrypted file // Unencrypted file
const body = await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb( const res = await api.getMedia(p.mxc)
// @ts-ignore const blob = await res.blob()
res.body
))
return { return {
name: p.name, name: p.name,
file: body file: blob
} }
} }
})) }))

View file

@ -1,6 +1,6 @@
// @ts-check // @ts-check
const {Readable} = require("stream") const {Transform} = require("stream")
const {ReadableStream} = require("stream/web") const {ReadableStream} = require("stream/web")
const {sync} = require("../../passthrough") const {sync} = require("../../passthrough")
@ -9,7 +9,6 @@ const sharp = require("sharp")
const api = sync.require("../../matrix/api") const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/mreq")} */ /** @type {import("../../matrix/mreq")} */
const mreq = sync.require("../../matrix/mreq") const mreq = sync.require("../../matrix/mreq")
const {streamType} = require("@cloudrac3r/stream-type")
const WIDTH = 160 const WIDTH = 160
const HEIGHT = 160 const HEIGHT = 160
@ -20,20 +19,14 @@ const HEIGHT = 160
*/ */
async function getAndResizeSticker(mxc) { async function getAndResizeSticker(mxc) {
const res = await api.getMedia(mxc) const res = await api.getMedia(mxc)
if (res.status !== 200) {
const root = await res.json()
throw new mreq.MatrixServerError(root, res.status, {mxc})
}
const streamIn = Readable.fromWeb(res.body) return res.body.pipeThrough(
const {streamThrough, type} = await streamType(streamIn) Transform.toWeb(
const animated = ["image/gif", "image/webp"].includes(type) sharp({animated: true})
const transformer = sharp({animated: animated})
.resize(WIDTH, HEIGHT, {fit: "inside", background: {r: 0, g: 0, b: 0, alpha: 0}}) .resize(WIDTH, HEIGHT, {fit: "inside", background: {r: 0, g: 0, b: 0, alpha: 0}})
.webp() .webp()
streamThrough.pipe(transformer) )
return Readable.toWeb(transformer) )
} }

View file

@ -1081,7 +1081,7 @@ async function eventToMessage(event, guild, channel, di) {
} }
// Assemble chunks into Discord messages content // Assemble chunks into Discord messages content
/** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]})[]} */ /** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Blob}[]})[]} */
const messages = chunks.map(content => ({ const messages = chunks.map(content => ({
content, content,
allowed_mentions, allowed_mentions,

View file

@ -129,7 +129,7 @@ async function sendError(roomID, source, type, e, payload) {
|| (e instanceof mreq.MatrixServerError && [502, 503].includes(e.httpStatus) && e.errcode === "CX_SERVER_ERROR") || (e instanceof mreq.MatrixServerError && [502, 503].includes(e.httpStatus) && e.errcode === "CX_SERVER_ERROR")
) { ) {
// Matrix homeserver is down (reverse proxy indicated failure) // Matrix homeserver is down (reverse proxy indicated failure)
homeserverStatus.homeserverStatus.setErrorWithPacket(payload) homeserverStatus.homeserverStatus.setErrorWithPacket([source, payload])
} }
} }
} }
@ -296,7 +296,7 @@ async event => {
// Try to bridge a failed event again? // Try to bridge a failed event again?
await onRetryReactionAdd(event) await onRetryReactionAdd(event)
} else { } else {
matrixCommandHandler.onReactionAdd(event) if (matrixCommandHandler.onReactionAdd(event)) return
await addReaction.addReaction(event) await addReaction.addReaction(event)
} }
})) }))

View file

@ -54,6 +54,7 @@ const homeserverStatus = new class HomeserverStatus {
.defineState("offline", { .defineState("offline", {
onEnter: [() => { onEnter: [() => {
console.log("[homeserver status] Offline. The failed event and further events will be queued.")
this.pingInterval = setInterval(async () => { this.pingInterval = setInterval(async () => {
const pingResult = await api.ping().catch(e => ({ok: false, status: "net", root: e.message})) const pingResult = await api.ping().catch(e => ({ok: false, status: "net", root: e.message}))
this.lastCheck = Date.now() this.lastCheck = Date.now()
@ -70,13 +71,22 @@ const homeserverStatus = new class HomeserverStatus {
.defineState("recovering", { .defineState("recovering", {
onEnter: [async () => { // Drain queue. onEnter: [async () => { // Drain queue.
if (!this.queue.isEmpty()) {
console.log(`[homeserver status] Online, playing ${this.queue.length} events from queue...`)
while (!this.queue.isEmpty()) { while (!this.queue.isEmpty()) {
const packet = this.queue.peekFront() // same position as .shift() const [source, packet] = this.queue.peekFront() // same position as .shift()
debugHomeserverStatus(`homeserver status: ${new Date().toISOString()} dq packet ${packet.t} ${packet.d?.content}`) debugHomeserverStatus(`homeserver status: ${new Date().toISOString()} dq sourceAndPacket ${packet.t} ${packet.d?.content}`)
if (source === "Discord") {
await discordPackets.dispatchPacketToBridge(passthrough.discord, packet) await discordPackets.dispatchPacketToBridge(passthrough.discord, packet)
} else {
// Unfortunately cannot await this, just have to trust it
passthrough.as.emit(`type:${packet.type}`, packet)
}
if (this.sm.currentStateName !== "recovering") return // got kicked out due to another error if (this.sm.currentStateName !== "recovering") return // got kicked out due to another error
this.queue.shift() this.queue.shift()
} }
}
console.log("[homeserver status] Back to real-time operation.")
this.sm.doTransition("recovered") this.sm.doTransition("recovered")
}], }],
onLeave: [], onLeave: [],
@ -88,7 +98,7 @@ const homeserverStatus = new class HomeserverStatus {
.defineTransition("recovering", "recovered", "online") .defineTransition("recovering", "recovered", "online")
.defineTransition("online", "check assume online", "checking") .defineTransition("online", "check assume online", "checking")
.defineTransition("online", "check assume offline", "checking") .defineTransition("online", "check assume offline", "checking")
.defineTransition("checking", "check ok", "recovering") .defineTransition("checking", "check ok", "online")
.defineTransition("checking", "check fail", "offline") .defineTransition("checking", "check fail", "offline")
this.sm.on("enter", st => debugHomeserverStatus(`homeserver status: ${st}`)) this.sm.on("enter", st => debugHomeserverStatus(`homeserver status: ${st}`))
@ -129,16 +139,16 @@ const homeserverStatus = new class HomeserverStatus {
/** /**
* When offline or recovering, call this for incoming packets to queue them to be sent in order later. * When offline or recovering, call this for incoming packets to queue them to be sent in order later.
*/ */
queuePacket(packet) { queuePacket(sourceAndPacket) {
assert(["offline", "recovering"].includes(this.sm.currentStateName)) assert(["offline", "recovering"].includes(this.sm.currentStateName))
this.queue.push(packet) this.queue.push(sourceAndPacket)
} }
setErrorWithPacket(packet) { setErrorWithPacket(sourceAndPacket) {
const wasRecovering = this.sm.currentStateName === "recovering" const wasRecovering = this.sm.currentStateName === "recovering"
this.sm.doTransition("error") this.sm.doTransition("error")
if (!wasRecovering) { // if was recovering then packet is already in the right place in queue if (!wasRecovering) { // if was recovering then sourceAndPacket is already in the right place in queue
this.queuePacket(packet) this.queuePacket(sourceAndPacket)
} }
} }
} }

View file

@ -222,7 +222,7 @@ const commands = [{
b.add("", `<img data-mx-emoticon height="48" src="${e.url}" title=":${e.name}:" alt=":${e.name}:">`) b.add("", `<img data-mx-emoticon height="48" src="${e.url}" title=":${e.name}:" alt=":${e.name}:">`)
} }
await api.sendState(event.room_id, type, key, pack) await api.sendState(event.room_id, type, key, pack)
api.sendEvent(event.room_id, "m.room.message", { await api.sendEvent(event.room_id, "m.room.message", {
...ctx, ...ctx,
...b.get() ...b.get()
}) })
@ -238,7 +238,7 @@ const commands = [{
console.log(`uploading emoji ${resizeOutput.data.length} bytes to :${e.name}:`) console.log(`uploading emoji ${resizeOutput.data.length} bytes to :${e.name}:`)
await discord.snow.assets.createGuildEmoji(guildID, {name: e.name, image: "data:image/png;base64," + resizeOutput.data.toString("base64")}) await discord.snow.assets.createGuildEmoji(guildID, {name: e.name, image: "data:image/png;base64," + resizeOutput.data.toString("base64")})
} }
api.sendEvent(event.room_id, "m.room.message", { await api.sendEvent(event.room_id, "m.room.message", {
...ctx, ...ctx,
msgtype: "m.text", msgtype: "m.text",
body: `Created ${toUpload.length} emojis` body: `Created ${toUpload.length} emojis`

View file

@ -77,6 +77,10 @@ async function mreq(method, url, bodyIn, extra = {}) {
}, extra) }, extra)
const res = await fetch(baseUrl + url, opts) const res = await fetch(baseUrl + url, opts)
if (!res.ok) {
throw await makeMatrixServerError(res, {baseUrl, url, ...opts})
}
const text = await res.text() const text = await res.text()
try { try {
/** @type {any} */ /** @type {any} */

View file

@ -18,10 +18,10 @@ html(lang="en")
max-width: !{WIDTH}px; max-width: !{WIDTH}px;
} }
.only-script { .only-script {
display: none; visibility: hidden;
} }
.yes-script .only-script { .yes-script .only-script {
display: revert; visibility: visible;
} }
.local { .local {
line-height: 1; line-height: 1;

View file

@ -4,7 +4,7 @@ const assert = require("assert/strict")
const {defineEventHandler, getValidatedRouterParams, setResponseStatus, setResponseHeader, createError, H3Event, getValidatedQuery} = require("h3") const {defineEventHandler, getValidatedRouterParams, setResponseStatus, setResponseHeader, createError, H3Event, getValidatedQuery} = require("h3")
const {z} = require("zod") const {z} = require("zod")
const {ReadableStream} = require("stream/web") const {ReadableStream} = require("stream/web")
const {Readable} = require("stream") const {Transform} = require("stream")
const sharp = require("sharp") const sharp = require("sharp")
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore /** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
@ -26,8 +26,8 @@ const sticker = sync.require("../../m2d/actions/sticker")
/** @type {{[presetKey: string]: (body: ReadableStream) => ReadableStream}} */ /** @type {{[presetKey: string]: (body: ReadableStream) => ReadableStream}} */
const MEDIA_THUMBNAIL_PRESETS = { const MEDIA_THUMBNAIL_PRESETS = {
avatar: body => avatar: body =>
Readable.toWeb( body.pipeThrough(
Readable.fromWeb(body).pipe( Transform.toWeb(
sharp() sharp()
.resize({height: 210, width: 210, fit: "cover"}) // the largest display of the webhook pfp on Discord Android in screen pixels .resize({height: 210, width: 210, fit: "cover"}) // the largest display of the webhook pfp on Discord Android in screen pixels
.jpeg({force: false, quality: 90}) // File size works out to up to ~110k for a PNG, less for a JPEG .jpeg({force: false, quality: 90}) // File size works out to up to ~110k for a PNG, less for a JPEG