Compare commits
3 commits
main
...
move-reply
| Author | SHA1 | Date | |
|---|---|---|---|
| 9eb2ba1458 | |||
| 95572446e6 | |||
| 5bfd753af3 |
7 changed files with 45 additions and 87 deletions
|
|
@ -8,10 +8,6 @@ Modern Matrix-to-Discord appservice bridge, created by [@cadence:cadence.moe](ht
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## AI policy
|
|
||||||
|
|
||||||
Out Of Your Element is 100% organically cultivated ethical hand-fed code. Let's work together to conserve that!
|
|
||||||
|
|
||||||
## Why a new bridge?
|
## Why a new bridge?
|
||||||
|
|
||||||
* Modern: Supports new Discord features like replies, threads and stickers, and new Matrix features like edits, spaces and space membership.
|
* Modern: Supports new Discord features like replies, threads and stickers, and new Matrix features like edits, spaces and space membership.
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
const assert = require("assert").strict
|
const assert = require("assert").strict
|
||||||
const {reg} = require("../../matrix/read-registration")
|
const {reg} = require("../../matrix/read-registration")
|
||||||
const Ty = require("../../types")
|
const Ty = require("../../types")
|
||||||
const {scheduler} = require("timers/promises")
|
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {sync, db, select, from} = passthrough
|
const {sync, db, select, from} = passthrough
|
||||||
|
|
@ -15,21 +14,14 @@ const file = sync.require("../../matrix/file")
|
||||||
const registerUser = sync.require("./register-user")
|
const registerUser = sync.require("./register-user")
|
||||||
|
|
||||||
/** @returns {Promise<Ty.PkMessage>} */
|
/** @returns {Promise<Ty.PkMessage>} */
|
||||||
async function fetchMessage(messageID, attempt = 1) {
|
async function fetchMessage(messageID) {
|
||||||
try {
|
try {
|
||||||
var res = await fetch(`https://api.pluralkit.me/v2/messages/${messageID}`)
|
var res = await fetch(`https://api.pluralkit.me/v2/messages/${messageID}`)
|
||||||
} catch (networkError) {
|
} catch (networkError) {
|
||||||
// Network issue, raise a more readable message
|
// Network issue, raise a more readable message
|
||||||
throw new Error(`Failed to connect to PK API: ${networkError.toString()}`)
|
throw new Error(`Failed to connect to PK API: ${networkError.toString()}`)
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) throw new Error(`PK API returned an error: ${await res.text()}`)
|
||||||
if (attempt < 2) {
|
|
||||||
await scheduler.wait(5000)
|
|
||||||
return fetchMessage(messageID, attempt + 1)
|
|
||||||
} else {
|
|
||||||
throw new Error(`PK API returned an error: ${await res.text()}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/** @type {any} */
|
/** @type {any} */
|
||||||
const root = await res.json()
|
const root = await res.json()
|
||||||
if (!root.member) throw new Error(`PK API didn't return member data: ${JSON.stringify(root)}`)
|
if (!root.member) throw new Error(`PK API didn't return member data: ${JSON.stringify(root)}`)
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ function waitFor(id, resolve, existsInDatabase) {
|
||||||
const GET_EVENT_PREPARED = from("event_message").select("event_id").and("WHERE event_id = ?").prepare().raw()
|
const GET_EVENT_PREPARED = from("event_message").select("event_id").and("WHERE event_id = ?").prepare().raw()
|
||||||
/**
|
/**
|
||||||
* @param {string} eventID
|
* @param {string} eventID
|
||||||
* @returns {Promise<boolean>} if false then the message did not arrive
|
* @returns {Promise<boolean>} if true then the message did not arrive
|
||||||
*/
|
*/
|
||||||
function waitForEvent(eventID) {
|
function waitForEvent(eventID) {
|
||||||
const {promise, resolve} = Promise.withResolvers()
|
const {promise, resolve} = Promise.withResolvers()
|
||||||
|
|
@ -109,7 +109,7 @@ function waitForEvent(eventID) {
|
||||||
const GET_MESSAGE_PREPARED = from("event_message").select("message_id").and("WHERE message_id = ?").prepare().raw()
|
const GET_MESSAGE_PREPARED = from("event_message").select("message_id").and("WHERE message_id = ?").prepare().raw()
|
||||||
/**
|
/**
|
||||||
* @param {string} messageID
|
* @param {string} messageID
|
||||||
* @returns {Promise<boolean>} if false then the message did not arrive
|
* @returns {Promise<boolean>} if true then the message did not arrive
|
||||||
*/
|
*/
|
||||||
function waitForMessage(messageID) {
|
function waitForMessage(messageID) {
|
||||||
const {promise, resolve} = Promise.withResolvers()
|
const {promise, resolve} = Promise.withResolvers()
|
||||||
|
|
@ -120,7 +120,7 @@ function waitForMessage(messageID) {
|
||||||
const GET_REACTION_EVENT_PREPARED = from("reaction").select("hashed_event_id").and("WHERE hashed_event_id = ?").prepare().raw()
|
const GET_REACTION_EVENT_PREPARED = from("reaction").select("hashed_event_id").and("WHERE hashed_event_id = ?").prepare().raw()
|
||||||
/**
|
/**
|
||||||
* @param {string} eventID
|
* @param {string} eventID
|
||||||
* @returns {Promise<boolean>} if false then the message did not arrive
|
* @returns {Promise<boolean>} if true then the message did not arrive
|
||||||
*/
|
*/
|
||||||
function waitForReactionEvent(eventID) {
|
function waitForReactionEvent(eventID) {
|
||||||
const {promise, resolve} = Promise.withResolvers()
|
const {promise, resolve} = Promise.withResolvers()
|
||||||
|
|
|
||||||
|
|
@ -55,8 +55,10 @@ class DiscordClient {
|
||||||
this.guildChannelMap = new Map()
|
this.guildChannelMap = new Map()
|
||||||
if (listen !== "no") {
|
if (listen !== "no") {
|
||||||
this.cloud.on("event", message => {
|
this.cloud.on("event", message => {
|
||||||
|
process.nextTick(() => {
|
||||||
discordPackets.onPacket(this, message, listen)
|
discordPackets.onPacket(this, message, listen)
|
||||||
})
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (CONNECTION_DEBUG) {
|
if (CONNECTION_DEBUG) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert").strict
|
const assert = require("assert")
|
||||||
const {scheduler} = require("timers/promises")
|
const {scheduler} = require("timers/promises")
|
||||||
const passthrough = require("../passthrough")
|
const passthrough = require("../passthrough")
|
||||||
const {sync} = passthrough
|
const {sync} = passthrough
|
||||||
|
|
@ -8,32 +8,7 @@ const {sync} = passthrough
|
||||||
/** @type {import("../matrix/homeserver-status")} */
|
/** @type {import("../matrix/homeserver-status")} */
|
||||||
const homeserverStatus = sync.require("../matrix/homeserver-status")
|
const homeserverStatus = sync.require("../matrix/homeserver-status")
|
||||||
|
|
||||||
const guildReadyStatus = new class {
|
let checkedHomeserver = false
|
||||||
/** @type {Set<string> | null} */
|
|
||||||
unavailableGuilds = null
|
|
||||||
_allReady = Promise.withResolvers()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} guildID
|
|
||||||
* @returns {boolean} true if it was the last one
|
|
||||||
*/
|
|
||||||
makeReady(guildID) {
|
|
||||||
assert(this.unavailableGuilds)
|
|
||||||
if (this.unavailableGuilds.delete(guildID) && this.unavailableGuilds.size === 0) {
|
|
||||||
this._allReady.resolve(null)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
allReady() {
|
|
||||||
return this.unavailableGuilds && this.unavailableGuilds.size === 0
|
|
||||||
}
|
|
||||||
|
|
||||||
waitForAllReady() {
|
|
||||||
return this._allReady.promise
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("./discord-client")} client
|
* @param {import("./discord-client")} client
|
||||||
|
|
@ -53,27 +28,12 @@ async function onPacket(client, message, listen) {
|
||||||
client.ready = true
|
client.ready = true
|
||||||
client.user = message.d.user
|
client.user = message.d.user
|
||||||
client.application = message.d.application
|
client.application = message.d.application
|
||||||
guildReadyStatus.unavailableGuilds = new Set(message.d.guilds.filter(g => g.unavailable).map(g => g.id))
|
|
||||||
console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`)
|
console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`)
|
||||||
process.stdout.write("Waiting for guilds to warm up... ")
|
|
||||||
interactions.registerInteractions()
|
interactions.registerInteractions()
|
||||||
|
|
||||||
} else if (message.t === "GUILD_CREATE") {
|
} else if (message.t === "GUILD_CREATE") {
|
||||||
message.d.members = message.d.members.filter(m => m.user.id === client.user.id) // only keep the bot account's member - it's needed for roles to determine private channels on web
|
message.d.members = message.d.members.filter(m => m.user.id === client.user.id) // only keep the bot's own member - it's needed to determine private channels on web
|
||||||
client.guilds.set(message.d.id, message.d)
|
client.guilds.set(message.d.id, message.d)
|
||||||
|
|
||||||
/*
|
|
||||||
Info about guilds is populated one guild at a time.
|
|
||||||
For m->d bridging to work, the guild needs to be populated, so we need to have GUILD_CREATE for the guild.
|
|
||||||
If we ping the homeserver, it will send us any pending events, so we need to wait for all GUILD_CREATES before we ping.
|
|
||||||
We must attempt a ping because we don't want to try sending missed d->m messages to an offline homeserver.
|
|
||||||
The "all guilds ready" delay can be removed if ONE of the following is done:
|
|
||||||
1. m->d can queue incoming events until their guild exists in memory
|
|
||||||
2. d->m missed messages can have their errors handled and added to queue, rather than pinging first
|
|
||||||
*/
|
|
||||||
const firstReady = guildReadyStatus.allReady()
|
|
||||||
const lastGuildReady = guildReadyStatus.makeReady(message.d.id)
|
|
||||||
|
|
||||||
const arr = []
|
const arr = []
|
||||||
client.guildChannelMap.set(message.d.id, arr)
|
client.guildChannelMap.set(message.d.id, arr)
|
||||||
for (const channel of message.d.channels || []) {
|
for (const channel of message.d.channels || []) {
|
||||||
|
|
@ -91,14 +51,28 @@ async function onPacket(client, message, listen) {
|
||||||
|
|
||||||
if (listen === "full") {
|
if (listen === "full") {
|
||||||
try {
|
try {
|
||||||
// Wait for guilds to be connected and homeserver to be online. If this is the last guild, a different code path is used to trigger the homeserver check.
|
/*
|
||||||
if (lastGuildReady) {
|
Info about guilds is populated one guild at a time.
|
||||||
process.stdout.write(`ok, ${client.guilds.size} available.\nConnecting to homeserver... `)
|
For m->d bridging to work, the guild needs to be populated, so we need to have GUILD_CREATE for the guild.
|
||||||
// await guildReadyStatus.waitForAllReady() - no need, we already checked this is the last guild
|
If we ping the homeserver, it will send us any pending events, so we need to wait for all GUILD_CREATES before we ping.
|
||||||
|
We must attempt a ping because we don't want to try sending missed d->m messages to an offline homeserver.
|
||||||
|
This delay can be removed if ONE of the following is done:
|
||||||
|
1. m->d can queue incoming events until their guild exists in memory
|
||||||
|
2. d->m missed messages can have their errors handled and added to queue, rather than pinging first
|
||||||
|
*/
|
||||||
|
let isMainCharacter = false
|
||||||
|
if (!checkedHomeserver) {
|
||||||
|
checkedHomeserver = true
|
||||||
|
isMainCharacter = true
|
||||||
|
console.log("Warming up guilds~")
|
||||||
|
}
|
||||||
|
await scheduler.wait(5000)
|
||||||
|
if (isMainCharacter) {
|
||||||
|
checkedHomeserver = true
|
||||||
|
process.stdout.write("Connecting to homeserver... ")
|
||||||
await homeserverStatus.homeserverStatus.waitForOnline(true)
|
await homeserverStatus.homeserverStatus.waitForOnline(true)
|
||||||
console.log("ok.\nReplaying past events. Welcome to Out Of Your Element.")
|
console.log("ok.\nReplaying past events. Welcome to Out Of Your Element.")
|
||||||
} else {
|
} else {
|
||||||
await guildReadyStatus.waitForAllReady()
|
|
||||||
await homeserverStatus.homeserverStatus.waitForOnline(false)
|
await homeserverStatus.homeserverStatus.waitForOnline(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,9 +81,7 @@ async function onPacket(client, message, listen) {
|
||||||
await eventDispatcher.checkMissedPins(client, message.d)
|
await eventDispatcher.checkMissedPins(client, message.d)
|
||||||
await eventDispatcher.checkMissedLeaves(client, message.d)
|
await eventDispatcher.checkMissedLeaves(client, message.d)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (firstReady) {
|
|
||||||
console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:")
|
console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:")
|
||||||
}
|
|
||||||
console.error(e)
|
console.error(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -253,4 +225,3 @@ async function dispatchPacketToBridge(client, message) {
|
||||||
|
|
||||||
module.exports.onPacket = onPacket
|
module.exports.onPacket = onPacket
|
||||||
module.exports.dispatchPacketToBridge = dispatchPacketToBridge
|
module.exports.dispatchPacketToBridge = dispatchPacketToBridge
|
||||||
module.exports.guildReadyStatus = guildReadyStatus
|
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,7 @@ async function sendEvent(event) {
|
||||||
channel_id: messageResponse.channel_id,
|
channel_id: messageResponse.channel_id,
|
||||||
guild_id: guild.id,
|
guild_id: guild.id,
|
||||||
embeds: messageResponse.embeds
|
embeds: messageResponse.embeds
|
||||||
}, guild)
|
}, guild, null)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,6 @@ const setupEmojis = sync.require("../actions/setup-emojis")
|
||||||
const userToMxid = sync.require("../../d2m/converters/user-to-mxid")
|
const userToMxid = sync.require("../../d2m/converters/user-to-mxid")
|
||||||
/** @type {import("../../web/routes/letter-avatar")} */
|
/** @type {import("../../web/routes/letter-avatar")} */
|
||||||
const letterAvatar = sync.require("../../web/routes/letter-avatar")
|
const letterAvatar = sync.require("../../web/routes/letter-avatar")
|
||||||
/** @type {import("../../d2m/actions/retrigger")} */
|
|
||||||
const retrigger = sync.require("../../d2m/actions/retrigger")
|
|
||||||
|
|
||||||
/** @type {[RegExp, string][]} */
|
/** @type {[RegExp, string][]} */
|
||||||
const markdownEscapes = [
|
const markdownEscapes = [
|
||||||
|
|
@ -698,7 +696,6 @@ async function eventToMessage(event, guild, channel, di) {
|
||||||
// Check if we have a pointer to what was edited
|
// Check if we have a pointer to what was edited
|
||||||
const originalEventId = relatesTo.event_id
|
const originalEventId = relatesTo.event_id
|
||||||
if (!originalEventId) return
|
if (!originalEventId) return
|
||||||
if (!await retrigger.waitForEvent(originalEventId)) return
|
|
||||||
messageIDsToEdit = select("event_message", "message_id", {event_id: originalEventId}, "ORDER BY part").pluck().all()
|
messageIDsToEdit = select("event_message", "message_id", {event_id: originalEventId}, "ORDER BY part").pluck().all()
|
||||||
if (!messageIDsToEdit.length) return
|
if (!messageIDsToEdit.length) return
|
||||||
|
|
||||||
|
|
@ -757,7 +754,7 @@ async function eventToMessage(event, guild, channel, di) {
|
||||||
.replace(/(?:\n|<br>)+/g, " ") // Should all be on one line
|
.replace(/(?:\n|<br>)+/g, " ") // Should all be on one line
|
||||||
.replace(/<span [^>]*data-mx-spoiler\b[^>]*>.*?<\/span>/g, "[spoiler]") // Good enough method of removing spoiler content. (I don't want to break out the HTML parser unless I have to.)
|
.replace(/<span [^>]*data-mx-spoiler\b[^>]*>.*?<\/span>/g, "[spoiler]") // Good enough method of removing spoiler content. (I don't want to break out the HTML parser unless I have to.)
|
||||||
.replace(/<[^>]+>/g, "") // Completely strip all HTML tags and formatting.
|
.replace(/<[^>]+>/g, "") // Completely strip all HTML tags and formatting.
|
||||||
), 50)
|
), 65)
|
||||||
replyLine = "-# > " + contentPreviewChunks[0]
|
replyLine = "-# > " + contentPreviewChunks[0]
|
||||||
if (contentPreviewChunks.length > 1) replyLine = replyLine.replace(/[,.']$/, "") + "..."
|
if (contentPreviewChunks.length > 1) replyLine = replyLine.replace(/[,.']$/, "") + "..."
|
||||||
replyLine += "\n"
|
replyLine += "\n"
|
||||||
|
|
@ -765,11 +762,6 @@ async function eventToMessage(event, guild, channel, di) {
|
||||||
}
|
}
|
||||||
|
|
||||||
replyLine = await getL1L2ReplyLine()
|
replyLine = await getL1L2ReplyLine()
|
||||||
const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
|
|
||||||
.select("reference_channel_id", "message_id").where({event_id: repliedToEventId}).and("ORDER BY part").get()
|
|
||||||
if (row) {
|
|
||||||
replyLine += `https://discord.com/channels/${guild.id}/${row.reference_channel_id}/${row.message_id} `
|
|
||||||
}
|
|
||||||
// If the event has been edited, the homeserver will include the relation in `unsigned`.
|
// If the event has been edited, the homeserver will include the relation in `unsigned`.
|
||||||
if (repliedToEvent.unsigned?.["m.relations"]?.["m.replace"]?.content?.["m.new_content"]) {
|
if (repliedToEvent.unsigned?.["m.relations"]?.["m.replace"]?.content?.["m.new_content"]) {
|
||||||
repliedToEvent = repliedToEvent.unsigned["m.relations"]["m.replace"] // Note: this changes which event_id is in repliedToEvent.
|
repliedToEvent = repliedToEvent.unsigned["m.relations"]["m.replace"] // Note: this changes which event_id is in repliedToEvent.
|
||||||
|
|
@ -780,7 +772,7 @@ async function eventToMessage(event, guild, channel, di) {
|
||||||
const fileReplyContentAlternative = attachmentEmojis.get(repliedToEvent.content.msgtype)
|
const fileReplyContentAlternative = attachmentEmojis.get(repliedToEvent.content.msgtype)
|
||||||
let contentPreview
|
let contentPreview
|
||||||
if (fileReplyContentAlternative) {
|
if (fileReplyContentAlternative) {
|
||||||
contentPreview = " " + fileReplyContentAlternative
|
contentPreview = fileReplyContentAlternative
|
||||||
} else if (repliedToEvent.unsigned?.redacted_because) {
|
} else if (repliedToEvent.unsigned?.redacted_because) {
|
||||||
contentPreview = "(in reply to a deleted message)"
|
contentPreview = "(in reply to a deleted message)"
|
||||||
} else if (typeof originalRepliedToContent !== "string") {
|
} else if (typeof originalRepliedToContent !== "string") {
|
||||||
|
|
@ -805,7 +797,7 @@ async function eventToMessage(event, guild, channel, di) {
|
||||||
repliedToContent = entities.decodeHTML5Strict(repliedToContent) // Remove entities like & "
|
repliedToContent = entities.decodeHTML5Strict(repliedToContent) // Remove entities like & "
|
||||||
const contentPreviewChunks = chunk(repliedToContent, 50)
|
const contentPreviewChunks = chunk(repliedToContent, 50)
|
||||||
if (contentPreviewChunks.length) {
|
if (contentPreviewChunks.length) {
|
||||||
contentPreview = ": " + contentPreviewChunks[0]
|
contentPreview = contentPreviewChunks[0]
|
||||||
contentPreview = contentPreview.replace(/\bhttps?:\/\/[^ )]*/g, url => {
|
contentPreview = contentPreview.replace(/\bhttps?:\/\/[^ )]*/g, url => {
|
||||||
const originalUrlIndex = originalRepliedToContent.indexOf(url)
|
const originalUrlIndex = originalRepliedToContent.indexOf(url)
|
||||||
if (originalUrlIndex !== -1 && originalRepliedToContent[originalUrlIndex + url.length]?.match(/[^ )>"'`]/)) { // URL was truncated by chunking, replace it
|
if (originalUrlIndex !== -1 && originalRepliedToContent[originalUrlIndex + url.length]?.match(/[^ )>"'`]/)) { // URL was truncated by chunking, replace it
|
||||||
|
|
@ -832,6 +824,11 @@ async function eventToMessage(event, guild, channel, di) {
|
||||||
if (!senderName) senderName = sender.match(/@([^:]*)/)?.[1]
|
if (!senderName) senderName = sender.match(/@([^:]*)/)?.[1]
|
||||||
if (senderName) replyLine += `**Ⓜ${senderName}**`
|
if (senderName) replyLine += `**Ⓜ${senderName}**`
|
||||||
}
|
}
|
||||||
|
const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
|
||||||
|
.select("reference_channel_id", "message_id").where({event_id: repliedToEventId}).and("ORDER BY part").get()
|
||||||
|
if (row) {
|
||||||
|
contentPreview = `[${contentPreview}](<https://discord.com/channels/${guild.id}/${row.reference_channel_id}/${row.message_id}>)`
|
||||||
|
}
|
||||||
replyLine = `-# > ${replyLine} ${contentPreview}\n`
|
replyLine = `-# > ${replyLine} ${contentPreview}\n`
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue