Compare commits

..

2 commits

Author SHA1 Message Date
Bea
d53e9efe9e
Look up thread IDs if channels cannot be found 2026-03-18 23:44:03 +00:00
Bea
b6717b046d
feat: make sims leave rooms when Discord user leaves guild
Listen for GUILD_MEMBER_REMOVE events to remove sim users from
bridged Matrix rooms when their corresponding Discord user leaves
(or is kicked/banned from) the guild. Also removes PluralKit proxy
sims owned by the departing user.

Includes a backfill check on GUILD_CREATE to clean up sims for
users who left while the bridge was offline.

Closes #58
2026-03-18 23:43:59 +00:00
27 changed files with 376 additions and 420 deletions

View file

@ -1,36 +0,0 @@
// @ts-check
const HeatSync = require("heatsync")
const sync = new HeatSync({watchFS: false})
const sqlite = require("better-sqlite3")
const db = new sqlite("ooye.db", {fileMustExist: true})
const passthrough = require("../src/passthrough")
Object.assign(passthrough, {db, sync})
const api = require("../src/matrix/api")
const utils = require("../src/matrix/utils")
const {reg} = require("../src/matrix/read-registration")
const rooms = db.prepare("select room_id, name, nick from channel_room").all()
;(async () => {
// Search for members starting with @_ooye_ and kick them if they are not in sim_member cache
for (const room of rooms) {
try {
const members = await api.getJoinedMembers(room.room_id)
for (const mxid of Object.keys(members.joined)) {
if (!mxid.startsWith("@" + reg.sender_localpart) && utils.eventSenderIsFromDiscord(mxid) && !db.prepare("select mxid from sim_member where mxid = ? and room_id = ?").get(mxid, room.room_id)) {
await api.leaveRoom(room.room_id, mxid)
}
}
} catch (e) {
if (e.message.includes("Appservice not in room")) {
// ok
} else {
throw e
}
}
}
})()

View file

@ -256,7 +256,7 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) {
/** /**
* Handling power levels separately. The spec doesn't specify what happens, Dendrite differs, * Handling power levels separately. The spec doesn't specify what happens, Dendrite differs,
* and Synapse does a very poorly thought out *shallow merge* of what I provide on top of what it creates. * and Synapse does an absolutely insane *shallow merge* of what I provide on top of what it creates.
* We don't want the `events` key to be overridden completely. * We don't want the `events` key to be overridden completely.
* https://github.com/matrix-org/synapse/blob/develop/synapse/handlers/room.py#L1170-L1210 * https://github.com/matrix-org/synapse/blob/develop/synapse/handlers/room.py#L1170-L1210
* https://github.com/matrix-org/matrix-spec/issues/492 * https://github.com/matrix-org/matrix-spec/issues/492

View file

@ -206,16 +206,14 @@ function _hashProfileContent(content, powerLevel) {
* 3. Calculate the power level the user should get based on their Discord permissions * 3. Calculate the power level the user should get based on their Discord permissions
* 4. Compare against the previously known state content, which is helpfully stored in the database * 4. Compare against the previously known state content, which is helpfully stored in the database
* 5. If the state content or power level have changed, send them to Matrix and update them in the database for next time * 5. If the state content or power level have changed, send them to Matrix and update them in the database for next time
* 6. If the sim is for a user-installed app, check which user it was added by
* @param {DiscordTypes.APIUser} user * @param {DiscordTypes.APIUser} user
* @param {Omit<DiscordTypes.APIGuildMember, "user"> | undefined} member * @param {Omit<DiscordTypes.APIGuildMember, "user"> | undefined} member
* @param {DiscordTypes.APIGuildChannel} channel * @param {DiscordTypes.APIGuildChannel} channel
* @param {DiscordTypes.APIGuild} guild * @param {DiscordTypes.APIGuild} guild
* @param {string} roomID * @param {string} roomID
* @param {DiscordTypes.APIMessageInteractionMetadata} [interactionMetadata]
* @returns {Promise<string>} mxid of the updated sim * @returns {Promise<string>} mxid of the updated sim
*/ */
async function syncUser(user, member, channel, guild, roomID, interactionMetadata) { async function syncUser(user, member, channel, guild, roomID) {
const mxid = await ensureSimJoined(user, roomID) const mxid = await ensureSimJoined(user, roomID)
const content = await memberToStateContent(user, member, guild.id) const content = await memberToStateContent(user, member, guild.id)
const powerLevel = memberToPowerLevel(user, member, guild, channel) const powerLevel = memberToPowerLevel(user, member, guild, channel)
@ -224,12 +222,6 @@ async function syncUser(user, member, channel, guild, roomID, interactionMetadat
allowOverwrite: !!member, allowOverwrite: !!member,
globalProfile: await userToGlobalProfile(user) globalProfile: await userToGlobalProfile(user)
}) })
const appInstalledByUser = user.bot && interactionMetadata?.authorizing_integration_owners?.[DiscordTypes.ApplicationIntegrationType.UserInstall]
if (appInstalledByUser) {
db.prepare("INSERT OR IGNORE INTO app_user_install (app_bot_id, user_id, guild_id) VALUES (?, ?, ?)").run(user.id, appInstalledByUser, guild.id)
}
return mxid return mxid
} }

View file

