Compare commits

...

8 commits

Author SHA1 Message Date
Bea
b80be3938e
chore: add global prettier ignore file
As prettier is not configured for this project, and the style doesn't
meet prettier's opinionated defaults, it's worth dropping this blanket
ignore until such a time (if ever) a prettier config is produced that
matches the styleguide of this project.
2026-04-14 00:27:32 +01:00
Bea
ca47d2f861
chore: editorconfig matching existing code format styles
An editor config has been written that matches existing indentation and
formatting used by this project, which should extend the courtesy of
intended tab use to other editors like NeoVim which are often conigured
to adhere to `.editorconfig` by default.

As part of this change, the official EditorConfig extension has been
added to VSCode recommendations, which should additionally enable
Code-like editors to additionally adhere to the rules of other files
like json and markdown which are the only types that deviate from
Cadence's global default.
2026-04-14 00:27:32 +01:00
Bea
67712c3f1f
Look up thread IDs if channels cannot be found 2026-04-14 00:27:32 +01:00
91bce76fc8 Use HTML to strip per-message profile fallback 2026-03-29 15:41:23 +13:00
nemesio65
12f4103870 d2m: Create voice channels as call rooms 2026-03-28 11:46:08 +13:00
e28eac6bfa Update domino 2026-03-28 11:45:51 +13:00
857fb7583b v3.5 2026-03-27 19:20:04 +13:00
59012d9613 Fix pinning random messages 2026-03-27 19:13:03 +13:00
12 changed files with 111 additions and 42 deletions

16
.editorconfig Normal file
View file

@ -0,0 +1,16 @@
root = true
[*]
indent_style = tab
tab_width = 3
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.json]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false

1
.prettierignore Normal file
View file

@ -0,0 +1 @@
*

5
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,5 @@
{
"recommendations": [
"editorconfig.editorconfig"
]
}

