diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 8920fb1..0000000 --- a/.editorconfig +++ /dev/null @@ -1,16 +0,0 @@ -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 diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 72e8ffc..0000000 --- a/.prettierignore +++ /dev/null @@ -1 +0,0 @@ -* diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index fd5bdfe..0000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "recommendations": [ - "editorconfig.editorconfig" - ] -} diff --git a/package-lock.json b/package-lock.json index 10b4668..7d04cbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "out-of-your-element", - "version": "3.4.0", + "version": "3.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "out-of-your-element", - "version": "3.4.0", + "version": "3.5.1", "license": "AGPL-3.0-or-later", "dependencies": { "@chriscdn/promise-semaphore": "^3.0.1", @@ -30,7 +30,7 @@ "enquirer": "^2.4.1", "entities": "^5.0.0", "get-relative-path": "^1.0.2", - "h3": "^1.15.1", + "h3": "^1.15.10", "heatsync": "^2.7.2", "htmx.org": "^2.0.4", "lru-cache": "^11.0.2", @@ -276,9 +276,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", - "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", "license": "MIT", "optional": true, "dependencies": { @@ -1163,9 +1163,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1488,9 +1488,9 @@ "license": "MIT" }, "node_modules/domino": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/domino/-/domino-2.1.6.tgz", - "integrity": "sha512-3VdM/SXBZX2omc9JF9nOPCtDaYQ67BGp5CoLpIQlO2KCAPETs8TcDHacF26jXadGbvUteZzRTeos2fhID5+ucQ==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/domino/-/domino-2.1.7.tgz", + "integrity": "sha512-3rcXhx0ixJV2nj8J0tljzejTF73A35LVVdnTQu79UAqTBFEgYPMgGtykMuu/BDqaOZphATku1ddRUn/RtqUHYQ==", "license": "BSD-2-Clause" }, "node_modules/emoji-regex": { @@ -1587,9 +1587,9 @@ } }, "node_modules/flatted": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", - "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -1617,9 +1617,9 @@ "license": "MIT" }, "node_modules/fullstore": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/fullstore/-/fullstore-4.0.0.tgz", - "integrity": "sha512-Y9hN79Q1CFU8akjGnTZoBnTzlA/o8wmtBijJOI8dKCmdC7GLX7OekpLxmbaeRetTOi4OdFGjfsg4c5dxP3jgPw==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/fullstore/-/fullstore-4.0.2.tgz", + "integrity": "sha512-syOev4kA0lZy4VkfBJZ99ZL4cIiSgiKt0G8SpP0kla1tpM1c+V/jBOVY/OqqGtR2XLVcM83SjFPFC3R2YIwqjQ==", "dev": true, "license": "MIT", "engines": { @@ -1688,9 +1688,9 @@ } }, "node_modules/h3": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.6.tgz", - "integrity": "sha512-oi15ESLW5LRthZ+qPCi5GNasY/gvynSKUQxgiovrY63bPAtG59wtM+LSrlcwvOHAXzGrXVLnI97brbkdPF9WoQ==", + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.10.tgz", + "integrity": "sha512-YzJeWSkDZxAhvmp8dexjRK5hxziRO7I9m0N53WhvYL5NiWfkUkzssVzY9jvGu0HBoLFW6+duYmNSn6MaZBCCtg==", "license": "MIT", "dependencies": { "cookie-es": "^1.2.2", @@ -1937,9 +1937,9 @@ "license": "MIT" }, "node_modules/json-with-bigint": { - "version": "3.5.7", - "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.7.tgz", - "integrity": "sha512-7ei3MdAI5+fJPVnKlW77TKNKwQ5ppSzWvhPuSuINT/GYW9ZOC1eRKOuhV9yHG5aEsUPj9BBx5JIekkmoLHxZOw==", + "version": "3.5.8", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz", + "integrity": "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 0e666aa..c85a362 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "out-of-your-element", - "version": "3.4.0", + "version": "3.5.1", "description": "A bridge between Matrix and Discord", "main": "index.js", "repository": { @@ -39,7 +39,7 @@ "enquirer": "^2.4.1", "entities": "^5.0.0", "get-relative-path": "^1.0.2", - "h3": "^1.15.1", + "h3": "^1.15.10", "heatsync": "^2.7.2", "htmx.org": "^2.0.4", "lru-cache": "^11.0.2", diff --git a/scripts/reset-web-password.js b/scripts/reset-web-password.js index 9131efb..7c3a1a2 100644 --- a/scripts/reset-web-password.js +++ b/scripts/reset-web-password.js @@ -13,5 +13,5 @@ const {prompt} = require("enquirer") reg.ooye.web_password = passwordResponse.web_password writeRegistration(reg) - console.log("Saved. Restart Out Of Your Element to apply this change.") + console.log("Saved. This change should be applied instantly.") })() diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index c2ec01a..7f110ad 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -193,6 +193,16 @@ async function channelToKState(channel, guild, di) { // Don't overwrite room topic if the topic has been customised 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 // (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() diff --git a/src/d2m/actions/create-room.test.js b/src/d2m/actions/create-room.test.js index 36fccba..c9e098b 100644 --- a/src/d2m/actions/create-room.test.js +++ b/src/d2m/actions/create-room.test.js @@ -190,6 +190,17 @@ test("channel2room: read-only discord channel", async t => { 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 => { t.deepEqual( _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, "hauntings"), diff --git a/src/d2m/actions/expression.js b/src/d2m/actions/expression.js index c7ab27a..0f714c6 100644 --- a/src/d2m/actions/expression.js +++ b/src/d2m/actions/expression.js @@ -34,7 +34,10 @@ async function emojisToState(emojis, guild) { if (e.data?.errcode === "M_TOO_LARGE") { // Very unlikely to happen. Only possible for 3x-series emojis uploaded shortly after animated emojis were introduced, when there was no 256 KB size limit. return } - console.error(`Trying to handle emoji ${emoji.name} (${emoji.id}), but...`) + e["emoji"] = { + name: emoji.name, + id: emoji.id + } throw e }) )) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 3f598f2..6e9ce7b 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -357,6 +357,17 @@ async function messageToEvent(message, guild, options = {}, di) { }] } + if (message.type === DiscordTypes.MessageType.ChannelFollowAdd) { + return [{ + $type: "m.room.message", + msgtype: "m.emote", + body: `set this room to receive announcements from ${message.content}`, + format: "org.matrix.custom.html", + formatted_body: tag`set this room to receive announcements from ${message.content}`, + "m.mentions": {} + }] + } + let isInteraction = (message.type === DiscordTypes.MessageType.ChatInputCommand || message.type === DiscordTypes.MessageType.ContextMenuCommand) && message.interaction && "name" in message.interaction let isThinkingInteraction = isInteraction && !!((message.flags || 0) & DiscordTypes.MessageFlags.Loading) @@ -658,7 +669,7 @@ async function messageToEvent(message, guild, options = {}, di) { const match = repliedToEventSenderMxid.match(/^@([^:]*)/) assert(match) repliedToDisplayName = referenced.author.username || match[1] || "a Matrix user" // grab the localpart as the display name, whatever - repliedToUserHtml = `${repliedToDisplayName}` + repliedToUserHtml = tag`${repliedToDisplayName}` } else { repliedToDisplayName = referenced.author.global_name || referenced.author.username || "a Discord user" repliedToUserHtml = repliedToDisplayName @@ -683,6 +694,12 @@ async function messageToEvent(message, guild, options = {}, di) { + html body = `${repliedToDisplayName}: ${repliedToBody}`.split("\n").map(line => "> " + line).join("\n") // scenario 1 part B for mentions + "\n\n" + body + } else if (referenced.type === DiscordTypes.MessageType.UserJoin) { + // Discord user join messages are bridged as joins, not text events. Generate substitute text for reply. + const joinerMxid = select("sim", "mxid", {user_id: referenced.author.id}).pluck().get() + const joinerHtml = joinerMxid ? tag`${repliedToDisplayName}` : tag`${repliedToDisplayName}` + html = `
${joinerHtml} joined the room
` + html + body = `> ${repliedToDisplayName} joined the room\n\n` + body } else { // repliedToUnknownEvent const dateDisplay = dUtils.howOldUnbridgedMessage(referenced.timestamp, message.timestamp) html = `
In reply to ${dateDisplay} from ${repliedToDisplayName}:` diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index c4b812d..b7f0867 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -4,6 +4,7 @@ const {MatrixServerError} = require("../../matrix/mreq") const data = require("../../../test/data") const {mockGetEffectivePower} = require("../../matrix/utils.test") const Ty = require("../../types") +const {db} = require("../../passthrough") /** * @param {string} roomID @@ -733,6 +734,31 @@ test("message2event: reply to a Discord message that wasn't bridged", async t => }]) }) +test("message2event: reply to a Discord member join (who didn't join on Matrix)", async t => { + const events = await messageToEvent(data.message.reply_to_member_join, data.guild.general) + t.deepEqual(events, [{ + $type: "m.room.message", + msgtype: "m.text", + body: "> PEASANT!! joined the room\n\nwhen the broke friend who we pay to bring food shows up at the medieval lord party", + format: "org.matrix.custom.html", + formatted_body: "
PEASANT!! joined the room
when the broke friend who we pay to bring food shows up at the medieval lord party", + "m.mentions": {} + }]) +}) + +test("message2event: reply to a Discord member join (who did join on Matrix)", async t => { + db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES ('1461677775554478161', 'peasant321_76775', 'peasant321_76775', '@_ooye_peasant321_76775:cadence.moe')").run() + const events = await messageToEvent(data.message.reply_to_member_join, data.guild.general) + t.deepEqual(events, [{ + $type: "m.room.message", + msgtype: "m.text", + body: "> PEASANT!! joined the room\n\nwhen the broke friend who we pay to bring food shows up at the medieval lord party", + format: "org.matrix.custom.html", + formatted_body: `
PEASANT!! joined the room
when the broke friend who we pay to bring food shows up at the medieval lord party`, + "m.mentions": {} + }]) +}) + test("message2event: simple written @mention for matrix user", async t => { const events = await messageToEvent(data.message.simple_written_at_mention_for_matrix, data.guild.general, {}, { api: { @@ -1142,6 +1168,19 @@ test("message2event: type 4 channel name change", async t => { }]) }) +test("message2event: type 12 channel follow add", async t => { + const events = await messageToEvent(data.special_message.channel_follow_add, data.guild.general) + t.deepEqual(events, [{ + $type: "m.room.message", + "m.mentions": {}, + msgtype: "m.emote", + body: "set this room to receive announcements from PluralKit #downtime", + format: "org.matrix.custom.html", + formatted_body: "set this room to receive announcements from PluralKit #downtime", + "m.mentions": {} + }]) +}) + test("message2event: thread start message reference", async t => { const events = await messageToEvent(data.special_message.thread_start_context, data.guild.general, {}, { api: { diff --git a/src/d2m/converters/pins-to-list.js b/src/d2m/converters/pins-to-list.js index 5a33c7c..4ad8800 100644 --- a/src/d2m/converters/pins-to-list.js +++ b/src/d2m/converters/pins-to-list.js @@ -22,7 +22,7 @@ function pinsToList(pins, kstate) { /** @type {string[]} */ const result = [] 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) } result.reverse() diff --git a/src/discord/interactions/matrix-info.js b/src/discord/interactions/matrix-info.js index c85cec2..dcc9943 100644 --- a/src/discord/interactions/matrix-info.js +++ b/src/discord/interactions/matrix-info.js @@ -54,6 +54,7 @@ async function _interact({guild_id, data}, {api}) { // from Matrix const event = await api.getEvent(message.room_id, message.event_id) const via = await utils.getViaServersQuery(message.room_id, api) + const channelsInGuild = discord.guildChannelMap.get(guild_id) assert(channelsInGuild) const inChannels = channelsInGuild @@ -61,8 +62,35 @@ async function _interact({guild_id, data}, {api}) { .map(/** @returns {DiscordTypes.APIGuildChannel} */ cid => discord.channels.get(cid)) .sort((a, b) => webGuild._getPosition(a, discord.channels) - webGuild._getPosition(b, discord.channels)) .filter(channel => from("channel_room").join("member_cache", "room_id").select("mxid").where({channel_id: channel.id, mxid: event.sender}).get()) + let inChannelsText = inChannels.map(c => `<#${c.id}>`).join(" β€’ ") + if (inChannelsText.length > 1024) { + inChannelsText = `In ${inChannels.length} channels` + } + const matrixMember = select("member_cache", ["displayname", "avatar_url"], {room_id: message.room_id, mxid: event.sender}).get() - const name = matrixMember?.displayname || event.sender + let name = matrixMember?.displayname || event.sender + let avatar = utils.getPublicUrlForMxc(matrixMember?.avatar_url) + + // Check for per-message profile + const perMessageProfile = event.content?.["com.beeper.per_message_profile"] + let profileNote = "" + if (perMessageProfile) { + if (perMessageProfile.displayname) { + name = perMessageProfile.displayname + } + if ("avatar_url" in perMessageProfile) { + if (perMessageProfile.avatar_url) { + // use provided avatar_url + avatar = utils.getPublicUrlForMxc(perMessageProfile.avatar_url) + } else if (perMessageProfile.avatar_url === "") { + // empty string avatar_url clears the avatar + avatar = undefined + } + // else, omitted/null falls back to member avatar + } + profileNote = "Sent with a per-message profile.\n" + } + return { type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, data: { @@ -70,13 +98,13 @@ async function _interact({guild_id, data}, {api}) { author: { name, url: `https://matrix.to/#/${event.sender}`, - icon_url: utils.getPublicUrlForMxc(matrixMember?.avatar_url) + icon_url: avatar }, - description: `This Matrix message was delivered to Discord by **Out Of Your Element**.\n[View on Matrix β†’]()\n\n**User ID**: [${event.sender}]()`, + description: `This Matrix message was delivered to Discord by **Out Of Your Element**.\n[View on Matrix β†’]()\n\n${profileNote}**User ID**: [${event.sender}]()`, color: 0x0dbd8b, fields: [{ name: "In Channels", - value: inChannels.map(c => `<#${c.id}>`).join(" β€’ ") + value: inChannelsText }, { name: "\u200b", value: idInfo diff --git a/src/discord/interactions/matrix-info.test.js b/src/discord/interactions/matrix-info.test.js index f455700..8347c12 100644 --- a/src/discord/interactions/matrix-info.test.js +++ b/src/discord/interactions/matrix-info.test.js @@ -85,3 +85,118 @@ test("matrix info: shows info for matrix source message", async t => { ) t.equal(called, 1) }) + +test("matrix info: shows username for per-message profile", async t => { + let called = 0 + const msg = await _interact({ + data: { + target_id: "1128118177155526666", + resolved: { + messages: { + "1141501302736695316": data.message.simple_reply_to_matrix_user + } + } + }, + guild_id: "112760669178241024" + }, { + api: { + async getEvent(roomID, eventID) { + called++ + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") + t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4") + return { + event_id: eventID, + room_id: roomID, + type: "m.room.message", + content: { + msgtype: "m.text", + body: "master chief: i like the halo", + format: "org.matrix.custom.html", + formatted_body: "master chief: i like the halo", + "com.beeper.per_message_profile": { + has_fallback: true, + displayname: "master chief", + avatar_url: "" + } + }, + sender: "@cadence:cadence.moe" + } + }, + async getJoinedMembers(roomID) { + return { + joined: {} + } + }, + async getStateEventOuter(roomID, type, key) { + return { + content: { + room_version: "11" + } + } + }, + async getStateEvent(roomID, type, key) { + return {} + } + } + }) + t.equal(msg.data.embeds[0].author.name, "master chief") + t.match(msg.data.embeds[0].description, "Sent with a per-message profile") + t.equal(called, 1) +}) + +test("matrix info: shows avatar for per-message profile", async t => { + let called = 0 + const msg = await _interact({ + data: { + target_id: "1128118177155526666", + resolved: { + messages: { + "1141501302736695316": data.message.simple_reply_to_matrix_user + } + } + }, + guild_id: "112760669178241024" + }, { + api: { + async getEvent(roomID, eventID) { + called++ + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") + t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4") + return { + event_id: eventID, + room_id: roomID, + type: "m.room.message", + content: { + msgtype: "m.text", + body: "?", + format: "org.matrix.custom.html", + formatted_body: "?", + "com.beeper.per_message_profile": { + avatar_url: "mxc://cadence.moe/HXfFuougamkURPPMflTJRxGc" + } + }, + sender: "@mystery:cadence.moe" + } + }, + async getJoinedMembers(roomID) { + return { + joined: {} + } + }, + async getStateEventOuter(roomID, type, key) { + return { + content: { + room_version: "11" + } + } + }, + async getStateEvent(roomID, type, key) { + return {} + } + } + }) + t.equal(msg.data.embeds[0].author.name, "@mystery:cadence.moe") + t.equal(msg.data.embeds[0].author.icon_url, "https://bridge.example.org/download/matrix/cadence.moe/HXfFuougamkURPPMflTJRxGc") + t.match(msg.data.embeds[0].description, "Sent with a per-message profile") + t.equal(called, 1) +}) diff --git a/src/discord/utils.js b/src/discord/utils.js index aed7068..0d400f1 100644 --- a/src/discord/utils.js +++ b/src/discord/utils.js @@ -182,6 +182,394 @@ function filterTo(xs, fn) { return filtered } +const supportedPlaintextPreviewExtensions = new Set([ + "4d", + "abnf", + "accesslog", + "actionscript", + "ada", + "adoc", + "alan", + "angelscript", + "ansi", + "apache", + "apacheconf", + "applescript", + "arcade", + "arduino", + "arm", + "armasm", + "as", + "asc", + "asciidoc", + "aspectj", + "ass", + "atom", + "autohotkey", + "autoit", + "avrasm", + "awk", + "axapta", + "bash", + "basic", + "bat", + "bbcode", + "bf", + "bind", + "blade", + "bnf", + "brainfuck", + "c", + "c++", + "cal", + "capnp", + "capnproto", + "cc", + "chaos", + "chapel", + "chpl", + "cisco", + "clj", + "clojure", + "cls", + "cmake.in", + "cmake", + "cmd", + "coffee", + "coffeescript", + "console", + "coq", + "cos", + "cpc", + "cpp", + "cr", + "craftcms", + "crm", + "crmsh", + "crystal", + "cs", + "csharp", + "cshtml", + "cson", + "csp", + "css", + "csv", + "cxx", + "cypher", + "d", + "dart", + "delphi", + "dfm", + "diff", + "django", + "dns", + "docker", + "dockerfile", + "dos", + "dpr", + "dsconfig", + "dst", + "dts", + "dust", + "dylan", + "ebnf", + "elixir", + "elm", + "erl", + "erlang", + "ex", + "extempore", + "f90", + "f95", + "fix", + "fortran", + "freepascal", + "fs", + "fsharp", + "gams", + "gauss", + "gawk", + "gcode", + "gdscript", + "gemspec", + "gf", + "gherkin", + "glsl", + "gms", + "gn", + "gni", + "go", + "godot", + "golang", + "golo", + "gololang", + "gradle", + "graph", + "groovy", + "gss", + "gyp", + "h", + "h++", + "haml", + "handlebars", + "haskell", + "haxe", + "hbs", + "hcl", + "hh", + "hpp", + "hs", + "html.handlebars", + "html.hbs", + "html", + "http", + "https", + "hx", + "hxx", + "hy", + "hylang", + "i", + "i7", + "iced", + "iecst", + "inform7", + "ini", + "ino", + "instances", + "iol", + "irb", + "irpf90", + "java", + "javascript", + "jinja", + "jolie", + "js", + "json", + "jsp", + "jsx", + "julia-repl", + "julia", + "k", + "kaos", + "kdb", + "kotlin", + "kt", + "lasso", + "lassoscript", + "lazarus", + "ldif", + "leaf", + "lean", + "less", + "lfm", + "lisp", + "livecodeserver", + "livescript", + "ln", + "lock", + "log", + "lpr", + "ls", + "ls", + "lua", + "mak", + "make", + "makefile", + "markdown", + "mathematica", + "matlab", + "mawk", + "maxima", + "md", + "mel", + "mercury", + "mirc", + "mizar", + "mk", + "mkd", + "mkdown", + "ml", + "ml", + "mm", + "mma", + "mojolicious", + "monkey", + "moon", + "moonscript", + "mrc", + "n1ql", + "nawk", + "nc", + "never", + "nginx", + "nginxconf", + "nim", + "nimrod", + "nix", + "nsis", + "obj-c", + "obj-c++", + "objc", + "objective-c++", + "objectivec", + "ocaml", + "ocl", + "ol", + "openscad", + "osascript", + "oxygene", + "p21", + "parser3", + "pas", + "pascal", + "patch", + "pcmk", + "perl", + "pf.conf", + "pf", + "pgsql", + "php", + "php3", + "php4", + "php5", + "php6", + "php7", + "pl", + "plaintext", + "plist", + "pm", + "podspec", + "pony", + "postgres", + "postgresql", + "powershell", + "pp", + "processing", + "profile", + "prolog", + "properties", + "proto", + "protobuf", + "ps", + "ps1", + "puppet", + "py", + "pycon", + "python-repl", + "python", + "qml", + "r", + "razor-cshtml", + "razor", + "rb", + "re", + "reasonml", + "rebol", + "red-system", + "red", + "redbol", + "rf", + "rib", + "robot", + "rpm-spec", + "rpm-specfile", + "rpm", + "rs", + "rsl", + "rss", + "ruby", + "ruleslanguage", + "rust", + "sas", + "SAS", + "sc", + "scad", + "scala", + "scheme", + "sci", + "scilab", + "scl", + "scss", + "sh", + "shell", + "shexc", + "smali", + "smalltalk", + "sml", + "sol", + "solidity", + "spec", + "specfile", + "sql", + "srt", + "ssa", + "st", + "stan", + "stanfuncs", + "stata", + "step", + "stp", + "structured-text", + "styl", + "stylus", + "subunit", + "supercollider", + "svelte", + "svg", + "swift", + "tao", + "tap", + "tcl", + "terraform", + "tex", + "text", + "tf", + "thor", + "thrift", + "tk", + "toml", + "tp", + "ts", + "tsql", + "tsx", + "ttml", + "twig", + "txt", + "typescript", + "unicorn-rails-log", + "v", + "vala", + "vb", + "vba", + "vbnet", + "vbs", + "vbscript", + "verilog", + "vhdl", + "vim", + "vtt", + "wl", + "x++", + "x86asm", + "xhtml", + "xjb", + "xl", + "xml", + "xpath", + "xq", + "xquery", + "xsd", + "xsl", + "xtlang", + "xtm", + "yaml", + "yml", + "zep", + "zephir", + "zone", + "zsh" +]) + module.exports.getPermissions = getPermissions module.exports.getDefaultPermissions = getDefaultPermissions module.exports.hasPermission = hasPermission @@ -194,3 +582,4 @@ module.exports.timestampToSnowflakeInexact = timestampToSnowflakeInexact module.exports.getPublicUrlForCdn = getPublicUrlForCdn module.exports.howOldUnbridgedMessage = howOldUnbridgedMessage module.exports.filterTo = filterTo +module.exports.supportedPlaintextPreviewExtensions = supportedPlaintextPreviewExtensions diff --git a/src/m2d/actions/update-pins.js b/src/m2d/actions/update-pins.js index d06f6e8..1ff2bb9 100644 --- a/src/m2d/actions/update-pins.js +++ b/src/m2d/actions/update-pins.js @@ -13,7 +13,7 @@ async function updatePins(pins, prev) { const diff = diffPins.diffPins(pins, prev) for (const [event_id, added] of diff) { 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 (added) { discord.snow.channel.addChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message pinned on Matrix") diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 1b23787..31caef0 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -550,13 +550,30 @@ async function eventToMessage(event, guild, channel, di) { /** @type {string[]} */ let messageIDsToEdit = [] let replyLine = "" + // Extract a basic display name from the sender const match = event.sender.match(/^@(.*?):/) if (match) displayName = match[1] + // Try to extract an accurate display name and avatar URL from the member event const member = await getMemberFromCacheOrHomeserver(event.room_id, event.sender, di?.api) if (member.displayname) displayName = member.displayname if (member.avatar_url) avatarURL = mxUtils.getPublicUrlForMxc(member.avatar_url) + + // 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 && "avatar_url" in perMessageProfile) { + if (perMessageProfile.avatar_url) { + // use provided avatar_url + avatarURL = mxUtils.getPublicUrlForMxc(perMessageProfile.avatar_url) + } else if (perMessageProfile.avatar_url === "") { + // empty string avatar_url clears the avatar + avatarURL = undefined + } + // else, omitted/null falls back to member avatar + } + // If the display name is too long to be put into the webhook (80 characters is the maximum), // put the excess characters into displayNameRunoff, later to be put at the top of the message let [displayNameShortened, displayNameRunoff] = splitDisplayName(displayName) @@ -859,8 +876,9 @@ async function eventToMessage(event, guild, channel, di) { const doc = domino.createDocument( // DOM parsers arrange elements in the and . Wrapping in a custom element ensures elements are reliably arranged in a single element. '' + input + '' - ); - const root = doc.getElementById("turndown-root"); + ) + const root = doc.getElementById("turndown-root") + assert(root) async function forEachNode(event, node) { for (; node; node = node.nextSibling) { // Check written mentions @@ -876,7 +894,8 @@ async function eventToMessage(event, guild, channel, di) { let preNode if (node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"]))) { if (preNode.firstChild?.nodeName === "CODE") { - const ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1] || "txt" + let ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1] + if (!dUtils.supportedPlaintextPreviewExtensions.has(ext)) ext = "txt" const filename = `inline_code.${ext}` // Build the replacement node const replacementCode = doc.createElement("code") @@ -913,6 +932,7 @@ async function eventToMessage(event, guild, channel, di) { } } 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. // First we need to determine which emojis are at the end. @@ -944,6 +964,10 @@ async function eventToMessage(event, guild, channel, di) { } else { // Looks like we're using the plaintext body! content = event.content.body + if (perMessageProfile?.has_fallback && perMessageProfile.displayname && content.startsWith(perMessageProfile.displayname + ": ")) { + // Strip the display name prefix fallback added for clients that don't support per-message profiles + content = content.slice(perMessageProfile.displayname.length + 2) + } if (event.content.msgtype === "m.emote") { content = `* ${displayName} ${content}` diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index 1c263b4..68d519a 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -1155,6 +1155,38 @@ test("event2message: code blocks are uploaded as attachments instead if they con ) }) +test("event2message: code blocks are uploaded as attachments instead if they contain incompatible backticks (use txt extension if discord does not recognise the language)", async t => { + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "wrong body", + format: "org.matrix.custom.html", + formatted_body: 'So if you run code like this
System.out.println("```");
it should print a markdown formatted code block' + }, + event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "cadence [they]", + content: "So if you run code like this `[inline_code.txt]` it should print a markdown formatted code block", + attachments: [{id: "0", filename: "inline_code.txt"}], + pendingFiles: [{name: "inline_code.txt", buffer: Buffer.from('System.out.println("```");')}], + avatar_url: undefined, + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) +}) + test("event2message: code blocks are uploaded as attachments instead if they contain incompatible backticks (default to txt file extension)", async t => { t.deepEqual( await eventToMessage({ @@ -5526,6 +5558,141 @@ test("event2message: known and unknown emojis in the end are used for sprite she ) }) +test("event2message: com.beeper.per_message_profile overrides displayname and avatar_url", async t => { + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "hello from unstable profile", + "com.beeper.per_message_profile": { + id: "custom-id", + displayname: "Unstable Name", + avatar_url: "mxc://maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo" + } + }, + event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "Unstable Name", + content: "hello from unstable profile", + avatar_url: "https://bridge.example.org/download/matrix/maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo", + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) +}) + +test("event2message: com.beeper.per_message_profile empty avatar_url clears avatar", async t => { + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "hello with cleared avatar", + "com.beeper.per_message_profile": { + id: "no-avatar", + displayname: "No Avatar User", + avatar_url: "" + } + }, + event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "No Avatar User", + content: "hello with cleared avatar", + avatar_url: undefined, + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) +}) + +test("event2message: data-mx-profile-fallback element is stripped from formatted_body when per-message profile is present", async t => { + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "Tidus Herboren: one more test", + format: "org.matrix.custom.html", + formatted_body: "Tidus Herboren: one more test", + "com.beeper.per_message_profile": { + id: "tidus", + displayname: "Tidus Herboren", + avatar_url: "mxc://maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo", + has_fallback: true + } + }, + event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "Tidus Herboren", + content: "one more test", + avatar_url: "https://bridge.example.org/download/matrix/maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo", + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) +}) + +test("event2message: displayname prefix is stripped from plain body when per-message profile has_fallback", async t => { + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "Tidus Herboren: one more test", + "com.beeper.per_message_profile": { + id: "tidus", + displayname: "Tidus Herboren", + has_fallback: true + } + }, + event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "Tidus Herboren", + content: "one more test", + avatar_url: undefined, + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) +}) + test("event2message: all unknown chess emojis are used for sprite sheet", async t => { t.deepEqual( await eventToMessage({ diff --git a/src/matrix/read-registration.js b/src/matrix/read-registration.js index 114bf75..d1243a7 100644 --- a/src/matrix/read-registration.js +++ b/src/matrix/read-registration.js @@ -78,6 +78,15 @@ function readRegistration() { /** @type {import("../types").AppServiceRegistrationConfig} */ // @ts-ignore let reg = readRegistration() +if (reg) { + fs.watch(registrationFilePath, {persistent: false}, () => { + let newReg = readRegistration() + if (newReg) { + Object.assign(reg, newReg) + } + }) +} + module.exports.registrationFilePath = registrationFilePath module.exports.readRegistration = readRegistration module.exports.getTemplateRegistration = getTemplateRegistration diff --git a/src/stdin.js b/src/stdin.js index fea5fad..2548d42 100644 --- a/src/stdin.js +++ b/src/stdin.js @@ -23,10 +23,26 @@ const setPresence = sync.require("./d2m/actions/set-presence") const channelWebhook = sync.require("./m2d/actions/channel-webhook") const guildID = "112760669178241024" +async function ping() { + const result = await api.ping().catch(e => ({ok: false, status: "net", root: e.message})) + if (result.ok) { + return "Ping OK. The homeserver and OOYE are talking to each other fine." + } else { + if (typeof result.root === "string") { + var msg = `Cannot reach homeserver: ${result.root}` + } else if (result.root.error) { + var msg = `Homeserver said: [${result.status}] ${result.root.error}` + } else { + var msg = `Homeserver said: [${result.status}] ${JSON.stringify(result.root)}` + } + return msg + "\nMatrix->Discord won't work until you fix this.\nIf your installation has recently changed, consider `npm run setup` again." + } +} + if (process.stdin.isTTY) { setImmediate(() => { if (!passthrough.repl) { - const cli = repl.start({ prompt: "", eval: customEval, writer: s => s }) + const cli = repl.start({prompt: "", eval: customEval, writer: s => s}) Object.assign(cli.context, passthrough) passthrough.repl = cli } diff --git a/test/data.js b/test/data.js index 45e0388..f3092bc 100644 --- a/test/data.js +++ b/test/data.js @@ -19,6 +19,26 @@ module.exports = { default_thread_rate_limit_per_user: 0, 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: { type: 0, topic: "Updates and release announcements for Out Of Your Element.", @@ -2015,6 +2035,80 @@ module.exports = { tts: false } }, + reply_to_member_join: { + type: 19, + content: "when the broke friend who we pay to bring food shows up at the medieval lord party", + mentions: [], + mention_roles: [], + attachments: [], + embeds: [], + timestamp: "2026-03-30T12:11:04.443000+00:00", + edited_timestamp: null, + flags: 0, + components: [], + id: "1488148556962332692", + channel_id: "475599038536744962", + author: { + id: "576945009408999426", + username: "randomllama121", + avatar: "08510a70f957106dad1580323c40cd7a", + discriminator: "0", + public_flags: 128, + flags: 128, + banner: null, + accent_color: null, + global_name: "random :3", + avatar_decoration_data: null, + collectibles: null, + display_name_styles: null, + banner_color: null, + clan: null, + primary_guild: null + }, + pinned: false, + mention_everyone: false, + tts: false, + message_reference: { + type: 0, + channel_id: "475599038536744962", + message_id: "1488146734352826478", + guild_id: "475599038536744960" + }, + referenced_message: { + type: 7, + content: "", + mentions: [], + mention_roles: [], + attachments: [], + embeds: [], + timestamp: "2026-03-30T12:03:49.899000+00:00", + edited_timestamp: null, + flags: 0, + components: [], + id: "1488146734352826478", + channel_id: "475599038536744962", + author: { + id: "1461677775554478161", + username: "peasant321_76775", + avatar: null, + discriminator: "0", + public_flags: 0, + flags: 0, + banner: null, + accent_color: null, + global_name: "PEASANT!!", + avatar_decoration_data: null, + collectibles: null, + display_name_styles: null, + banner_color: null, + clan: null, + primary_guild: null + }, + pinned: false, + mention_everyone: false, + tts: false + } + }, attachment_no_content: { id: "1124628646670389348", type: 0, @@ -6170,6 +6264,37 @@ module.exports = { components: [], position: 12 }, + channel_follow_add: { + type: 12, + content: "PluralKit #downtime", + attachments: [], + embeds: [], + timestamp: "2026-03-24T23:16:04.097Z", + edited_timestamp: null, + flags: 0, + components: [], + id: "1486141581047369888", + channel_id: "1451125453082591314", + author: { + id: "154058479798059009", + username: "exaptations", + discriminator: "0", + avatar: "57b5cfe09a48a5902f2eb8fa65bb1b80", + bot: false, + flags: 0, + globalName: "Exa", + }, + pinned: false, + mentions: [], + mention_roles: [], + mention_everyone: false, + tts: false, + message_reference: { + type: 0, + channel_id: "1015204661701124206", + guild_id: "466707357099884544" + } + }, updated_to_start_thread_from_here: { t: "MESSAGE_UPDATE", s: 19,