@ -1,37 +1,124 @@
// @ts-check // @ts-check
const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {sync, db, select, from} = passthrough const {discord, sync, db, select, from} = passthrough
/** @type {import("../../matrix/api")} */ /** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api") const api = sync.require("../../matrix/api")
/** @type {import("../converters/remove-member-mxids")} */
const removeMemberMxids = sync.require("../converters/remove-member-mxids")
/** /**
* @param {string} userID discord user ID that left * Make a specific sim leave all rooms in a guild and clean up the database.
* @param {string} guildID discord guild ID that they left * @param {string} mxid
* @param {string} guildID
* @returns {Promise<number>} number of rooms left
*/
async function removeSimFromGuild(mxid, guildID) {
const rooms = from("sim_member")
.join("channel_room", "room_id")
.where({mxid, guild_id: guildID})
.pluck("room_id")
.all()
for (const roomID of rooms) {
try {
await api.leaveRoom(roomID, mxid)
} catch (e) {
// Room may no longer exist or sim may already have left
console.log(`[remove member] failed to leave room ${roomID}: ${e}`)
}
db.prepare("DELETE FROM sim_member WHERE mxid = ? AND room_id = ?").run(mxid, roomID)
}
return rooms.length
}
/**
* Remove a user's sim and their PluralKit proxy sims from all rooms in a guild.
* Called when a Discord user leaves a guild.
* @param {string} userID Discord user ID
* @param {string} guildID Discord guild ID
*/ */
async function removeMember(userID, guildID) { async function removeMember(userID, guildID) {
const {userAppDeletions, membership} = removeMemberMxids.removeMemberMxids(userID, guildID) // Remove the user's own sim
db.transaction(() => { const mxid = select("sim", "mxid", {user_id: userID}).pluck().get()
for (const d of userAppDeletions) { if (mxid) {
db.prepare("DELETE FROM app_user_install WHERE guild_id = ? and user_id = ?").run(guildID, d) const count = await removeSimFromGuild(mxid, guildID)
if (count) console.log(`[remove member] removed sim for ${userID} from ${count} rooms in guild ${guildID}`)
} }
})()
for (const m of membership) { // Remove PluralKit proxy sims owned by this user
try { const pkUserIDs = select("sim_proxy", "user_id", {proxy_owner_id: userID}).pluck().all()
await api.leaveRoom(m.room_id, m.mxid) for (const pkUserID of pkUserIDs) {
} catch (e) { const pkMxid = select("sim", "mxid", {user_id: pkUserID}).pluck().get()
if (String(e).includes("not in room")) { if (!pkMxid) continue
// no further action needed const count = await removeSimFromGuild(pkMxid, guildID)
} else { if (count) console.log(`[remove member] removed pk sim for ${pkUserID} (owner: ${userID}) from ${count} rooms in guild ${guildID}`)
throw e
}
}
// Update cache to say that the member isn't in the room any more
// You'd think this would happen automatically when the leave event arrives at Matrix's event dispatcher, but that isn't 100% reliable.
db.prepare("DELETE FROM sim_member WHERE room_id = ? AND mxid = ?").run(m.room_id, m.mxid)
} }
} }
/**
* Backfill: check all sims in guild rooms and remove those whose Discord users have left the guild.
* Called on GUILD_CREATE to catch removals that happened while the bridge was offline.
* @param {DiscordTypes.GatewayGuildCreateDispatchData} guild
*/
async function checkMissedMembers(guild) {
if (guild.unavailable) return
// Find all distinct regular Discord user IDs with sims in this guild's rooms
// Exclude PK sims (UUIDs with dashes) and webhook sims (with underscores)
const rows = from("sim_member")
.join("channel_room", "room_id")
.join("sim", "mxid")
.where({guild_id: guild.id})
.and("AND user_id NOT LIKE '%-%' AND user_id NOT LIKE '%\\_%' ESCAPE '\\'")
.pluck("user_id")
.all()
const userIDs = [...new Set(rows)]
for (const userID of userIDs) {
try {
await discord.snow.guild.getGuildMember(guild.id, userID)
} catch (e) {
if (String(e).includes("10007") || String(e).includes("Unknown Member")) {
console.log(`[remove member] backfill: user ${userID} is no longer in guild ${guild.id}`)
await removeMember(userID, guild.id)
}
// Other errors (rate limits, network issues) - skip this user
}
}
// Also check PK proxy owners who may not have a regular sim but whose PK sims are in guild rooms
const pkRows = from("sim_member")
.join("channel_room", "room_id")
.join("sim", "mxid")
.join("sim_proxy", "user_id")
.where({guild_id: guild.id})
.pluck("proxy_owner_id")
.all()
const pkOwnerIDs = [...new Set(pkRows)].filter(id => !userIDs.includes(id))
for (const ownerID of pkOwnerIDs) {
try {
await discord.snow.guild.getGuildMember(guild.id, ownerID)
} catch (e) {
if (String(e).includes("10007") || String(e).includes("Unknown Member")) {
console.log(`[remove member] backfill: pk owner ${ownerID} is no longer in guild ${guild.id}`)
// Only remove PK sims for this owner, not their own sim (they don't have one in this guild)
const pkUserIDs = select("sim_proxy", "user_id", {proxy_owner_id: ownerID}).pluck().all()
for (const pkUserID of pkUserIDs) {
const pkMxid = select("sim", "mxid", {user_id: pkUserID}).pluck().get()
if (!pkMxid) continue
const count = await removeSimFromGuild(pkMxid, guild.id)
if (count) console.log(`[remove member] backfill: removed pk sim for ${pkUserID} (owner: ${ownerID}) from ${count} rooms in guild ${guild.id}`)
}
}
}
}
}
module.exports.removeSimFromGuild = removeSimFromGuild
module.exports.removeMember = removeMember module.exports.removeMember = removeMember
module.exports.checkMissedMembers = checkMissedMembers

View file

@ -0,0 +1,202 @@
// @ts-check
const {test} = require("supertape")
const {removeSimFromGuild, removeMember, checkMissedMembers} = require("./remove-member")
const passthrough = require("../../passthrough")
const {db, sync, discord} = passthrough
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
function setupTestSim(userID, simName, mxid) {
db.prepare("INSERT OR IGNORE INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?)").run(userID, simName, simName, mxid)
}
function setupTestSimMember(mxid, roomID) {
db.prepare("INSERT OR IGNORE INTO sim_member (mxid, room_id, hashed_profile_content) VALUES (?, ?, NULL)").run(mxid, roomID)
}
function cleanupTestSim(userID) {
const mxid = db.prepare("SELECT mxid FROM sim WHERE user_id = ?").pluck().get(userID)
if (mxid) {
db.prepare("DELETE FROM sim_member WHERE mxid = ?").run(mxid)
}
db.prepare("DELETE FROM sim WHERE user_id = ?").run(userID)
}
test("remove-member: removeSimFromGuild removes sim from all guild rooms", async t => {
const mxid = "@_ooye_testrm1:cadence.moe"
const guildID = "112760669178241024"
const roomID1 = "!kLRqKKUQXcibIMtOpl:cadence.moe"
const roomID2 = "!fGgIymcYWOqjbSRUdV:cadence.moe"
setupTestSim("999999990", "testrm1", mxid)
setupTestSimMember(mxid, roomID1)
setupTestSimMember(mxid, roomID2)
const leftRooms = []
const originalLeaveRoom = api.leaveRoom
api.leaveRoom = async (roomID, userMxid) => { leftRooms.push({roomID, mxid: userMxid}) }
try {
const count = await removeSimFromGuild(mxid, guildID)
t.equal(count, 2)
t.equal(leftRooms.length, 2)
t.ok(leftRooms.some(r => r.roomID === roomID1))
t.ok(leftRooms.some(r => r.roomID === roomID2))
const remaining = db.prepare("SELECT COUNT(*) FROM sim_member WHERE mxid = ?").pluck().get(mxid)
t.equal(remaining, 0)
} finally {
api.leaveRoom = originalLeaveRoom
cleanupTestSim("999999990")
}
})
test("remove-member: removeSimFromGuild only affects rooms in the specified guild", async t => {
const mxid = "@_ooye_testrm2:cadence.moe"
const guildID = "112760669178241024"
const guildRoom = "!kLRqKKUQXcibIMtOpl:cadence.moe" // guild 112760669178241024
const otherGuildRoom = "!BnKuBPCvyfOkhcUjEu:cadence.moe" // guild 66192955777486848
setupTestSim("999999991", "testrm2", mxid)
setupTestSimMember(mxid, guildRoom)
setupTestSimMember(mxid, otherGuildRoom)
const leftRooms = []
const originalLeaveRoom = api.leaveRoom
api.leaveRoom = async (roomID, userMxid) => { leftRooms.push({roomID, mxid: userMxid}) }
try {
const count = await removeSimFromGuild(mxid, guildID)
t.equal(count, 1)
t.equal(leftRooms[0].roomID, guildRoom)
const otherRemaining = db.prepare("SELECT COUNT(*) FROM sim_member WHERE mxid = ? AND room_id = ?").pluck().get(mxid, otherGuildRoom)
t.equal(otherRemaining, 1, "other guild's room should be untouched")
} finally {
api.leaveRoom = originalLeaveRoom
cleanupTestSim("999999991")
}
})
test("remove-member: removeSimFromGuild handles leaveRoom errors gracefully", async t => {
const mxid = "@_ooye_testrm3:cadence.moe"
const guildID = "112760669178241024"
const roomID = "!kLRqKKUQXcibIMtOpl:cadence.moe"
setupTestSim("999999992", "testrm3", mxid)
setupTestSimMember(mxid, roomID)
const originalLeaveRoom = api.leaveRoom
api.leaveRoom = async () => { throw new Error("not in room") }
try {
const count = await removeSimFromGuild(mxid, guildID)
t.equal(count, 1)
const remaining = db.prepare("SELECT COUNT(*) FROM sim_member WHERE mxid = ?").pluck().get(mxid)
t.equal(remaining, 0, "sim_member should be deleted even if leaveRoom fails")
} finally {
api.leaveRoom = originalLeaveRoom
cleanupTestSim("999999992")
}
})
test("remove-member: removeMember removes user sim and their PK proxy sims", async t => {
const userID = "999999993"
const mxid = "@_ooye_testrm4:cadence.moe"
const pkUserID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
const pkMxid = "@_ooye__pk_testrmpk:cadence.moe"
const guildID = "112760669178241024"
const roomID = "!kLRqKKUQXcibIMtOpl:cadence.moe"
setupTestSim(userID, "testrm4", mxid)
setupTestSimMember(mxid, roomID)
setupTestSim(pkUserID, "_pk_testrmpk", pkMxid)
setupTestSimMember(pkMxid, roomID)
db.prepare("INSERT OR IGNORE INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES (?, ?, ?)").run(pkUserID, userID, "Test PK")
const leftRooms = []
const originalLeaveRoom = api.leaveRoom
api.leaveRoom = async (roomID, userMxid) => { leftRooms.push({roomID, mxid: userMxid}) }
try {
await removeMember(userID, guildID)
t.equal(leftRooms.length, 2)
t.ok(leftRooms.some(r => r.mxid === mxid), "user sim should leave")
t.ok(leftRooms.some(r => r.mxid === pkMxid), "pk sim should leave")
t.equal(db.prepare("SELECT COUNT(*) FROM sim_member WHERE mxid = ?").pluck().get(mxid), 0)
t.equal(db.prepare("SELECT COUNT(*) FROM sim_member WHERE mxid = ?").pluck().get(pkMxid), 0)
} finally {
api.leaveRoom = originalLeaveRoom
db.prepare("DELETE FROM sim_proxy WHERE user_id = ?").run(pkUserID)
cleanupTestSim(userID)
cleanupTestSim(pkUserID)
}
})
test("remove-member: removeMember does nothing for unknown user", async t => {
const leftRooms = []
const originalLeaveRoom = api.leaveRoom
api.leaveRoom = async (roomID, userMxid) => { leftRooms.push({roomID, mxid: userMxid}) }
try {
await removeMember("000000000000000000", "112760669178241024")
t.equal(leftRooms.length, 0)
} finally {
api.leaveRoom = originalLeaveRoom
}
})
test("remove-member: checkMissedMembers removes sims for departed users", async t => {
const userID = "999999994"
const mxid = "@_ooye_testrm5:cadence.moe"
const guildID = "112760669178241024"
const roomID = "!kLRqKKUQXcibIMtOpl:cadence.moe"
setupTestSim(userID, "testrm5", mxid)
setupTestSimMember(mxid, roomID)
const leftRooms = []
const originalLeaveRoom = api.leaveRoom
api.leaveRoom = async (roomID, userMxid) => { leftRooms.push({roomID, mxid: userMxid}) }
const originalSnow = discord.snow
discord.snow = {
guild: {
getGuildMember: async (gid, uid) => {
if (uid === userID) throw new Error('{"message": "Unknown Member", "code": 10007}')
return {}
}
}
}
try {
await checkMissedMembers({id: guildID})
t.ok(leftRooms.some(r => r.mxid === mxid), "departed user's sim should be removed")
t.equal(db.prepare("SELECT COUNT(*) FROM sim_member WHERE mxid = ?").pluck().get(mxid), 0)
} finally {
api.leaveRoom = originalLeaveRoom
discord.snow = originalSnow
cleanupTestSim(userID)
}
})
test("remove-member: checkMissedMembers skips unavailable guilds", async t => {
const leftRooms = []
const originalLeaveRoom = api.leaveRoom
api.leaveRoom = async (roomID, userMxid) => { leftRooms.push({roomID, mxid: userMxid}) }
try {
await checkMissedMembers({id: "112760669178241024", unavailable: true})
t.equal(leftRooms.length, 0, "should not process unavailable guilds")
} finally {
api.leaveRoom = originalLeaveRoom
}
})

