Compare commits
4 commits
e6c30f80b5
...
c7313035a4
| Author | SHA1 | Date | |
|---|---|---|---|
| c7313035a4 | |||
| 493bc25602 | |||
| f176b547ce | |||
| 1758b7aa22 |
9 changed files with 445 additions and 184 deletions
|
|
@ -434,7 +434,7 @@ async function unbridgeChannel(channelID) {
|
|||
async function unbridgeDeletedChannel(channel, guildID) {
|
||||
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
|
||||
assert.ok(roomID)
|
||||
const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").get()
|
||||
const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").where({guild_id: guildID}).get()
|
||||
assert.ok(row)
|
||||
|
||||
let botInRoom = true
|
||||
|
|
@ -458,7 +458,7 @@ async function unbridgeDeletedChannel(channel, guildID) {
|
|||
// delete webhook on discord
|
||||
const webhook = select("webhook", ["webhook_id", "webhook_token"], {channel_id: channel.id}).get()
|
||||
if (webhook) {
|
||||
await discord.snow.webhook.deleteWebhook(webhook.webhook_id, webhook.webhook_token)
|
||||
await discord.snow.webhook.deleteWebhook(webhook.webhook_id, webhook.webhook_token).catch(() => {})
|
||||
db.prepare("DELETE FROM webhook WHERE channel_id = ?").run(channel.id)
|
||||
}
|
||||
|
||||
|
|
@ -568,6 +568,7 @@ module.exports.createAllForGuild = createAllForGuild
|
|||
module.exports.channelToKState = channelToKState
|
||||
module.exports.postApplyPowerLevels = postApplyPowerLevels
|
||||
module.exports._convertNameAndTopic = convertNameAndTopic
|
||||
module.exports._syncSpaceMember = _syncSpaceMember
|
||||
module.exports.unbridgeChannel = unbridgeChannel
|
||||
module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel
|
||||
module.exports.existsOrAutocreatable = existsOrAutocreatable
|
||||
|
|
|
|||
|
|
@ -151,16 +151,9 @@ async function syncUser(messageID, author, roomID, shouldActuallySync) {
|
|||
const mxid = await ensureSimJoined(pkMessage, roomID)
|
||||
|
||||
if (shouldActuallySync) {
|
||||
// Build current profile data
|
||||
// Build current profile data and sync if the hash has changed
|
||||
const content = await memberToStateContent(pkMessage, author)
|
||||
const currentHash = registerUser._hashProfileContent(content, 0)
|
||||
const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get()
|
||||
|
||||
// Only do the actual sync if the hash has changed since we last looked
|
||||
if (existingHash !== currentHash) {
|
||||
await api.sendState(roomID, "m.room.member", mxid, content, mxid)
|
||||
db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid)
|
||||
}
|
||||
await registerUser._sendSyncUser(roomID, mxid, content, null)
|
||||
}
|
||||
|
||||
return mxid
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ let hasher = null
|
|||
// @ts-ignore
|
||||
require("xxhash-wasm")().then(h => hasher = h)
|
||||
|
||||
const supportsMsc4069 = api.versions().then(v => !!v?.unstable_features?.["org.matrix.msc4069"]).catch(() => false)
|
||||
|
||||
/**
|
||||
* A sim is an account that is being simulated by the bridge to copy events from the other side.
|
||||
* @param {DiscordTypes.APIUser} user
|
||||
|
|
@ -98,6 +100,23 @@ async function ensureSimJoined(user, roomID) {
|
|||
return mxid
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DiscordTypes.APIUser} user
|
||||
*/
|
||||
async function userToGlobalProfile(user) {
|
||||
const globalProfile = {}
|
||||
|
||||
globalProfile.displayname = user.username
|
||||
if (user.global_name) globalProfile.displayname = user.global_name
|
||||
|
||||
if (user.avatar) {
|
||||
const avatarPath = file.userAvatar(user) // the user avatar only
|
||||
globalProfile.avatar_url = await file.uploadDiscordFileToMxc(avatarPath)
|
||||
}
|
||||
|
||||
return globalProfile
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DiscordTypes.APIUser} user
|
||||
* @param {Omit<DiscordTypes.APIGuildMember, "user"> | undefined} member
|
||||
|
|
@ -201,21 +220,45 @@ async function syncUser(user, member, channel, guild, roomID) {
|
|||
const mxid = await ensureSimJoined(user, roomID)
|
||||
const content = await memberToStateContent(user, member, guild.id)
|
||||
const powerLevel = memberToPowerLevel(user, member, guild, channel)
|
||||
const currentHash = _hashProfileContent(content, powerLevel)
|
||||
await _sendSyncUser(roomID, mxid, content, powerLevel, {
|
||||
// do not overwrite pre-existing data if we already have data and `member` is not accessible, because this would replace good data with bad data
|
||||
allowOverwrite: !!member,
|
||||
globalProfile: await userToGlobalProfile(user)
|
||||
})
|
||||
return mxid
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} roomID
|
||||
* @param {string} mxid
|
||||
* @param {{displayname: string, avatar_url?: string}} content
|
||||
* @param {number | null} powerLevel
|
||||
* @param {{allowOverwrite?: boolean, globalProfile?: {displayname: string, avatar_url?: string}}} [options]
|
||||
*/
|
||||
async function _sendSyncUser(roomID, mxid, content, powerLevel, options) {
|
||||
const currentHash = _hashProfileContent(content, powerLevel ?? 0)
|
||||
const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get()
|
||||
// only do the actual sync if the hash has changed since we last looked
|
||||
const hashHasChanged = existingHash !== currentHash
|
||||
// however, do not overwrite pre-existing data if we already have data and `member` is not accessible, because this would replace good data with bad data
|
||||
const wouldOverwritePreExisting = existingHash && !member
|
||||
if (hashHasChanged && !wouldOverwritePreExisting) {
|
||||
// always okay to add new data. for overwriting, restrict based on options.allowOverwrite, if present
|
||||
const overwriteOkay = !existingHash || (options?.allowOverwrite ?? true)
|
||||
if (hashHasChanged && overwriteOkay) {
|
||||
const actions = []
|
||||
// Update room member state
|
||||
await api.sendState(roomID, "m.room.member", mxid, content, mxid)
|
||||
actions.push(api.sendState(roomID, "m.room.member", mxid, content, mxid))
|
||||
// Update power levels
|
||||
await api.setUserPower(roomID, mxid, powerLevel)
|
||||
if (powerLevel != null) {
|
||||
actions.push(api.setUserPower(roomID, mxid, powerLevel))
|
||||
}
|
||||
// Update global profile (if supported by server)
|
||||
if (await supportsMsc4069) {
|
||||
actions.push(api.profileSetDisplayname(mxid, options?.globalProfile?.displayname || content.displayname, true))
|
||||
actions.push(api.profileSetAvatarUrl(mxid, options?.globalProfile?.avatar_url || content.avatar_url, true))
|
||||
}
|
||||
await Promise.all(actions)
|
||||
// Update cached hash
|
||||
db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid)
|
||||
}
|
||||
return mxid
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -254,5 +297,7 @@ module.exports._hashProfileContent = _hashProfileContent
|
|||
module.exports.ensureSim = ensureSim
|
||||
module.exports.ensureSimJoined = ensureSimJoined
|
||||
module.exports.syncUser = syncUser
|
||||
module.exports._sendSyncUser = _sendSyncUser
|
||||
module.exports.syncAllUsersInRoom = syncAllUsersInRoom
|
||||
module.exports._memberToPowerLevel = memberToPowerLevel
|
||||
module.exports.supportsMsc4069 = supportsMsc4069
|
||||
|
|
|
|||
|
|
@ -128,16 +128,9 @@ async function syncUser(author, roomID, shouldActuallySync) {
|
|||
const mxid = await ensureSimJoined(fakeUserID, author, roomID)
|
||||
|
||||
if (shouldActuallySync) {
|
||||
// Build current profile data
|
||||
// Build current profile data and sync if the hash has changed
|
||||
const content = await authorToStateContent(author)
|
||||
const currentHash = registerUser._hashProfileContent(content, 0)
|
||||
const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get()
|
||||
|
||||
// Only do the actual sync if the hash has changed since we last looked
|
||||
if (existingHash !== currentHash) {
|
||||
await api.sendState(roomID, "m.room.member", mxid, content, mxid)
|
||||
db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid)
|
||||
}
|
||||
await registerUser._sendSyncUser(roomID, mxid, content, null)
|
||||
}
|
||||
|
||||
return mxid
|
||||
|
|
|
|||
|
|
@ -605,7 +605,7 @@ async function eventToMessage(event, guild, di) {
|
|||
}
|
||||
attachments.push({id: "0", filename})
|
||||
pendingFiles.push({name: filename, mxc: event.content.url})
|
||||
} else if (shouldProcessTextEvent) {
|
||||
} else {
|
||||
// Handling edits. If the edit was an edit of a reply, edits do not include the reply reference, so we need to fetch up to 2 more events.
|
||||
// this event ---is an edit of--> original event ---is a reply to--> past event
|
||||
await (async () => {
|
||||
|
|
@ -738,157 +738,159 @@ async function eventToMessage(event, guild, di) {
|
|||
replyLine = `-# > ${replyLine}${contentPreview}\n`
|
||||
})()
|
||||
|
||||
if (event.content.format === "org.matrix.custom.html" && event.content.formatted_body) {
|
||||
let input = event.content.formatted_body
|
||||
if (event.content.msgtype === "m.emote") {
|
||||
input = `* ${displayName} ${input}`
|
||||
}
|
||||
|
||||
// Handling mentions of Discord users
|
||||
input = input.replace(/("https:\/\/matrix.to\/#\/((?:@|%40)[^"]+)")>/g, (whole, attributeValue, mxid) => {
|
||||
mxid = decodeURIComponent(mxid)
|
||||
if (mxUtils.eventSenderIsFromDiscord(mxid)) {
|
||||
// Handle mention of an OOYE sim user by their mxid
|
||||
const id = select("sim", "user_id", {mxid}).pluck().get()
|
||||
if (!id) return whole
|
||||
return `${attributeValue} data-user-id="${id}">`
|
||||
} else {
|
||||
// Handle mention of a Matrix user by their mxid
|
||||
// Check if this Matrix user is actually the sim user from another old bridge in the room?
|
||||
const match = mxid.match(/[^:]*discord[^:]*_([0-9]{6,}):/) // try to match @_discord_123456, @_discordpuppet_123456, etc.
|
||||
if (match) return `${attributeValue} data-user-id="${match[1]}">`
|
||||
// Nope, just a real Matrix user.
|
||||
return whole
|
||||
if (shouldProcessTextEvent) {
|
||||
if (event.content.format === "org.matrix.custom.html" && event.content.formatted_body) {
|
||||
let input = event.content.formatted_body
|
||||
if (event.content.msgtype === "m.emote") {
|
||||
input = `* ${displayName} ${input}`
|
||||
}
|
||||
})
|
||||
|
||||
// Handling mentions of rooms and room-messages
|
||||
input = await handleRoomOrMessageLinks(input, di)
|
||||
|
||||
// Stripping colons after mentions
|
||||
input = input.replace(/( data-user-id.*?<\/a>):?/g, "$1")
|
||||
input = input.replace(/("https:\/\/matrix.to.*?<\/a>):?/g, "$1")
|
||||
|
||||
// Element adds a bunch of <br> before </blockquote> but doesn't render them. I can't figure out how this even works in the browser, so let's just delete those.
|
||||
input = input.replace(/(?:\n|<br ?\/?>\s*)*<\/blockquote>/g, "</blockquote>")
|
||||
|
||||
// The matrix spec hasn't decided whether \n counts as a newline or not, but I'm going to count it, because if it's in the data it's there for a reason.
|
||||
// But I should not count it if it's between block elements.
|
||||
input = input.replace(/(<\/?([^ >]+)[^>]*>)?\n(<\/?([^ >]+)[^>]*>)?/g, (whole, beforeContext, beforeTag, afterContext, afterTag) => {
|
||||
// console.error(beforeContext, beforeTag, afterContext, afterTag)
|
||||
if (typeof beforeTag !== "string" && typeof afterTag !== "string") {
|
||||
return "<br>"
|
||||
}
|
||||
beforeContext = beforeContext || ""
|
||||
beforeTag = beforeTag || ""
|
||||
afterContext = afterContext || ""
|
||||
afterTag = afterTag || ""
|
||||
if (!mxUtils.BLOCK_ELEMENTS.includes(beforeTag.toUpperCase()) && !mxUtils.BLOCK_ELEMENTS.includes(afterTag.toUpperCase())) {
|
||||
return beforeContext + "<br>" + afterContext
|
||||
} else {
|
||||
return whole
|
||||
}
|
||||
})
|
||||
|
||||
// Note: Element's renderers on Web and Android currently collapse whitespace, like the browser does. Turndown also collapses whitespace which is good for me.
|
||||
// If later I'm using a client that doesn't collapse whitespace and I want turndown to follow suit, uncomment the following line of code, and it Just Works:
|
||||
// input = input.replace(/ /g, " ")
|
||||
// There is also a corresponding test to uncomment, named "event2message: whitespace is retained"
|
||||
|
||||
// Handling written @mentions: we need to look for candidate Discord members to join to the room
|
||||
// This shouldn't apply to code blocks, links, or inside attributes. So editing the HTML tree instead of regular expressions is a sensible choice here.
|
||||
// We're using the domino parser because Turndown uses the same and can reuse this tree.
|
||||
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.
|
||||
'<x-turndown id="turndown-root">' + input + '</x-turndown>'
|
||||
);
|
||||
const root = doc.getElementById("turndown-root");
|
||||
async function forEachNode(node) {
|
||||
for (; node; node = node.nextSibling) {
|
||||
// Check written mentions
|
||||
if (node.nodeType === 3 && node.nodeValue.includes("@") && !nodeIsChildOf(node, ["A", "CODE", "PRE"])) {
|
||||
const result = await checkWrittenMentions(node.nodeValue, event.sender, event.room_id, guild, di)
|
||||
if (result) {
|
||||
node.nodeValue = result.content
|
||||
ensureJoined.push(...result.ensureJoined)
|
||||
allowedMentionsParse.push(...result.allowedMentionsParse)
|
||||
}
|
||||
// Handling mentions of Discord users
|
||||
input = input.replace(/("https:\/\/matrix.to\/#\/((?:@|%40)[^"]+)")>/g, (whole, attributeValue, mxid) => {
|
||||
mxid = decodeURIComponent(mxid)
|
||||
if (mxUtils.eventSenderIsFromDiscord(mxid)) {
|
||||
// Handle mention of an OOYE sim user by their mxid
|
||||
const id = select("sim", "user_id", {mxid}).pluck().get()
|
||||
if (!id) return whole
|
||||
return `${attributeValue} data-user-id="${id}">`
|
||||
} else {
|
||||
// Handle mention of a Matrix user by their mxid
|
||||
// Check if this Matrix user is actually the sim user from another old bridge in the room?
|
||||
const match = mxid.match(/[^:]*discord[^:]*_([0-9]{6,}):/) // try to match @_discord_123456, @_discordpuppet_123456, etc.
|
||||
if (match) return `${attributeValue} data-user-id="${match[1]}">`
|
||||
// Nope, just a real Matrix user.
|
||||
return whole
|
||||
}
|
||||
// Check for incompatible backticks in code blocks
|
||||
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"
|
||||
const filename = `inline_code.${ext}`
|
||||
// Build the replacement <code> node
|
||||
const replacementCode = doc.createElement("code")
|
||||
replacementCode.textContent = `[${filename}]`
|
||||
// Build its containing <span> node
|
||||
const replacement = doc.createElement("span")
|
||||
replacement.appendChild(doc.createTextNode(" "))
|
||||
replacement.appendChild(replacementCode)
|
||||
replacement.appendChild(doc.createTextNode(" "))
|
||||
// Replace the code block with the <span>
|
||||
preNode.replaceWith(replacement)
|
||||
// Upload the code as an attachment
|
||||
const content = getCodeContent(preNode.firstChild)
|
||||
attachments.push({id: String(attachments.length), filename})
|
||||
pendingFiles.push({name: filename, buffer: Buffer.from(content, "utf8")})
|
||||
}
|
||||
})
|
||||
|
||||
// Handling mentions of rooms and room-messages
|
||||
input = await handleRoomOrMessageLinks(input, di)
|
||||
|
||||
// Stripping colons after mentions
|
||||
input = input.replace(/( data-user-id.*?<\/a>):?/g, "$1")
|
||||
input = input.replace(/("https:\/\/matrix.to.*?<\/a>):?/g, "$1")
|
||||
|
||||
// Element adds a bunch of <br> before </blockquote> but doesn't render them. I can't figure out how this even works in the browser, so let's just delete those.
|
||||
input = input.replace(/(?:\n|<br ?\/?>\s*)*<\/blockquote>/g, "</blockquote>")
|
||||
|
||||
// The matrix spec hasn't decided whether \n counts as a newline or not, but I'm going to count it, because if it's in the data it's there for a reason.
|
||||
// But I should not count it if it's between block elements.
|
||||
input = input.replace(/(<\/?([^ >]+)[^>]*>)?\n(<\/?([^ >]+)[^>]*>)?/g, (whole, beforeContext, beforeTag, afterContext, afterTag) => {
|
||||
// console.error(beforeContext, beforeTag, afterContext, afterTag)
|
||||
if (typeof beforeTag !== "string" && typeof afterTag !== "string") {
|
||||
return "<br>"
|
||||
}
|
||||
beforeContext = beforeContext || ""
|
||||
beforeTag = beforeTag || ""
|
||||
afterContext = afterContext || ""
|
||||
afterTag = afterTag || ""
|
||||
if (!mxUtils.BLOCK_ELEMENTS.includes(beforeTag.toUpperCase()) && !mxUtils.BLOCK_ELEMENTS.includes(afterTag.toUpperCase())) {
|
||||
return beforeContext + "<br>" + afterContext
|
||||
} else {
|
||||
return whole
|
||||
}
|
||||
})
|
||||
|
||||
// Note: Element's renderers on Web and Android currently collapse whitespace, like the browser does. Turndown also collapses whitespace which is good for me.
|
||||
// If later I'm using a client that doesn't collapse whitespace and I want turndown to follow suit, uncomment the following line of code, and it Just Works:
|
||||
// input = input.replace(/ /g, " ")
|
||||
// There is also a corresponding test to uncomment, named "event2message: whitespace is retained"
|
||||
|
||||
// Handling written @mentions: we need to look for candidate Discord members to join to the room
|
||||
// This shouldn't apply to code blocks, links, or inside attributes. So editing the HTML tree instead of regular expressions is a sensible choice here.
|
||||
// We're using the domino parser because Turndown uses the same and can reuse this tree.
|
||||
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.
|
||||
'<x-turndown id="turndown-root">' + input + '</x-turndown>'
|
||||
);
|
||||
const root = doc.getElementById("turndown-root");
|
||||
async function forEachNode(node) {
|
||||
for (; node; node = node.nextSibling) {
|
||||
// Check written mentions
|
||||
if (node.nodeType === 3 && node.nodeValue.includes("@") && !nodeIsChildOf(node, ["A", "CODE", "PRE"])) {
|
||||
const result = await checkWrittenMentions(node.nodeValue, event.sender, event.room_id, guild, di)
|
||||
if (result) {
|
||||
node.nodeValue = result.content
|
||||
ensureJoined.push(...result.ensureJoined)
|
||||
allowedMentionsParse.push(...result.allowedMentionsParse)
|
||||
}
|
||||
}
|
||||
// Check for incompatible backticks in code blocks
|
||||
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"
|
||||
const filename = `inline_code.${ext}`
|
||||
// Build the replacement <code> node
|
||||
const replacementCode = doc.createElement("code")
|
||||
replacementCode.textContent = `[${filename}]`
|
||||
// Build its containing <span> node
|
||||
const replacement = doc.createElement("span")
|
||||
replacement.appendChild(doc.createTextNode(" "))
|
||||
replacement.appendChild(replacementCode)
|
||||
replacement.appendChild(doc.createTextNode(" "))
|
||||
// Replace the code block with the <span>
|
||||
preNode.replaceWith(replacement)
|
||||
// Upload the code as an attachment
|
||||
const content = getCodeContent(preNode.firstChild)
|
||||
attachments.push({id: String(attachments.length), filename})
|
||||
pendingFiles.push({name: filename, buffer: Buffer.from(content, "utf8")})
|
||||
}
|
||||
}
|
||||
await forEachNode(node.firstChild)
|
||||
}
|
||||
await forEachNode(node.firstChild)
|
||||
}
|
||||
await forEachNode(root)
|
||||
|
||||
// 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.
|
||||
endOfMessageEmojis = []
|
||||
let match
|
||||
let last = input.length
|
||||
while ((match = input.slice(0, last).match(/<img [^>]*>\s*$/))) {
|
||||
if (!match[0].includes("data-mx-emoticon")) break
|
||||
const mxcUrl = match[0].match(/\bsrc="(mxc:\/\/[^"]+)"/)
|
||||
if (mxcUrl) endOfMessageEmojis.unshift(mxcUrl[1])
|
||||
assert(typeof match.index === "number", "Your JavaScript implementation does not comply with TC39: https://tc39.es/ecma262/multipage/text-processing.html#sec-regexpbuiltinexec")
|
||||
last = match.index
|
||||
}
|
||||
|
||||
// @ts-ignore bad type from turndown
|
||||
content = turndownService.turndown(root)
|
||||
|
||||
// Put < > around any surviving matrix.to links to hide the URL previews
|
||||
content = content.replace(/\bhttps?:\/\/matrix\.to\/[^<>\n )]*/g, "<$&>")
|
||||
|
||||
// It's designed for commonmark, we need to replace the space-space-newline with just newline
|
||||
content = content.replace(/ \n/g, "\n")
|
||||
|
||||
// If there's a blockquote at the start of the message body and this message is a reply, they should be visually separated
|
||||
if (replyLine && content.startsWith("> ")) content = "\n" + content
|
||||
|
||||
// SPRITE SHEET EMOJIS FEATURE:
|
||||
content = await uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles, di?.mxcDownloader)
|
||||
} else {
|
||||
// Looks like we're using the plaintext body!
|
||||
content = event.content.body
|
||||
|
||||
if (event.content.msgtype === "m.emote") {
|
||||
content = `* ${displayName} ${content}`
|
||||
}
|
||||
|
||||
content = await handleRoomOrMessageLinks(content, di) // Replace matrix.to links with discord.com equivalents where possible
|
||||
content = content.replace(/\bhttps?:\/\/matrix\.to\/[^<>\n )]*/, "<$&>") // Put < > around any surviving matrix.to links to hide the URL previews
|
||||
|
||||
const result = await checkWrittenMentions(content, event.sender, event.room_id, guild, di)
|
||||
if (result) {
|
||||
content = result.content
|
||||
ensureJoined.push(...result.ensureJoined)
|
||||
allowedMentionsParse.push(...result.allowedMentionsParse)
|
||||
}
|
||||
|
||||
// Markdown needs to be escaped, though take care not to escape the middle of links
|
||||
// @ts-ignore bad type from turndown
|
||||
content = turndownService.escape(content)
|
||||
}
|
||||
await forEachNode(root)
|
||||
|
||||
// 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.
|
||||
endOfMessageEmojis = []
|
||||
let match
|
||||
let last = input.length
|
||||
while ((match = input.slice(0, last).match(/<img [^>]*>\s*$/))) {
|
||||
if (!match[0].includes("data-mx-emoticon")) break
|
||||
const mxcUrl = match[0].match(/\bsrc="(mxc:\/\/[^"]+)"/)
|
||||
if (mxcUrl) endOfMessageEmojis.unshift(mxcUrl[1])
|
||||
assert(typeof match.index === "number", "Your JavaScript implementation does not comply with TC39: https://tc39.es/ecma262/multipage/text-processing.html#sec-regexpbuiltinexec")
|
||||
last = match.index
|
||||
}
|
||||
|
||||
// @ts-ignore bad type from turndown
|
||||
content = turndownService.turndown(root)
|
||||
|
||||
// Put < > around any surviving matrix.to links to hide the URL previews
|
||||
content = content.replace(/\bhttps?:\/\/matrix\.to\/[^<>\n )]*/g, "<$&>")
|
||||
|
||||
// It's designed for commonmark, we need to replace the space-space-newline with just newline
|
||||
content = content.replace(/ \n/g, "\n")
|
||||
|
||||
// If there's a blockquote at the start of the message body and this message is a reply, they should be visually separated
|
||||
if (replyLine && content.startsWith("> ")) content = "\n" + content
|
||||
|
||||
// SPRITE SHEET EMOJIS FEATURE:
|
||||
content = await uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles, di?.mxcDownloader)
|
||||
} else {
|
||||
// Looks like we're using the plaintext body!
|
||||
content = event.content.body
|
||||
|
||||
if (event.content.msgtype === "m.emote") {
|
||||
content = `* ${displayName} ${content}`
|
||||
}
|
||||
|
||||
content = await handleRoomOrMessageLinks(content, di) // Replace matrix.to links with discord.com equivalents where possible
|
||||
content = content.replace(/\bhttps?:\/\/matrix\.to\/[^<>\n )]*/, "<$&>") // Put < > around any surviving matrix.to links to hide the URL previews
|
||||
|
||||
const result = await checkWrittenMentions(content, event.sender, event.room_id, guild, di)
|
||||
if (result) {
|
||||
content = result.content
|
||||
ensureJoined.push(...result.ensureJoined)
|
||||
allowedMentionsParse.push(...result.allowedMentionsParse)
|
||||
}
|
||||
|
||||
// Markdown needs to be escaped, though take care not to escape the middle of links
|
||||
// @ts-ignore bad type from turndown
|
||||
content = turndownService.escape(content)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2671,6 +2671,99 @@ test("event2message: rich reply to a state event with no body", async t => {
|
|||
)
|
||||
})
|
||||
|
||||
test("event2message: rich reply with an image", async t => {
|
||||
let called = 0
|
||||
t.deepEqual(
|
||||
await eventToMessage({
|
||||
type: "m.room.message",
|
||||
sender: "@cadence:cadence.moe",
|
||||
content: {
|
||||
body: "image.png",
|
||||
info: {
|
||||
size: 470379,
|
||||
mimetype: "image/png",
|
||||
thumbnail_info: {
|
||||
w: 800,
|
||||
h: 450,
|
||||
mimetype: "image/png",
|
||||
size: 183014
|
||||
},
|
||||
w: 1920,
|
||||
h: 1080,
|
||||
"xyz.amorgan.blurhash": "L24_wtVt00xuxvR%NFX74Toz?waL",
|
||||
thumbnail_url: "mxc://cadence.moe/lPtnjlleowWCXGOHKVDyoXGn"
|
||||
},
|
||||
msgtype: "m.image",
|
||||
"m.relates_to": {
|
||||
"m.in_reply_to": {
|
||||
event_id: "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4"
|
||||
}
|
||||
},
|
||||
url: "mxc://cadence.moe/yxMobQMbSqNHpajxgSHtaooG"
|
||||
},
|
||||
origin_server_ts: 1764127662631,
|
||||
unsigned: {
|
||||
membership: "join",
|
||||
age: 97,
|
||||
transaction_id: "m1764127662540.2"
|
||||
},
|
||||
event_id: "$QOxkw7u8vjTrrdKxEUO13JWSixV7UXAZU1freT1SkHc",
|
||||
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
|
||||
}, data.guild.general, {
|
||||
api: {
|
||||
getEvent(roomID, eventID) {
|
||||
called++
|
||||
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
||||
t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4")
|
||||
return {
|
||||
type: "m.room.message",
|
||||
sender: "@cadence:cadence.moe",
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "you have to check every diff above insane on this set https://osu.ppy.sh/beatmapsets/2263303#osu/4826296"
|
||||
},
|
||||
origin_server_ts: 1763639396419,
|
||||
unsigned: {
|
||||
membership: "join",
|
||||
age: 486586696,
|
||||
transaction_id: "m1763639396324.578"
|
||||
},
|
||||
event_id: "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4",
|
||||
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
{
|
||||
ensureJoined: [],
|
||||
messagesToDelete: [],
|
||||
messagesToEdit: [],
|
||||
messagesToSend: [
|
||||
{
|
||||
content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/112760669178241024/1128118177155526666 **Ⓜcadence [they]**: you have to check every diff above insane on this...",
|
||||
allowed_mentions: {
|
||||
parse: ["users", "roles"]
|
||||
},
|
||||
attachments: [
|
||||
{
|
||||
filename: "image.png",
|
||||
id: "0",
|
||||
},
|
||||
],
|
||||
avatar_url: undefined,
|
||||
pendingFiles: [
|
||||
{
|
||||
mxc: "mxc://cadence.moe/yxMobQMbSqNHpajxgSHtaooG",
|
||||
name: "image.png",
|
||||
},
|
||||
],
|
||||
username: "cadence [they]",
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test("event2message: raw mentioning discord users in plaintext body works", async t => {
|
||||
t.deepEqual(
|
||||
await eventToMessage({
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
const util = require("util")
|
||||
const Ty = require("../types")
|
||||
const {discord, db, sync, as, select} = require("../passthrough")
|
||||
const {tag} = require("@cloudrac3r/html-template-tag")
|
||||
|
||||
/** @type {import("./actions/send-event")} */
|
||||
const sendEvent = sync.require("./actions/send-event")
|
||||
|
|
@ -121,10 +122,10 @@ async function sendError(roomID, source, type, e, payload) {
|
|||
|
||||
// Where
|
||||
const stack = stringifyErrorStack(e)
|
||||
builder.addLine(`Error trace:\n${stack}`, `<details><summary>Error trace</summary><pre>${stack}</pre></details>`)
|
||||
builder.addLine(`Error trace:\n${stack}`, tag`<details><summary>Error trace</summary><pre>${stack}</pre></details>`)
|
||||
|
||||
// How
|
||||
builder.addLine("", `<details><summary>Original payload</summary><pre>${util.inspect(payload, false, 4, false)}</pre></details>`)
|
||||
builder.addLine("", tag`<details><summary>Original payload</summary><pre>${util.inspect(payload, false, 4, false)}</pre></details>`)
|
||||
}
|
||||
|
||||
// Send
|
||||
|
|
@ -322,14 +323,25 @@ sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member",
|
|||
*/
|
||||
async event => {
|
||||
if (event.state_key[0] !== "@") return
|
||||
const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}`
|
||||
|
||||
if (event.content.membership === "invite" && event.state_key === `@${reg.sender_localpart}:${reg.ooye.server_name}`) {
|
||||
if (event.content.membership === "invite" && event.state_key === bot) {
|
||||
// We were invited to a room. We should join, and register the invite details for future reference in web.
|
||||
let attemptedApiMessage = "According to unsigned invite data."
|
||||
let inviteRoomState = event.unsigned?.invite_room_state
|
||||
if (!Array.isArray(inviteRoomState) || inviteRoomState.length === 0) {
|
||||
try {
|
||||
inviteRoomState = await api.getInviteState(event.room_id)
|
||||
attemptedApiMessage = "According to SSS API."
|
||||
} catch (e) {
|
||||
attemptedApiMessage = "According to unsigned invite data. SSS API unavailable: " + e.toString()
|
||||
}
|
||||
}
|
||||
const name = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.name", "name")
|
||||
const topic = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.topic", "topic")
|
||||
const avatar = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.avatar", "url")
|
||||
const creationType = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.create", "type")
|
||||
if (!name) return await api.leaveRoomWithReason(event.room_id, "Please only invite me to rooms that have a name/avatar set. Update the room details and reinvite!")
|
||||
if (!name) return await api.leaveRoomWithReason(event.room_id, `Please only invite me to rooms that have a name/avatar set. Update the room details and reinvite! (${attemptedApiMessage})`)
|
||||
await api.joinRoom(event.room_id)
|
||||
db.prepare("INSERT OR IGNORE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, creationType, name, topic, avatar)
|
||||
if (avatar) utils.getPublicUrlForMxc(avatar) // make sure it's available in the media_proxy allowed URLs
|
||||
|
|
@ -342,7 +354,6 @@ async event => {
|
|||
db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key)
|
||||
|
||||
// Unregister room's use as a direct chat if the bot itself left
|
||||
const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}`
|
||||
if (event.state_key === bot) {
|
||||
db.prepare("DELETE FROM direct WHERE room_id = ?").run(event.room_id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,6 +137,24 @@ function getStateEvent(roomID, type, key) {
|
|||
return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state/${type}/${key}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} roomID
|
||||
* @returns {Promise<Ty.Event.InviteStrippedState[]>}
|
||||
*/
|
||||
async function getInviteState(roomID) {
|
||||
/** @type {Ty.R.SSS} */
|
||||
const root = await mreq.mreq("POST", "/client/unstable/org.matrix.simplified_msc3575/sync", {
|
||||
room_subscriptions: {
|
||||
[roomID]: {
|
||||
timeline_limit: 0,
|
||||
required_state: []
|
||||
}
|
||||
}
|
||||
})
|
||||
const roomResponse = root.rooms[roomID]
|
||||
return "stripped_state" in roomResponse ? roomResponse.stripped_state : roomResponse.invite_state
|
||||
}
|
||||
|
||||
/**
|
||||
* "Any of the AS's users must be in the room. This API is primarily for Application Services and should be faster to respond than /members as it can be implemented more efficiently on the server."
|
||||
* @param {string} roomID
|
||||
|
|
@ -299,16 +317,34 @@ async function sendTyping(roomID, isTyping, mxid, duration) {
|
|||
})
|
||||
}
|
||||
|
||||
async function profileSetDisplayname(mxid, displayname) {
|
||||
await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/displayname`, mxid), {
|
||||
/**
|
||||
* @param {string} mxid
|
||||
* @param {string} displayname
|
||||
* @param {boolean} [inhibitPropagate]
|
||||
*/
|
||||
async function profileSetDisplayname(mxid, displayname, inhibitPropagate) {
|
||||
const params = {}
|
||||
if (inhibitPropagate) params["org.matrix.msc4069.propagate"] = false
|
||||
await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/displayname`, mxid, params), {
|
||||
displayname
|
||||
})
|
||||
}
|
||||
|
||||
async function profileSetAvatarUrl(mxid, avatar_url) {
|
||||
await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/avatar_url`, mxid), {
|
||||
avatar_url
|
||||
})
|
||||
/**
|
||||
* @param {string} mxid
|
||||
* @param {string} avatar_url
|
||||
* @param {boolean} [inhibitPropagate]
|
||||
*/
|
||||
async function profileSetAvatarUrl(mxid, avatar_url, inhibitPropagate) {
|
||||
const params = {}
|
||||
if (inhibitPropagate) params["org.matrix.msc4069.propagate"] = false
|
||||
if (avatar_url) {
|
||||
await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/avatar_url`, mxid, params), {
|
||||
avatar_url
|
||||
})
|
||||
} else {
|
||||
await mreq.mreq("DELETE", path(`/client/v3/profile/${mxid}/avatar_url`, mxid, params))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -472,6 +508,10 @@ function getProfile(mxid) {
|
|||
return mreq.mreq("GET", `/client/v3/profile/${mxid}`)
|
||||
}
|
||||
|
||||
function versions() {
|
||||
return mreq.mreq("GET", "/client/versions")
|
||||
}
|
||||
|
||||
module.exports.path = path
|
||||
module.exports.register = register
|
||||
module.exports.createRoom = createRoom
|
||||
|
|
@ -483,6 +523,7 @@ module.exports.getEvent = getEvent
|
|||
module.exports.getEventForTimestamp = getEventForTimestamp
|
||||
module.exports.getAllState = getAllState
|
||||
module.exports.getStateEvent = getStateEvent
|
||||
module.exports.getInviteState = getInviteState
|
||||
module.exports.getJoinedMembers = getJoinedMembers
|
||||
module.exports.getMembers = getMembers
|
||||
module.exports.getHierarchy = getHierarchy
|
||||
|
|
@ -507,3 +548,4 @@ module.exports.getAccountData = getAccountData
|
|||
module.exports.setAccountData = setAccountData
|
||||
module.exports.setPresence = setPresence
|
||||
module.exports.getProfile = getProfile
|
||||
module.exports.versions = versions
|
||||
|
|
|
|||
81
src/types.d.ts
vendored
81
src/types.d.ts
vendored
|
|
@ -166,6 +166,37 @@ export namespace Event {
|
|||
content: any
|
||||
}
|
||||
|
||||
export type InviteStrippedState = {
|
||||
type: string
|
||||
state_key: string
|
||||
sender: string
|
||||
content: Event.M_Room_Create | Event.M_Room_Name | Event.M_Room_Avatar | Event.M_Room_Topic | Event.M_Room_JoinRules | Event.M_Room_CanonicalAlias
|
||||
}
|
||||
|
||||
export type M_Room_Create = {
|
||||
additional_creators: string[]
|
||||
"m.federate"?: boolean
|
||||
room_version: string
|
||||
type?: string
|
||||
predecessor?: {
|
||||
room_id: string
|
||||
event_id?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type M_Room_JoinRules = {
|
||||
join_rule: "public" | "knock" | "invite" | "private" | "restricted" | "knock_restricted"
|
||||
allow?: {
|
||||
type: string
|
||||
room_id: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export type M_Room_CanonicalAlias = {
|
||||
alias?: string
|
||||
alt_aliases?: string[]
|
||||
}
|
||||
|
||||
export type M_Room_Message = {
|
||||
msgtype: "m.text" | "m.emote"
|
||||
body: string
|
||||
|
|
@ -375,8 +406,58 @@ export namespace R {
|
|||
room_id: string
|
||||
servers: string[]
|
||||
}
|
||||
|
||||
export type SSS = {
|
||||
pos: string
|
||||
lists: {
|
||||
[list_key: string]: {
|
||||
count: number
|
||||
}
|
||||
}
|
||||
rooms: {
|
||||
[room_id: string]: {
|
||||
bump_stamp: number
|
||||
/** Omitted if user not in room (peeking) */
|
||||
membership?: Membership
|
||||
/** Names of lists that match this room */
|
||||
lists: string[]
|
||||
}
|
||||
// If user has been in the room - at least, that's what the spec says. Synapse returns some of these, such as `name` and `avatar`, for invites as well. Go nuts.
|
||||
& {
|
||||
name?: string
|
||||
avatar?: string
|
||||
heroes?: any[]
|
||||
/** According to account data */
|
||||
is_dm?: boolean
|
||||
/** If false, omitted fields are unchanged from their previous value. If true, omitted fields means the fields are not set. */
|
||||
initial?: boolean
|
||||
expanded_timeline?: boolean
|
||||
required_state?: Event.StateOuter<any>[]
|
||||
timeline_events?: Event.Outer<any>[]
|
||||
prev_batch?: string
|
||||
limited?: boolean
|
||||
num_live?: number
|
||||
joined_count?: number
|
||||
invited_count?: number
|
||||
notification_count?: number
|
||||
highlight_count?: number
|
||||
}
|
||||
// If user is invited or knocked
|
||||
& ({
|
||||
/** @deprecated */
|
||||
invite_state: Event.InviteStrippedState[]
|
||||
} | {
|
||||
stripped_state: Event.InviteStrippedState[]
|
||||
})
|
||||
}
|
||||
extensions: {
|
||||
[extension_key: string]: any
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type Membership = "invite" | "knock" | "join" | "leave" | "ban"
|
||||
|
||||
export type Pagination<T> = {
|
||||
chunk: T[]
|
||||
next_batch?: string
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue