Compare commits

..

10 commits

13 changed files with 69 additions and 294 deletions

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "out-of-your-element",
"version": "3.6.0",
"version": "3.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "out-of-your-element",
"version": "3.6.0",
"version": "3.5.1",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@chriscdn/promise-semaphore": "^3.0.1",

View file

@ -1,6 +1,6 @@
{
"name": "out-of-your-element",
"version": "3.6.0",
"version": "3.5.1",
"description": "A bridge between Matrix and Discord",
"main": "index.js",
"repository": {

View file

@ -60,7 +60,8 @@ async function sendMessage(message, channel, guild, row) {
const detailedResultsMessage = await pollEnd.endPoll(message)
if (detailedResultsMessage) {
const threadParent = select("channel_room", "thread_parent", {channel_id: message.channel_id}).pluck().get()
const {channelID, threadID} = dUtils.swapThreadID(message.channel_id, threadParent)
const channelID = threadParent ? threadParent : message.channel_id
const threadID = threadParent ? message.channel_id : undefined
sentResultsMessage = await channelWebhook.sendMessageWithWebhook(channelID, detailedResultsMessage, threadID)
}
}

View file

@ -1,5 +1,6 @@
// @ts-check
const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough")
const {discord, select, db} = passthrough
@ -69,18 +70,12 @@ async function doSpeedbump(messageID) {
* Check whether to slow down a message, and do it. After it passes the speedbump, return whether it's okay or if it's been deleted.
* @param {string} channelID
* @param {string} messageID
* @param {string} [userID] if provided, only slow down the message when the user has used PK before
* @returns whether it was deleted, and data about the channel's (not thread's) speedbump
*/
async function maybeDoSpeedbump(channelID, messageID, userID) {
let row = select("channel_room", ["room_id", "thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: channelID}).get()
if (row?.thread_parent) row = select("channel_room", ["room_id", "thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: row.thread_parent}).get() // webhooks belong to the channel, not the thread
if (!row?.speedbump_webhook_id) return {affected: false, row: null} // channel not affected, no speedbump
if (userID) {
if (row.speedbump_webhook_id === userID) return {affected: false, row} // shortcut
const userHasProxy = select("sim_proxy", "user_id", {proxy_owner_id: userID}).pluck().get()
if (!userHasProxy) return {affected: false, row} // user has not used PK before, no speedbump
}
async function maybeDoSpeedbump(channelID, messageID) {
let row = select("channel_room", ["thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: channelID}).get()
if (row?.thread_parent) row = select("channel_room", ["thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: row.thread_parent}).get() // webhooks belong to the channel, not the thread
if (!row?.speedbump_webhook_id) return {affected: false, row: null} // not affected, no speedbump
const affected = await doSpeedbump(messageID)
return {affected, row} // maybe affected, and there is a speedbump
}

View file

@ -265,9 +265,8 @@ function getFormattedInteraction(interaction, isThinkingInteraction) {
* @param {any} newEvents merge into events
* @param {any} events will be modified
* @param {boolean} forceSameMsgtype whether m.text may only be combined with m.text, etc
* @param {boolean} [forceMerge] if true, must merge event, will error if it had to append
*/
function mergeTextEvents(newEvents, events, forceSameMsgtype, forceMerge = false) {
function mergeTextEvents(newEvents, events, forceSameMsgtype) {
let prev = events.at(-1)
for (const ne of newEvents) {
const isAllText = prev?.body && prev?.formatted_body && ["m.text", "m.notice"].includes(ne.msgtype) && ["m.text", "m.notice"].includes(prev?.msgtype)
@ -279,8 +278,6 @@ function mergeTextEvents(newEvents, events, forceSameMsgtype, forceMerge = false
rep.addLine(ne.body, ne.formatted_body)
prev.body = rep.body
prev.formatted_body = rep.formattedBody
} else if (forceMerge) {
throw new Error("Unable to merge events")
} else {
events.push(ne)
}
@ -557,7 +554,7 @@ async function messageToEvent(message, guild, options = {}, di) {
// Handling emojis that we don't know about. The emoji has to be present in the DB for it to be picked up in the emoji markdown converter.
// So we scan the message ahead of time for all its emojis and ensure they are in the DB.
const emojiMatches = [...content.matchAll(/<(a?):([^:>]+):([0-9]+)>/g)]
const emojiMatches = [...content.matchAll(/<(a?):([^:>]{1,64}):([0-9]+)>/g)]
await Promise.all(emojiMatches.map(match => {
const id = match[3]
const name = match[2]
@ -970,8 +967,7 @@ async function messageToEvent(message, guild, options = {}, di) {
// May only be a section accessory or in an action row (up to 5)
if (component.style === DiscordTypes.ButtonStyle.Link) {
assert(component.label) // required for Discord to validate link buttons
const link = await transformContentMessageLinks(component.url)
stack.msb.add(`[${component.label} ${link}] `, tag`<a href="${link}">${component.label}</a> `)
stack.msb.add(`[${component.label} ${component.url}] `, tag`<a href="${component.url}">${component.label}</a> `)
}
}
@ -984,19 +980,7 @@ async function messageToEvent(message, guild, options = {}, di) {
const {body, formatted_body} = stack.msb.get()
if (body.trim().length) {
// Create new message if Components V2 (cannot have regular content)
if ((message.flags ?? 0) & DiscordTypes.MessageFlags.IsComponentsV2) {
await addTextEvent(body, formatted_body, "m.text")
}
// Add to existing message if legacy components https://docs.discord.com/developers/components/reference#legacy-message-component-behavior
else {
mergeTextEvents([{
msgtype: "m.text",
body,
format: "org.matrix.custom.html",
formatted_body
}], events, false, true)
}
await addTextEvent(body, formatted_body, "m.text")
}
}

View file

@ -1,7 +1,6 @@
const {test} = require("supertape")
const {messageToEvent} = require("./message-to-event")
const data = require("../../../test/data")
const {mockGetEffectivePower} = require("../../matrix/utils.test")
test("message2event components: pk question mark output", async t => {
const events = await messageToEvent(data.message_with_components.pk_question_mark_response, data.guild.general, {})
@ -78,24 +77,3 @@ test("message2event components: pk question mark output", async t => {
msgtype: "m.text",
}])
})
test("message2event components: pk ping message legacy components", async t => {
const events = await messageToEvent(data.message_with_components.pk_ping_components_v1, data.guild.general, {}, {
api: {
async getJoinedMembers() {
return {joined: {}}
},
getEffectivePower: mockGetEffectivePower()
}
})
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "❭ cadence used `/🔔 Ping author`"
+ "\nPsst, **Red** (@cadence.worm:), you have been pinged by @cadence.worm:."
+ "\n[Jump https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$l9FMmsEbh9K0NUReeEpWOMZYGRlUOE8yLcm6P-TYHSM?via=cadence.moe] ",
format: "org.matrix.custom.html",
formatted_body: "<blockquote><sub>❭ <a href=\"https://matrix.to/#/@_ooye_cadence:cadence.moe\">cadence</a> used <code>/🔔 Ping author</code></sub></blockquote>Psst, <strong>Red</strong> (<a href=\"https://matrix.to/#/@_ooye_cadence:cadence.moe\">@cadence.worm</a>), you have been pinged by <a href=\"https://matrix.to/#/@_ooye_cadence:cadence.moe\">@cadence.worm</a>.<br><a href=\"https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$l9FMmsEbh9K0NUReeEpWOMZYGRlUOE8yLcm6P-TYHSM?via=cadence.moe\">Jump</a> ",
"m.mentions": {}
}])
})

View file

@ -313,7 +313,7 @@ module.exports = {
if (!createRoom.existsOrAutocreatable(channel, guild.id)) return // Check that the sending-to room exists or is autocreatable
const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id, message.author.id)
const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id)
if (affected) return
// @ts-ignore
@ -335,11 +335,13 @@ module.exports = {
if (dUtils.isEphemeralMessage(data)) return // Ephemeral messages are for the eyes of the receiver only!
// Edits need to go through the speedbump as well. If the message is delayed but the edit isn't, we don't have anything to edit from.
const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id, data.author.id)
const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id)
if (affected) return
// Check that the sending-to room exists, and deal with Eventual Consistency(TM)
if (!await retrigger.waitForMessage(data.id)) return
if (!row) {
// Check that the sending-to room exists, and deal with Eventual Consistency(TM)
if (!await retrigger.waitForMessage(data.id)) return
}
/** @type {DiscordTypes.GatewayMessageCreateDispatchData} */
// @ts-ignore

View file

@ -91,32 +91,40 @@ function registerInteractions() {
async function dispatchInteraction(interaction) {
const interactionId = interaction.data?.["custom_id"] || interaction.data?.["name"]
try {
if (interactionId === "Matrix info") {
await matrixInfo.interact(interaction)
} else if (interactionId === "invite") {
await invite.interact(interaction)
} else if (interactionId === "invite_channel") {
await invite.interactButton(interaction)
} else if (interactionId === "Permissions") {
await permissions.interact(interaction)
} else if (interactionId === "permissions_edit") {
await permissions.interactEdit(interaction)
} else if (interactionId === "Responses") {
/** @type {DiscordTypes.APIMessageApplicationCommandGuildInteraction} */ // @ts-ignore
const messageInteraction = interaction
if (select("poll", "message_id", {message_id: messageInteraction.data.target_id}).get()) {
await pollResponses.interact(messageInteraction)
if (interaction.type === DiscordTypes.InteractionType.MessageComponent || interaction.type === DiscordTypes.InteractionType.ModalSubmit) {
// All we get is custom_id, don't know which context the button was clicked in.
// So we namespace these ourselves in the custom_id. Currently the only existing namespace is POLL_.
if (interaction.data.custom_id.startsWith("POLL_")) {
await poll.interact(interaction)
} else {
await reactions.interact(messageInteraction)
throw new Error(`Unknown message component ${interaction.data.custom_id}`)
}
} else if (interactionId === "ping") {
await ping.interact(interaction)
} else if (interactionId === "privacy") {
await privacy.interact(interaction)
} else if (interactionId.startsWith("POLL_")) {
await poll.interact(interaction)
} else {
throw new Error(`Unknown interaction ${interactionId}`)
if (interactionId === "Matrix info") {
await matrixInfo.interact(interaction)
} else if (interactionId === "invite") {
await invite.interact(interaction)
} else if (interactionId === "invite_channel") {
await invite.interactButton(interaction)
} else if (interactionId === "Permissions") {
await permissions.interact(interaction)
} else if (interactionId === "permissions_edit") {
await permissions.interactEdit(interaction)
} else if (interactionId === "Responses") {
/** @type {DiscordTypes.APIMessageApplicationCommandGuildInteraction} */ // @ts-ignore
const messageInteraction = interaction
if (select("poll", "message_id", {message_id: messageInteraction.data.target_id}).get()) {
await pollResponses.interact(messageInteraction)
} else {
await reactions.interact(messageInteraction)
}
} else if (interactionId === "ping") {
await ping.interact(interaction)
} else if (interactionId === "privacy") {
await privacy.interact(interaction)
} else {
throw new Error(`Unknown interaction ${interactionId}`)
}
}
} catch (e) {
let stackLines = null

View file

@ -182,18 +182,6 @@ function filterTo(xs, fn) {
return filtered
}
/**
* The parameters correspond to the columns of the channel_room table.
* @param {string} rowChannelID thread ID, OR channel ID if there is no thread
* @param {string | null | undefined} rowThreadParent channel ID if there is a thread
*/
function swapThreadID(rowChannelID, rowThreadParent) {
return {
channelID: rowThreadParent ? rowThreadParent : rowChannelID,
threadID: rowThreadParent ? rowChannelID : undefined
}
}
const supportedPlaintextPreviewExtensions = new Set([
"4d",
"abnf",
@ -594,5 +582,4 @@ module.exports.timestampToSnowflakeInexact = timestampToSnowflakeInexact
module.exports.getPublicUrlForCdn = getPublicUrlForCdn
module.exports.howOldUnbridgedMessage = howOldUnbridgedMessage
module.exports.filterTo = filterTo
module.exports.swapThreadID = swapThreadID
module.exports.supportedPlaintextPreviewExtensions = supportedPlaintextPreviewExtensions

View file

@ -1,12 +1,18 @@
// @ts-check
const Ty = require("../../types")
const DiscordTypes = require("discord-api-types/v10")
const {Readable} = require("stream")
const assert = require("assert").strict
const crypto = require("crypto")
const passthrough = require("../../passthrough")
const {sync, db, select} = passthrough
const {sync, discord, db, select} = passthrough
/** @type {import("../../discord/utils")} */
const dUtils = sync.require("../../discord/utils")
const {reg} = require("../../matrix/read-registration")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/utils")} */
const utils = sync.require("../../matrix/utils")
/** @type {import("../converters/poll-components")} */
const pollComponents = sync.require("../converters/poll-components")
/** @type {import("./channel-webhook")} */
@ -27,11 +33,10 @@ async function updateVote(event) {
// If poll was started on Matrix, the Discord version is using components, so we can update that to the current status
if (messageRow.source === 0) {
const row = select("channel_room", ["channel_id", "thread_parent"], {room_id: event.room_id}).get()
assert(row)
const {channelID, threadID} = dUtils.swapThreadID(row.channel_id, row.thread_parent)
await webhook.editMessageWithWebhook(channelID, messageID, pollComponents.getPollComponentsFromDatabase(messageID), threadID)
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
assert(channelID)
await webhook.editMessageWithWebhook(channelID, messageID, pollComponents.getPollComponentsFromDatabase(messageID))
}
}
module.exports.updateVote = updateVote
module.exports.updateVote = updateVote

View file

@ -122,7 +122,7 @@ block body
#role-add.s-popover(popover style="display: revert").ws2.px0.py4.bs-lg.overflow-visible
.s-popover--arrow.s-popover--arrow__tc
+add-roles-menu(guild, guild_id)
p.fc-light.mb0.mt8 Matrix users will start with these roles. If your main channels are gated by a role, use this to let Matrix users skip the gate.
p.fc-medium.mb0.mt8 Matrix users will start with these roles. If your main channels are gated by a role, use this to let Matrix users skip the gate.
h3.mt32.fs-category Features
.s-card.d-grid.px0.g16
@ -191,14 +191,14 @@ block body
label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id)
+discord(channel, true, "Announcement")
else
.s-empty-state.p8 No Discord channels available.
.s-empty-state.p8 All Discord channels are linked.
.fl-grow1.s-btn-group.fd-column.w30
each room in unlinkedRooms
input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id)
label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id)
+matrix(room, true)
else
.s-empty-state.p8 No Matrix rooms available.
.s-empty-state.p8 All Matrix rooms are linked.
input(type="hidden" name="guild_id" value=guild_id)
div
button.s-btn.s-btn__icon.s-btn__filled#link-button

View file

@ -5473,189 +5473,6 @@ module.exports = {
content: '-# Original Message ID: 1466556003645657118 · <t:1769724599:f>'
}
]
},
pk_ping_components_v1: {
type: 23,
content: "Psst, **Red** (<@772659086046658620>), you have been pinged by <@772659086046658620>.",
mentions: [
{
id: "772659086046658620",
username: "cadence.worm",
avatar: "466df0c98b1af1e1388f595b4c1ad1b9",
discriminator: "0",
public_flags: 0,
flags: 0,
banner: null,
accent_color: null,
global_name: "cadence",
avatar_decoration_data: null,
collectibles: null,
display_name_styles: null,
banner_color: null,
clan: {
identity_guild_id: "532245108070809601",
identity_enabled: true,
tag: "doll",
badge: "dba08126b4e810a0e096cc7cd5bc37f0"
},
primary_guild: {
identity_guild_id: "532245108070809601",
identity_enabled: true,
tag: "doll",
badge: "dba08126b4e810a0e096cc7cd5bc37f0"
}
}
],
mention_roles: [],
attachments: [],
embeds: [],
timestamp: "2026-03-25T07:07:02.626000+00:00",
edited_timestamp: null,
flags: 0,
components: [
{
type: 1,
id: 1,
components: [
{
type: 2,
id: 2,
style: 5,
label: "Jump",
url: "https://discord.com/channels/1160893336324931584/1160894080998461480/1440549403667468320"
}
]
}
],
id: "1486260105908457653",
channel_id: "1160894080998461480",
author: {
id: "466378653216014359",
username: "PluralKit",
avatar: "b78ef67a081737a830b60aa47d9ebcd9",
discriminator: "4020",
public_flags: 65536,
flags: 65536,
bot: true,
banner: null,
accent_color: null,
global_name: null,
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,
application_id: "466378653216014359",
interaction: {
id: "1486260103928614932",
type: 2,
name: "🔔 Ping author",
user: {
id: "772659086046658620",
username: "cadence.worm",
avatar: "466df0c98b1af1e1388f595b4c1ad1b9",
discriminator: "0",
public_flags: 0,
flags: 0,
banner: null,
accent_color: null,
global_name: "cadence",
avatar_decoration_data: null,
collectibles: null,
display_name_styles: null,
banner_color: null,
clan: {
identity_guild_id: "532245108070809601",
identity_enabled: true,
tag: "doll",
badge: "dba08126b4e810a0e096cc7cd5bc37f0"
},
primary_guild: {
identity_guild_id: "532245108070809601",
identity_enabled: true,
tag: "doll",
badge: "dba08126b4e810a0e096cc7cd5bc37f0"
}
}
},
webhook_id: "466378653216014359",
message_reference: {
type: 0,
channel_id: "1160894080998461480",
message_id: "1440549403667468320",
guild_id: "1160893336324931584"
},
interaction_metadata: {
id: "1486260103928614932",
type: 2,
user: {
id: "772659086046658620",
username: "cadence.worm",
avatar: "466df0c98b1af1e1388f595b4c1ad1b9",
discriminator: "0",
public_flags: 0,
flags: 0,
banner: null,
accent_color: null,
global_name: "cadence",
avatar_decoration_data: null,
collectibles: null,
display_name_styles: null,
banner_color: null,
clan: {
identity_guild_id: "532245108070809601",
identity_enabled: true,
tag: "doll",
badge: "dba08126b4e810a0e096cc7cd5bc37f0"
},
primary_guild: {
identity_guild_id: "532245108070809601",
identity_enabled: true,
tag: "doll",
badge: "dba08126b4e810a0e096cc7cd5bc37f0"
}
},
authorizing_integration_owners: { "0": "1160893336324931584" },
name: "🔔 Ping author",
command_type: 3,
target_message_id: "1440549403667468320"
},
referenced_message: {
type: 0,
content: "test",
mentions: [],
mention_roles: [],
attachments: [],
embeds: [],
timestamp: "2025-11-19T03:49:01.948000+00:00",
edited_timestamp: null,
flags: 0,
components: [],
id: "1440549403667468320",
channel_id: "1160894080998461480",
author: {
id: "1195662438662680720",
username: "special name",
avatar: "a82347890f2739e5880cd82b8c1a708e",
discriminator: "0000",
public_flags: 0,
flags: 0,
bot: true,
global_name: null,
clan: null,
primary_guild: null
},
pinned: false,
mention_everyone: false,
tts: false,
application_id: "466378653216014359",
webhook_id: "1195662438662680720"
}
}
},
message_update: {

View file

@ -95,8 +95,7 @@ WITH a (message_id, channel_id) AS (VALUES
('1381212840957972480', '112760669178241024'),
('1401760355339862066', '112760669178241024'),
('1439351590262800565', '1438284564815548418'),
('1404133238414376971', '112760669178241024'),
('1440549403667468320', '1160894080998461480'))
('1404133238414376971', '112760669178241024'))
SELECT message_id, max(historical_room_index) as historical_room_index FROM a INNER JOIN historical_channel_room ON historical_channel_room.reference_channel_id = a.channel_id GROUP BY message_id;
INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES
@ -144,8 +143,7 @@ INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part
('$7P2O_VTQNHvavX5zNJ35DV-dbJB1Ag80tGQP_JzGdhk', 'm.room.message', 'm.text', '1401760355339862066', 0, 0, 0),
('$ielAnR6geu0P1Tl5UXfrbxlIf-SV9jrNprxrGXP3v7M', 'm.room.message', 'm.image', '1439351590262800565', 0, 0, 0),
('$uUKLcTQvik5tgtTGDKuzn0Ci4zcCvSoUcYn2X7mXm9I', 'm.room.message', 'm.text', '1404133238414376971', 0, 1, 1),
('$LhmoWWvYyn5_AHkfb6FaXmLI6ZOC1kloql5P40YDmIk', 'm.room.message', 'm.notice', '1404133238414376971', 1, 0, 1),
('$l9FMmsEbh9K0NUReeEpWOMZYGRlUOE8yLcm6P-TYHSM', 'm.room.message', 'm.text', '1440549403667468320', 0, 0, 1);
('$LhmoWWvYyn5_AHkfb6FaXmLI6ZOC1kloql5P40YDmIk', 'm.room.message', 'm.notice', '1404133238414376971', 1, 0, 1);
INSERT INTO file (discord_url, mxc_url) VALUES
('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'),