48
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "out-of-your-element", "name": "out-of-your-element",
"version": "3.4.0", "version": "3.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "out-of-your-element", "name": "out-of-your-element",
"version": "3.4.0", "version": "3.5.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@chriscdn/promise-semaphore": "^3.0.1", "@chriscdn/promise-semaphore": "^3.0.1",
@ -30,7 +30,7 @@
"enquirer": "^2.4.1", "enquirer": "^2.4.1",
"entities": "^5.0.0", "entities": "^5.0.0",
"get-relative-path": "^1.0.2", "get-relative-path": "^1.0.2",
"h3": "^1.15.1", "h3": "^1.15.10",
"heatsync": "^2.7.2", "heatsync": "^2.7.2",
"htmx.org": "^2.0.4", "htmx.org": "^2.0.4",
"lru-cache": "^11.0.2", "lru-cache": "^11.0.2",
@ -276,9 +276,9 @@
} }
}, },
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.9.0", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
"integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@ -1163,9 +1163,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.4", "version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1488,9 +1488,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/domino": { "node_modules/domino": {
"version": "2.1.6", "version": "2.1.7",
"resolved": "https://registry.npmjs.org/domino/-/domino-2.1.6.tgz", "resolved": "https://registry.npmjs.org/domino/-/domino-2.1.7.tgz",
"integrity": "sha512-3VdM/SXBZX2omc9JF9nOPCtDaYQ67BGp5CoLpIQlO2KCAPETs8TcDHacF26jXadGbvUteZzRTeos2fhID5+ucQ==", "integrity": "sha512-3rcXhx0ixJV2nj8J0tljzejTF73A35LVVdnTQu79UAqTBFEgYPMgGtykMuu/BDqaOZphATku1ddRUn/RtqUHYQ==",
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
@ -1587,9 +1587,9 @@
} }
}, },
"node_modules/flatted": { "node_modules/flatted": {
"version": "3.4.1", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@ -1617,9 +1617,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/fullstore": { "node_modules/fullstore": {
"version": "4.0.0", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/fullstore/-/fullstore-4.0.0.tgz", "resolved": "https://registry.npmjs.org/fullstore/-/fullstore-4.0.2.tgz",
"integrity": "sha512-Y9hN79Q1CFU8akjGnTZoBnTzlA/o8wmtBijJOI8dKCmdC7GLX7OekpLxmbaeRetTOi4OdFGjfsg4c5dxP3jgPw==", "integrity": "sha512-syOev4kA0lZy4VkfBJZ99ZL4cIiSgiKt0G8SpP0kla1tpM1c+V/jBOVY/OqqGtR2XLVcM83SjFPFC3R2YIwqjQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -1688,9 +1688,9 @@
} }
}, },
"node_modules/h3": { "node_modules/h3": {
"version": "1.15.6", "version": "1.15.10",
"resolved": "https://registry.npmjs.org/h3/-/h3-1.15.6.tgz", "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.10.tgz",
"integrity": "sha512-oi15ESLW5LRthZ+qPCi5GNasY/gvynSKUQxgiovrY63bPAtG59wtM+LSrlcwvOHAXzGrXVLnI97brbkdPF9WoQ==", "integrity": "sha512-YzJeWSkDZxAhvmp8dexjRK5hxziRO7I9m0N53WhvYL5NiWfkUkzssVzY9jvGu0HBoLFW6+duYmNSn6MaZBCCtg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cookie-es": "^1.2.2", "cookie-es": "^1.2.2",
@ -1937,9 +1937,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/json-with-bigint": { "node_modules/json-with-bigint": {
"version": "3.5.7", "version": "3.5.8",
"resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.7.tgz", "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz",
"integrity": "sha512-7ei3MdAI5+fJPVnKlW77TKNKwQ5ppSzWvhPuSuINT/GYW9ZOC1eRKOuhV9yHG5aEsUPj9BBx5JIekkmoLHxZOw==", "integrity": "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },

View file

@ -1,6 +1,6 @@
{ {
"name": "out-of-your-element", "name": "out-of-your-element",
"version": "3.4.0", "version": "3.5.0",
"description": "A bridge between Matrix and Discord", "description": "A bridge between Matrix and Discord",
"main": "index.js", "main": "index.js",
"repository": { "repository": {
@ -39,7 +39,7 @@
"enquirer": "^2.4.1", "enquirer": "^2.4.1",
"entities": "^5.0.0", "entities": "^5.0.0",
"get-relative-path": "^1.0.2", "get-relative-path": "^1.0.2",
"h3": "^1.15.1", "h3": "^1.15.10",
"heatsync": "^2.7.2", "heatsync": "^2.7.2",
"htmx.org": "^2.0.4", "htmx.org": "^2.0.4",
"lru-cache": "^11.0.2", "lru-cache": "^11.0.2",

View file

@ -51,10 +51,24 @@ const preparedInsert = backfill.prepare("INSERT INTO backfill (channel_id, messa
async function event(event) { async function event(event) {
if (event.t !== "GUILD_CREATE") return if (event.t !== "GUILD_CREATE") return
const channel = event.d.channels.find(c => c.id === channelID) let channel = event.d.channels.find(c => c.id === channelID) || (event.d.threads || []).find(c => c.id === channelID)
if (!channel) return
const guild_id = event.d.id const guild_id = event.d.id
if (!channel) {
// May be an archived thread not present in GUILD_CREATE data - try fetching via API
try {
const fetched = await discord.snow.channel.getChannel(channelID)
if (!fetched.guild_id || fetched.guild_id !== guild_id) return
fetched.guild_id = guild_id
discord.channels.set(fetched.id, fetched)
channel = fetched
} catch (e) {
return
}
}
if (!channel) return
try { try {
await createRoom.syncRoom(channelID) await createRoom.syncRoom(channelID)
let last = backfill.prepare("SELECT cast(max(message_id) as TEXT) FROM backfill WHERE channel_id = ?").pluck().get(channelID) || "0" let last = backfill.prepare("SELECT cast(max(message_id) as TEXT) FROM backfill WHERE channel_id = ?").pluck().get(channelID) || "0"

View file

@ -193,6 +193,16 @@ async function channelToKState(channel, guild, di) {
// Don't overwrite room topic if the topic has been customised // Don't overwrite room topic if the topic has been customised
if (hasCustomTopic) delete channelKState["m.room.topic/"] if (hasCustomTopic) delete channelKState["m.room.topic/"]
// Make voice channels be a Matrix voice room (MSC3417)
if (channel.type === DiscordTypes.ChannelType.GuildVoice) {
creationContent.type = "org.matrix.msc3417.call"
channelKState["org.matrix.msc3401.call/"] = {
"m.intent": "m.room",
"m.type": "m.voice",
"m.name": customName || channel.name
}
}
// Don't add a space parent if it's self service // Don't add a space parent if it's self service
// (The person setting up self-service has already put it in their preferred space to be able to get this far.) // (The person setting up self-service has already put it in their preferred space to be able to get this far.)
const autocreate = select("guild_active", "autocreate", {guild_id: guild.id}).pluck().get() const autocreate = select("guild_active", "autocreate", {guild_id: guild.id}).pluck().get()

View file

@ -190,6 +190,17 @@ test("channel2room: read-only discord channel", async t => {
t.equal(api.getCalled(), 2) t.equal(api.getCalled(), 2)
}) })
test("channel2room: voice channel", async t => {
const api = mockAPI(t)
const state = kstateStripConditionals(await channelToKState(testData.channel.voice, testData.guild.general, {api}).then(x => x.channelKState))
t.equal(state["m.room.create/"].type, "org.matrix.msc3417.call")
t.deepEqual(state["org.matrix.msc3401.call/"], {
"m.intent": "m.room",
"m.name": "🍞丨[8user] Piece",
"m.type": "m.voice"
})
})
test("convertNameAndTopic: custom name and topic", t => { test("convertNameAndTopic: custom name and topic", t => {
t.deepEqual( t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, "hauntings"), _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, "hauntings"),

View file

@ -22,7 +22,7 @@ function pinsToList(pins, kstate) {
/** @type {string[]} */ /** @type {string[]} */
const result = [] const result = []
for (const pin of pins.items) { for (const pin of pins.items) {
const eventID = select("event_message", "event_id", {message_id: pin.message.id, part: 0}).pluck().get() const eventID = select("event_message", "event_id", {message_id: pin.message.id}, "ORDER BY part ASC").pluck().get()
if (eventID && !alreadyPinned.includes(eventID)) result.push(eventID) if (eventID && !alreadyPinned.includes(eventID)) result.push(eventID)
} }
result.reverse() result.reverse()

View file

@ -13,7 +13,7 @@ async function updatePins(pins, prev) {
const diff = diffPins.diffPins(pins, prev) const diff = diffPins.diffPins(pins, prev)
for (const [event_id, added] of diff) { for (const [event_id, added] of diff) {
const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
.select("reference_channel_id", "message_id").get() .select("reference_channel_id", "message_id").where({event_id}).and("ORDER BY part ASC").get()
if (!row) continue if (!row) continue
if (added) { if (added) {
discord.snow.channel.addChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message pinned on Matrix") discord.snow.channel.addChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message pinned on Matrix")

View file

@ -816,16 +816,6 @@ async function eventToMessage(event, guild, channel, di) {
if (shouldProcessTextEvent) { if (shouldProcessTextEvent) {
if (event.content.format === "org.matrix.custom.html" && event.content.formatted_body) { if (event.content.format === "org.matrix.custom.html" && event.content.formatted_body) {
let input = event.content.formatted_body let input = event.content.formatted_body
if (perMessageProfile?.has_fallback) {
// Strip fallback elements added for clients that don't support per-message profiles.
// Deviates from recommended regexp in MSC to be less strict. Avoiding an HTML parser for performance reasons.
// ┌────A────┐ Opening HTML tag: capture tag name and stay within tag
// ┆ ┆┌─────────────B────────────┐ This text in the tag somewhere, presumably an attribute name
// ┆ ┆┆ ┆┌─C──┐ Rest of the opening tag
// ┆ ┆┆ ┆┆ ┆┌─D─┐ Tag content (no more tags allowed within)
// ┆ ┆┆ ┆┆ ┆┆ ┆┌─E──┐ Closing tag matching opening tag name
input = input.replace(/<(\w+)[^>]*\bdata-mx-profile-fallback\b[^>]*>[^<]*<\/\1>/g, "")
}
if (event.content.msgtype === "m.emote") { if (event.content.msgtype === "m.emote") {
input = `* ${displayName} ${input}` input = `* ${displayName} ${input}`
} }
@ -886,8 +876,9 @@ async function eventToMessage(event, guild, channel, di) {
const doc = domino.createDocument( const doc = domino.createDocument(
// DOM parsers arrange elements in the <head> and <body>. Wrapping in a custom element ensures elements are reliably arranged in a single element. // DOM parsers arrange elements in the <head> and <body>. Wrapping in a custom element ensures elements are reliably arranged in a single element.
'<x-turndown id="turndown-root">' + input + '</x-turndown>' '<x-turndown id="turndown-root">' + input + '</x-turndown>'
); )
const root = doc.getElementById("turndown-root"); const root = doc.getElementById("turndown-root")
assert(root)
async function forEachNode(event, node) { async function forEachNode(event, node) {
for (; node; node = node.nextSibling) { for (; node; node = node.nextSibling) {
// Check written mentions // Check written mentions
@ -940,6 +931,7 @@ async function eventToMessage(event, guild, channel, di) {
} }
} }
await forEachNode(event, root) await forEachNode(event, root)
if (perMessageProfile?.has_fallback) root.querySelectorAll("[data-mx-profile-fallback]").forEach(x => x.remove())
// SPRITE SHEET EMOJIS FEATURE: Emojis at the end of the message that we don't know about will be reuploaded as a sprite sheet. // SPRITE SHEET EMOJIS FEATURE: Emojis at the end of the message that we don't know about will be reuploaded as a sprite sheet.
// First we need to determine which emojis are at the end. // First we need to determine which emojis are at the end.

View file

@ -19,6 +19,26 @@ module.exports = {
default_thread_rate_limit_per_user: 0, default_thread_rate_limit_per_user: 0,
guild_id: "112760669178241024" guild_id: "112760669178241024"
}, },
voice: {
voice_background_display: null,
version: 1774469910848,
user_limit: 0,
type: 2,
theme_color: null,
status: null,
rtc_region: null,
rate_limit_per_user: 0,
position: 0,
permission_overwrites: [],
parent_id: "805261291908104252",
nsfw: false,
name: "🍞丨[8user] Piece",
last_message_id: "1459912691098325137",
id: "1036840786093953084",
flags: 0,
bitrate: 256000,
guild_id: "112760669178241024"
},
updates: { updates: {
type: 0, type: 0,
topic: "Updates and release announcements for Out Of Your Element.", topic: "Updates and release announcements for Out Of Your Element.",