View file

@ -51,7 +51,7 @@ async function sendMessage(message, channel, guild, row) {
if (message.author.id === discord.application.id) { if (message.author.id === discord.application.id) {
// no need to sync the bot's own user // no need to sync the bot's own user
} else { } else {
senderMxid = await registerUser.syncUser(message.author, message.member, channel, guild, roomID, message.interaction_metadata) senderMxid = await registerUser.syncUser(message.author, message.member, channel, guild, roomID)
} }
} }

View file

@ -1,7 +1,5 @@
// @ts-check // @ts-check
const assert = require("assert").strict
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {sync, select} = passthrough const {sync, select} = passthrough
/** @type {import("../../matrix/api")} */ /** @type {import("../../matrix/api")} */
@ -28,7 +26,7 @@ const presenceLoopInterval = 28e3
// Cache the list of enabled guilds rather than accessing it like multiple times per second when any user changes presence // Cache the list of enabled guilds rather than accessing it like multiple times per second when any user changes presence
const guildPresenceSetting = new class { const guildPresenceSetting = new class {
/** @private @type {Set<string>} */ guilds = new Set() /** @private @type {Set<string>} */ guilds
constructor() { constructor() {
this.update() this.update()
} }
@ -42,7 +40,7 @@ const guildPresenceSetting = new class {
class Presence extends sync.reloadClassMethods(() => Presence) { class Presence extends sync.reloadClassMethods(() => Presence) {
/** @type {string} */ userID /** @type {string} */ userID
/** @type {{presence: "online" | "offline" | "unavailable", status_msg?: string} | undefined} */ data /** @type {{presence: "online" | "offline" | "unavailable", status_msg?: string}} */ data
/** @private @type {?string | undefined} */ mxid /** @private @type {?string | undefined} */ mxid
/** @private @type {number} */ delay = Math.random() /** @private @type {number} */ delay = Math.random()
@ -68,7 +66,6 @@ class Presence extends sync.reloadClassMethods(() => Presence) {
// I haven't tried, but I assume Synapse explodes if you try to update too many presences at the same time. // I haven't tried, but I assume Synapse explodes if you try to update too many presences at the same time.
// This random delay will space them out over the whole 28 second cycle. // This random delay will space them out over the whole 28 second cycle.
setTimeout(() => { setTimeout(() => {
assert(this.data)
api.setPresence(this.data, mxid).catch(() => {}) api.setPresence(this.data, mxid).catch(() => {})
}, this.delay * presenceLoopInterval).unref() }, this.delay * presenceLoopInterval).unref()
} }

View file

@ -35,10 +35,10 @@ function getDiscordParseCallbacks(message, guild, useHTML, spoilers = []) {
/** @param {{id: string, type: "discordUser"}} node */ /** @param {{id: string, type: "discordUser"}} node */
user: node => { user: node => {
const mxid = select("sim", "mxid", {user_id: node.id}).pluck().get() const mxid = select("sim", "mxid", {user_id: node.id}).pluck().get()
const interactionMetadata = message.interaction_metadata const interaction = message.interaction_metadata || message.interaction
const username = message.mentions?.find(ment => ment.id === node.id)?.username const username = message.mentions?.find(ment => ment.id === node.id)?.username
|| message.referenced_message?.mentions?.find(ment => ment.id === node.id)?.username || message.referenced_message?.mentions?.find(ment => ment.id === node.id)?.username
|| (interactionMetadata?.user.id === node.id ? interactionMetadata.user.username : null) || (interaction?.user.id === node.id ? interaction.user.username : null)
|| (message.author?.id === node.id ? message.author.username : null) || (message.author?.id === node.id ? message.author.username : null)
|| "unknown-user" || "unknown-user"
if (mxid && useHTML) { if (mxid && useHTML) {
@ -357,8 +357,9 @@ async function messageToEvent(message, guild, options = {}, di) {
}] }]
} }
let isInteraction = (message.type === DiscordTypes.MessageType.ChatInputCommand || message.type === DiscordTypes.MessageType.ContextMenuCommand) && message.interaction && "name" in message.interaction const interaction = message.interaction_metadata || message.interaction
let isThinkingInteraction = isInteraction && !!((message.flags || 0) & DiscordTypes.MessageFlags.Loading) const isInteraction = message.type === DiscordTypes.MessageType.ChatInputCommand && !!interaction && "name" in interaction
const isThinkingInteraction = isInteraction && !!((message.flags || 0) & DiscordTypes.MessageFlags.Loading)
/** /**
@type {{room?: boolean, user_ids?: string[]}} @type {{room?: boolean, user_ids?: string[]}}
@ -399,16 +400,6 @@ async function messageToEvent(message, guild, options = {}, di) {
} else if (message.referenced_message) { } else if (message.referenced_message) {
repliedToUnknownEvent = true repliedToUnknownEvent = true
} }
} else if (message.type === DiscordTypes.MessageType.ContextMenuCommand && message.interaction && message.message_reference?.message_id) {
// It could be a /plu/ral emulated reply
if (message.interaction.name.startsWith("Reply ") && message.content.startsWith("-# [↪](")) {
const row = await getHistoricalEventRow(message.message_reference?.message_id)
if (row && "event_id" in row) {
repliedToEventRow = Object.assign(row, {channel_id: row.reference_channel_id})
message.content = message.content.replace(/^.*\n/, "")
isInteraction = false // declutter
}
}
} else if (dUtils.isWebhookMessage(message) && message.embeds[0]?.author?.name?.endsWith("↩️")) { } else if (dUtils.isWebhookMessage(message) && message.embeds[0]?.author?.name?.endsWith("↩️")) {
// It could be a PluralKit emulated reply, let's see if it has a message link // It could be a PluralKit emulated reply, let's see if it has a message link
const isEmulatedReplyToText = message.embeds[0].description?.startsWith("**[Reply to:]") const isEmulatedReplyToText = message.embeds[0].description?.startsWith("**[Reply to:]")
@ -694,8 +685,8 @@ async function messageToEvent(message, guild, options = {}, di) {
} }
} }
if (isInteraction && !isThinkingInteraction && message.interaction && events.length === 0) { if (isInteraction && !isThinkingInteraction && events.length === 0) {
const formattedInteraction = getFormattedInteraction(message.interaction, false) const formattedInteraction = getFormattedInteraction(interaction, false)
body = `${formattedInteraction.body}\n${body}` body = `${formattedInteraction.body}\n${body}`
html = `${formattedInteraction.html}${html}` html = `${formattedInteraction.html}${html}`
} }
@ -791,8 +782,8 @@ async function messageToEvent(message, guild, options = {}, di) {
events.push(...forwardedEvents) events.push(...forwardedEvents)
} }
if (isInteraction && isThinkingInteraction && message.interaction) { if (isThinkingInteraction) {
const formattedInteraction = getFormattedInteraction(message.interaction, true) const formattedInteraction = getFormattedInteraction(interaction, true)
await addTextEvent(formattedInteraction.body, formattedInteraction.html, "m.notice") await addTextEvent(formattedInteraction.body, formattedInteraction.html, "m.notice")
} }

View file

@ -65,7 +65,7 @@ test("message2event components: pk question mark output", async t => {
+ "<hr>" + "<hr>"
+ "<blockquote><p><strong>System:</strong> INX (<code>xffgnx</code>)" + "<blockquote><p><strong>System:</strong> INX (<code>xffgnx</code>)"
+ "<br><strong>Member:</strong> Lillith (<code>pphhoh</code>)" + "<br><strong>Member:</strong> Lillith (<code>pphhoh</code>)"
+ "<br><strong>Sent by:</strong> infinidoge1337 (<a href=\"https://matrix.to/#/@_ooye_infinidoge1337:cadence.moe\">@unknown-user</a>)" + "<br><strong>Sent by:</strong> infinidoge1337 (@unknown-user:)"
+ "<br><br><strong>Account Roles (7)</strong>" + "<br><br><strong>Account Roles (7)</strong>"
+ "<br>§b, !, ‼, Ears Port Ping, Ears Update Ping, Yttr Ping, unsup Ping</p>" + "<br>§b, !, ‼, Ears Port Ping, Ears Update Ping, Yttr Ping, unsup Ping</p>"
+ `🖼️ <a href="https://files.inx.moe/p/cdn/lillith.webp">https://files.inx.moe/p/cdn/lillith.webp</a>` + `🖼️ <a href="https://files.inx.moe/p/cdn/lillith.webp">https://files.inx.moe/p/cdn/lillith.webp</a>`

View file

@ -125,8 +125,8 @@ test("message2event embeds: blockquote in embed", async t => {
t.equal(called, 1, "should call getJoinedMembers once") t.equal(called, 1, "should call getJoinedMembers once")
}) })
test("message2event embeds: extreme html is all escaped", async t => { test("message2event embeds: crazy html is all escaped", async t => {
const events = await messageToEvent(data.message_with_embeds.extreme_html_escaping, data.guild.general) const events = await messageToEvent(data.message_with_embeds.escaping_crazy_html_tags, data.guild.general)
t.deepEqual(events, [{ t.deepEqual(events, [{
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.notice", msgtype: "m.notice",

View file

@ -1,38 +0,0 @@
// @ts-check
const passthrough = require("../../passthrough")
const {db, select, from} = passthrough
/**
* @param {string} userID discord user ID that left
* @param {string} guildID discord guild ID that they left
*/
function removeMemberMxids(userID, guildID) {
// Get sims for user and remove
let membership = from("sim").join("sim_member", "mxid").join("channel_room", "room_id")
.select("room_id", "mxid").where({user_id: userID, guild_id: guildID}).and("ORDER BY room_id, mxid").all()
membership = membership.concat(from("sim_proxy").join("sim", "user_id").join("sim_member", "mxid").join("channel_room", "room_id")
.select("room_id", "mxid").where({proxy_owner_id: userID, guild_id: guildID}).and("ORDER BY room_id, mxid").all())
// Get user installed apps and remove
/** @type {string[]} */
let userAppDeletions = []
// 1. Select apps that have 1 user remaining
/** @type {Set<string>} */
const appsWithOneUser = new Set(db.prepare("SELECT app_bot_id FROM app_user_install WHERE guild_id = ? GROUP BY app_bot_id HAVING count(*) = 1").pluck().all(guildID))
// 2. Select apps installed by this user
const appsFromThisUser = new Set(select("app_user_install", "app_bot_id", {guild_id: guildID, user_id: userID}).pluck().all())
if (appsFromThisUser.size) userAppDeletions.push(userID)
// Then remove user installed apps if this was the last user with them
const appsToRemove = appsWithOneUser.intersection(appsFromThisUser)
for (const botID of appsToRemove) {
// Remove sims for user installed app
const appRemoval = removeMemberMxids(botID, guildID)
membership = membership.concat(appRemoval.membership)
userAppDeletions = userAppDeletions.concat(appRemoval.userAppDeletions)
}
return {membership, userAppDeletions}
}
module.exports.removeMemberMxids = removeMemberMxids

View file

@ -1,43 +0,0 @@
// @ts-check
const {test} = require("supertape")
const {removeMemberMxids} = require("./remove-member-mxids")
test("remove member mxids: would remove mxid for all rooms in this server", t => {
t.deepEqual(removeMemberMxids("772659086046658620", "112760669178241024"), {
userAppDeletions: [],
membership: [{
mxid: "@_ooye_cadence:cadence.moe",
room_id: "!fGgIymcYWOqjbSRUdV:cadence.moe"
}, {
mxid: "@_ooye_cadence:cadence.moe",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}]
})
})
test("remove member mxids: removes sims too", t => {
t.deepEqual(removeMemberMxids("196188877885538304", "112760669178241024"), {
userAppDeletions: [],
membership: [{
mxid: '@_ooye_ampflower:cadence.moe',
room_id: '!qzDBLKlildpzrrOnFZ:cadence.moe'
}, {
mxid: '@_ooye__pk_zoego:cadence.moe',
room_id: '!qzDBLKlildpzrrOnFZ:cadence.moe'
}]
})
})
test("remove member mxids: removes apps too", t => {
t.deepEqual(removeMemberMxids("197126718400626689", "66192955777486848"), {
userAppDeletions: ["197126718400626689"],
membership: [{
mxid: '@_ooye_infinidoge1337:cadence.moe',
room_id: '!BnKuBPCvyfOkhcUjEu:cadence.moe'
}, {
mxid: '@_ooye_evil_lillith_sheher:cadence.moe',
room_id: '!BnKuBPCvyfOkhcUjEu:cadence.moe'
}]
})
})

View file

@ -23,7 +23,7 @@ class DiscordClient {
/** @type {import("cloudstorm").IClientOptions["intents"]} */ /** @type {import("cloudstorm").IClientOptions["intents"]} */
const intents = [ const intents = [
"DIRECT_MESSAGES", "DIRECT_MESSAGE_REACTIONS", "DIRECT_MESSAGE_TYPING", "DIRECT_MESSAGES", "DIRECT_MESSAGE_REACTIONS", "DIRECT_MESSAGE_TYPING",
"GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS", "GUILD_MESSAGE_POLLS", "GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MEMBERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS", "GUILD_MESSAGE_POLLS",
"MESSAGE_CONTENT" "MESSAGE_CONTENT"
] ]
if (reg.ooye.receive_presences !== false) intents.push("GUILD_PRESENCES") if (reg.ooye.receive_presences !== false) intents.push("GUILD_PRESENCES")

View file

@ -49,9 +49,9 @@ const utils = {
if (listen === "full") { if (listen === "full") {
try { try {
await eventDispatcher.checkMissedExpressions(message.d) await eventDispatcher.checkMissedExpressions(message.d)
await eventDispatcher.checkMissedMessages(client, message.d)
await eventDispatcher.checkMissedPins(client, message.d) await eventDispatcher.checkMissedPins(client, message.d)
await eventDispatcher.checkMissedLeaves(client, message.d) await eventDispatcher.checkMissedMessages(client, message.d)
await eventDispatcher.checkMissedMembers(message.d)
} catch (e) { } catch (e) {
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)

View file

@ -32,14 +32,14 @@ const speedbump = sync.require("./actions/speedbump")
const retrigger = sync.require("./actions/retrigger") const retrigger = sync.require("./actions/retrigger")
/** @type {import("./actions/set-presence")} */ /** @type {import("./actions/set-presence")} */
const setPresence = sync.require("./actions/set-presence") const setPresence = sync.require("./actions/set-presence")
/** @type {import("./actions/remove-member")} */
const removeMember = sync.require("./actions/remove-member")
/** @type {import("./actions/poll-vote")} */ /** @type {import("./actions/poll-vote")} */
const vote = sync.require("./actions/poll-vote") const vote = sync.require("./actions/poll-vote")
/** @type {import("../m2d/event-dispatcher")} */ /** @type {import("../m2d/event-dispatcher")} */
const matrixEventDispatcher = sync.require("../m2d/event-dispatcher") const matrixEventDispatcher = sync.require("../m2d/event-dispatcher")
/** @type {import("../discord/interactions/matrix-info")} */ /** @type {import("../discord/interactions/matrix-info")} */
const matrixInfoInteraction = sync.require("../discord/interactions/matrix-info") const matrixInfoInteraction = sync.require("../discord/interactions/matrix-info")
/** @type {import("./actions/remove-member")} */
const removeMember = sync.require("./actions/remove-member")
const {Semaphore} = require("@chriscdn/promise-semaphore") const {Semaphore} = require("@chriscdn/promise-semaphore")
const checkMissedPinsSema = new Semaphore() const checkMissedPinsSema = new Semaphore()
@ -125,7 +125,6 @@ module.exports = {
// Send in order // Send in order
for (let i = Math.min(messages.length, latestBridgedMessageIndex)-1; i >= 0; i--) { for (let i = Math.min(messages.length, latestBridgedMessageIndex)-1; i >= 0; i--) {
const message = messages[i] const message = messages[i]
if (message.type === DiscordTypes.MessageType.UserJoin) continue // since join announcements don't become events, it would be a repetition to act on them during backfill
if (!members.has(message.author.id)) members.set(message.author.id, await client.snow.guild.getGuildMember(guild.id, message.author.id).catch(() => undefined)) if (!members.has(message.author.id)) members.set(message.author.id, await client.snow.guild.getGuildMember(guild.id, message.author.id).catch(() => undefined))
await module.exports.MESSAGE_CREATE(client, { await module.exports.MESSAGE_CREATE(client, {
@ -175,31 +174,6 @@ module.exports = {
await createSpace.syncSpaceExpressions(data, true) await createSpace.syncSpaceExpressions(data, true)
}, },
/**
* When logging back in, check if any members left while we were gone.
* Do this by getting the member list from Discord and seeing who we still have locally that isn't there in the response.
* @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayGuildCreateDispatchData} guild
*/
async checkMissedLeaves(client, guild) {
const maxLimit = 1000
if (guild.member_count >= maxLimit) return // too large to want to scan
const discordMembers = await client.snow.guild.getGuildMembers(guild.id, {limit: maxLimit})
if (discordMembers.length >= maxLimit) return // response was maxed out, there are guild members that weren't listed, can't act safely
const discordMembersSet = new Set(discordMembers.map(m => m.user.id))
// no indexes on this one but I'll cope
const membersAddedOnMatrix = new Set(from("sim").join("sim_member", "mxid").join("channel_room", "room_id")
.pluck("user_id").selectUnsafe("DISTINCT user_id").where({guild_id: guild.id}).and("AND user_id not like '%-%' and user_id not like '%\\_%' escape '\\'").all())
const userInstalledAppIDs = new Set(from("app_user_install").pluck("app_bot_id").selectUnsafe("DISTINCT app_bot_id").where({guild_id: guild.id}).all())
// loop over members added on matrix and if the member does not exist on discord-side then they should be removed
for (const userID of membersAddedOnMatrix) {
if (userInstalledAppIDs.has(userID)) continue // skip user installed apps here since they're never true members - they'll be removed by removeMember when the associated user is removed
if (!discordMembersSet.has(userID)) {
await removeMember.removeMember(userID, guild.id)
}
}
},
/** /**
* Announces to the parent room that the thread room has been created. * Announces to the parent room that the thread room has been created.
* See notes.md, "Ignore MESSAGE_UPDATE and bridge THREAD_CREATE as the announcement" * See notes.md, "Ignore MESSAGE_UPDATE and bridge THREAD_CREATE as the announcement"
@ -239,14 +213,6 @@ module.exports = {
} }
}, },
/**
* @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayGuildMemberRemoveDispatchData} data
*/
async GUILD_MEMBER_REMOVE(client, data) {
await removeMember.removeMember(data.user.id, data.guild_id)
},
/** /**
* @param {import("./discord-client")} client * @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayChannelUpdateDispatchData} channelOrThread * @param {DiscordTypes.GatewayChannelUpdateDispatchData} channelOrThread
@ -449,5 +415,24 @@ module.exports = {
const status = data.status const status = data.status
if (!status) return if (!status) return
setPresence.presenceTracker.incomingPresence(data.user.id, data.guild_id, status) setPresence.presenceTracker.incomingPresence(data.user.id, data.guild_id, status)
},
/**
* When a Discord user leaves (or is kicked/banned from) the guild, make their sims leave the bridged Matrix rooms.
* Also removes PluralKit proxy sims owned by the departing user.
* @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayGuildMemberRemoveDispatchData} data
*/
async GUILD_MEMBER_REMOVE(client, data) {
if (data.user.id === client.user.id) return // Don't process if the bot itself is removed
await removeMember.removeMember(data.user.id, data.guild_id)
},
/**
* When logging back in, check for sims whose Discord users have left the guild while the bridge was offline.
* @param {DiscordTypes.GatewayGuildCreateDispatchData} guild
*/
async checkMissedMembers(guild) {
await removeMember.checkMissedMembers(guild)
} }
} }

View file

@ -4,6 +4,6 @@ CREATE TABLE "role_default" (
"guild_id" TEXT NOT NULL, "guild_id" TEXT NOT NULL,
"role_id" TEXT NOT NULL, "role_id" TEXT NOT NULL,
PRIMARY KEY ("guild_id", "role_id") PRIMARY KEY ("guild_id", "role_id")
) WITHOUT ROWID; );
COMMIT; COMMIT;

View file

@ -1,10 +0,0 @@
BEGIN TRANSACTION;
CREATE TABLE "app_user_install" (
"guild_id" TEXT NOT NULL,
"app_bot_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
PRIMARY KEY ("guild_id", "app_bot_id", "user_id")
) WITHOUT ROWID;
COMMIT;

View file

@ -1,10 +1,4 @@
export type Models = { export type Models = {
app_user_install: {
guild_id: string
app_bot_id: string
user_id: string
}
auto_emoji: { auto_emoji: {
name: string name: string
emoji_id: string emoji_id: string

View file

@ -114,7 +114,7 @@ function hasAllPermissions(resolvedPermissions, permissionsToCheckFor) {
* @param {DiscordTypes.APIMessage} message * @param {DiscordTypes.APIMessage} message
*/ */
function isWebhookMessage(message) { function isWebhookMessage(message) {
return message.webhook_id && message.type !== DiscordTypes.MessageType.ChatInputCommand && message.type !== DiscordTypes.MessageType.ContextMenuCommand return message.webhook_id && message.type !== DiscordTypes.MessageType.ChatInputCommand
} }
/** /**

View file

@ -471,8 +471,7 @@ async function checkWrittenMentions(content, senderMxid, roomID, guild, di) {
// @ts-ignore - typescript doesn't know about indices yet // @ts-ignore - typescript doesn't know about indices yet
content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `@everyone` + content.slice(writtenMentionMatch.indices[1][1]), content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `@everyone` + content.slice(writtenMentionMatch.indices[1][1]),
ensureJoined: [], ensureJoined: [],
allowedMentionsParse: ["everyone"], allowedMentionsParse: ["everyone"]
allowedMentionsUsers: []
} }
} }
} else if (writtenMentionMatch[1].length < 40) { // the API supports up to 100 characters, but really if you're searching more than 40, something messed up } else if (writtenMentionMatch[1].length < 40) { // the API supports up to 100 characters, but really if you're searching more than 40, something messed up
@ -483,8 +482,7 @@ async function checkWrittenMentions(content, senderMxid, roomID, guild, di) {
// @ts-ignore - typescript doesn't know about indices yet // @ts-ignore - typescript doesn't know about indices yet
content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `<@${results[0].user.id}>` + content.slice(writtenMentionMatch.indices[1][1]), content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `<@${results[0].user.id}>` + content.slice(writtenMentionMatch.indices[1][1]),
ensureJoined: [results[0].user], ensureJoined: [results[0].user],
allowedMentionsParse: [], allowedMentionsParse: []
allowedMentionsUsers: [results[0].user.id]
} }
} }
} }
@ -546,7 +544,6 @@ async function eventToMessage(event, guild, channel, di) {
let displayName = event.sender let displayName = event.sender
let avatarURL = undefined let avatarURL = undefined
const allowedMentionsParse = ["users", "roles"] const allowedMentionsParse = ["users", "roles"]
const allowedMentionsUsers = []
/** @type {string[]} */ /** @type {string[]} */
let messageIDsToEdit = [] let messageIDsToEdit = []
let replyLine = "" let replyLine = ""
@ -766,7 +763,7 @@ async function eventToMessage(event, guild, channel, di) {
// Generate a reply preview for a standard message // Generate a reply preview for a standard message
repliedToContent = repliedToContent.replace(/.*<\/mx-reply>/s, "") // Remove everything before replies, so just use the actual message body repliedToContent = repliedToContent.replace(/.*<\/mx-reply>/s, "") // Remove everything before replies, so just use the actual message body
repliedToContent = repliedToContent.replace(/^\s*<blockquote>.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards repliedToContent = repliedToContent.replace(/^\s*<blockquote>.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards
repliedToContent = repliedToContent.replace(/(?:\n|<br ?\/?>)+/g, " ") // Should all be on one line repliedToContent = repliedToContent.replace(/(?:\n|<br>)+/g, " ") // Should all be on one line
repliedToContent = repliedToContent.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.) repliedToContent = repliedToContent.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.)
repliedToContent = repliedToContent.replace(/<img([^>]*)>/g, (_, att) => { // Convert Matrix emoji images into Discord emoji markdown repliedToContent = repliedToContent.replace(/<img([^>]*)>/g, (_, att) => { // Convert Matrix emoji images into Discord emoji markdown
const mxcUrlMatch = att.match(/\bsrc="(mxc:\/\/[^"]+)"/) const mxcUrlMatch = att.match(/\bsrc="(mxc:\/\/[^"]+)"/)
@ -989,34 +986,16 @@ async function eventToMessage(event, guild, channel, di) {
} }
} }
// Complete content
content = displayNameRunoff + replyLine + content content = displayNameRunoff + replyLine + content
// Split into 2000 character chunks // Split into 2000 character chunks
const chunks = chunk(content, 2000) const chunks = chunk(content, 2000)
// If m.mentions is specified and valid, overwrite allowedMentionsParse with a converted m.mentions
let allowed_mentions = {parse: allowedMentionsParse}
if (event.content["m.mentions"]) {
// Combine requested mentions with detected written mentions to get the full list
if (Array.isArray(event.content["m.mentions"].user_ids)) {
for (const mxid of event.content["m.mentions"].user_ids) {
const user_id = select("sim", "user_id", {mxid}).pluck().get()
if (!user_id) continue
allowedMentionsUsers.push(
select("sim_proxy", "proxy_owner_id", {user_id}).pluck().get() || user_id
)
}
}
// Specific mentions were requested, so do not parse users
allowed_mentions.parse = allowed_mentions.parse.filter(x => x !== "users")
allowed_mentions.users = allowedMentionsUsers
}
// Assemble chunks into Discord messages content
/** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]})[]} */ /** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]})[]} */
const messages = chunks.map(content => ({ const messages = chunks.map(content => ({
content, content,
allowed_mentions, allowed_mentions: {
parse: allowedMentionsParse
},
username: displayNameShortened, username: displayNameShortened,
avatar_url: avatarURL avatar_url: avatarURL
})) }))

View file

@ -266,8 +266,7 @@ test("event2message: markdown in link text does not attempt to be escaped becaus
content: "hey [@mario sports mix [she/her]](<https://matrix.to/#/%40cadence%3Acadence.moe>), is it possible to listen on a unix socket?", content: "hey [@mario sports mix [she/her]](<https://matrix.to/#/%40cadence%3Acadence.moe>), is it possible to listen on a unix socket?",
avatar_url: undefined, avatar_url: undefined,
allowed_mentions: { allowed_mentions: {
parse: ["roles"], parse: ["users", "roles"]
users: []
} }
}] }]
} }
@ -548,8 +547,7 @@ test("event2message: links don't have angle brackets added by accident", async t
content: "Wanted to automate WG→AWG config enrichment and ended up basically coding a batch INI processor.\nhttps://github.com/Erquint/wgcbp", content: "Wanted to automate WG→AWG config enrichment and ended up basically coding a batch INI processor.\nhttps://github.com/Erquint/wgcbp",
avatar_url: undefined, avatar_url: undefined,
allowed_mentions: { allowed_mentions: {
parse: ["roles"], parse: ["users", "roles"]
users: []
} }
}] }]
} }
@ -1298,8 +1296,7 @@ test("event2message: lists have appropriate line breaks", async t => {
content: `i am not certain what you mean by "already exists with as discord". my goals are\n\n* bridgeing specific channels with existing matrix rooms\n * optionally maybe entire "servers"\n* offering the bridge as a public service`, content: `i am not certain what you mean by "already exists with as discord". my goals are\n\n* bridgeing specific channels with existing matrix rooms\n * optionally maybe entire "servers"\n* offering the bridge as a public service`,
avatar_url: undefined, avatar_url: undefined,
allowed_mentions: { allowed_mentions: {
parse: ["roles"], parse: ["users", "roles"]
users: []
} }
}] }]
} }
@ -1340,8 +1337,7 @@ test("event2message: ordered list start attribute works", async t => {
content: `i am not certain what you mean by "already exists with as discord". my goals are\n\n1. bridgeing specific channels with existing matrix rooms\n 2. optionally maybe entire "servers"\n2. offering the bridge as a public service`, content: `i am not certain what you mean by "already exists with as discord". my goals are\n\n1. bridgeing specific channels with existing matrix rooms\n 2. optionally maybe entire "servers"\n2. offering the bridge as a public service`,
avatar_url: undefined, avatar_url: undefined,
allowed_mentions: { allowed_mentions: {
parse: ["roles"], parse: ["users", "roles"]
users: []
} }
}] }]
} }
@ -1467,118 +1463,6 @@ test("event2message: rich reply to a sim user", async t => {
) )
}) })
test("event2message: rich reply to a sim user, explicitly enabling mentions in client", async t => {
t.deepEqual(
await eventToMessage({
"type": "m.room.message",
"sender": "@cadence:cadence.moe",
"content": {
"msgtype": "m.text",
"body": "> <@_ooye_kyuugryphon:cadence.moe> Slow news day.\n\nTesting this reply, ignore",
"format": "org.matrix.custom.html",
"formatted_body": "<mx-reply><blockquote><a href=\"https://matrix.to/#/!fGgIymcYWOqjbSRUdV:cadence.moe/$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04?via=cadence.moe&via=feather.onl\">In reply to</a> <a href=\"https://matrix.to/#/@_ooye_kyuugryphon:cadence.moe\">@_ooye_kyuugryphon:cadence.moe</a><br>Slow news day.</blockquote></mx-reply>Testing this reply, ignore",
"m.relates_to": {
"m.in_reply_to": {
"event_id": "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04"
}
},
"m.mentions": {
user_ids: ["@_ooye_kyuugryphon:cadence.moe"]
}
},
"origin_server_ts": 1693029683016,
"unsigned": {
"age": 91,
"transaction_id": "m1693029682894.510"
},
"event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8",
"room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe"
}, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", {
type: "m.room.message",
content: {
msgtype: "m.text",
body: "Slow news day."
},
sender: "@_ooye_kyuugryphon:cadence.moe"
})
}
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "cadence [they]",
content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:"
+ " Slow news day."
+ "\nTesting this reply, ignore",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
allowed_mentions: {
parse: ["roles"],
users: ["111604486476181504"]
}
}]
}
)
})
test("event2message: rich reply to a sim user, explicitly disabling mentions in client", async t => {
t.deepEqual(
await eventToMessage({
"type": "m.room.message",
"sender": "@cadence:cadence.moe",
"content": {
"msgtype": "m.text",
"body": "> <@_ooye_kyuugryphon:cadence.moe> Slow news day.\n\nTesting this reply, ignore",
"format": "org.matrix.custom.html",
"formatted_body": "<mx-reply><blockquote><a href=\"https://matrix.to/#/!fGgIymcYWOqjbSRUdV:cadence.moe/$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04?via=cadence.moe&via=feather.onl\">In reply to</a> <a href=\"https://matrix.to/#/@_ooye_kyuugryphon:cadence.moe\">@_ooye_kyuugryphon:cadence.moe</a><br>Slow news day.</blockquote></mx-reply>Testing this reply, ignore",
"m.relates_to": {
"m.in_reply_to": {
"event_id": "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04"
}
},
"m.mentions": {}
},
"origin_server_ts": 1693029683016,
"unsigned": {
"age": 91,
"transaction_id": "m1693029682894.510"
},
"event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8",
"room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe"
}, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", {
type: "m.room.message",
content: {
msgtype: "m.text",
body: "Slow news day."
},
sender: "@_ooye_kyuugryphon:cadence.moe"
})
}
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "cadence [they]",
content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:"
+ " Slow news day."
+ "\nTesting this reply, ignore",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
allowed_mentions: {
parse: ["roles"],
users: []
}
}]
}
)
})
test("event2message: rich reply to a rich reply to a multi-line message should correctly strip reply fallback", async t => { test("event2message: rich reply to a rich reply to a multi-line message should correctly strip reply fallback", async t => {
t.deepEqual( t.deepEqual(
await eventToMessage({ await eventToMessage({
@ -1943,9 +1827,9 @@ test("event2message: should suppress embeds for links in reply preview", async t
sender: "@rnl:cadence.moe", sender: "@rnl:cadence.moe",
content: { content: {
msgtype: "m.text", msgtype: "m.text",
body: `> <@cadence:cadence.moe> https://www.youtube.com/watch?v=uX32idb1jMw\n\nEveryone in the comments is like "you're so ahead of the curve" and "can't believe this came out before the scandal" but I thought Mr Beast sucking was a common sentiment`, body: `> <@cadence:cadence.moe> https://www.youtube.com/watch?v=uX32idb1jMw\n\nEveryone in the comments is like "you're so ahead of the curve" and "crazy that this came out before the scandal" but I thought Mr Beast sucking was a common sentiment`,
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!fGgIymcYWOqjbSRUdV:cadence.moe/$qmyjr-ISJtnOM5WTWLI0fT7uSlqRLgpyin2d2NCglCU?via=cadence.moe">In reply to</a> <a href="https://matrix.to/#/@cadence:cadence.moe">@cadence:cadence.moe</a><br>https://www.youtube.com/watch?v=uX32idb1jMw</blockquote></mx-reply>Everyone in the comments is like "you're so ahead of the curve" and "can't believe this came out before the scandal" but I thought Mr Beast sucking was a common sentiment`, formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!fGgIymcYWOqjbSRUdV:cadence.moe/$qmyjr-ISJtnOM5WTWLI0fT7uSlqRLgpyin2d2NCglCU?via=cadence.moe">In reply to</a> <a href="https://matrix.to/#/@cadence:cadence.moe">@cadence:cadence.moe</a><br>https://www.youtube.com/watch?v=uX32idb1jMw</blockquote></mx-reply>Everyone in the comments is like "you're so ahead of the curve" and "crazy that this came out before the scandal" but I thought Mr Beast sucking was a common sentiment`,
"m.relates_to": { "m.relates_to": {
"m.in_reply_to": { "m.in_reply_to": {
event_id: "$qmyjr-ISJtnOM5WTWLI0fT7uSlqRLgpyin2d2NCglCU" event_id: "$qmyjr-ISJtnOM5WTWLI0fT7uSlqRLgpyin2d2NCglCU"
@ -1975,7 +1859,7 @@ test("event2message: should suppress embeds for links in reply preview", async t
username: "RNL", username: "RNL",
content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1273204543739396116 **Ⓜcadence [they]**:" content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1273204543739396116 **Ⓜcadence [they]**:"
+ " <https://www.youtube.com/watch?v=uX32idb1jMw>" + " <https://www.youtube.com/watch?v=uX32idb1jMw>"
+ `\nEveryone in the comments is like "you're so ahead of the curve" and "can't believe this came out before the scandal" but I thought Mr Beast sucking was a common sentiment`, + `\nEveryone in the comments is like "you're so ahead of the curve" and "crazy that this came out before the scandal" but I thought Mr Beast sucking was a common sentiment`,
avatar_url: undefined, avatar_url: undefined,
allowed_mentions: { allowed_mentions: {
parse: ["users", "roles"] parse: ["users", "roles"]

View file

@ -423,10 +423,7 @@ async event => {
if (event.content.membership === "leave" || event.content.membership === "ban") { if (event.content.membership === "leave" || event.content.membership === "ban") {
// Member is gone // Member is gone
// if Matrix member, data was cached in member_cache
db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key) db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key)
// if Discord member (so kicked/banned by Matrix user), data was cached in sim_member
db.prepare("DELETE FROM sim_member WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key)
// Unregister room's use as a direct chat and/or an invite target if the bot itself left // Unregister room's use as a direct chat and/or an invite target if the bot itself left
if (event.state_key === utils.bot) { if (event.state_key === utils.bot) {

View file

@ -88,12 +88,6 @@ html(lang="en")
--_ts-multiple-bg: var(--green-400); --_ts-multiple-bg: var(--green-400);
--_ts-multiple-fc: var(--white); --_ts-multiple-fc: var(--white);
} }
.s-avatar {
--_av-bg: var(--white);
}
.s-avatar .s-avatar--letter {
color: var(--white);
}
.s-btn__dropdown:has(+ :popover-open) { .s-btn__dropdown:has(+ :popover-open) {
background-color: var(--theme-topbar-item-background-hover, var(--black-200)) !important; background-color: var(--theme-topbar-item-background-hover, var(--black-200)) !important;
} }

View file

@ -83,13 +83,7 @@ function tryStatic(event, fallthrough) {
// Everything else // Everything else
else { else {
const mime = mimeTypes.lookup(id) const mime = mimeTypes.lookup(id)
if (typeof mime === "string") { if (typeof mime === "string") defaultContentType(event, mime)
if (mime.startsWith("text/")) {
defaultContentType(event, mime + "; charset=utf-8") // usually wise
} else {
defaultContentType(event, mime)
}
}
return { return {
size: stats.size size: stats.size
} }
@ -100,7 +94,7 @@ function tryStatic(event, fallthrough) {
const path = join(publicDir, id) const path = join(publicDir, id)
return pugSync.renderPath(event, path, {}) return pugSync.renderPath(event, path, {})
} else { } else {
return fs.createReadStream(join(publicDir, id)) return fs.promises.readFile(join(publicDir, id))
} }
} }
}) })

View file

@ -4617,7 +4617,7 @@ module.exports = {
flags: 0, flags: 0,
components: [] components: []
}, },
extreme_html_escaping: { escaping_crazy_html_tags: {
id: "1158894131322552391", id: "1158894131322552391",
type: 0, type: 0,
content: "", content: "",

View file

@ -38,28 +38,15 @@ INSERT INTO sim (user_id, username, sim_name, mxid) VALUES
('1109360903096369153', 'Amanda', 'amanda', '@_ooye_amanda:cadence.moe'), ('1109360903096369153', 'Amanda', 'amanda', '@_ooye_amanda:cadence.moe'),
('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '_pk_zoego', '_pk_zoego', '@_ooye__pk_zoego:cadence.moe'), ('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '_pk_zoego', '_pk_zoego', '@_ooye__pk_zoego:cadence.moe'),
('320067006521147393', 'papiophidian', 'papiophidian', '@_ooye_papiophidian:cadence.moe'), ('320067006521147393', 'papiophidian', 'papiophidian', '@_ooye_papiophidian:cadence.moe'),
('772659086046658620', 'cadence.worm', 'cadence', '@_ooye_cadence:cadence.moe'), ('772659086046658620', 'cadence.worm', 'cadence', '@_ooye_cadence:cadence.moe');
('196188877885538304', 'ampflower', 'ampflower', '@_ooye_ampflower:cadence.moe'),
('1458668878107381800', 'Evil Lillith (she/her)', 'evil_lillith_sheher', '@_ooye_evil_lillith_sheher:cadence.moe'),
('197126718400626689', 'infinidoge1337', 'infinidoge1337', '@_ooye_infinidoge1337:cadence.moe');
INSERT INTO sim_member (mxid, room_id, hashed_profile_content) VALUES INSERT INTO sim_member (mxid, room_id, hashed_profile_content) VALUES
('@_ooye_bojack_horseman:cadence.moe', '!hYnGGlPHlbujVVfktC:cadence.moe', NULL), ('@_ooye_bojack_horseman:cadence.moe', '!hYnGGlPHlbujVVfktC:cadence.moe', NULL),
('@_ooye_cadence:cadence.moe', '!BnKuBPCvyfOkhcUjEu:cadence.moe', NULL), ('@_ooye_cadence:cadence.moe', '!BnKuBPCvyfOkhcUjEu:cadence.moe', NULL);
('@_ooye_cadence:cadence.moe', '!kLRqKKUQXcibIMtOpl:cadence.moe', NULL),
('@_ooye_cadence:cadence.moe', '!fGgIymcYWOqjbSRUdV:cadence.moe', NULL),
('@_ooye_ampflower:cadence.moe', '!qzDBLKlildpzrrOnFZ:cadence.moe', NULL),
('@_ooye__pk_zoego:cadence.moe', '!qzDBLKlildpzrrOnFZ:cadence.moe', NULL),
('@_ooye_infinidoge1337:cadence.moe', '!BnKuBPCvyfOkhcUjEu:cadence.moe', NULL),
('@_ooye_evil_lillith_sheher:cadence.moe', '!BnKuBPCvyfOkhcUjEu:cadence.moe', NULL);
INSERT INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES INSERT INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES
('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '196188877885538304', 'Azalea &flwr; 🌺'); ('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '196188877885538304', 'Azalea &flwr; 🌺');
INSERT INTO app_user_install (guild_id, app_bot_id, user_id) VALUES
('66192955777486848', '1458668878107381800', '197126718400626689');
INSERT INTO message_room (message_id, historical_room_index) INSERT INTO message_room (message_id, historical_room_index)
WITH a (message_id, channel_id) AS (VALUES WITH a (message_id, channel_id) AS (VALUES
('1106366167788044450', '122155380120748034'), ('1106366167788044450', '122155380120748034'),

View file

@ -143,6 +143,7 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not
require("../src/d2m/actions/create-room.test") require("../src/d2m/actions/create-room.test")
require("../src/d2m/actions/create-space.test") require("../src/d2m/actions/create-space.test")
require("../src/d2m/actions/register-user.test") require("../src/d2m/actions/register-user.test")
require("../src/d2m/actions/remove-member.test")
require("../src/d2m/converters/edit-to-changes.test") require("../src/d2m/converters/edit-to-changes.test")
require("../src/d2m/converters/emoji-to-key.test") require("../src/d2m/converters/emoji-to-key.test")
require("../src/d2m/converters/find-mentions.test") require("../src/d2m/converters/find-mentions.test")
@ -152,7 +153,6 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not
require("../src/d2m/converters/message-to-event.test.embeds") require("../src/d2m/converters/message-to-event.test.embeds")
require("../src/d2m/converters/message-to-event.test.pk") require("../src/d2m/converters/message-to-event.test.pk")
require("../src/d2m/converters/pins-to-list.test") require("../src/d2m/converters/pins-to-list.test")
require("../src/d2m/converters/remove-member-mxids.test")
require("../src/d2m/converters/remove-reaction.test") require("../src/d2m/converters/remove-reaction.test")
require("../src/d2m/converters/thread-to-announcement.test") require("../src/d2m/converters/thread-to-announcement.test")
require("../src/d2m/converters/user-to-mxid.test") require("../src/d2m/converters/user-to-mxid.test")