[WIP] threads. #74

Draft
Guzio wants to merge 147 commits from Guzio/out-of-your-element:mergable-fr-fr into main
14 changed files with 160 additions and 76 deletions
Showing only changes of commit ea43d3b285 - Show all commits

Fix conflicts; rename a var to something more sensible; less string concat because Cadence said she doesn't like it.

Guzio 2026-10-19 13:04:18 +02:00

View file

@ -14,8 +14,6 @@ const channelWebhook = sync.require("./channel-webhook")
const eventToMessage = sync.require("../converters/event-to-message")
/** @type {import("../../matrix/api")}) */
const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/utils")}) */
const utils = sync.require("../../matrix/utils")
/** @type {import("../../d2m/actions/register-user")} */
const registerUser = sync.require("../../d2m/actions/register-user")
/** @type {import("../../d2m/actions/edit-message")} */
@ -23,6 +21,12 @@ const editMessage = sync.require("../../d2m/actions/edit-message")
/** @type {import("../actions/emoji-sheet")} */
const emojiSheet = sync.require("../actions/emoji-sheet")
/**
* @type {Map<string, string>} room id -> mxid
* Keep track of the sender of the latest event per room, this is needed to trim display name runoff for subsequent messages
*/
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
* @returns {Promise<DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]}>}
@ -87,7 +91,12 @@ async function sendEvent(event) {
// no need to sync the matrix member to the other side. but if I did need to, this is where I'd do it
const di = {api, snow: discord.snow, mxcDownloader: emojiSheet.getAndConvertEmoji}
const di = {
api,
snow: discord.snow,
mxcDownloader: emojiSheet.getAndConvertEmoji,
noRunoff: latestSenderPerRoom.get(event.room_id) === event.sender
}
if (event.type === "org.matrix.msc3381.poll.end") {
// Validity already checked by dispatcher. Poll is definitely closed. Update it and DI necessary data.
@ -193,3 +202,4 @@ async function sendEvent(event) {
}
module.exports.sendEvent = sendEvent
module.exports.latestSenderPerRoom = latestSenderPerRoom

View file

@ -538,7 +538,7 @@ async function getL1L2ReplyLine(called = false) {
* @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker | Ty.Event.Outer_M_Room_Message_Encrypted_File | Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Start | Ty.Event.Outer_Org_Matrix_Msc3381_Poll_End} event
* @param {DiscordTypes.APIGuild} guild
* @param {DiscordTypes.APIGuildTextChannel} channel
* @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, pollEnd?: {messageID: string}}} di simple-as-nails dependency injection for the matrix API
* @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, pollEnd?: {messageID: string}, noRunoff: boolean}} di simple-as-nails dependency injection for the matrix API
*/
async function eventToMessage(event, guild, channel, di) {
let displayName = event.sender
@ -561,7 +561,16 @@ async function eventToMessage(event, guild, channel, di) {
// MSC4144: Override display name and avatar from per-message profile if present
const perMessageProfile = event.content["com.beeper.per_message_profile"]
if (perMessageProfile?.displayname) displayName = perMessageProfile.displayname
if (perMessageProfile?.displayname) {
// TODO: Ideally this should filter on the "language" key, rather than just picking the first, once we're aware of the guild's preferred locale.
// Regardless, these are intended to be in preference order, and the PMP user could always choose to reorder them if the wrong choice is made here.
const pronouns = perMessageProfile["io.fsky.nyx.pronouns"]?.[0]?.summary
let pronounsSuffix = ""
if (pronouns) {
pronounsSuffix = ` (${pronouns})`
}
displayName = `${perMessageProfile.displayname}${pronounsSuffix} | ${displayName}`
}
if (perMessageProfile && "avatar_url" in perMessageProfile) {
if (perMessageProfile.avatar_url) {
// use provided avatar_url
@ -577,7 +586,7 @@ async function eventToMessage(event, guild, channel, di) {
// put the excess characters into displayNameRunoff, later to be put at the top of the message
let [displayNameShortened, displayNameRunoff] = splitDisplayName(displayName)
// If the message type is m.emote, the full name is already included at the start of the message, so remove any runoff
if (event.type === "m.room.message" && event.content.msgtype === "m.emote") {
if (di?.noRunoff || (event.type === "m.room.message" && event.content.msgtype === "m.emote")) {
displayNameRunoff = ""
}

View file

@ -5985,7 +5985,7 @@ test("event2message: com.beeper.per_message_profile overrides displayname and av
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "Unstable Name",
username: "Unstable Name | cadence [they]",
content: "hello from unstable profile",
avatar_url: "https://bridge.example.org/download/matrix/maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo?preset=avatar",
allowed_mentions: {
@ -6018,7 +6018,7 @@ test("event2message: com.beeper.per_message_profile empty avatar_url clears avat
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "No Avatar User",
username: "No Avatar User | cadence [they]",
content: "hello with cleared avatar",
avatar_url: "https://bridge.example.org/download/letter-avatar?letter=N&hue=90",
allowed_mentions: {
@ -6054,7 +6054,7 @@ test("event2message: data-mx-profile-fallback element is stripped from formatted
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "Tidus Herboren",
username: "Tidus Herboren | cadence [they]",
content: "one more test",
avatar_url: "https://bridge.example.org/download/matrix/maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo?preset=avatar",
allowed_mentions: {
@ -6087,7 +6087,7 @@ test("event2message: displayname prefix is stripped from plain body when per-mes
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "Tidus Herboren",
username: "Tidus Herboren | cadence [they]",
content: "one more test",
avatar_url: "https://bridge.example.org/download/letter-avatar?letter=T&hue=90",
allowed_mentions: {
@ -6098,6 +6098,57 @@ test("event2message: displayname prefix is stripped from plain body when per-mes
)
})
test("event2message: pronouns can be displayed with per-message profile", async t => {
t.deepEqual(
await eventToMessage({
type: "m.room.message",
sender: "@exa:sleeping.town",
content: {
msgtype: "m.text",
body: "Well, I can't really do it now...",
"com.beeper.per_message_profile": {
id: "Kryn",
displayname: "Kryn",
"eu.she-a.color": {
on_dark: "#FFCC00",
on_light: "#976800"
},
"io.fsky.nyx.pronouns": [{
language: "en",
summary: "fae/she"
}]
}
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}, {}, {}, {
api: {
async getStateEvent(roomID, type, key) {
t.equal(type, "m.room.member")
t.equal(key, "@exa:sleeping.town")
return {
displayname: "Exa",
membership: "join"
}
}
}
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "Kryn (fae/she) | Exa",
content: "Well, I can't really do it now...",
avatar_url: "https://bridge.example.org/download/letter-avatar?letter=K&hue=150",
allowed_mentions: {
parse: ["users", "roles"]
}
}]
}
)
})
test("event2message: all unknown chess emojis are used for sprite sheet", async t => {
t.deepEqual(
await eventToMessage({

View file

@ -192,37 +192,39 @@ sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message",
* @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} event it is a m.room.message because that's what this listener is filtering for
*/
async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return
if (await handleForums(event)) return
if (!utils.eventSenderIsFromDiscord(event.sender)){
if (await handleForums(event)) return
let processCommands = true
if (event.content["m.relates_to"]?.rel_type === "m.thread") {
/**@type {string|null} */
let toRedact = event.room_id
const bridgedTo = utils.getThreadRoomFromThreadEvent(event.content["m.relates_to"].event_id)
processCommands = false
let processCommands = true
if (event.content["m.relates_to"]?.rel_type === "m.thread") {
/**@type {string|null} */
let redactAndMoveShouldOccurIn = event.room_id
const bridgedTo = utils.getThreadRoomFromThreadEvent(event.content["m.relates_to"].event_id)
processCommands = false
if (bridgedTo) event.room_id = bridgedTo;
else if (!await bridgeThread(event)) toRedact = null; //Don't remove anything, if there is nowhere to relocate it to.
if (bridgedTo) event.room_id = bridgedTo;
else if (!await bridgeThread(event)) redactAndMoveShouldOccurIn = null; //Don't remove anything, if there is nowhere to relocate it to.
if (toRedact) {
api.redactEvent(toRedact, event.event_id)
event.content["m.relates_to"] = undefined
api.sendEvent(event.room_id, event.type, {...event.content, body: event.content.body+"\n ~ "+event.sender, formatted_body: event.content.formatted_body ? event.content.formatted_body+"<br> ~ "+event.sender :undefined })
if (redactAndMoveShouldOccurIn) {
api.redactEvent(redactAndMoveShouldOccurIn, event.event_id)
event.content["m.relates_to"] = undefined
api.sendEvent(event.room_id, event.type, {...event.content, body: `${event.content.body}\n ~ ${event.sender}`, formatted_body: event.content.formatted_body ? `${event.content.formatted_body}<br> ~ ${event.sender}` : undefined })
}
}
const messageResponses = await sendEvent.sendEvent(event)
if (!messageResponses.length) return
if (event.type === "m.room.message" && event.content.msgtype === "m.text" && processCommands) {
await matrixCommandHandler.parseAndExecute(
// @ts-ignore - TypeScript doesn't know that the event.content.msgtype === "m.text" check ensures that event isn't of type Ty.Event.Outer_M_Room_Message_File (which, indeed, wouldn't fit here)
event
)
}
}
const messageResponses = await sendEvent.sendEvent(event)
if (!messageResponses.length) return
if (event.type === "m.room.message" && event.content.msgtype === "m.text" && processCommands) {
await matrixCommandHandler.parseAndExecute(
// @ts-ignore - TypeScript doesn't know that the event.content.msgtype === "m.text" check ensures that event isn't of type Ty.Event.Outer_M_Room_Message_File (which, indeed, wouldn't fit here)
event
)
if (!event.content["m.relates_to"]) { // Ignore edits since they don't update the timeline on Discord
sendEvent.latestSenderPerRoom.set(event.room_id, event.sender)
}
retrigger.finishedBridging(event.event_id)
await api.ackEvent(event)
}))
sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker",

View file

@ -12,8 +12,8 @@ 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("./web-utils")} */
const webUtils = sync.require("./web-utils")
/** @type {import("../js/errors")} */
const errors = sync.require("../js/errors")
@ -88,8 +88,8 @@ function renderPath(event, path, fragments, locals) {
const requiredVars = []
const template = compileFile(path, {pretty, plugins: fragmentSelectorPlugins(fragments, requiredVars)})
pugCache.set(cacheKey, async (event, locals) => {
const session = await auth.useSession(event)
const managed = await auth.getManagedGuilds(event)
const session = await webUtils.useSession(event)
const managed = await webUtils.getManagedGuilds(event)
const rel = (to, paramsObject) => {
let result = getRelativePath(event.path, to)
if (paramsObject) {

View file

@ -6,8 +6,8 @@ const {defineEventHandler, createError, readValidatedBody, getRequestHeader, set
const {as, db, sync, select, discord} = require("../../passthrough")
/** @type {import("../auth")} */
const auth = sync.require("../auth")
/** @type {import("../web-utils")} */
const webUtils = sync.require("../web-utils")
/** @type {import("../pug-sync")} */
const pugSync = sync.require("../pug-sync")
/** @type {import("../../d2m/actions/set-presence")} */
@ -56,7 +56,7 @@ function defineToggle(key, inputOptions) {
})
/** @type {Record<T, string?> & Record<"guild_id", string> & Record<string, unknown>} */ // @ts-ignore
const parsedBody = await readValidatedBody(event, bodySchema.parse)
const managed = await auth.getManagedGuilds(event)
const managed = await webUtils.getManagedGuilds(event)
if (!managed.has(parsedBody.guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"})
const value = options.transform(parsedBody[key])
@ -108,7 +108,7 @@ as.router.post("/api/privacy-level", defineToggle("privacy_level", {
as.router.post("/api/default-roles", defineEventHandler(async event => {
const parsedBody = await readValidatedBody(event, schema.defaultRoles.parse)
const managed = await auth.getManagedGuilds(event)
const managed = await webUtils.getManagedGuilds(event)
const guildID = parsedBody.guild_id
if (!managed.has(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"})
@ -148,7 +148,7 @@ as.router.post("/api/room-mention-role", defineEventHandler(async event => {
channel_id: z.string(),
room_mention_role: z.string().regex(/^(?:[0-9]+)?$/)
}).parse)
const managed = await auth.getManagedGuilds(event)
const managed = await webUtils.getManagedGuilds(event)
if (!managed.has(body.guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"})
const room = select("channel_room", "room_id", {channel_id: body.channel_id, guild_id: body.guild_id}).get()
if (!room) throw createError({status: 404, message: "Linked channel not found"})

View file

@ -15,8 +15,8 @@ const {discord, as, sync, select, from, db} = require("../../passthrough")
const pugSync = sync.require("../pug-sync")
/** @type {import("../../d2m/actions/create-space")} */
const createSpace = sync.require("../../d2m/actions/create-space")
/** @type {import("../auth")} */
const auth = require("../auth")
/** @type {import("../web-utils")} */
const webUtils = require("../web-utils")
/** @type {import("../../discord/utils")} */
const dUtils = sync.require("../../discord/utils")
/** @type {import("../../matrix/utils")} */
@ -178,8 +178,8 @@ function getInviteTargetSpaces(mxid) {
as.router.get("/guild", defineEventHandler(async event => {
const {guild_id} = await getValidatedQuery(event, schema.guild.parse)
const session = await auth.useSession(event)
const managed = await auth.getManagedGuilds(event)
const session = await webUtils.useSession(event)
const managed = await webUtils.getManagedGuilds(event)
const row = from("guild_active").join("guild_space", "guild_id", "left").select("space_id", "privacy_level", "autocreate").where({guild_id}).get()
// @ts-ignore
const guild = discord.guilds.get(guild_id)
@ -220,7 +220,7 @@ as.router.get("/explain", defineEventHandler(async event => {
as.router.get("/qr", defineEventHandler(async event => {
const {guild_id} = await getValidatedQuery(event, schema.qr.parse)
const managed = await auth.getManagedGuilds(event)
const managed = await webUtils.getManagedGuilds(event)
const row = from("guild_active").join("guild_space", "guild_id", "left").select("space_id", "privacy_level", "autocreate").where({guild_id}).get()
// @ts-ignore
const guild = discord.guilds.get(guild_id)
@ -253,7 +253,7 @@ as.router.get("/invite", defineEventHandler(async event => {
as.router.post("/api/invite", defineEventHandler(async event => {
const parsedBody = await readValidatedBody(event, schema.invite.parse)
const managed = await auth.getManagedGuilds(event)
const managed = await webUtils.getManagedGuilds(event)
const api = getAPI(event)
// Check guild ID or nonce

View file

@ -7,8 +7,8 @@ const Ty = require("../../types")
const DiscordTypes = require("discord-api-types/v10")
const {discord, db, as, sync, select, from} = require("../../passthrough")
/** @type {import("../auth")} */
const auth = sync.require("../auth")
/** @type {import("../web-utils")} */
const webUtils = sync.require("../web-utils")
/** @type {import("../../matrix/utils")}*/
const utils = sync.require("../../matrix/utils")
/** @type {import("./guild")}*/
@ -98,8 +98,8 @@ async function validateAndUnbridgeChannel(event, channel_id, guild_id) {
as.router.post("/api/link-space", defineEventHandler(async event => {
const parsedBody = await readValidatedBody(event, schema.linkSpace.parse)
const session = await auth.useSession(event)
const managed = await auth.getManagedGuilds(event)
const session = await webUtils.useSession(event)
const managed = await webUtils.getManagedGuilds(event)
const api = getAPI(event)
// Check guild ID
@ -148,7 +148,7 @@ as.router.post("/api/link-space", defineEventHandler(async event => {
as.router.post("/api/link", defineEventHandler(async event => {
const parsedBody = await readValidatedBody(event, schema.link.parse)
const managed = await auth.getManagedGuilds(event)
const managed = await webUtils.getManagedGuilds(event)
const api = getAPI(event)
const createRoom = getCreateRoom(event)
const createSpace = getCreateSpace(event)
@ -249,7 +249,7 @@ as.router.post("/api/link", defineEventHandler(async event => {
as.router.post("/api/unlink", defineEventHandler(async event => {
const {channel_id, guild_id} = await readValidatedBody(event, schema.unlink.parse)
const managed = await auth.getManagedGuilds(event)
const managed = await webUtils.getManagedGuilds(event)
// Check guild ID or nonce
if (!managed.has(guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"})
@ -266,7 +266,7 @@ as.router.post("/api/unlink", defineEventHandler(async event => {
as.router.post("/api/unlink-space", defineEventHandler(async event => {
const {guild_id} = await readValidatedBody(event, schema.unlinkSpace.parse)
const managed = await auth.getManagedGuilds(event)
const managed = await webUtils.getManagedGuilds(event)
const api = getAPI(event)
const snow = getSnow(event)

View file

@ -12,8 +12,8 @@ const {sync} = require("../../passthrough")
const assert = require("assert").strict
/** @type {import("../pug-sync")} */
const pugSync = sync.require("../pug-sync")
/** @type {import("../auth")} */
const auth = sync.require("../auth")
/** @type {import("../web-utils")} */
const webUtils = sync.require("../web-utils")
const schema = {
form: z.object({
@ -52,12 +52,11 @@ as.router.get("/log-in-with-matrix", defineEventHandler(async event => {
return pugSync.render(event, "log-in-with-matrix.pug", {next})
}
const userAgent = getRequestHeader(event, "User-Agent")
if (userAgent?.match(/bot|matrix/)) throw createError({status: 400, data: "Sorry URL previewer, you can't have this URL."})
webUtils.rejectUrlPreviewer(event)
if (!validToken.has(token)) return sendRedirect(event, `${reg.ooye.bridge_origin}/log-in-with-matrix`, 302)
const session = await auth.useSession(event)
const session = await webUtils.useSession(event)
const mxid = validToken.get(token)
assert(mxid)
validToken.delete(token)

View file

@ -9,8 +9,8 @@ const getRelativePath = require("get-relative-path")
const {as, db, sync} = require("../../passthrough")
const {id, permissions} = require("../../../addbot")
/** @type {import("../auth")} */
const auth = sync.require("../auth")
/** @type {import("../web-utils")} */
const webUtils = sync.require("../web-utils")
const {reg} = require("../../matrix/read-registration")
const redirect_uri = `${reg.ooye.bridge_origin}/oauth`
@ -53,7 +53,7 @@ function getOauth2Token(event) {
}
as.router.get("/oauth", defineEventHandler(async event => {
const session = await auth.useSession(event)
const session = await webUtils.useSession(event)
const parsedFirstQuery = await getValidatedQuery(event, schema.first.safeParse)
let scope = "guilds"

View file

@ -6,10 +6,8 @@ const {defineEventHandler, createError, readValidatedBody, getRequestHeader, set
const {as, db, sync, select, discord} = require("../../passthrough")
/** @type {import("../auth")} */
const auth = sync.require("../auth")
/** @type {import("../pug-sync")} */
const pugSync = sync.require("../pug-sync")
/** @type {import("../web-utils")} */
const webUtils = sync.require("../web-utils")
const schema = {
optOut: z.object({
@ -23,7 +21,7 @@ as.router.post("/api/opt-out", defineEventHandler(async event => {
throw createError({status: 403, message: "Forbidden", data: "JavaScript is required for the opt-out form."})
}
const session = await auth.useSession(event)
const session = await webUtils.useSession(event)
if (!session.data.userID) {
throw createError({status: 401, message: "Unauthorised", data: "Log in first."})
}

View file

@ -4,8 +4,8 @@ const {z} = require("zod")
const {defineEventHandler, readValidatedBody, sendRedirect} = require("h3")
const {as, sync} = require("../../passthrough")
/** @type {import("../auth")} */
const auth = sync.require("../auth")
/** @type {import("../web-utils")} */
const webUtils = sync.require("../web-utils")
const schema = {
password: z.object({
@ -15,7 +15,7 @@ const schema = {
as.router.post("/api/password", defineEventHandler(async event => {
const {password} = await readValidatedBody(event, schema.password.parse)
const session = await auth.useSession(event)
const session = await webUtils.useSession(event)
await session.update({password})
return sendRedirect(event, "../")
}))

View file

@ -8,6 +8,8 @@ const {z} = require("zod")
const pugSync = sync.require("../pug-sync")
/** @type {import("../../discord/time")} */
const discordTime = sync.require("../../discord/time")
/** @type {import("../web-utils")} */
const webUtils = sync.require("../web-utils")
const schema = {
time: z.object({
@ -17,6 +19,7 @@ const schema = {
}
as.router.get("/time/:timestamp", defineEventHandler(async event => {
webUtils.rejectUrlPreviewer(event) // url previewers can't show the local time, making it useless. hiding for everyone's sake
const {timestamp} = await getValidatedRouterParams(event, schema.time.parse)
const formatOptions = discordTime.getFormatOptions()
const text = discordTime.convertDiscordTime(timestamp)
@ -24,6 +27,7 @@ as.router.get("/time/:timestamp", defineEventHandler(async event => {
}))
as.router.get("/time/:timestamp/:style", defineEventHandler(async event => {
webUtils.rejectUrlPreviewer(event)
const {timestamp, style} = await getValidatedRouterParams(event, schema.time.parse)
const formatOptions = discordTime.getFormatOptions(style)
const text = discordTime.convertDiscordTime(timestamp, style)

View file

@ -1,6 +1,7 @@
// @ts-check
const h3 = require("h3")
const {getRequestHeader, createError} = require("h3")
const {db} = require("../passthrough")
const {reg} = require("../matrix/read-registration")
@ -29,5 +30,15 @@ function useSession(event) {
return h3.useSession(event, {password: reg.as_token, maxAge: 365 * 24 * 60 * 60})
}
/**
* Rejects by throwing createError.
* @param {h3.H3Event} event
*/
function rejectUrlPreviewer(event) {
const userAgent = getRequestHeader(event, "User-Agent")
if (userAgent?.match(/bot|matrix|tuwunel/i)) throw createError({status: 400, data: "Sorry URL previewer, you can't have this URL."})
}
module.exports.getManagedGuilds = getManagedGuilds
module.exports.useSession = useSession
module.exports.rejectUrlPreviewer = rejectUrlPreviewer