Compare commits

..

1 commit
main ... main

Author SHA1 Message Date
e031558177 Added a comment explaining sims to db\orm-defs.d.ts 2024-03-06 17:08:43 -05:00
158 changed files with 3257 additions and 11835 deletions

13
.gitignore vendored
View file

@ -1,16 +1,7 @@
# Secrets node_modules
config.js config.js
registration.yaml registration.yaml
ooye.db*
events.db*
# Automatically generated
node_modules
coverage coverage
db/ooye.db*
test/res/* test/res/*
!test/res/lottie* !test/res/lottie*
icon.svg
*~
.#*
\#*#
launch.json

9
addbot.js Executable file → Normal file
View file

@ -1,18 +1,15 @@
#!/usr/bin/env node
// @ts-check // @ts-check
const {reg} = require("./src/matrix/read-registration") const config = require("./config")
const token = reg.ooye.discord_token
const id = Buffer.from(token.split(".")[0], "base64").toString()
function addbot() { function addbot() {
const token = config.discordToken
const id = Buffer.from(token.split(".")[0], "base64")
return `Open this link to add the bot to a Discord server:\nhttps://discord.com/oauth2/authorize?client_id=${id}&scope=bot&permissions=1610883072 ` return `Open this link to add the bot to a Discord server:\nhttps://discord.com/oauth2/authorize?client_id=${id}&scope=bot&permissions=1610883072 `
} }
/* c8 ignore next 3 */
if (process.argv.find(a => a.endsWith("addbot") || a.endsWith("addbot.js"))) { if (process.argv.find(a => a.endsWith("addbot") || a.endsWith("addbot.js"))) {
console.log(addbot()) console.log(addbot())
} }
module.exports.id = id
module.exports.addbot = addbot module.exports.addbot = addbot

View file

@ -1,3 +1,3 @@
#!/usr/bin/env sh #!/usr/bin/env sh
echo "Open this link to add the bot to a Discord server:" echo "Open this link to add the bot to a Discord server:"
echo "https://discord.com/oauth2/authorize?client_id=$(grep discord_token registration.yaml | sed -E 's!.*: ["'\'']([A-Za-z0-9+=/_-]*).*!\1!g' | base64 -d)&scope=bot&permissions=1610883072" echo "https://discord.com/oauth2/authorize?client_id=$(grep discordToken config.js | sed -E 's!.*: ["'\'']([A-Za-z0-9+=/_-]*).*!\1!g' | base64 -d)&scope=bot&permissions=1610883072"

3
config.example.js Normal file
View file

@ -0,0 +1,3 @@
module.exports = {
discordToken: "yes"
}

View file

@ -2,8 +2,7 @@
const assert = require("assert").strict const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const Ty = require("../../types") const reg = require("../../matrix/read-registration")
const {reg} = require("../../matrix/read-registration")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {discord, sync, db, select} = passthrough const {discord, sync, db, select} = passthrough
@ -13,8 +12,8 @@ const file = sync.require("../../matrix/file")
const api = sync.require("../../matrix/api") const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/kstate")} */ /** @type {import("../../matrix/kstate")} */
const ks = sync.require("../../matrix/kstate") const ks = sync.require("../../matrix/kstate")
/** @type {import("../../discord/utils")} */ /** @type {import("./create-space")}) */
const utils = sync.require("../../discord/utils") const createSpace = sync.require("./create-space") // watch out for the require loop
/** /**
* There are 3 levels of room privacy: * There are 3 levels of room privacy:
@ -50,24 +49,21 @@ async function roomToKState(roomID) {
* @param {string} roomID * @param {string} roomID
* @param {any} kstate * @param {any} kstate
*/ */
async function applyKStateDiffToRoom(roomID, kstate) { function applyKStateDiffToRoom(roomID, kstate) {
const events = await ks.kstateToState(kstate) const events = ks.kstateToState(kstate)
return Promise.all(events.map(({type, state_key, content}) => return Promise.all(events.map(({type, state_key, content}) =>
api.sendState(roomID, type, state_key, content) api.sendState(roomID, type, state_key, content)
)) ))
} }
/** /**
* @param {{id: string, name: string, topic?: string?, type: number, parent_id?: string?}} channel * @param {{id: string, name: string, topic?: string?, type: number}} channel
* @param {{id: string}} guild * @param {{id: string}} guild
* @param {string | null | undefined} customName * @param {string | null | undefined} customName
*/ */
function convertNameAndTopic(channel, guild, customName) { function convertNameAndTopic(channel, guild, customName) {
// @ts-ignore
const parentChannel = discord.channels.get(channel.parent_id)
let channelPrefix = let channelPrefix =
( parentChannel?.type === DiscordTypes.ChannelType.GuildForum ? "" ( channel.type === DiscordTypes.ChannelType.PublicThread ? "[⛓️] "
: channel.type === DiscordTypes.ChannelType.PublicThread ? "[⛓️] "
: channel.type === DiscordTypes.ChannelType.PrivateThread ? "[🔒⛓️] " : channel.type === DiscordTypes.ChannelType.PrivateThread ? "[🔒⛓️] "
: channel.type === DiscordTypes.ChannelType.GuildVoice ? "[🔊] " : channel.type === DiscordTypes.ChannelType.GuildVoice ? "[🔊] "
: "") : "")
@ -88,36 +84,26 @@ function convertNameAndTopic(channel, guild, customName) {
* Async because it may create the guild and/or upload the guild icon to mxc. * Async because it may create the guild and/or upload the guild icon to mxc.
* @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel * @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel
* @param {DiscordTypes.APIGuild} guild * @param {DiscordTypes.APIGuild} guild
* @param {{api: {getStateEvent: typeof api.getStateEvent}}} di simple-as-nails dependency injection for the matrix API
*/ */
async function channelToKState(channel, guild, di) { async function channelToKState(channel, guild) {
// @ts-ignore const spaceID = await createSpace.ensureSpace(guild)
const parentChannel = discord.channels.get(channel.parent_id) assert(typeof spaceID === "string")
const guildRow = select("guild_space", ["space_id", "privacy_level"], {guild_id: guild.id}).get() const privacyLevel = select("guild_space", "privacy_level", {space_id: spaceID}).pluck().get()
assert(guildRow) assert(typeof privacyLevel === "number")
/** Used for membership/permission checks. */ const row = select("channel_room", ["nick", "custom_avatar"], {channel_id: channel.id}).get()
let guildSpaceID = guildRow.space_id const customName = row?.nick
/** Used as the literal parent on Matrix, for categorisation. Will be the same as `guildSpaceID` unless it's a forum channel's thread, in which case a different space is used to group those threads. */ const customAvatar = row?.custom_avatar
let parentSpaceID = guildSpaceID
if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum) {
parentSpaceID = await ensureRoom(channel.parent_id)
assert(typeof parentSpaceID === "string")
}
const channelRow = select("channel_room", ["nick", "custom_avatar"], {channel_id: channel.id}).get()
const customName = channelRow?.nick
const customAvatar = channelRow?.custom_avatar
const [convertedName, convertedTopic] = convertNameAndTopic(channel, guild, customName) const [convertedName, convertedTopic] = convertNameAndTopic(channel, guild, customName)
const avatarEventContent = {} const avatarEventContent = {}
if (customAvatar) { if (customAvatar) {
avatarEventContent.url = customAvatar avatarEventContent.url = customAvatar
} else if (guild.icon) { } else if (guild.icon) {
avatarEventContent.url = {$url: file.guildIcon(guild)} avatarEventContent.discord_path = file.guildIcon(guild)
avatarEventContent.url = await file.uploadDiscordFileToMxc(avatarEventContent.discord_path) // TODO: somehow represent future values in kstate (callbacks?), while still allowing for diffing, so test cases don't need to touch the media API
} }
const privacyLevel = guildRow.privacy_level
let history_visibility = PRIVACY_ENUMS.ROOM_HISTORY_VISIBILITY[privacyLevel] let history_visibility = PRIVACY_ENUMS.ROOM_HISTORY_VISIBILITY[privacyLevel]
if (channel["thread_metadata"]) history_visibility = "world_readable" if (channel["thread_metadata"]) history_visibility = "world_readable"
@ -126,40 +112,30 @@ async function channelToKState(channel, guild, di) {
join_rule: "restricted", join_rule: "restricted",
allow: [{ allow: [{
type: "m.room_membership", type: "m.room_membership",
room_id: guildSpaceID room_id: spaceID
}] }]
} }
if (PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel] !== "restricted") { if (PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel] !== "restricted") {
join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]} join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]}
} }
const everyonePermissions = utils.getPermissions([], guild.roles, undefined, channel.permission_overwrites)
const everyoneCanMentionEveryone = utils.hasAllPermissions(everyonePermissions, ["MentionEveryone"])
const globalAdmins = select("member_power", ["mxid", "power_level"], {room_id: "*"}).all()
const globalAdminPower = globalAdmins.reduce((a, c) => (a[c.mxid] = c.power_level, a), {})
/** @type {Ty.Event.M_Power_Levels} */
const spacePowerEvent = await di.api.getStateEvent(guildSpaceID, "m.room.power_levels", "")
const spacePower = spacePowerEvent.users
const channelKState = { const channelKState = {
"m.room.name/": {name: convertedName}, "m.room.name/": {name: convertedName},
"m.room.topic/": {topic: convertedTopic}, "m.room.topic/": {topic: convertedTopic},
"m.room.avatar/": avatarEventContent, "m.room.avatar/": avatarEventContent,
"m.room.guest_access/": {guest_access: PRIVACY_ENUMS.GUEST_ACCESS[privacyLevel]}, "m.room.guest_access/": {guest_access: PRIVACY_ENUMS.GUEST_ACCESS[privacyLevel]},
"m.room.history_visibility/": {history_visibility}, "m.room.history_visibility/": {history_visibility},
[`m.space.parent/${parentSpaceID}`]: { [`m.space.parent/${spaceID}`]: {
via: [reg.ooye.server_name], via: [reg.ooye.server_name],
canonical: true canonical: true
}, },
/** @type {{join_rule: string, [x: string]: any}} */ /** @type {{join_rule: string, [x: string]: any}} */
"m.room.join_rules/": join_rules, "m.room.join_rules/": join_rules,
"m.room.power_levels/": { "m.room.power_levels/": {
notifications: { events: {
room: everyoneCanMentionEveryone ? 0 : 20 "m.room.avatar": 0
}, },
users: {...spacePower, ...globalAdminPower} users: reg.ooye.invite.reduce((a, c) => (a[c] = 100, a), {})
}, },
"chat.schildi.hide_ui/read_receipts": { "chat.schildi.hide_ui/read_receipts": {
hidden: true hidden: true
@ -173,7 +149,7 @@ async function channelToKState(channel, guild, di) {
network: { network: {
id: guild.id, id: guild.id,
displayname: guild.name, displayname: guild.name,
avatar_url: {$url: file.guildIcon(guild)} avatar_url: await file.uploadDiscordFileToMxc(file.guildIcon(guild))
}, },
channel: { channel: {
id: channel.id, id: channel.id,
@ -183,7 +159,7 @@ async function channelToKState(channel, guild, di) {
} }
} }
return {spaceID: parentSpaceID, privacyLevel, channelKState} return {spaceID, privacyLevel, channelKState}
} }
/** /**
@ -199,9 +175,6 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) {
let threadParent = null let threadParent = null
if (channel.type === DiscordTypes.ChannelType.PublicThread) threadParent = channel.parent_id if (channel.type === DiscordTypes.ChannelType.PublicThread) threadParent = channel.parent_id
let spaceCreationContent = {}
if (channel.type === DiscordTypes.ChannelType.GuildForum) spaceCreationContent = {creation_content: {type: "m.space"}}
// Name and topic can be done earlier in room creation rather than in initial_state // Name and topic can be done earlier in room creation rather than in initial_state
// https://spec.matrix.org/latest/client-server-api/#creation // https://spec.matrix.org/latest/client-server-api/#creation
const name = kstate["m.room.name/"].name const name = kstate["m.room.name/"].name
@ -218,8 +191,7 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) {
preset: PRIVACY_ENUMS.PRESET[privacyLevel], // This is closest to what we want, but properties from kstate override it anyway preset: PRIVACY_ENUMS.PRESET[privacyLevel], // This is closest to what we want, but properties from kstate override it anyway
visibility: PRIVACY_ENUMS.VISIBILITY[privacyLevel], visibility: PRIVACY_ENUMS.VISIBILITY[privacyLevel],
invite: [], invite: [],
initial_state: await ks.kstateToState(kstate), initial_state: ks.kstateToState(kstate)
...spaceCreationContent
}) })
db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent) VALUES (?, ?, ?, NULL, ?)").run(channel.id, roomID, channel.name, threadParent) db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent) VALUES (?, ?, ?, NULL, ?)").run(channel.id, roomID, channel.name, threadParent)
@ -272,61 +244,6 @@ function channelToGuild(channel) {
return guild return guild
} }
/**
* This function handles whether it's allowed to bridge messages in this channel, and if so, where to.
* This has to account for whether self-service is enabled for the guild or not.
* This also has to account for different channel types, like forum channels (which need the
* parent forum to already exist, and ignore the self-service setting), or thread channels (which
* need the parent channel to already exist, and ignore the self-service setting).
* @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel text channel or thread
* @param {string} guildID
* @returns obj if bridged; 1 if autocreatable; null/undefined if guild is not bridged; 0 if self-service and not autocreatable thread
*/
function existsOrAutocreatable(channel, guildID) {
// 1. If the channel is already linked somewhere, it's always okay to bridge to that destination, no matter what. Yippee!
const existing = select("channel_room", ["room_id", "thread_parent"], {channel_id: channel.id}).get()
if (existing) return existing
// 2. If the guild is an autocreate guild, it's always okay to bridge to that destination, and
// we'll need to create any dependent resources recursively.
const autocreate = select("guild_active", "autocreate", {guild_id: guildID}).pluck().get()
if (autocreate === 1) return autocreate
// 3. If the guild is not approved for bridging yet, we can't bridge there.
// They need to decide one way or another whether it's self-service before we can continue.
if (autocreate == null) return autocreate
// 4. If we got here, the guild is in self-service mode.
// New channels won't be able to create new rooms. But forum threads or channel threads could be fine.
if ([DiscordTypes.ChannelType.PublicThread, DiscordTypes.ChannelType.PrivateThread, DiscordTypes.ChannelType.AnnouncementThread].includes(channel.type)) {
// In self-service mode, threads rely on the parent resource already existing.
/** @type {DiscordTypes.APIGuildTextChannel} */ // @ts-ignore
const parent = discord.channels.get(channel.parent_id)
assert(parent)
const parentExisting = existsOrAutocreatable(parent, guildID)
if (parentExisting) return 1 // Autocreatable
}
// 5. If we got here, the guild is in self-service mode and the channel is truly not bridged.
return autocreate
}
/**
* @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel text channel or thread
* @param {string} guildID
* @returns obj if bridged; 1 if autocreatable. (throws if not autocreatable)
*/
function assertExistsOrAutocreatable(channel, guildID) {
const existing = existsOrAutocreatable(channel, guildID)
if (existing === 0) {
throw new Error(`Guild ${guildID} is self-service, so won't create a Matrix room for channel ${channel.id}`)
}
if (!existing) {
throw new Error(`Guild ${guildID} is not bridged, so won't create a Matrix room for channel ${channel.id}`)
}
return existing
}
/* /*
Ensure flow: Ensure flow:
1. Get IDs 1. Get IDs
@ -345,7 +262,6 @@ function assertExistsOrAutocreatable(channel, guildID) {
*/ */
/** /**
* Create room and/or sync room data. Please check that a channel_room entry exists or autocreate = 1 before calling this.
* @param {string} channelID * @param {string} channelID
* @param {boolean} shouldActuallySync false if just need to ensure room exists (which is a quick database check), true if also want to sync room data when it does exist (slow) * @param {boolean} shouldActuallySync false if just need to ensure room exists (which is a quick database check), true if also want to sync room data when it does exist (slow)
* @returns {Promise<string>} room ID * @returns {Promise<string>} room ID
@ -360,11 +276,11 @@ async function _syncRoom(channelID, shouldActuallySync) {
await inflightRoomCreate.get(channelID) // just waiting, and then doing a new db query afterwards, is the simplest way of doing it await inflightRoomCreate.get(channelID) // just waiting, and then doing a new db query afterwards, is the simplest way of doing it
} }
const existing = assertExistsOrAutocreatable(channel, guild.id) const existing = select("channel_room", ["room_id", "thread_parent"], {channel_id: channelID}).get()
if (existing === 1) { if (!existing) {
const creation = (async () => { const creation = (async () => {
const {spaceID, privacyLevel, channelKState} = await channelToKState(channel, guild, {api}) const {spaceID, privacyLevel, channelKState} = await channelToKState(channel, guild)
const roomID = await createRoom(channel, guild, spaceID, channelKState, privacyLevel) const roomID = await createRoom(channel, guild, spaceID, channelKState, privacyLevel)
inflightRoomCreate.delete(channelID) // OK to release inflight waiters now. they will read the correct `existing` row inflightRoomCreate.delete(channelID) // OK to release inflight waiters now. they will read the correct `existing` row
return roomID return roomID
@ -381,7 +297,7 @@ async function _syncRoom(channelID, shouldActuallySync) {
console.log(`[room sync] to matrix: ${channel.name}`) console.log(`[room sync] to matrix: ${channel.name}`)
const {spaceID, channelKState} = await channelToKState(channel, guild, {api}) // calling this in both branches because we don't want to calculate this if not syncing const {spaceID, channelKState} = await channelToKState(channel, guild) // calling this in both branches because we don't want to calculate this if not syncing
// sync channel state to room // sync channel state to room
const roomKState = await roomToKState(roomID) const roomKState = await roomToKState(roomID)
@ -402,12 +318,12 @@ async function _syncRoom(channelID, shouldActuallySync) {
return roomID return roomID
} }
/** Ensures the room exists. If it doesn't, creates the room with an accurate initial state. Please check that a channel_room entry exists or guild autocreate = 1 before calling this. */ /** Ensures the room exists. If it doesn't, creates the room with an accurate initial state. */
function ensureRoom(channelID) { function ensureRoom(channelID) {
return _syncRoom(channelID, false) return _syncRoom(channelID, false)
} }
/** Actually syncs. Gets all room state from the homeserver in order to diff, and uploads the icon to mxc if it has changed. Please check that a channel_room entry exists or guild autocreate = 1 before calling this. */ /** Actually syncs. Gets all room state from the homeserver in order to diff, and uploads the icon to mxc if it has changed. */
function syncRoom(channelID) { function syncRoom(channelID) {
return _syncRoom(channelID, true) return _syncRoom(channelID, true)
} }
@ -416,16 +332,11 @@ async function _unbridgeRoom(channelID) {
/** @ts-ignore @type {DiscordTypes.APIGuildChannel} */ /** @ts-ignore @type {DiscordTypes.APIGuildChannel} */
const channel = discord.channels.get(channelID) const channel = discord.channels.get(channelID)
assert.ok(channel) assert.ok(channel)
assert.ok(channel.guild_id) return unbridgeDeletedChannel(channel.id, channel.guild_id)
return unbridgeDeletedChannel(channel, channel.guild_id)
} }
/** async function unbridgeDeletedChannel(channelID, guildID) {
* @param {{id: string, topic?: string?}} channel const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
* @param {string} guildID
*/
async function unbridgeDeletedChannel(channel, guildID) {
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
assert.ok(roomID) assert.ok(roomID)
const spaceID = select("guild_space", "space_id", {guild_id: guildID}).pluck().get() const spaceID = select("guild_space", "space_id", {guild_id: guildID}).pluck().get()
assert.ok(spaceID) assert.ok(spaceID)
@ -435,11 +346,7 @@ async function unbridgeDeletedChannel(channel, guildID) {
await api.sendState(spaceID, "m.space.child", roomID, {}) await api.sendState(spaceID, "m.space.child", roomID, {})
// remove declaration that the room is bridged // remove declaration that the room is bridged
await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channel.id}`, {}) await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channelID}`, {})
if ("topic" in channel) {
// previously the Matrix topic would say the channel ID. we should remove that
await api.sendState(roomID, "m.room.topic", "", {topic: channel.topic || ""})
}
// send a notification in the room // send a notification in the room
await api.sendEvent(roomID, "m.room.message", { await api.sendEvent(roomID, "m.room.message", {
@ -451,7 +358,8 @@ async function unbridgeDeletedChannel(channel, guildID) {
await api.leaveRoom(roomID) await api.leaveRoom(roomID)
// delete room from database // delete room from database
db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channel.id) const {changes} = db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channelID)
assert.equal(changes, 1)
} }
/** /**
@ -504,5 +412,3 @@ module.exports.postApplyPowerLevels = postApplyPowerLevels
module.exports._convertNameAndTopic = convertNameAndTopic module.exports._convertNameAndTopic = convertNameAndTopic
module.exports._unbridgeRoom = _unbridgeRoom module.exports._unbridgeRoom = _unbridgeRoom
module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel
module.exports.existsOrAutocreatable = existsOrAutocreatable
module.exports.assertExistsOrAutocreatable = assertExistsOrAutocreatable

View file

@ -1,97 +1,42 @@
// @ts-check // @ts-check
const mixin = require("@cloudrac3r/mixin-deep")
const {channelToKState, _convertNameAndTopic} = require("./create-room") const {channelToKState, _convertNameAndTopic} = require("./create-room")
const {kstateStripConditionals} = require("../../matrix/kstate") const {kstateStripConditionals} = require("../../matrix/kstate")
const {test} = require("supertape") const {test} = require("supertape")
const testData = require("../../../test/data") const testData = require("../../test/data")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {db} = passthrough const {db} = passthrough
test("channel2room: discoverable privacy room", async t => { test("channel2room: discoverable privacy room", async t => {
let called = 0
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
called++
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {users: {"@example:matrix.org": 50}}
}
db.prepare("UPDATE guild_space SET privacy_level = 2").run() db.prepare("UPDATE guild_space SET privacy_level = 2").run()
t.deepEqual( t.deepEqual(
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)), kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general).then(x => x.channelKState)),
Object.assign({}, testData.room.general, { Object.assign({}, testData.room.general, {
"m.room.guest_access/": {guest_access: "forbidden"}, "m.room.guest_access/": {guest_access: "forbidden"},
"m.room.join_rules/": {join_rule: "public"}, "m.room.join_rules/": {join_rule: "public"},
"m.room.history_visibility/": {history_visibility: "world_readable"}, "m.room.history_visibility/": {history_visibility: "world_readable"}
"m.room.power_levels/": mixin({users: {"@example:matrix.org": 50}}, testData.room.general["m.room.power_levels/"])
}) })
) )
t.equal(called, 1)
}) })
test("channel2room: linkable privacy room", async t => { test("channel2room: linkable privacy room", async t => {
let called = 0
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
called++
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {users: {"@example:matrix.org": 50}}
}
db.prepare("UPDATE guild_space SET privacy_level = 1").run() db.prepare("UPDATE guild_space SET privacy_level = 1").run()
t.deepEqual( t.deepEqual(
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)), kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general).then(x => x.channelKState)),
Object.assign({}, testData.room.general, { Object.assign({}, testData.room.general, {
"m.room.guest_access/": {guest_access: "forbidden"}, "m.room.guest_access/": {guest_access: "forbidden"},
"m.room.join_rules/": {join_rule: "public"}, "m.room.join_rules/": {join_rule: "public"}
"m.room.power_levels/": mixin({users: {"@example:matrix.org": 50}}, testData.room.general["m.room.power_levels/"])
}) })
) )
t.equal(called, 1)
}) })
test("channel2room: invite-only privacy room", async t => { test("channel2room: invite-only privacy room", async t => {
let called = 0
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
called++
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {users: {"@example:matrix.org": 50}}
}
db.prepare("UPDATE guild_space SET privacy_level = 0").run() db.prepare("UPDATE guild_space SET privacy_level = 0").run()
t.deepEqual( t.deepEqual(
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)), kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general).then(x => x.channelKState)),
Object.assign({}, testData.room.general, { testData.room.general
"m.room.power_levels/": mixin({users: {"@example:matrix.org": 50}}, testData.room.general["m.room.power_levels/"])
})
) )
t.equal(called, 1)
})
test("channel2room: room where limited people can mention everyone", async t => {
let called = 0
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
called++
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {users: {"@example:matrix.org": 50}}
}
const limitedGuild = mixin({}, testData.guild.general)
limitedGuild.roles[0].permissions = (BigInt(limitedGuild.roles[0].permissions) - 131072n).toString()
const limitedRoom = mixin({}, testData.room.general, {"m.room.power_levels/": {
notifications: {room: 20},
users: {"@example:matrix.org": 50}
}})
t.deepEqual(
kstateStripConditionals(await channelToKState(testData.channel.general, limitedGuild, {api: {getStateEvent}}).then(x => x.channelKState)),
limitedRoom
)
t.equal(called, 1)
}) })
test("convertNameAndTopic: custom name and topic", t => { test("convertNameAndTopic: custom name and topic", t => {

View file

@ -1,10 +1,9 @@
// @ts-check // @ts-check
const assert = require("assert").strict const assert = require("assert").strict
const {isDeepStrictEqual} = require("util")
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const Ty = require("../../types") const deepEqual = require("deep-equal")
const {reg} = require("../../matrix/read-registration") const reg = require("../../matrix/read-registration")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {discord, sync, db, select} = passthrough const {discord, sync, db, select} = passthrough
@ -31,8 +30,6 @@ async function createSpace(guild, kstate) {
const topic = kstate["m.room.topic/"]?.topic || undefined const topic = kstate["m.room.topic/"]?.topic || undefined
assert(name) assert(name)
const globalAdmins = select("member_power", "mxid", {room_id: "*"}).pluck().all()
const roomID = await createRoom.postApplyPowerLevels(kstate, async kstate => { const roomID = await createRoom.postApplyPowerLevels(kstate, async kstate => {
return api.createRoom({ return api.createRoom({
name, name,
@ -42,12 +39,12 @@ async function createSpace(guild, kstate) {
events_default: 100, // space can only be managed by bridge events_default: 100, // space can only be managed by bridge
invite: 0 // any existing member can invite others invite: 0 // any existing member can invite others
}, },
invite: globalAdmins, invite: reg.ooye.invite,
topic, topic,
creation_content: { creation_content: {
type: "m.space" type: "m.space"
}, },
initial_state: await ks.kstateToState(kstate) initial_state: ks.kstateToState(kstate)
}) })
}) })
db.prepare("INSERT INTO guild_space (guild_id, space_id) VALUES (?, ?)").run(guild.id, roomID) db.prepare("INSERT INTO guild_space (guild_id, space_id) VALUES (?, ?)").run(guild.id, roomID)
@ -59,18 +56,19 @@ async function createSpace(guild, kstate) {
* @param {number} privacyLevel * @param {number} privacyLevel
*/ */
async function guildToKState(guild, privacyLevel) { async function guildToKState(guild, privacyLevel) {
assert.equal(typeof privacyLevel, "number") const avatarEventContent = {}
const globalAdmins = select("member_power", ["mxid", "power_level"], {room_id: "*"}).all() if (guild.icon) {
avatarEventContent.discord_path = file.guildIcon(guild)
avatarEventContent.url = await file.uploadDiscordFileToMxc(avatarEventContent.discord_path) // TODO: somehow represent future values in kstate (callbacks?), while still allowing for diffing, so test cases don't need to touch the media API
}
const guildKState = { const guildKState = {
"m.room.name/": {name: guild.name}, "m.room.name/": {name: guild.name},
"m.room.avatar/": { "m.room.avatar/": avatarEventContent,
$if: guild.icon,
url: {$url: file.guildIcon(guild)}
},
"m.room.guest_access/": {guest_access: createRoom.PRIVACY_ENUMS.GUEST_ACCESS[privacyLevel]}, "m.room.guest_access/": {guest_access: createRoom.PRIVACY_ENUMS.GUEST_ACCESS[privacyLevel]},
"m.room.history_visibility/": {history_visibility: createRoom.PRIVACY_ENUMS.SPACE_HISTORY_VISIBILITY[privacyLevel]}, "m.room.history_visibility/": {history_visibility: createRoom.PRIVACY_ENUMS.SPACE_HISTORY_VISIBILITY[privacyLevel]},
"m.room.join_rules/": {join_rule: createRoom.PRIVACY_ENUMS.SPACE_JOIN_RULES[privacyLevel]}, "m.room.join_rules/": {join_rule: createRoom.PRIVACY_ENUMS.SPACE_JOIN_RULES[privacyLevel]},
"m.room.power_levels/": {users: globalAdmins.reduce((a, c) => (a[c.mxid] = c.power_level, a), {})} // used in guild initial creation postApplyPowerLevels "m.room.power_levels/": {users: reg.ooye.invite.reduce((a, c) => (a[c] = 100, a), {})}
} }
return guildKState return guildKState
@ -92,9 +90,6 @@ async function _syncSpace(guild, shouldActuallySync) {
const row = select("guild_space", ["space_id", "privacy_level"], {guild_id: guild.id}).get() const row = select("guild_space", ["space_id", "privacy_level"], {guild_id: guild.id}).get()
if (!row) { if (!row) {
const autocreate = select("guild_active", "autocreate", {guild_id: guild.id}).pluck().get()
assert.equal(autocreate, 1, `refusing to implicitly create guild ${guild.id}. set the guild_active data first before calling ensureSpace/syncSpace.`)
const creation = (async () => { const creation = (async () => {
const guildKState = await guildToKState(guild, createRoom.DEFAULT_PRIVACY_LEVEL) // New spaces will have to use the default privacy level; we obviously can't look up the existing entry const guildKState = await guildToKState(guild, createRoom.DEFAULT_PRIVACY_LEVEL) // New spaces will have to use the default privacy level; we obviously can't look up the existing entry
const spaceID = await createSpace(guild, guildKState) const spaceID = await createSpace(guild, guildKState)
@ -127,8 +122,7 @@ async function _syncSpace(guild, shouldActuallySync) {
// don't try to update rooms with custom avatars though // don't try to update rooms with custom avatars though
const roomsWithCustomAvatars = select("channel_room", "room_id", {}, "WHERE custom_avatar IS NOT NULL").pluck().all() const roomsWithCustomAvatars = select("channel_room", "room_id", {}, "WHERE custom_avatar IS NOT NULL").pluck().all()
const state = await ks.kstateToState(spaceKState) const childRooms = ks.kstateToState(spaceKState).filter(({type, state_key, content}) => {
const childRooms = state.filter(({type, state_key, content}) => {
return type === "m.space.child" && "via" in content && !roomsWithCustomAvatars.includes(state_key) return type === "m.space.child" && "via" in content && !roomsWithCustomAvatars.includes(state_key)
}).map(({state_key}) => state_key) }).map(({state_key}) => state_key)
@ -187,15 +181,17 @@ async function syncSpaceFully(guildID) {
const spaceDiff = ks.diffKState(spaceKState, guildKState) const spaceDiff = ks.diffKState(spaceKState, guildKState)
await createRoom.applyKStateDiffToRoom(spaceID, spaceDiff) await createRoom.applyKStateDiffToRoom(spaceID, spaceDiff)
const childRooms = await api.getFullHierarchy(spaceID) const childRooms = ks.kstateToState(spaceKState).filter(({type, content}) => {
return type === "m.space.child" && "via" in content
}).map(({state_key}) => state_key)
for (const {room_id} of childRooms) { for (const roomID of childRooms) {
const channelID = select("channel_room", "channel_id", {room_id}).pluck().get() const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get()
if (!channelID) continue if (!channelID) continue
if (discord.channels.has(channelID)) { if (discord.channels.has(channelID)) {
await createRoom.syncRoom(channelID) await createRoom.syncRoom(channelID)
} else { } else {
await createRoom.unbridgeDeletedChannel({id: channelID}, guildID) await createRoom.unbridgeDeletedChannel(channelID, guildID)
} }
} }
@ -230,7 +226,7 @@ async function syncSpaceExpressions(data, checkBeforeSync) {
// State event not found. This space doesn't have any existing emojis. We create a dummy empty event for comparison's sake. // State event not found. This space doesn't have any existing emojis. We create a dummy empty event for comparison's sake.
existing = fn([]) existing = fn([])
} }
if (isDeepStrictEqual(existing, content)) return if (deepEqual(existing, content, {strict: true})) return
} }
api.sendState(spaceID, "im.ponies.room_emotes", eventKey, content) api.sendState(spaceID, "im.ponies.room_emotes", eventKey, content)
} }

View file

@ -33,9 +33,9 @@ async function deleteMessageBulk(data) {
if (!roomID) return if (!roomID) return
const sids = JSON.stringify(data.ids) const sids = JSON.stringify(data.ids)
const eventsToRedact = from("event_message").pluck("event_id").and("WHERE message_id IN (SELECT value FROM json_each(?))").all(sids) const eventsToRedact = from("event_message").pluck("event_id").and("WHERE message_id IN (SELECT value FROM json_each(?)").all(sids)
db.prepare("DELETE FROM message_channel WHERE message_id IN (SELECT value FROM json_each(?))").run(sids) db.prepare("DELETE FROM message_channel WHERE message_id IN (SELECT value FROM json_each(?)").run(sids)
db.prepare("DELETE FROM event_message WHERE message_id IN (SELECT value FROM json_each(?))").run(sids) db.prepare("DELETE FROM event_message WHERE message_id IN (SELECT value FROM json_each(?)").run(sids)
for (const eventID of eventsToRedact) { for (const eventID of eventsToRedact) {
// Awaiting will make it go slower, but since this could be a long-running operation either way, we want to leave rate limit capacity for other operations // Awaiting will make it go slower, but since this could be a long-running operation either way, we want to leave rate limit capacity for other operations
await api.redactEvent(roomID, eventID) await api.redactEvent(roomID, eventID)

View file

@ -53,7 +53,7 @@ async function editMessage(message, guild, row) {
const sendNewEventParts = new Set() const sendNewEventParts = new Set()
for (const promotion of promotions) { for (const promotion of promotions) {
if ("eventID" in promotion) { if ("eventID" in promotion) {
db.prepare(`UPDATE event_message SET ${promotion.column} = ? WHERE event_id = ?`).run(promotion.value ?? 0, promotion.eventID) db.prepare(`UPDATE event_message SET ${promotion.column} = 0 WHERE event_id = ?`).run(promotion.eventID)
} else if ("nextEvent" in promotion) { } else if ("nextEvent" in promotion) {
sendNewEventParts.add(promotion.column) sendNewEventParts.add(promotion.column)
} }

View file

@ -30,7 +30,7 @@ async function emojisToState(emojis) {
} }
db.prepare("INSERT OR IGNORE INTO emoji (emoji_id, name, animated, mxc_url) VALUES (?, ?, ?, ?)").run(emoji.id, emoji.name, +!!emoji.animated, url) db.prepare("INSERT OR IGNORE INTO emoji (emoji_id, name, animated, mxc_url) VALUES (?, ?, ?, ?)").run(emoji.id, emoji.name, +!!emoji.animated, url)
}).catch(e => { }).catch(e => {
if (e.data?.errcode === "M_TOO_LARGE") { // Very unlikely to happen. Only possible for 3x-series emojis uploaded shortly after animated emojis were introduced, when there was no 256 KB size limit. if (e.data.errcode === "M_TOO_LARGE") { // Very unlikely to happen. Only possible for 3x-series emojis uploaded shortly after animated emojis were introduced, when there was no 256 KB size limit.
return return
} }
console.error(`Trying to handle emoji ${emoji.name} (${emoji.id}), but...`) console.error(`Trying to handle emoji ${emoji.name} (${emoji.id}), but...`)
@ -66,7 +66,7 @@ async function stickersToState(stickers) {
while (shortcodes.includes(shortcode)) shortcode = shortcode + "~" while (shortcodes.includes(shortcode)) shortcode = shortcode + "~"
shortcodes.push(shortcode) shortcodes.push(shortcode)
result.images[shortcode] = { result.images[shortcodes] = {
info: { info: {
mimetype: file.stickerFormat.get(sticker.format_type)?.mime || "image/png" mimetype: file.stickerFormat.get(sticker.format_type)?.mime || "image/png"
}, },

View file

@ -1,7 +1,7 @@
// @ts-check // @ts-check
const assert = require("assert") const assert = require("assert")
const {reg} = require("../../matrix/read-registration") const reg = require("../../matrix/read-registration")
const Ty = require("../../types") const Ty = require("../../types")
const fetch = require("node-fetch").default const fetch = require("node-fetch").default
@ -131,7 +131,7 @@ async function syncUser(author, pkMessage, roomID) {
db.prepare("INSERT OR IGNORE INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES (?, ?, ?)").run(pkMessage.member.uuid, pkMessage.sender, author.username) db.prepare("INSERT OR IGNORE INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES (?, ?, ?)").run(pkMessage.member.uuid, pkMessage.sender, author.username)
// Sync the member state // Sync the member state
const content = await memberToStateContent(pkMessage, author) const content = await memberToStateContent(pkMessage, author)
const currentHash = registerUser._hashProfileContent(content, 0) const currentHash = registerUser._hashProfileContent(content)
const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get() 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 // only do the actual sync if the hash has changed since we last looked
if (existingHash !== currentHash) { if (existingHash !== currentHash) {
@ -142,19 +142,8 @@ async function syncUser(author, pkMessage, roomID) {
} }
/** @returns {Promise<Ty.PkMessage>} */ /** @returns {Promise<Ty.PkMessage>} */
async function fetchMessage(messageID) { function fetchMessage(messageID) {
// Their backend is weird. Sometimes it says "message not found" (code 20006) on the first try, so we make multiple attempts. return fetch(`https://api.pluralkit.me/v2/messages/${messageID}`).then(res => res.json())
let attempts = 0
do {
var res = await fetch(`https://api.pluralkit.me/v2/messages/${messageID}`)
if (res.ok) return res.json()
// I think the backend needs some time to update.
await new Promise(resolve => setTimeout(resolve, 2000))
} while (++attempts < 3)
const errorMessage = await res.json()
throw new Error(`PK API returned an error after ${attempts} tries: ${JSON.stringify(errorMessage)}`)
} }
module.exports._memberToStateContent = memberToStateContent module.exports._memberToStateContent = memberToStateContent

View file

@ -1,9 +1,7 @@
// @ts-check // @ts-check
const assert = require("assert").strict const assert = require("assert")
const {reg} = require("../../matrix/read-registration") const reg = require("../../matrix/read-registration")
const DiscordTypes = require("discord-api-types/v10")
const mixin = require("@cloudrac3r/mixin-deep")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {discord, sync, db, select} = passthrough const {discord, sync, db, select} = passthrough
@ -11,8 +9,6 @@ const {discord, sync, db, select} = passthrough
const api = sync.require("../../matrix/api") const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/file")} */ /** @type {import("../../matrix/file")} */
const file = sync.require("../../matrix/file") const file = sync.require("../../matrix/file")
/** @type {import("../../discord/utils")} */
const utils = sync.require("../../discord/utils")
/** @type {import("../converters/user-to-mxid")} */ /** @type {import("../converters/user-to-mxid")} */
const userToMxid = sync.require("../converters/user-to-mxid") const userToMxid = sync.require("../converters/user-to-mxid")
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore /** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
@ -22,7 +18,7 @@ require("xxhash-wasm")().then(h => hasher = h)
/** /**
* A sim is an account that is being simulated by the bridge to copy events from the other side. * A sim is an account that is being simulated by the bridge to copy events from the other side.
* @param {DiscordTypes.APIUser} user * @param {import("discord-api-types/v10").APIUser} user
* @returns mxid * @returns mxid
*/ */
async function createSim(user) { async function createSim(user) {
@ -50,7 +46,7 @@ async function createSim(user) {
/** /**
* Ensure a sim is registered for the user. * Ensure a sim is registered for the user.
* If there is already a sim, use that one. If there isn't one yet, register a new sim. * If there is already a sim, use that one. If there isn't one yet, register a new sim.
* @param {DiscordTypes.APIUser} user * @param {import("discord-api-types/v10").APIUser} user
* @returns {Promise<string>} mxid * @returns {Promise<string>} mxid
*/ */
async function ensureSim(user) { async function ensureSim(user) {
@ -66,7 +62,7 @@ async function ensureSim(user) {
/** /**
* Ensure a sim is registered for the user and is joined to the room. * Ensure a sim is registered for the user and is joined to the room.
* @param {DiscordTypes.APIUser} user * @param {import("discord-api-types/v10").APIUser} user
* @param {string} roomID * @param {string} roomID
* @returns {Promise<string>} mxid * @returns {Promise<string>} mxid
*/ */
@ -96,8 +92,8 @@ async function ensureSimJoined(user, roomID) {
} }
/** /**
* @param {DiscordTypes.APIUser} user * @param {import("discord-api-types/v10").APIUser} user
* @param {Omit<DiscordTypes.APIGuildMember, "user">} member * @param {Omit<import("discord-api-types/v10").APIGuildMember, "user">} member
*/ */
async function memberToStateContent(user, member, guildID) { async function memberToStateContent(user, member, guildID) {
let displayname = user.username let displayname = user.username
@ -127,46 +123,8 @@ async function memberToStateContent(user, member, guildID) {
return content return content
} }
/** function _hashProfileContent(content) {
* https://gitdab.com/cadence/out-of-your-element/issues/9 const unsignedHash = hasher.h64(`${content.displayname}\u0000${content.avatar_url}`)
* @param {DiscordTypes.APIUser} user
* @param {Omit<DiscordTypes.APIGuildMember, "user">} member
* @param {DiscordTypes.APIGuild} guild
* @param {DiscordTypes.APIGuildChannel} channel
* @returns {number} 0 to 100
*/
function memberToPowerLevel(user, member, guild, channel) {
const permissions = utils.getPermissions(member.roles, guild.roles, user.id, channel.permission_overwrites)
/*
* PL 100 = Administrator = People who can brick the room. RATIONALE:
* - Administrator.
* - Manage Webhooks: People who remove the webhook can break the room.
* - Manage Guild: People who can manage guild can add bots.
* - Manage Channels: People who can manage the channel can delete it.
* (Setting sim users to PL 100 is safe because even though we can't demote the sims we can use code to make the sims demote themselves.)
*/
if (guild.owner_id === user.id || utils.hasSomePermissions(permissions, ["Administrator", "ManageWebhooks", "ManageGuild", "ManageChannels"])) return 100
/*
* PL 50 = Moderator = People who can manage people and messages in many ways. RATIONALE:
* - Manage Messages: Can moderate by pinning or deleting the conversation.
* - Manage Nicknames: Can moderate by removing inappropriate nicknames.
* - Manage Threads: Can moderate by deleting conversations.
* - Kick Members & Ban Members: Can moderate by removing disruptive people.
* - Mute Members & Deafen Members: Can moderate by silencing disruptive people in ways they can't undo.
* - Moderate Members.
*/
if (utils.hasSomePermissions(permissions, ["ManageMessages", "ManageNicknames", "ManageThreads", "KickMembers", "BanMembers", "MuteMembers", "DeafenMembers", "ModerateMembers"])) return 50
/* PL 20 = Mention Everyone for technical reasons. */
if (utils.hasSomePermissions(permissions, ["MentionEveryone"])) return 20
return 0
}
/**
* @param {any} content
* @param {number} powerLevel
*/
function _hashProfileContent(content, powerLevel) {
const unsignedHash = hasher.h64(`${content.displayname}\u0000${content.avatar_url}\u0000${powerLevel}`)
const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
return signedHash return signedHash
} }
@ -175,67 +133,48 @@ function _hashProfileContent(content, powerLevel) {
* Sync profile data for a sim user. This function follows the following process: * Sync profile data for a sim user. This function follows the following process:
* 1. Join the sim to the room if needed * 1. Join the sim to the room if needed
* 2. Make an object of what the new room member state content would be, including uploading the profile picture if it hasn't been done before * 2. Make an object of what the new room member state content would be, including uploading the profile picture if it hasn't been done before
* 3. Calculate the power level the user should get based on their Discord permissions * 3. 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 * 4. If the state content has changed, send it to Matrix and update it 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 * @param {import("discord-api-types/v10").APIUser} user
* @param {DiscordTypes.APIUser} user * @param {Omit<import("discord-api-types/v10").APIGuildMember, "user">} member
* @param {Omit<DiscordTypes.APIGuildMember, "user">} member
* @param {DiscordTypes.APIGuildChannel} channel
* @param {DiscordTypes.APIGuild} guild
* @param {string} roomID
* @returns {Promise<string>} mxid of the updated sim * @returns {Promise<string>} mxid of the updated sim
*/ */
async function syncUser(user, member, channel, guild, roomID) { async function syncUser(user, member, guildID, 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, guildID)
const powerLevel = memberToPowerLevel(user, member, guild, channel) const currentHash = _hashProfileContent(content)
const currentHash = _hashProfileContent(content, powerLevel)
const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get() 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 // only do the actual sync if the hash has changed since we last looked
if (existingHash !== currentHash) { if (existingHash !== currentHash) {
// Update room member state
await api.sendState(roomID, "m.room.member", mxid, content, mxid) await api.sendState(roomID, "m.room.member", mxid, content, mxid)
// Update power levels
const powerLevelsStateContent = await api.getStateEvent(roomID, "m.room.power_levels", "")
const oldPowerLevel = powerLevelsStateContent.users?.[mxid] || 0
mixin(powerLevelsStateContent, {users: {[mxid]: powerLevel}})
if (powerLevel === 0) delete powerLevelsStateContent.users[mxid] // keep the event compact
const sendPowerLevelAs = powerLevel < oldPowerLevel ? mxid : undefined // bridge bot won't not have permission to demote equal power users, so do this action as themselves
await api.sendState(roomID, "m.room.power_levels", "", powerLevelsStateContent, sendPowerLevelAs)
// Update cached hash
db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid) db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid)
} }
return mxid return mxid
} }
/**
* @param {string} roomID
*/
async function syncAllUsersInRoom(roomID) { async function syncAllUsersInRoom(roomID) {
const mxids = select("sim_member", "mxid", {room_id: roomID}).pluck().all() const mxids = select("sim_member", "mxid", {room_id: roomID}).pluck().all()
const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get() const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get()
assert.ok(typeof channelID === "string") assert.ok(typeof channelID === "string")
/** @ts-ignore @type {DiscordTypes.APIGuildChannel} */ /** @ts-ignore @type {import("discord-api-types/v10").APIGuildChannel} */
const channel = discord.channels.get(channelID) const channel = discord.channels.get(channelID)
const guildID = channel.guild_id const guildID = channel.guild_id
assert.ok(typeof guildID === "string") assert.ok(typeof guildID === "string")
/** @ts-ignore @type {DiscordTypes.APIGuild} */
const guild = discord.guilds.get(guildID)
for (const mxid of mxids) { for (const mxid of mxids) {
const userID = select("sim", "user_id", {mxid}).pluck().get() const userID = select("sim", "user_id", {mxid}).pluck().get()
assert.ok(typeof userID === "string") assert.ok(typeof userID === "string")
/** @ts-ignore @type {Required<DiscordTypes.APIGuildMember>} */ /** @ts-ignore @type {Required<import("discord-api-types/v10").APIGuildMember>} */
const member = await discord.snow.guild.getGuildMember(guildID, userID) const member = await discord.snow.guild.getGuildMember(guildID, userID)
/** @ts-ignore @type {Required<DiscordTypes.APIUser>} user */ /** @ts-ignore @type {Required<import("discord-api-types/v10").APIUser>} user */
const user = member.user const user = member.user
assert.ok(user) assert.ok(user)
console.log(`[user sync] to matrix: ${user.username} in ${channel.name}`) console.log(`[user sync] to matrix: ${user.username} in ${channel.name}`)
await syncUser(user, member, channel, guild, roomID) await syncUser(user, member, guildID, roomID)
} }
} }

View file

@ -1,6 +1,6 @@
const {_memberToStateContent} = require("./register-user") const {_memberToStateContent} = require("./register-user")
const {test} = require("supertape") const {test} = require("supertape")
const testData = require("../../../test/data") const testData = require("../../test/data")
test("member2state: without member nick or avatar", async t => { test("member2state: without member nick or avatar", async t => {
t.deepEqual( t.deepEqual(

View file

@ -23,7 +23,16 @@ async function removeSomeReactions(data) {
const eventIDForMessage = select("event_message", "event_id", {message_id: data.message_id, reaction_part: 0}).pluck().get() const eventIDForMessage = select("event_message", "event_id", {message_id: data.message_id, reaction_part: 0}).pluck().get()
if (!eventIDForMessage) return if (!eventIDForMessage) return
const reactions = await api.getFullRelations(roomID, eventIDForMessage, "m.annotation") /** @type {Ty.Event.Outer<Ty.Event.M_Reaction>[]} */
let reactions = []
/** @type {string | undefined} */
let nextBatch = undefined
do {
/** @type {Ty.Pagination<Ty.Event.Outer<Ty.Event.M_Reaction>>} */
const res = await api.getRelations(roomID, eventIDForMessage, {from: nextBatch}, "m.annotation")
reactions = reactions.concat(res.chunk)
nextBatch = res.next_batch
} while (nextBatch)
// Run the proper strategy and any strategy-specific database changes // Run the proper strategy and any strategy-specific database changes
const removals = await const removals = await

View file

@ -1,7 +1,6 @@
// @ts-check // @ts-check
const assert = require("assert").strict const assert = require("assert")
const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const { discord, sync, db } = passthrough const { discord, sync, db } = passthrough
@ -19,28 +18,32 @@ const createRoom = sync.require("../actions/create-room")
const dUtils = sync.require("../../discord/utils") const dUtils = sync.require("../../discord/utils")
/** /**
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message * @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
* @param {DiscordTypes.APIGuildChannel} channel * @param {import("discord-api-types/v10").APIGuild} guild
* @param {DiscordTypes.APIGuild} guild
* @param {{speedbump_id: string, speedbump_webhook_id: string} | null} row data about the webhook which is proxying messages in this channel * @param {{speedbump_id: string, speedbump_webhook_id: string} | null} row data about the webhook which is proxying messages in this channel
*/ */
async function sendMessage(message, channel, guild, row) { async function sendMessage(message, guild, row) {
const roomID = await createRoom.ensureRoom(message.channel_id) const roomID = await createRoom.ensureRoom(message.channel_id)
let senderMxid = null let senderMxid = null
if (!dUtils.isWebhookMessage(message)) { if (!dUtils.isWebhookMessage(message)) {
if (message.author.id === discord.application.id) { if (message.member) { // available on a gateway message create event
// no need to sync the bot's own user senderMxid = await registerUser.syncUser(message.author, message.member, message.guild_id, roomID)
} else if (message.member) { // available on a gateway message create event
senderMxid = await registerUser.syncUser(message.author, message.member, channel, guild, roomID)
} else { // well, good enough... } else { // well, good enough...
senderMxid = await registerUser.ensureSimJoined(message.author, roomID) senderMxid = await registerUser.ensureSimJoined(message.author, roomID)
} }
} else if (row && row.speedbump_webhook_id === message.webhook_id) { } else if (row && row.speedbump_webhook_id === message.webhook_id) {
// Handle the PluralKit public instance // Handle the PluralKit public instance
if (row.speedbump_id === "466378653216014359") { if (row.speedbump_id === "466378653216014359") {
const pkMessage = await registerPkUser.fetchMessage(message.id) const root = await registerPkUser.fetchMessage(message.id)
senderMxid = await registerPkUser.syncUser(message.author, pkMessage, roomID) // Member is null if member was deleted. We just got this message, so member surely exists.
if (!root.member) {
const e = new Error("PK API did not return a member")
message["__pk_response__"] = root
console.error(root)
throw e
}
senderMxid = await registerPkUser.syncUser(message.author, root, roomID)
} }
} }

View file

@ -3,55 +3,30 @@
const assert = require("assert").strict const assert = require("assert").strict
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {sync, select, from} = passthrough const {discord, sync, db, select, from} = passthrough
/** @type {import("./message-to-event")} */ /** @type {import("./message-to-event")} */
const messageToEvent = sync.require("../converters/message-to-event") const messageToEvent = sync.require("../converters/message-to-event")
/** @type {import("../../m2d/converters/utils")} */ /** @type {import("../actions/register-user")} */
const utils = sync.require("../../m2d/converters/utils") const registerUser = sync.require("../actions/register-user")
/** @type {import("../actions/create-room")} */
function eventCanBeEdited(ev) { const createRoom = sync.require("../actions/create-room")
// Discord does not allow files, images, attachments, or videos to be edited.
if (ev.old.event_type === "m.room.message" && ev.old.event_subtype !== "m.text" && ev.old.event_subtype !== "m.emote" && ev.old.event_subtype !== "m.notice") {
return false
}
// Discord does not allow stickers to be edited.
if (ev.old.event_type === "m.sticker") {
return false
}
// Anything else is fair game.
return true
}
/** /**
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message * @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
* IMPORTANT: This may not have all the normal fields! The API documentation doesn't provide possible types, just says it's all optional!
* Since I don't have a spec, I will have to capture some real traffic and add it as test cases... I hope they don't change anything later...
* @param {import("discord-api-types/v10").APIGuild} guild * @param {import("discord-api-types/v10").APIGuild} guild
* @param {import("../../matrix/api")} api simple-as-nails dependency injection for the matrix API * @param {import("../../matrix/api")} api simple-as-nails dependency injection for the matrix API
*/ */
async function editToChanges(message, guild, api) { async function editToChanges(message, guild, api) {
// If it is a user edit, allow deleting old messages (e.g. they might have removed text from an image).
// If it is the system adding a generated embed to a message, don't delete old messages since the system only sends partial data.
// Since an update in August 2024, the system always provides the full data of message updates. I'll leave in the old code since it won't cause problems.
const isGeneratedEmbed = !("content" in message)
// Figure out what events we will be replacing // Figure out what events we will be replacing
const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get() const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
assert(roomID) assert(roomID)
const oldEventRows = select("event_message", ["event_id", "event_type", "event_subtype", "part", "reaction_part"], {message_id: message.id}).all()
/** @type {string?} Null if we don't have a sender in the room, which will happen if it's a webhook's message. The bridge bot will do the edit instead. */ /** @type {string?} Null if we don't have a sender in the room, which will happen if it's a webhook's message. The bridge bot will do the edit instead. */
let senderMxid = null const senderMxid = from("sim").join("sim_member", "mxid").where({user_id: message.author.id, room_id: roomID}).pluck("mxid").get() || null
if (message.author) {
senderMxid = from("sim").join("sim_member", "mxid").where({user_id: message.author.id, room_id: roomID}).pluck("mxid").get() || null const oldEventRows = select("event_message", ["event_id", "event_type", "event_subtype", "part", "reaction_part"], {message_id: message.id}).all()
} else {
// Should be a system generated embed. We want the embed to be sent by the same user who sent the message, so that the messages get grouped in most clients.
const eventID = oldEventRows[0].event_id // a calling function should have already checked that there is at least one message to edit
const event = await api.getEvent(roomID, eventID)
if (utils.eventSenderIsFromDiscord(event.sender)) {
senderMxid = event.sender
}
}
// Figure out what we will be replacing them with // Figure out what we will be replacing them with
@ -73,8 +48,7 @@ async function editToChanges(message, guild, api) {
let eventsToRedact = [] let eventsToRedact = []
/** 3. Events that are present in the new version only, and should be sent as new, with references back to the context */ /** 3. Events that are present in the new version only, and should be sent as new, with references back to the context */
let eventsToSend = [] let eventsToSend = []
/** 4. Events that are matched and have definitely not changed, so they don't need to be edited or replaced at all. */ // 4. Events that are matched and have definitely not changed, so they don't need to be edited or replaced at all. This is represented as nothing.
let unchangedEvents = []
function shift() { function shift() {
newFallbackContent.shift() newFallbackContent.shift()
@ -107,61 +81,48 @@ async function editToChanges(message, guild, api) {
shift() shift()
} }
// Anything remaining in oldEventRows is present in the old version only and should be redacted. // Anything remaining in oldEventRows is present in the old version only and should be redacted.
eventsToRedact = oldEventRows.map(e => ({old: e})) eventsToRedact = oldEventRows
// If this is a generated embed update, only allow the embeds to be updated, since the system only sends data about events. Ignore changes to other things.
if (isGeneratedEmbed) {
unchangedEvents.push(...eventsToRedact.filter(e => e.old.event_subtype !== "m.notice")) // Move them from eventsToRedact to unchangedEvents.
eventsToRedact = eventsToRedact.filter(e => e.old.event_subtype === "m.notice")
}
// Now, everything in eventsToSend and eventsToRedact is a real change, but everything in eventsToReplace might not have actually changed!
// (Example: a MESSAGE_UPDATE for a text+image message - Discord does not allow the image to be changed, but the text might have been.)
// So we'll remove entries from eventsToReplace that *definitely* cannot have changed. (This is category 4 mentioned above.) Everything remaining *may* have changed.
unchangedEvents.push(...eventsToReplace.filter(ev => !eventCanBeEdited(ev))) // Move them from eventsToRedact to unchangedEvents.
eventsToReplace = eventsToReplace.filter(eventCanBeEdited)
// We want to maintain exactly one part = 0 and one reaction_part = 0 database row at all times. // We want to maintain exactly one part = 0 and one reaction_part = 0 database row at all times.
// This would be disrupted if existing events that are (reaction_)part = 0 will be redacted. /** @type {({column: string, eventID: string} | {column: string, nextEvent: true})[]} */
// If that is the case, pick a different existing or newly sent event to be (reaction_)part = 0.
/** @type {({column: string, eventID: string, value?: number} | {column: string, nextEvent: true})[]} */
const promotions = [] const promotions = []
for (const column of ["part", "reaction_part"]) { for (const column of ["part", "reaction_part"]) {
const candidatesForParts = unchangedEvents.concat(eventsToReplace)
// If no events with part = 0 exist (or will exist), we need to do some management. // If no events with part = 0 exist (or will exist), we need to do some management.
if (!candidatesForParts.some(e => e.old[column] === 0)) { if (!eventsToReplace.some(e => e.old[column] === 0)) {
// Try to find an existing event to promote. Bigger order is better. if (eventsToReplace.length) {
if (candidatesForParts.length) { // We can choose an existing event to promote. Bigger order is better.
const order = e => 2*+(e.event_type === "m.room.message") + 1*+(e.old.event_subtype === "m.text") const order = e => 2*+(e.event_type === "m.room.message") + 1*+(e.event_subtype === "m.text")
candidatesForParts.sort((a, b) => order(b) - order(a)) eventsToReplace.sort((a, b) => order(b) - order(a))
if (column === "part") { if (column === "part") {
promotions.push({column, eventID: candidatesForParts[0].old.event_id}) // part should be the first one promotions.push({column, eventID: eventsToReplace[0].old.event_id}) // part should be the first one
} else if (eventsToSend.length) {
promotions.push({column, nextEvent: true}) // reaction_part should be the last one
} else { } else {
promotions.push({column, eventID: candidatesForParts[candidatesForParts.length - 1].old.event_id}) // reaction_part should be the last one promotions.push({column, eventID: eventsToReplace[eventsToReplace.length - 1].old.event_id}) // reaction_part should be the last one
} }
} } else {
// Or, if there are no existing events to promote and new events will be sent, whatever gets sent will be the next part = 0. // No existing events to promote, but new events are being sent. Whatever gets sent will be the next part = 0.
else {
promotions.push({column, nextEvent: true}) promotions.push({column, nextEvent: true})
} }
} }
} }
// If adding events, try to keep reactions attached to the bottom of the group (unless reactions have already been added) // Now, everything in eventsToSend and eventsToRedact is a real change, but everything in eventsToReplace might not have actually changed!
if (eventsToSend.length && !promotions.length) { // (Example: a MESSAGE_UPDATE for a text+image message - Discord does not allow the image to be changed, but the text might have been.)
const existingReaction = select("reaction", "message_id", {message_id: message.id}).pluck().get() // So we'll remove entries from eventsToReplace that *definitely* cannot have changed. (This is category 4 mentioned above.) Everything remaining *may* have changed.
if (!existingReaction) { eventsToReplace = eventsToReplace.filter(ev => {
const existingPartZero = unchangedEvents.concat(eventsToReplace).find(p => p.old.reaction_part === 0) // Discord does not allow files, images, attachments, or videos to be edited.
assert(existingPartZero) // will exist because a reaction_part=0 always exists and no events are being removed if (ev.old.event_type === "m.room.message" && ev.old.event_subtype !== "m.text" && ev.old.event_subtype !== "m.emote" && ev.old.event_subtype !== "m.notice") {
promotions.push({column: "reaction_part", eventID: existingPartZero.old.event_id, value: 1}) // update the current reaction_part to 1 return false
promotions.push({column: "reaction_part", nextEvent: true}) // the newly created event will have reaction_part = 0
} }
// Discord does not allow stickers to be edited.
if (ev.old.event_type === "m.sticker") {
return false
} }
// Anything else is fair game.
return true
})
// Removing unnecessary properties before returning // Removing unnecessary properties before returning
eventsToRedact = eventsToRedact.map(e => e.old.event_id) eventsToRedact = eventsToRedact.map(e => e.event_id)
eventsToReplace = eventsToReplace.map(e => ({oldID: e.old.event_id, newContent: makeReplacementEventContent(e.old.event_id, e.newFallbackContent, e.newInnerContent)})) eventsToReplace = eventsToReplace.map(e => ({oldID: e.old.event_id, newContent: makeReplacementEventContent(e.old.event_id, e.newFallbackContent, e.newInnerContent)}))
return {roomID, eventsToReplace, eventsToRedact, eventsToSend, senderMxid, promotions} return {roomID, eventsToReplace, eventsToRedact, eventsToSend, senderMxid, promotions}

View file

@ -1,6 +1,6 @@
const {test} = require("supertape") const {test} = require("supertape")
const {editToChanges} = require("./edit-to-changes") const {editToChanges} = require("./edit-to-changes")
const data = require("../../../test/data") const data = require("../../test/data")
const Ty = require("../../types") const Ty = require("../../types")
test("edit2changes: edit by webhook", async t => { test("edit2changes: edit by webhook", async t => {
@ -99,9 +99,9 @@ test("edit2changes: change file type", async t => {
t.deepEqual(eventsToRedact, ["$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCJ"]) t.deepEqual(eventsToRedact, ["$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCJ"])
t.deepEqual(eventsToSend, [{ t.deepEqual(eventsToSend, [{
$type: "m.room.message", $type: "m.room.message",
body: "📝 Uploaded file: https://bridge.example.org/download/discordcdn/112760669178241024/1141501302497615912/gaze_into_my_dark_mind.txt (20 MB)", body: "📝 Uploaded file: https://cdn.discordapp.com/attachments/112760669178241024/1141501302497615912/gaze_into_my_dark_mind.txt (20 MB)",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: "📝 Uploaded file: <a href=\"https://bridge.example.org/download/discordcdn/112760669178241024/1141501302497615912/gaze_into_my_dark_mind.txt\">gaze_into_my_dark_mind.txt</a> (20 MB)", formatted_body: "📝 Uploaded file: <a href=\"https://cdn.discordapp.com/attachments/112760669178241024/1141501302497615912/gaze_into_my_dark_mind.txt\">gaze_into_my_dark_mind.txt</a> (20 MB)",
"m.mentions": {}, "m.mentions": {},
msgtype: "m.text" msgtype: "m.text"
}]) }])
@ -109,7 +109,7 @@ test("edit2changes: change file type", async t => {
t.deepEqual(promotions, [{column: "part", nextEvent: true}, {column: "reaction_part", nextEvent: true}]) t.deepEqual(promotions, [{column: "part", nextEvent: true}, {column: "reaction_part", nextEvent: true}])
}) })
test("edit2changes: add caption back to that image (due to it having a reaction, the reaction_part will not be moved)", async t => { test("edit2changes: add caption back to that image", async t => {
const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.added_caption_to_image, data.guild.general, {}) const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.added_caption_to_image, data.guild.general, {})
t.deepEqual(eventsToRedact, []) t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, [{ t.deepEqual(eventsToSend, [{
@ -235,93 +235,3 @@ test("edit2changes: promotes the text event when multiple rows have part = 1 (sh
} }
]) ])
}) })
test("edit2changes: generated embed", async t => {
let called = 0
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_social_media_image, data.guild.general, {
async getEvent(roomID, eventID) {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
t.equal(eventID, "$mPSzglkCu-6cZHbYro0RW2u5mHvbH9aXDjO5FCzosc0")
return {sender: "@_ooye_cadence:cadence.moe"}
}
})
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToReplace, [])
t.deepEqual(eventsToSend, [{
$type: "m.room.message",
msgtype: "m.notice",
body: "| via hthrflwrs on cohost"
+ "\n| \n| ## This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO https://cohost.org/jkap/post/4794219-empty"
+ "\n| \n| 1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:"
+ "\n| \n| * Both players draw eight cards"
+ "\n| * Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand"
+ "\n| * Both players present their best five-or-less-card pok...",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><sub>hthrflwrs on cohost</sub>`
+ `</p><p><strong><a href="https://cohost.org/jkap/post/4794219-empty">This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO</a></strong>`
+ `</p><p>1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:`
+ `<br><br><ul><li>Both players draw eight cards`
+ `</li><li>Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand`
+ `</li><li>Both players present their best five-or-less-card pok...</li></ul></p></blockquote>`,
"m.mentions": {}
}])
t.deepEqual(promotions, [{
"column": "reaction_part",
"eventID": "$mPSzglkCu-6cZHbYro0RW2u5mHvbH9aXDjO5FCzosc0",
"value": 1,
}, {
"column": "reaction_part",
"nextEvent": true,
}])
t.equal(senderMxid, "@_ooye_cadence:cadence.moe")
t.equal(called, 1)
})
test("edit2changes: generated embed on a reply", async t => {
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_on_reply, data.guild.general, {})
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToReplace, [{
oldID: "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF",
newContent: {
$type: "m.room.message",
// Unfortunately the edited message doesn't include the message_reference field. Fine. Whatever. It looks normal if you're using a good client.
body: "> a Discord user: [Replied-to message content wasn't provided by Discord]"
+ "\n\n* https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM",
format: "org.matrix.custom.html",
formatted_body: "<mx-reply><blockquote><a href=\"https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM\">In reply to</a> a Discord user<br>[Replied-to message content wasn't provided by Discord]</blockquote></mx-reply>* <a href=\"https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM\">https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM</a>",
"m.mentions": {},
"m.new_content": {
body: "https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM",
format: "org.matrix.custom.html",
formatted_body: "<a href=\"https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM\">https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM</a>",
"m.mentions": {},
msgtype: "m.text",
},
"m.relates_to": {
event_id: "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF",
rel_type: "m.replace",
},
msgtype: "m.text",
},
}])
t.deepEqual(eventsToSend, [{
$type: "m.room.message",
msgtype: "m.notice",
body: "| ## Matrix - Decentralised and secure communication https://matrix.to/"
+ "\n| \n| You're invited to talk on Matrix. If you don't already have a client this link will help you pick one, and join the conversation. If you already have one, this link will help you join the conversation",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><strong><a href="https://matrix.to/">Matrix - Decentralised and secure communication</a></strong>`
+ `</p><p>You're invited to talk on Matrix. If you don't already have a client this link will help you pick one, and join the conversation. If you already have one, this link will help you join the conversation</p></blockquote>`,
"m.mentions": {}
}])
t.deepEqual(promotions, [{
"column": "reaction_part",
"eventID": "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF",
"value": 1,
}, {
"column": "reaction_part",
"nextEvent": true,
}])
t.equal(senderMxid, "@_ooye_cadence:cadence.moe")
})

View file

@ -2,7 +2,7 @@
const {test} = require("supertape") const {test} = require("supertape")
const {emojiToKey} = require("./emoji-to-key") const {emojiToKey} = require("./emoji-to-key")
const data = require("../../../test/data") const data = require("../../test/data")
const Ty = require("../../types") const Ty = require("../../types")
test("emoji2key: unicode emoji works", async t => { test("emoji2key: unicode emoji works", async t => {

View file

@ -2,7 +2,7 @@
const assert = require("assert") const assert = require("assert")
const stream = require("stream") const stream = require("stream")
const {PNG} = require("@cloudrac3r/pngjs") const {PNG} = require("pngjs")
const SIZE = 160 // Discord's display size on 1x displays is 160 const SIZE = 160 // Discord's display size on 1x displays is 160

View file

@ -0,0 +1,143 @@
const {test} = require("supertape")
const {messageToEvent} = require("./message-to-event")
const data = require("../../test/data")
const Ty = require("../../types")
test("message2event embeds: nothing but a field", async t => {
const events = await messageToEvent(data.message_with_embeds.nothing_but_a_field, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.notice",
body: "| ### Amanda 🎵#2192 :online:"
+ "\n| willow tree, branch 0"
+ "\n| ** Uptime:**\n| 3m 55s\n| ** Memory:**\n| 64.45MB",
format: "org.matrix.custom.html",
formatted_body: '<blockquote><p><strong>Amanda 🎵#2192 <img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/LCEqjStXCxvRQccEkuslXEyZ\" title=\":online:\" alt=\":online:\">'
+ '<br>willow tree, branch 0</strong>'
+ '<br><strong> Uptime:</strong><br>3m 55s'
+ '<br><strong> Memory:</strong><br>64.45MB</p></blockquote>'
}])
})
test("message2event embeds: reply with just an embed", async t => {
const events = await messageToEvent(data.message_with_embeds.reply_with_only_embed, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.notice",
"m.mentions": {},
body: "| ## ⏺️ dynastic (@dynastic) https://twitter.com/i/user/719631291747078145"
+ "\n| \n| ## https://twitter.com/i/status/1707484191963648161"
+ "\n| \n| does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?"
+ "\n| \n| ### Retweets"
+ "\n| 119"
+ "\n| \n| ### Likes"
+ "\n| 5581"
+ "\n| — Twitter",
format: "org.matrix.custom.html",
formatted_body: '<blockquote><p><strong><a href="https://twitter.com/i/user/719631291747078145">⏺️ dynastic (@dynastic)</a></strong></p>'
+ '<p><strong><a href="https://twitter.com/i/status/1707484191963648161">https://twitter.com/i/status/1707484191963648161</a></strong>'
+ '</p><p>does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?'
+ '</p><p><strong>Retweets</strong><br>119</p><p><strong>Likes</strong><br>5581</p>— Twitter</blockquote>'
}])
})
test("message2event embeds: image embed and attachment", async t => {
const events = await messageToEvent(data.message_with_embeds.image_embed_and_attachment, data.guild.general, {}, {
api: {
async getJoinedMembers(roomID) {
return {joined: []}
}
}
})
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "https://tootsuite.net/Warp-Gate2.gif\ntanget: @ monster spawner",
format: "org.matrix.custom.html",
formatted_body: '<a href="https://tootsuite.net/Warp-Gate2.gif">https://tootsuite.net/Warp-Gate2.gif</a><br>tanget: @ monster spawner',
"m.mentions": {}
}, {
$type: "m.room.message",
msgtype: "m.image",
url: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR",
body: "Screenshot_20231001_034036.jpg",
external_url: "https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg?ex=651a1faa&is=6518ce2a&hm=eb5ca80a3fa7add8765bf404aea2028a28a2341e4a62435986bcdcf058da82f3&",
filename: "Screenshot_20231001_034036.jpg",
info: {
h: 1170,
w: 1080,
size: 51981,
mimetype: "image/jpeg"
},
"m.mentions": {}
}])
})
test("message2event embeds: blockquote in embed", async t => {
let called = 0
const events = await messageToEvent(data.message_with_embeds.blockquote_in_embed, data.guild.general, {}, {
api: {
async getStateEvent(roomID, type, key) {
called++
t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {
users: {
"@_ooye_bot:cadence.moe": 100
}
}
},
async getJoinedMembers(roomID) {
called++
t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe")
return {
joined: {
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
"@user:example.invalid": {display_name: null, avatar_url: null}
}
}
}
}
})
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: ":emoji: **4 |** #wonderland",
format: "org.matrix.custom.html",
formatted_body: `<img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/mwZaCtRGAQQyOItagDeCocEO\" title=\":emoji:\" alt=\":emoji:\"> <strong>4 |</strong> <a href=\"https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe?via=cadence.moe&via=example.invalid\">#wonderland</a>`,
"m.mentions": {}
}, {
$type: "m.room.message",
msgtype: "m.notice",
body: "| ## ⏺️ minimus https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo?via=cadence.moe&via=example.invalid\n| \n| reply draft\n| > The following is a message composed via consensus of the Stinker Council.\n| > \n| > For those who are not currently aware of our existence, we represent the organization known as Wonderland. Our previous mission centered around the assortment and study of puzzling objects, entities and other assorted phenomena. This mission was the focus of our organization for more than 28 years.\n| > \n| > Due to circumstances outside of our control, this directive has now changed. Our new mission will be the extermination of the stinker race.\n| > \n| > There will be no further communication.\n| \n| [Go to Message](https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo?via=cadence.moe&via=example.invalid)",
format: "org.matrix.custom.html",
formatted_body: "<blockquote><p><strong><a href=\"https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo?via=cadence.moe&amp;via=example.invalid\">⏺️ minimus</a></strong></p><p>reply draft<br><blockquote>The following is a message composed via consensus of the Stinker Council.<br><br>For those who are not currently aware of our existence, we represent the organization known as Wonderland. Our previous mission centered around the assortment and study of puzzling objects, entities and other assorted phenomena. This mission was the focus of our organization for more than 28 years.<br><br>Due to circumstances outside of our control, this directive has now changed. Our new mission will be the extermination of the stinker race.<br><br>There will be no further communication.</blockquote></p><p><a href=\"https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo?via=cadence.moe&amp;via=example.invalid\">Go to Message</a></p></blockquote>",
"m.mentions": {}
}])
t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each")
})
test("message2event embeds: crazy html is all escaped", async t => {
const events = await messageToEvent(data.message_with_embeds.escaping_crazy_html_tags, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.notice",
body: "| ## ⏺️ <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;) https://a.co/&amp;<script>"
+ "\n| \n| ## <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;) https://a.co/&amp;<script>"
+ "\n| \n| <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;)"
+ "\n| \n| ### <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;)"
+ "\n| <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;)"
+ "\n| — <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;)",
format: "org.matrix.custom.html",
formatted_body: `<blockquote>`
+ `<p><strong><a href="https://a.co/&amp;amp;&lt;script&gt;">⏺️ &lt;strong&gt;[&lt;span data-mx-color=&#39;#123456&#39;&gt;Hey&lt;script&gt;](https://a.co/&amp;amp;)</a></strong></p>`
+ `<p><strong><a href=\"https://a.co/&amp;amp;&lt;script&gt;">&lt;strong&gt;[&lt;span data-mx-color='#123456'&gt;Hey&lt;script&gt;](<a href="https://a.co/&amp;amp">https://a.co/&amp;amp</a>;)</a></strong></p>`
+ `<p>&lt;strong&gt;<a href="https://a.co/&amp;amp;">&lt;span data-mx-color='#123456'&gt;Hey&lt;script&gt;</a></p>`
+ `<p><strong>&lt;strong&gt;[&lt;span data-mx-color='#123456'&gt;Hey&lt;script&gt;](<a href=\"https://a.co/&amp;amp\">https://a.co/&amp;amp</a>;)</strong>`
+ `<br>&lt;strong&gt;<a href="https://a.co/&amp;amp;">&lt;span data-mx-color='#123456'&gt;Hey&lt;script&gt;</a></p>`
+ `— &lt;strong&gt;[&lt;span data-mx-color=&#39;#123456&#39;&gt;Hey&lt;script&gt;](https://a.co/&amp;amp;)</blockquote>`,
"m.mentions": {}
}])
})

View file

@ -1,10 +1,10 @@
// @ts-check // @ts-check
const assert = require("assert").strict const assert = require("assert").strict
const markdown = require("@cloudrac3r/discord-markdown") const markdown = require("discord-markdown")
const pb = require("prettier-bytes") const pb = require("prettier-bytes")
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const {tag} = require("@cloudrac3r/html-template-tag") const {tag} = require("html-template-tag")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {sync, db, discord, select, from} = passthrough const {sync, db, discord, select, from} = passthrough
@ -18,7 +18,7 @@ const lottie = sync.require("../actions/lottie")
const mxUtils = sync.require("../../m2d/converters/utils") const mxUtils = sync.require("../../m2d/converters/utils")
/** @type {import("../../discord/utils")} */ /** @type {import("../../discord/utils")} */
const dUtils = sync.require("../../discord/utils") const dUtils = sync.require("../../discord/utils")
const {reg} = require("../../matrix/read-registration") const reg = require("../../matrix/read-registration")
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
@ -32,10 +32,7 @@ function getDiscordParseCallbacks(message, guild, useHTML) {
/** @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 interaction = message.interaction_metadata || message.interaction const username = message.mentions.find(ment => ment.id === node.id)?.username || node.id
const username = message.mentions.find(ment => ment.id === node.id)?.username
|| (interaction?.user.id === node.id ? interaction.user.username : null)
|| node.id
if (mxid && useHTML) { if (mxid && useHTML) {
return `<a href="https://matrix.to/#/${mxid}">@${username}</a>` return `<a href="https://matrix.to/#/${mxid}">@${username}</a>`
} else { } else {
@ -61,7 +58,7 @@ function getDiscordParseCallbacks(message, guild, useHTML) {
emoji: node => { emoji: node => {
if (useHTML) { if (useHTML) {
const mxc = select("emoji", "mxc_url", {emoji_id: node.id}).pluck().get() const mxc = select("emoji", "mxc_url", {emoji_id: node.id}).pluck().get()
assert(mxc, `Emoji consistency assertion failed for ${node.name}:${node.id}`) // All emojis should have been added ahead of time in the messageToEvent function. assert(mxc) // All emojis should have been added ahead of time in the messageToEvent function.
return `<img data-mx-emoticon height="32" src="${mxc}" title=":${node.name}:" alt=":${node.name}:">` return `<img data-mx-emoticon height="32" src="${mxc}" title=":${node.name}:" alt=":${node.name}:">`
} else { } else {
return `:${node.name}:` return `:${node.name}:`
@ -103,7 +100,6 @@ const embedTitleParser = markdown.markdownEngine.parserFor({
* @param {DiscordTypes.APIAttachment} attachment * @param {DiscordTypes.APIAttachment} attachment
*/ */
async function attachmentToEvent(mentions, attachment) { async function attachmentToEvent(mentions, attachment) {
const external_url = dUtils.getPublicUrlForCdn(attachment.url)
const emoji = const emoji =
attachment.content_type?.startsWith("image/jp") ? "📸" attachment.content_type?.startsWith("image/jp") ? "📸"
: attachment.content_type?.startsWith("image/") ? "🖼️" : attachment.content_type?.startsWith("image/") ? "🖼️"
@ -117,9 +113,9 @@ async function attachmentToEvent(mentions, attachment) {
$type: "m.room.message", $type: "m.room.message",
"m.mentions": mentions, "m.mentions": mentions,
msgtype: "m.text", msgtype: "m.text",
body: `${emoji} Uploaded SPOILER file: ${external_url} (${pb(attachment.size)})`, body: `${emoji} Uploaded SPOILER file: ${attachment.url} (${pb(attachment.size)})`,
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `<blockquote>${emoji} Uploaded SPOILER file: <a href="${external_url}">${external_url}</a> (${pb(attachment.size)})</blockquote>` formatted_body: `<blockquote>${emoji} Uploaded SPOILER file: <a href="${attachment.url}">${attachment.url}</a> (${pb(attachment.size)})</blockquote>`
} }
} }
// for large files, always link them instead of uploading so I don't use up all the space in the content repo // for large files, always link them instead of uploading so I don't use up all the space in the content repo
@ -128,9 +124,9 @@ async function attachmentToEvent(mentions, attachment) {
$type: "m.room.message", $type: "m.room.message",
"m.mentions": mentions, "m.mentions": mentions,
msgtype: "m.text", msgtype: "m.text",
body: `${emoji} Uploaded file: ${external_url} (${pb(attachment.size)})`, body: `${emoji} Uploaded file: ${attachment.url} (${pb(attachment.size)})`,
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `${emoji} Uploaded file: <a href="${external_url}">${attachment.filename}</a> (${pb(attachment.size)})` formatted_body: `${emoji} Uploaded file: <a href="${attachment.url}">${attachment.filename}</a> (${pb(attachment.size)})`
} }
} else if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) { } else if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) {
return { return {
@ -138,7 +134,7 @@ async function attachmentToEvent(mentions, attachment) {
"m.mentions": mentions, "m.mentions": mentions,
msgtype: "m.image", msgtype: "m.image",
url: await file.uploadDiscordFileToMxc(attachment.url), url: await file.uploadDiscordFileToMxc(attachment.url),
external_url, external_url: attachment.url,
body: attachment.description || attachment.filename, body: attachment.description || attachment.filename,
filename: attachment.filename, filename: attachment.filename,
info: { info: {
@ -154,7 +150,7 @@ async function attachmentToEvent(mentions, attachment) {
"m.mentions": mentions, "m.mentions": mentions,
msgtype: "m.video", msgtype: "m.video",
url: await file.uploadDiscordFileToMxc(attachment.url), url: await file.uploadDiscordFileToMxc(attachment.url),
external_url, external_url: attachment.url,
body: attachment.description || attachment.filename, body: attachment.description || attachment.filename,
filename: attachment.filename, filename: attachment.filename,
info: { info: {
@ -170,7 +166,7 @@ async function attachmentToEvent(mentions, attachment) {
"m.mentions": mentions, "m.mentions": mentions,
msgtype: "m.audio", msgtype: "m.audio",
url: await file.uploadDiscordFileToMxc(attachment.url), url: await file.uploadDiscordFileToMxc(attachment.url),
external_url, external_url: attachment.url,
body: attachment.description || attachment.filename, body: attachment.description || attachment.filename,
filename: attachment.filename, filename: attachment.filename,
info: { info: {
@ -185,7 +181,7 @@ async function attachmentToEvent(mentions, attachment) {
"m.mentions": mentions, "m.mentions": mentions,
msgtype: "m.file", msgtype: "m.file",
url: await file.uploadDiscordFileToMxc(attachment.url), url: await file.uploadDiscordFileToMxc(attachment.url),
external_url, external_url: attachment.url,
body: attachment.description || attachment.filename, body: attachment.description || attachment.filename,
filename: attachment.filename, filename: attachment.filename,
info: { info: {
@ -197,12 +193,11 @@ async function attachmentToEvent(mentions, attachment) {
} }
/** /**
* @param {DiscordTypes.APIMessage} message * @param {import("discord-api-types/v10").APIMessage} message
* @param {DiscordTypes.APIGuild} guild * @param {import("discord-api-types/v10").APIGuild} guild
* @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean, alwaysReturnFormattedBody?: boolean}} options default values: * @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean}} options default values:
* - includeReplyFallback: true * - includeReplyFallback: true
* - includeEditFallbackStar: false * - includeEditFallbackStar: false
* - alwaysReturnFormattedBody: false - formatted_body will be skipped if it is the same as body because the message is plaintext. if you want the formatted_body to be returned anyway, for example to merge it with another message, then set this to true.
* @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API * @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API
*/ */
async function messageToEvent(message, guild, options = {}, di) { async function messageToEvent(message, guild, options = {}, di) {
@ -234,16 +229,6 @@ async function messageToEvent(message, guild, options = {}, di) {
}] }]
} }
const interaction = message.interaction_metadata || message.interaction
if (message.type === DiscordTypes.MessageType.ChatInputCommand && interaction && "name" in interaction) {
// Commands are sent by the responding bot. Need to attach the metadata of the person using the command at the top.
let content = message.content
if (content) content = `\n${content}`
else if ((message.flags || 0) & DiscordTypes.MessageFlags.Loading) content = " — interaction loading..."
content = `> ↪️ <@${interaction.user.id}> used \`/${interaction.name}\`${content}`
message = {...message, content} // editToChanges reuses the object so we can't mutate it. have to clone it
}
/** /**
@type {{room?: boolean, user_ids?: string[]}} @type {{room?: boolean, user_ids?: string[]}}
We should consider the following scenarios for mentions: We should consider the following scenarios for mentions:
@ -264,8 +249,6 @@ async function messageToEvent(message, guild, options = {}, di) {
let repliedToEventRow = null let repliedToEventRow = null
let repliedToEventSenderMxid = null let repliedToEventSenderMxid = null
if (message.mention_everyone) mentions.room = true
function addMention(mxid) { function addMention(mxid) {
if (!mentions.user_ids) mentions.user_ids = [] if (!mentions.user_ids) mentions.user_ids = []
if (!mentions.user_ids.includes(mxid)) mentions.user_ids.push(mxid) if (!mentions.user_ids.includes(mxid)) mentions.user_ids.push(mxid)
@ -353,13 +336,8 @@ async function messageToEvent(message, guild, options = {}, di) {
result = `https://matrix.to/#/${roomID}/${eventID}?${via}` result = `https://matrix.to/#/${roomID}/${eventID}?${via}`
} else { } else {
const ts = dUtils.snowflakeToTimestampExact(messageID) const ts = dUtils.snowflakeToTimestampExact(messageID)
try {
const {event_id} = await di.api.getEventForTimestamp(roomID, ts) const {event_id} = await di.api.getEventForTimestamp(roomID, ts)
result = `https://matrix.to/#/${roomID}/${event_id}?${via}` result = `https://matrix.to/#/${roomID}/${event_id}?${via}`
} catch (e) {
// M_NOT_FOUND: Unable to find event from <ts> in direction Direction.FORWARDS
result = `[unknown event, timestamp resolution failed, in room: https://matrix.to/#/${roomID}?${via}]`
}
} }
} else { } else {
result = `${match[0]} [event is from another server]` result = `${match[0]} [event is from another server]`
@ -371,13 +349,6 @@ async function messageToEvent(message, guild, options = {}, di) {
return content return content
} }
/**
* Translate Discord attachment links into links that go via the bridge, so they last forever.
*/
function transformAttachmentLinks(content) {
return content.replace(/https:\/\/(cdn|media)\.discordapp\.(?:com|net)\/attachments\/([0-9]+)\/([0-9]+)\/([-A-Za-z0-9_.,]+)/g, url => dUtils.getPublicUrlForCdn(url))
}
/** /**
* Translate links and emojis and mentions and stuff. Give back the text and HTML so they can be combined into bigger events. * Translate links and emojis and mentions and stuff. Give back the text and HTML so they can be combined into bigger events.
* @param {string} content Partial or complete Discord message content * @param {string} content Partial or complete Discord message content
@ -386,12 +357,11 @@ async function messageToEvent(message, guild, options = {}, di) {
* @param {any} customHtmlOutput * @param {any} customHtmlOutput
*/ */
async function transformContent(content, customOptions = {}, customParser = null, customHtmlOutput = null) { async function transformContent(content, customOptions = {}, customParser = null, customHtmlOutput = null) {
content = transformAttachmentLinks(content)
content = await transformContentMessageLinks(content) content = await transformContentMessageLinks(content)
// Handling emojis that we don't know about. The emoji has to be present in the DB for it to be picked up in the emoji markdown converter. // Handling emojis that we don't know about. The emoji has to be present in the DB for it to be picked up in the emoji markdown converter.
// So we scan the message ahead of time for all its emojis and ensure they are in the DB. // So we scan the message ahead of time for all its emojis and ensure they are in the DB.
const emojiMatches = [...content.matchAll(/<(a?):([^:>]{1,64}):([0-9]+)>/g)] const emojiMatches = [...content.matchAll(/<(a?):([^:>]{2,64}):([0-9]+)>/g)]
await Promise.all(emojiMatches.map(match => { await Promise.all(emojiMatches.map(match => {
const id = match[3] const id = match[3]
const name = match[2] const name = match[2]
@ -424,18 +394,13 @@ async function messageToEvent(message, guild, options = {}, di) {
discordOnly: true, discordOnly: true,
escapeHTML: false, escapeHTML: false,
...customOptions ...customOptions
}) }, null, null)
return {body, html} return {body, html}
} }
/** // FIXME: What was the scanMentions parameter supposed to activate? It's unused.
* After converting Discord content to Matrix plaintext and HTML content, post-process the bodies and push the resulting text event async function addTextEvent(body, html, msgtype, {scanMentions}) {
* @param {string} body matrix event plaintext body
* @param {string} html matrix event HTML body
* @param {string} msgtype matrix event msgtype (maybe m.text or m.notice)
*/
async function addTextEvent(body, html, msgtype) {
// Star * prefix for fallback edits // Star * prefix for fallback edits
if (options.includeEditFallbackStar) { if (options.includeEditFallbackStar) {
body = "* " + body body = "* " + body
@ -443,7 +408,7 @@ async function messageToEvent(message, guild, options = {}, di) {
} }
const flags = message.flags || 0 const flags = message.flags || 0
if (flags & DiscordTypes.MessageFlags.IsCrosspost) { if (flags & 2) {
body = `[🔀 ${message.author.username}]\n` + body body = `[🔀 ${message.author.username}]\n` + body
html = `🔀 <strong>${message.author.username}</strong><br>` + html html = `🔀 <strong>${message.author.username}</strong><br>` + html
} }
@ -463,23 +428,21 @@ async function messageToEvent(message, guild, options = {}, di) {
repliedToUserHtml = repliedToDisplayName repliedToUserHtml = repliedToDisplayName
} }
let repliedToContent = message.referenced_message?.content let repliedToContent = message.referenced_message?.content
if (repliedToContent?.match(/^(-# )?> (-# )?<:L1:/)) { if (repliedToContent?.startsWith("> <:L1:")) {
// If the Discord user is replying to a Matrix user's reply, the fallback is going to contain the emojis and stuff from the bridged rep of the Matrix user's reply quote. // If the Discord user is replying to a Matrix user's reply, the fallback is going to contain the emojis and stuff from the bridged rep of the Matrix user's reply quote.
// Need to remove that previous reply rep from this fallback body. The fallbody body should only contain the Matrix user's actual message. // Need to remove that previous reply rep from this fallback body. The fallbody body should only contain the Matrix user's actual message.
// ┌──────A─────┐ A reply rep starting with >quote or -#smalltext >quote. Match until the end of the line. repliedToContent = repliedToContent.split("\n").slice(2).join("\n")
// ┆ ┆┌─B─┐ There may be up to 2 reply rep lines in a row if it was created in the old format. Match all lines.
repliedToContent = repliedToContent.replace(/^((-# )?> .*\n){1,2}/, "")
} }
if (repliedToContent == "") repliedToContent = "[Media]" if (repliedToContent == "") repliedToContent = "[Media]"
else if (!repliedToContent) repliedToContent = "[Replied-to message content wasn't provided by Discord]" else if (!repliedToContent) repliedToContent = "[Replied-to message content wasn't provided by Discord]"
const repliedToHtml = markdown.toHTML(repliedToContent, { const repliedToHtml = markdown.toHTML(repliedToContent, {
discordCallback: getDiscordParseCallbacks(message, guild, true) discordCallback: getDiscordParseCallbacks(message, guild, true)
}) }, null, null)
const repliedToBody = markdown.toHTML(repliedToContent, { const repliedToBody = markdown.toHTML(repliedToContent, {
discordCallback: getDiscordParseCallbacks(message, guild, false), discordCallback: getDiscordParseCallbacks(message, guild, false),
discordOnly: true, discordOnly: true,
escapeHTML: false, escapeHTML: false,
}) }, null, null)
html = `<mx-reply><blockquote><a href="https://matrix.to/#/${repliedToEventRow.room_id}/${repliedToEventRow.event_id}">In reply to</a> ${repliedToUserHtml}` html = `<mx-reply><blockquote><a href="https://matrix.to/#/${repliedToEventRow.room_id}/${repliedToEventRow.event_id}">In reply to</a> ${repliedToUserHtml}`
+ `<br>${repliedToHtml}</blockquote></mx-reply>` + `<br>${repliedToHtml}</blockquote></mx-reply>`
+ html + html
@ -497,7 +460,7 @@ async function messageToEvent(message, guild, options = {}, di) {
const isPlaintext = body === html const isPlaintext = body === html
if (!isPlaintext || options.alwaysReturnFormattedBody) { if (!isPlaintext) {
Object.assign(newTextMessageEvent, { Object.assign(newTextMessageEvent, {
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: html formatted_body: html
@ -515,62 +478,9 @@ async function messageToEvent(message, guild, options = {}, di) {
message.content = "changed the channel name to **" + message.content + "**" message.content = "changed the channel name to **" + message.content + "**"
} }
// Forwarded content appears first
if (message.message_reference?.type === DiscordTypes.MessageReferenceType.Forward && message.message_snapshots?.length) {
// Forwarded notice
const eventID = select("event_message", "event_id", {message_id: message.message_reference.message_id}).pluck().get()
const room = select("channel_room", ["room_id", "name", "nick"], {channel_id: message.message_reference.channel_id}).get()
const forwardedNotice = new mxUtils.MatrixStringBuilder()
if (room) {
const roomName = room && (room.nick || room.name)
const via = await getViaServersMemo(room.room_id)
if (eventID) {
forwardedNotice.addLine(
`[🔀 Forwarded from #${roomName}]`,
tag`🔀 <em>Forwarded from <a href="https://matrix.to/#/${room.room_id}/${eventID}?${via}">${roomName}</a></em>`
)
} else {
forwardedNotice.addLine(
`[🔀 Forwarded from #${roomName}]`,
tag`🔀 <em>Forwarded from <a href="https://matrix.to/#/${room.room_id}?${via}">${roomName}</a></em>`
)
}
} else {
forwardedNotice.addLine(
`[🔀 Forwarded message]`,
tag`🔀 <em>Forwarded message</em>`
)
}
// Forwarded content
// @ts-ignore
const forwardedEvents = await messageToEvent(message.message_snapshots[0].message, guild, {includeReplyFallback: false, includeEditFallbackStar: false, alwaysReturnFormattedBody: true}, di)
// Indent
for (const event of forwardedEvents) {
if (["m.text", "m.notice"].includes(event.msgtype)) {
event.msgtype = "m.notice"
event.body = event.body.split("\n").map(l => "» " + l).join("\n")
event.formatted_body = `<blockquote>${event.formatted_body}</blockquote>`
}
}
// Try to merge the forwarded content with the forwarded notice
let {body, formatted_body} = forwardedNotice.get()
if (forwardedEvents.length >= 1 && ["m.text", "m.notice"].includes(forwardedEvents[0].msgtype)) { // Try to merge the forwarded content and the forwarded notice
forwardedEvents[0].body = body + "\n" + forwardedEvents[0].body
forwardedEvents[0].formatted_body = formatted_body + "<br>" + forwardedEvents[0].formatted_body
} else {
await addTextEvent(body, formatted_body, "m.notice")
}
events.push(...forwardedEvents)
}
// Then text content
if (message.content) {
// Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention. // Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention.
const matches = [...message.content.matchAll(/@ ?([a-z0-9._]+)\b/gi)] const matches = [...message.content.matchAll(/@ ?([a-z0-9._]+)\b/gi)]
if (matches.length && matches.some(m => m[1].match(/[a-z]/i) && m[1] !== "everyone" && m[1] !== "here")) { if (matches.length && matches.some(m => m[1].match(/[a-z]/i))) {
const writtenMentionsText = matches.map(m => m[1].toLowerCase()) const writtenMentionsText = matches.map(m => m[1].toLowerCase())
const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get() const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
assert(roomID) assert(roomID)
@ -585,15 +495,15 @@ async function messageToEvent(message, guild, options = {}, di) {
} }
} }
// Text content appears first
if (message.content) {
const {body, html} = await transformContent(message.content) const {body, html} = await transformContent(message.content)
await addTextEvent(body, html, msgtype) await addTextEvent(body, html, msgtype, {scanMentions: true})
} }
// Then attachments // Then attachments
if (message.attachments) {
const attachmentEvents = await Promise.all(message.attachments.map(attachmentToEvent.bind(null, mentions))) const attachmentEvents = await Promise.all(message.attachments.map(attachmentToEvent.bind(null, mentions)))
events.push(...attachmentEvents) events.push(...attachmentEvents)
}
// Then embeds // Then embeds
for (const embed of message.embeds || []) { for (const embed of message.embeds || []) {
@ -601,26 +511,13 @@ async function messageToEvent(message, guild, options = {}, di) {
continue // Matrix's own URL previews are fine for images. continue // Matrix's own URL previews are fine for images.
} }
if (embed.url?.startsWith("https://discord.com/")) {
continue // If discord creates an embed preview for a discord channel link, don't copy that embed
}
// Start building up a replica ("rep") of the embed in Discord-markdown format, which we will convert into both plaintext and formatted body at once // Start building up a replica ("rep") of the embed in Discord-markdown format, which we will convert into both plaintext and formatted body at once
const rep = new mxUtils.MatrixStringBuilder() const rep = new mxUtils.MatrixStringBuilder()
// Provider
if (embed.provider?.name) {
if (embed.provider.url) {
rep.addParagraph(`via ${embed.provider.name} ${embed.provider.url}`, tag`<sub><a href="${embed.provider.url}">${embed.provider.name}</a></sub>`)
} else {
rep.addParagraph(`via ${embed.provider.name}`, tag`<sub>${embed.provider.name}</sub>`)
}
}
// Author and URL into a paragraph // Author and URL into a paragraph
let authorNameText = embed.author?.name || "" let authorNameText = embed.author?.name || ""
if (authorNameText && embed.author?.icon_url) authorNameText = `⏺️ ${authorNameText}` // using the emoji instead of an image if (authorNameText && embed.author?.icon_url) authorNameText = `⏺️ ${authorNameText}` // using the emoji instead of an image
if (authorNameText) { if (authorNameText || embed.author?.url) {
if (embed.author?.url) { if (embed.author?.url) {
const authorURL = await transformContentMessageLinks(embed.author.url) const authorURL = await transformContentMessageLinks(embed.author.url)
rep.addParagraph(`## ${authorNameText} ${authorURL}`, tag`<strong><a href="${authorURL}">${authorNameText}</a></strong>`) rep.addParagraph(`## ${authorNameText} ${authorURL}`, tag`<strong><a href="${authorURL}">${authorNameText}</a></strong>`)
@ -637,11 +534,11 @@ async function messageToEvent(message, guild, options = {}, di) {
} else { } else {
rep.addParagraph(`## ${body}`, `<strong>${html}</strong>`) rep.addParagraph(`## ${body}`, `<strong>${html}</strong>`)
} }
} else if (embed.url) {
rep.addParagraph(`## ${embed.url}`, tag`<strong><a href="${embed.url}">${embed.url}</a></strong>`)
} }
let embedTypeShouldShowDescription = embed.type !== "video" // Discord doesn't display descriptions for videos if (embed.description) {
if (embed.provider?.name === "YouTube") embedTypeShouldShowDescription = true // But I personally like showing the descriptions for YouTube videos specifically
if (embed.description && embedTypeShouldShowDescription) {
const {body, html} = await transformContent(embed.description) const {body, html} = await transformContent(embed.description)
rep.addParagraph(body, html) rep.addParagraph(body, html)
} }
@ -655,12 +552,8 @@ async function messageToEvent(message, guild, options = {}, di) {
rep.addParagraph(fieldRep.get().body, fieldRep.get().formatted_body) rep.addParagraph(fieldRep.get().body, fieldRep.get().formatted_body)
} }
let chosenImage = embed.image?.url if (embed.image?.url) rep.addParagraph(`📸 ${embed.image.url}`)
// the thumbnail seems to be used for "article" type but displayed big at the bottom by discord if (embed.video?.url) rep.addParagraph(`🎞️ ${embed.video.url}`)
if (embed.type === "article" && embed.thumbnail?.url && !chosenImage) chosenImage = embed.thumbnail.url
if (chosenImage) rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`)
if (embed.video?.url) rep.addParagraph(`🎞️ ${dUtils.getPublicUrlForCdn(embed.video.url)}`)
if (embed.footer?.text) rep.addLine(`${embed.footer.text}`, tag`${embed.footer.text}`) if (embed.footer?.text) rep.addLine(`${embed.footer.text}`, tag`${embed.footer.text}`)
let {body, formatted_body: html} = rep.get() let {body, formatted_body: html} = rep.get()
@ -668,7 +561,7 @@ async function messageToEvent(message, guild, options = {}, di) {
html = `<blockquote>${html}</blockquote>` html = `<blockquote>${html}</blockquote>`
// Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person // Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person
await addTextEvent(body, html, "m.notice") await addTextEvent(body, html, "m.notice", {scanMentions: false})
} }
// Then stickers // Then stickers

View file

@ -1,6 +1,6 @@
const {test} = require("supertape") const {test} = require("supertape")
const {messageToEvent} = require("./message-to-event") const {messageToEvent} = require("./message-to-event")
const data = require("../../../test/data") const data = require("../../test/data")
const Ty = require("../../types") const Ty = require("../../types")
/** /**

View file

@ -1,7 +1,6 @@
const {test} = require("supertape") const {test} = require("supertape")
const {messageToEvent} = require("./message-to-event") const {messageToEvent} = require("./message-to-event")
const {MatrixServerError} = require("../../matrix/mreq") const data = require("../../test/data")
const data = require("../../../test/data")
const Ty = require("../../types") const Ty = require("../../types")
/** /**
@ -65,44 +64,6 @@ test("message2event: simple user mention", async t => {
test("message2event: simple room mention", async t => { test("message2event: simple room mention", async t => {
let called = 0 let called = 0
const events = await messageToEvent(data.message.simple_room_mention, data.guild.general, {}, { const events = await messageToEvent(data.message.simple_room_mention, data.guild.general, {}, {
api: {
async getStateEvent(roomID, type, key) {
called++
t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {
users: {
"@_ooye_bot:cadence.moe": 100
}
}
},
async getJoinedMembers(roomID) {
called++
t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe")
return {
joined: {
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
"@user:matrix.org": {display_name: null, avatar_url: null}
}
}
}
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "#worm-farm",
format: "org.matrix.custom.html",
formatted_body: '<a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe?via=cadence.moe&via=matrix.org">#worm-farm</a>'
}])
t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each")
})
test("message2event: nicked room mention", async t => {
let called = 0
const events = await messageToEvent(data.message.nicked_room_mention, data.guild.general, {}, {
api: { api: {
async getStateEvent(roomID, type, key) { async getStateEvent(roomID, type, key) {
called++ called++
@ -268,54 +229,6 @@ test("message2event: message link that OOYE doesn't know about", async t => {
t.equal(called, 3, "getEventForTimestamp, getStateEvent, and getJoinedMembers should be called once each") t.equal(called, 3, "getEventForTimestamp, getStateEvent, and getJoinedMembers should be called once each")
}) })
test("message2event: message timestamp failed to fetch", async t => {
let called = 0
const events = await messageToEvent(data.message.message_link_to_before_ooye, data.guild.general, {}, {
api: {
async getEventForTimestamp(roomID, ts) {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
throw new MatrixServerError({
errcode: "M_NOT_FOUND",
error: "Unable to find event from 1726762095974 in direction Direction.FORWARDS"
}, {})
},
async getStateEvent(roomID, type, key) { // for ?via calculation
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {
users: {
"@_ooye_bot:cadence.moe": 100
}
}
},
async getJoinedMembers(roomID) { // for ?via calculation
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
return {
joined: {
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
"@user:matrix.org": {display_name: null, avatar_url: null}
}
}
}
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "Me: I'll scroll up to find a certain message I'll send\n_scrolls up and clicks message links for god knows how long_\n_completely forgets what they were looking for and simply begins scrolling up to find some fun moments_\n_stumbles upon:_ "
+ "[unknown event, timestamp resolution failed, in room: https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&via=matrix.org]",
format: "org.matrix.custom.html",
formatted_body: "Me: I'll scroll up to find a certain message I'll send<br><em>scrolls up and clicks message links for god knows how long</em><br><em>completely forgets what they were looking for and simply begins scrolling up to find some fun moments</em><br><em>stumbles upon:</em> "
+ '[unknown event, timestamp resolution failed, in room: <a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&amp;via=matrix.org">https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&amp;via=matrix.org</a>]'
}])
t.equal(called, 3, "getEventForTimestamp, getStateEvent, and getJoinedMembers should be called once each")
})
test("message2event: message link from another server", async t => { test("message2event: message link from another server", async t => {
const events = await messageToEvent(data.message.message_link_from_another_server, data.guild.general) const events = await messageToEvent(data.message.message_link_from_another_server, data.guild.general)
t.deepEqual(events, [{ t.deepEqual(events, [{
@ -337,7 +250,7 @@ test("message2event: attachment with no content", async t => {
msgtype: "m.image", msgtype: "m.image",
url: "mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM", url: "mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM",
body: "image.png", body: "image.png",
external_url: "https://bridge.example.org/download/discordcdn/497161332244742154/1124628646431297546/image.png", external_url: "https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png",
filename: "image.png", filename: "image.png",
info: { info: {
mimetype: "image/png", mimetype: "image/png",
@ -354,9 +267,9 @@ test("message2event: spoiler attachment", async t => {
$type: "m.room.message", $type: "m.room.message",
"m.mentions": {}, "m.mentions": {},
msgtype: "m.text", msgtype: "m.text",
body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)", body: "📄 Uploaded SPOILER file: https://cdn.discordapp.com/attachments/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: "<blockquote>📄 Uploaded SPOILER file: <a href=\"https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci\">https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci</a> (74 KB)</blockquote>" formatted_body: "<blockquote>📄 Uploaded SPOILER file: <a href=\"https://cdn.discordapp.com/attachments/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci\">https://cdn.discordapp.com/attachments/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci</a> (74 KB)</blockquote>"
}]) }])
}) })
@ -373,7 +286,7 @@ test("message2event: stickers", async t => {
msgtype: "m.image", msgtype: "m.image",
url: "mxc://cadence.moe/ZDCNYnkPszxGKgObUIFmvjus", url: "mxc://cadence.moe/ZDCNYnkPszxGKgObUIFmvjus",
body: "image.png", body: "image.png",
external_url: "https://bridge.example.org/download/discordcdn/122155380120748034/1106366167486038016/image.png", external_url: "https://cdn.discordapp.com/attachments/122155380120748034/1106366167486038016/image.png",
filename: "image.png", filename: "image.png",
info: { info: {
mimetype: "image/png", mimetype: "image/png",
@ -427,7 +340,7 @@ test("message2event: skull webp attachment with content", async t => {
mimetype: "image/webp", mimetype: "image/webp",
size: 74290 size: 74290
}, },
external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1128084747910918195/skull.webp", external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1128084747910918195/skull.webp",
filename: "skull.webp", filename: "skull.webp",
url: "mxc://cadence.moe/sDxWmDErBhYBxtDcJQgBETes" url: "mxc://cadence.moe/sDxWmDErBhYBxtDcJQgBETes"
}]) }])
@ -461,7 +374,7 @@ test("message2event: reply to skull webp attachment with content", async t => {
mimetype: "image/jpeg", mimetype: "image/jpeg",
size: 85906 size: 85906
}, },
external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1128084851023675515/RDT_20230704_0936184915846675925224905.jpg", external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1128084851023675515/RDT_20230704_0936184915846675925224905.jpg",
filename: "RDT_20230704_0936184915846675925224905.jpg", filename: "RDT_20230704_0936184915846675925224905.jpg",
url: "mxc://cadence.moe/WlAbFSiNRIHPDEwKdyPeGywa" url: "mxc://cadence.moe/WlAbFSiNRIHPDEwKdyPeGywa"
}]) }])
@ -551,7 +464,7 @@ test("message2event: reply with a video", async t => {
body: "Ins_1960637570.mp4", body: "Ins_1960637570.mp4",
filename: "Ins_1960637570.mp4", filename: "Ins_1960637570.mp4",
url: "mxc://cadence.moe/kMqLycqMURhVpwleWkmASpnU", url: "mxc://cadence.moe/kMqLycqMURhVpwleWkmASpnU",
external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1197621094786531358/Ins_1960637570.mp4", external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1197621094786531358/Ins_1960637570.mp4?ex=65bbee8f&is=65a9798f&hm=ae14f7824c3d526c5e11c162e012e1ee405fd5776e1e9302ed80ccd86503cfda&",
info: { info: {
h: 854, h: 854,
mimetype: "video/mp4", mimetype: "video/mp4",
@ -572,7 +485,7 @@ test("message2event: voice message", async t => {
t.deepEqual(events, [{ t.deepEqual(events, [{
$type: "m.room.message", $type: "m.room.message",
body: "voice-message.ogg", body: "voice-message.ogg",
external_url: "https://bridge.example.org/download/discordcdn/1099031887500034088/1112476845502365786/voice-message.ogg", external_url: "https://cdn.discordapp.com/attachments/1099031887500034088/1112476845502365786/voice-message.ogg?ex=65c92d4c&is=65b6b84c&hm=0654bab5027474cbe23875954fa117cf44d8914c144cd151879590fa1baf8b1c&",
filename: "voice-message.ogg", filename: "voice-message.ogg",
info: { info: {
duration: 3960.0000381469727, duration: 3960.0000381469727,
@ -595,7 +508,7 @@ test("message2event: misc file", async t => {
}, { }, {
$type: "m.room.message", $type: "m.room.message",
body: "the.yml", body: "the.yml",
external_url: "https://bridge.example.org/download/discordcdn/122155380120748034/1174514575220158545/the.yml", external_url: "https://cdn.discordapp.com/attachments/122155380120748034/1174514575220158545/the.yml?ex=65cd6270&is=65baed70&hm=8c5f1b571784e3c7f99628492298815884e351ae0dc7c2ae40dd22d97caf27d9&",
filename: "the.yml", filename: "the.yml",
info: { info: {
mimetype: "text/plain; charset=utf-8", mimetype: "text/plain; charset=utf-8",
@ -646,84 +559,6 @@ test("message2event: simple reply in thread to a matrix user's reply", async t =
}]) }])
}) })
test("message2event: infinidoge's reply to ami's matrix smalltext reply to infinidoge", async t => {
const events = await messageToEvent(data.message.infinidoge_reply_to_ami_matrix_smalltext_reply_to_infinidoge, data.guild.general, {}, {
api: {
getEvent: mockGetEvent(t, "!BnKuBPCvyfOkhcUjEu:cadence.moe", "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4", {
type: "m.room.message",
sender: "@ami:the-apothecary.club",
content: {
msgtype: "m.text",
body: `> <@_ooye_infinidoge:cadence.moe> Neat that they thought of that\n\nlet me guess they got a lot of bug reports like "empty chest with no loot?"`,
format: "org.matrix.custom.html",
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$baby?via=cadence.moe">In reply to</a> <a href="https://matrix.to/#/@_ooye_infinidoge:cadence.moe">@_ooye_infinidoge:cadence.moe</a><br>Neat that they thought of that</blockquote></mx-reply>let me guess they got a lot of bug reports like "empty chest with no loot?"`,
"m.relates_to": {
"m.in_reply_to": {
event_id: "$baby"
}
}
},
event_id: "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4",
room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe"
})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4"
}
},
"m.mentions": {
user_ids: ["@ami:the-apothecary.club"]
},
msgtype: "m.text",
body: `> Ami (she/her): let me guess they got a lot of bug reports like "empty chest with no loot?"\n\nMost likely`,
format: "org.matrix.custom.html",
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4">In reply to</a> <a href="https://matrix.to/#/@ami:the-apothecary.club">Ami (she/her)</a><br>let me guess they got a lot of bug reports like "empty chest with no loot?"</blockquote></mx-reply>Most likely`,
}])
})
test("message2event: infinidoge's reply to ami's matrix smalltext singleline reply to infinidoge", async t => {
const events = await messageToEvent(data.message.infinidoge_reply_to_ami_matrix_smalltext_singleline_reply_to_infinidoge, data.guild.general, {}, {
api: {
getEvent: mockGetEvent(t, "!BnKuBPCvyfOkhcUjEu:cadence.moe", "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4", {
type: "m.room.message",
sender: "@ami:the-apothecary.club",
content: {
msgtype: "m.text",
body: `> <@_ooye_infinidoge:cadence.moe> Neat that they thought of that\n\nlet me guess they got a lot of bug reports like "empty chest with no loot?"`,
format: "org.matrix.custom.html",
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$baby?via=cadence.moe">In reply to</a> <a href="https://matrix.to/#/@_ooye_infinidoge:cadence.moe">@_ooye_infinidoge:cadence.moe</a><br>Neat that they thought of that</blockquote></mx-reply>let me guess they got a lot of bug reports like "empty chest with no loot?"`,
"m.relates_to": {
"m.in_reply_to": {
event_id: "$baby"
}
}
},
event_id: "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4",
room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe"
})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4"
}
},
"m.mentions": {
user_ids: ["@ami:the-apothecary.club"]
},
msgtype: "m.text",
body: `> Ami (she/her): let me guess they got a lot of bug reports like "empty chest with no loot?"\n\nMost likely`,
format: "org.matrix.custom.html",
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4">In reply to</a> <a href="https://matrix.to/#/@ami:the-apothecary.club">Ami (she/her)</a><br>let me guess they got a lot of bug reports like "empty chest with no loot?"</blockquote></mx-reply>Most likely`,
}])
})
test("message2event: simple written @mention for matrix user", async t => { test("message2event: simple written @mention for matrix user", async t => {
const events = await messageToEvent(data.message.simple_written_at_mention_for_matrix, data.guild.general, {}, { const events = await messageToEvent(data.message.simple_written_at_mention_for_matrix, data.guild.general, {}, {
api: { api: {
@ -837,7 +672,7 @@ test("message2event: very large attachment is linked instead of being uploaded",
content: "hey", content: "hey",
attachments: [{ attachments: [{
filename: "hey.jpg", filename: "hey.jpg",
url: "https://cdn.discordapp.com/attachments/123/456/789.mega", url: "https://discord.com/404/hey.jpg",
content_type: "application/i-made-it-up", content_type: "application/i-made-it-up",
size: 100e6 size: 100e6
}] }]
@ -851,9 +686,9 @@ test("message2event: very large attachment is linked instead of being uploaded",
$type: "m.room.message", $type: "m.room.message",
"m.mentions": {}, "m.mentions": {},
msgtype: "m.text", msgtype: "m.text",
body: "📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)", body: "📄 Uploaded file: https://discord.com/404/hey.jpg (100 MB)",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: '📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)' formatted_body: '📄 Uploaded file: <a href="https://discord.com/404/hey.jpg">hey.jpg</a> (100 MB)'
}]) }])
}) })
@ -954,183 +789,3 @@ test("message2event: crossposted announcements say where they are crossposted fr
formatted_body: "🔀 <strong>Chewey Bot Official Server #announcements</strong><br>All text based commands are now inactive on Chewey Bot<br>To continue using commands you'll need to use them as slash commands" formatted_body: "🔀 <strong>Chewey Bot Official Server #announcements</strong><br>All text based commands are now inactive on Chewey Bot<br>To continue using commands you'll need to use them as slash commands"
}]) }])
}) })
test("message2event: @everyone", async t => {
const events = await messageToEvent(data.message_mention_everyone.at_everyone)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "@room",
"m.mentions": {
room: true
}
}])
})
test("message2event: @here", async t => {
const events = await messageToEvent(data.message_mention_everyone.at_here)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "@room",
"m.mentions": {
room: true
}
}])
})
test("message2event: @everyone without permission", async t => {
const events = await messageToEvent(data.message_mention_everyone.at_everyone_without_permission)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "@everyone <-- this is testing that it DOESN'T mention. if this mentions everyone then my apologies.",
format: "org.matrix.custom.html",
formatted_body: "@everyone &lt;-- this is testing that it DOESN'T mention. if this mentions everyone then my apologies.",
"m.mentions": {}
}])
})
test("message2event: @here without permission", async t => {
const events = await messageToEvent(data.message_mention_everyone.at_here_without_permission)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "@here <-- this is testing that it DOESN'T mention. if this mentions people then my apologies.",
format: "org.matrix.custom.html",
formatted_body: "@here &lt;-- this is testing that it DOESN'T mention. if this mentions people then my apologies.",
"m.mentions": {}
}])
})
test("message2event: @everyone within a link", async t => {
const events = await messageToEvent(data.message_mention_everyone.at_everyone_within_link)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "https://github.com/@everyone",
format: "org.matrix.custom.html",
formatted_body: `<a href="https://github.com/@everyone">https://github.com/@everyone</a>`,
"m.mentions": {}
}])
})
test("message2event: forwarded image", async t => {
const events = await messageToEvent(data.message.forwarded_image)
t.deepEqual(events, [
{
$type: "m.room.message",
body: "[🔀 Forwarded message]",
format: "org.matrix.custom.html",
formatted_body: "🔀 <em>Forwarded message</em>",
"m.mentions": {},
msgtype: "m.notice",
},
{
$type: "m.room.message",
body: "100km.gif",
external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1296237494987133070/100km.gif",
filename: "100km.gif",
info: {
h: 300,
mimetype: "image/gif",
size: 2965649,
w: 300,
},
"m.mentions": {},
msgtype: "m.image",
url: "mxc://cadence.moe/qDAotmebTfEIfsAIVCEZptLh",
},
])
})
test("message2event: constructed forwarded message", async t => {
const events = await messageToEvent(data.message.constructed_forwarded_message, {}, {}, {
api: {
async getJoinedMembers() {
return {
joined: {
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
"@user:matrix.org": {display_name: null, avatar_url: null}
}
}
}
}
})
t.deepEqual(events, [
{
$type: "m.room.message",
body: "[🔀 Forwarded from #wonderland]"
+ "\n» What's cooking, good looking? :hipposcope:",
format: "org.matrix.custom.html",
formatted_body: `🔀 <em>Forwarded from <a href="https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$tBIT8mO7XTTCgIINyiAIy6M2MSoPAdJenRl_RLyYuaE?via=cadence.moe&amp;via=matrix.org">wonderland</a></em>`
+ `<br><blockquote>What's cooking, good looking? <img data-mx-emoticon height="32" src="mxc://cadence.moe/WbYqNlACRuicynBfdnPYtmvc" title=":hipposcope:" alt=":hipposcope:"></blockquote>`,
"m.mentions": {},
msgtype: "m.notice",
},
{
$type: "m.room.message",
body: "100km.gif",
external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1296237494987133070/100km.gif",
filename: "100km.gif",
info: {
h: 300,
mimetype: "image/gif",
size: 2965649,
w: 300,
},
"m.mentions": {},
msgtype: "m.image",
url: "mxc://cadence.moe/qDAotmebTfEIfsAIVCEZptLh",
},
{
$type: "m.room.message",
body: "» | ## This man"
+ "\n» | "
+ "\n» | ## This man is 100 km away from your house"
+ "\n» | "
+ "\n» | ### Distance away"
+ "\n» | 99 km"
+ "\n» | "
+ "\n» | ### Distance away"
+ "\n» | 98 km",
format: "org.matrix.custom.html",
formatted_body: "<blockquote><blockquote><p><strong>This man</strong></p><p><strong>This man is 100 km away from your house</strong></p><p><strong>Distance away</strong><br>99 km</p><p><strong>Distance away</strong><br>98 km</p></blockquote></blockquote>",
"m.mentions": {},
msgtype: "m.notice"
}
])
})
test("message2event: constructed forwarded text", async t => {
const events = await messageToEvent(data.message.constructed_forwarded_text, {}, {}, {
api: {
async getJoinedMembers() {
return {
joined: {
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
"@user:matrix.org": {display_name: null, avatar_url: null}
}
}
}
}
})
t.deepEqual(events, [
{
$type: "m.room.message",
body: "[🔀 Forwarded from #amanda-spam]"
+ "\n» What's cooking, good looking?",
format: "org.matrix.custom.html",
formatted_body: `🔀 <em>Forwarded from <a href="https://matrix.to/#/!CzvdIdUQXgUjDVKxeU:cadence.moe?via=cadence.moe&amp;via=matrix.org">amanda-spam</a></em>`
+ `<br><blockquote>What's cooking, good looking?</blockquote>`,
"m.mentions": {},
msgtype: "m.notice",
},
{
$type: "m.room.message",
body: "What's cooking everybody ‼️",
"m.mentions": {},
msgtype: "m.text",
}
])
})

View file

@ -1,5 +1,5 @@
const {test} = require("supertape") const {test} = require("supertape")
const data = require("../../../test/data") const data = require("../../test/data")
const {pinsToList} = require("./pins-to-list") const {pinsToList} = require("./pins-to-list")
test("pins2list: converts known IDs, ignores unknown IDs", t => { test("pins2list: converts known IDs, ignores unknown IDs", t => {

View file

@ -12,7 +12,7 @@ const utils = sync.require("../../m2d/converters/utils")
* @typedef ReactionRemoveRequest * @typedef ReactionRemoveRequest
* @prop {string} eventID * @prop {string} eventID
* @prop {string | null} mxid * @prop {string | null} mxid
* @prop {bigint} [hash] * @prop {BigInt} [hash]
*/ */
/** /**

View file

@ -10,9 +10,7 @@ function fakeSpecificReactionRemoval(userID, emoji, emojiID) {
channel_id: "THE_CHANNEL", channel_id: "THE_CHANNEL",
message_id: "THE_MESSAGE", message_id: "THE_MESSAGE",
user_id: userID, user_id: userID,
emoji: {id: emojiID, name: emoji}, emoji: {id: emojiID, name: emoji}
burst: false,
type: 0
} }
} }

File diff suppressed because one or more lines are too long

Binary file not shown.

View file

@ -4,9 +4,10 @@ const assert = require("assert").strict
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {discord, sync, db, select} = passthrough const {discord, sync, db, select} = passthrough
/** @type {import("../../matrix/read-registration")} */
const reg = sync.require("../../matrix/read-registration.js")
/** @type {import("../../m2d/converters/utils")} */ /** @type {import("../../m2d/converters/utils")} */
const mxUtils = sync.require("../../m2d/converters/utils") const mxUtils = sync.require("../../m2d/converters/utils")
const {reg} = require("../../matrix/read-registration.js")
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))

View file

@ -1,6 +1,6 @@
const {test} = require("supertape") const {test} = require("supertape")
const {threadToAnnouncement} = require("./thread-to-announcement") const {threadToAnnouncement} = require("./thread-to-announcement")
const data = require("../../../test/data") const data = require("../../test/data")
const Ty = require("../../types") const Ty = require("../../types")
/** /**

View file

@ -1,7 +1,6 @@
// @ts-check // @ts-check
const assert = require("assert") const assert = require("assert")
const {reg} = require("../../matrix/read-registration")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {select} = passthrough const {select} = passthrough
@ -25,10 +24,6 @@ function downcaseUsername(user) {
.replace(/[^a-z0-9._=/-]*/g, "") .replace(/[^a-z0-9._=/-]*/g, "")
// remove leading and trailing dashes and underscores... // remove leading and trailing dashes and underscores...
.replace(/(?:^[_-]*|[_-]*$)/g, "") .replace(/(?:^[_-]*|[_-]*$)/g, "")
// If requested, also make the Discord user ID part of the username
if (reg.ooye.include_user_id_in_mxid) {
downcased = user.id + "_" + downcased
}
// The new length must be at least 2 characters (in other words, it should have some content) // The new length must be at least 2 characters (in other words, it should have some content)
if (downcased.length < 2) { if (downcased.length < 2) {
downcased = user.id downcased = user.id

View file

@ -1,7 +1,7 @@
const {test} = require("supertape") const {test} = require("supertape")
const tryToCatch = require("try-to-catch") const tryToCatch = require("try-to-catch")
const assert = require("assert") const assert = require("assert")
const data = require("../../../test/data") const data = require("../../test/data")
const {userToSimName} = require("./user-to-mxid") const {userToSimName} = require("./user-to-mxid")
test("user2name: cannot create user for a webhook", async t => { test("user2name: cannot create user for a webhook", async t => {
@ -44,11 +44,3 @@ test("user2name: uses ID when name has only disallowed characters", t => {
test("user2name: works on special user", t => { test("user2name: works on special user", t => {
t.equal(userToSimName(data.user.clyde_ai), "clyde_ai") t.equal(userToSimName(data.user.clyde_ai), "clyde_ai")
}) })
test("user2name: includes ID if requested in config", t => {
const {reg} = require("../../matrix/read-registration")
reg.ooye.include_user_id_in_mxid = true
t.equal(userToSimName({username: "Harry Styles!", discriminator: "0001", id: "123456"}), "123456_harry_styles")
t.equal(userToSimName({username: "f***", discriminator: "0001", id: "123456"}), "123456_f")
reg.ooye.include_user_id_in_mxid = false
})

View file

@ -6,7 +6,7 @@ const { Client: CloudStorm } = require("cloudstorm")
const passthrough = require("../passthrough") const passthrough = require("../passthrough")
const { sync } = passthrough const { sync } = passthrough
/** @type {import("./discord-packets")} */ /** @type {typeof import("./discord-packets")} */
const discordPackets = sync.require("./discord-packets") const discordPackets = sync.require("./discord-packets")
class DiscordClient { class DiscordClient {
@ -57,9 +57,6 @@ class DiscordClient {
addEventLogger("error", "Error") addEventLogger("error", "Error")
addEventLogger("disconnected", "Disconnected") addEventLogger("disconnected", "Disconnected")
addEventLogger("ready", "Ready") addEventLogger("ready", "Ready")
this.snow.requestHandler.on("requestError", (requestID, error) => {
console.error("request error:", error)
})
} }
} }

View file

@ -4,11 +4,7 @@
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../passthrough") const passthrough = require("../passthrough")
const {sync, db} = passthrough const { sync } = passthrough
function populateGuildID(guildID, channelID) {
db.prepare("UPDATE channel_room SET guild_id = ? WHERE channel_id = ?").run(guildID, channelID)
}
const utils = { const utils = {
/** /**
@ -20,8 +16,6 @@ const utils = {
// requiring this later so that the client is already constructed by the time event-dispatcher is loaded // requiring this later so that the client is already constructed by the time event-dispatcher is loaded
/** @type {typeof import("./event-dispatcher")} */ /** @type {typeof import("./event-dispatcher")} */
const eventDispatcher = sync.require("./event-dispatcher") const eventDispatcher = sync.require("./event-dispatcher")
/** @type {import("../discord/register-interactions")} */
const interactions = sync.require("../discord/register-interactions")
// Client internals, keep track of the state we need // Client internals, keep track of the state we need
if (message.t === "READY") { if (message.t === "READY") {
@ -40,16 +34,13 @@ const utils = {
channel.guild_id = message.d.id channel.guild_id = message.d.id
arr.push(channel.id) arr.push(channel.id)
client.channels.set(channel.id, channel) client.channels.set(channel.id, channel)
populateGuildID(message.d.id, channel.id)
} }
for (const thread of message.d.threads || []) { for (const thread of message.d.threads || []) {
// @ts-ignore // @ts-ignore
thread.guild_id = message.d.id thread.guild_id = message.d.id
arr.push(thread.id) arr.push(thread.id)
client.channels.set(thread.id, thread) client.channels.set(thread.id, thread)
populateGuildID(message.d.id, thread.id)
} }
if (listen === "full") { if (listen === "full") {
eventDispatcher.checkMissedExpressions(message.d) eventDispatcher.checkMissedExpressions(message.d)
eventDispatcher.checkMissedPins(client, message.d) eventDispatcher.checkMissedPins(client, message.d)
@ -98,11 +89,7 @@ const utils = {
} else if (message.t === "THREAD_CREATE") { } else if (message.t === "THREAD_CREATE") {
client.channels.set(message.d.id, message.d) client.channels.set(message.d.id, message.d)
if (message.d["guild_id"]) {
populateGuildID(message.d["guild_id"], message.d.id)
const channels = client.guildChannelMap.get(message.d["guild_id"])
if (channels && !channels.includes(message.d.id)) channels.push(message.d.id)
}
} else if (message.t === "CHANNEL_UPDATE" || message.t === "THREAD_UPDATE") { } else if (message.t === "CHANNEL_UPDATE" || message.t === "THREAD_UPDATE") {
client.channels.set(message.d.id, message.d) client.channels.set(message.d.id, message.d)
@ -124,15 +111,14 @@ const utils = {
client.guildChannelMap.delete(message.d.id) client.guildChannelMap.delete(message.d.id)
} else if (message.t === "CHANNEL_CREATE") { } else if (message.t === "CHANNEL_CREATE" || message.t === "CHANNEL_DELETE") {
if (message.t === "CHANNEL_CREATE") {
client.channels.set(message.d.id, message.d) client.channels.set(message.d.id, message.d)
if (message.d["guild_id"]) { // obj[prop] notation can be used to access a property without typescript complaining that it doesn't exist on all values something can have if (message.d["guild_id"]) { // obj[prop] notation can be used to access a property without typescript complaining that it doesn't exist on all values something can have
populateGuildID(message.d["guild_id"], message.d.id)
const channels = client.guildChannelMap.get(message.d["guild_id"]) const channels = client.guildChannelMap.get(message.d["guild_id"])
if (channels && !channels.includes(message.d.id)) channels.push(message.d.id) if (channels && !channels.includes(message.d.id)) channels.push(message.d.id)
} }
} else {
} else if (message.t === "CHANNEL_DELETE") {
client.channels.delete(message.d.id) client.channels.delete(message.d.id)
if (message.d["guild_id"]) { if (message.d["guild_id"]) {
const channels = client.guildChannelMap.get(message.d["guild_id"]) const channels = client.guildChannelMap.get(message.d["guild_id"])
@ -142,6 +128,7 @@ const utils = {
} }
} }
} }
}
// Event dispatcher for OOYE bridge operations // Event dispatcher for OOYE bridge operations
if (listen === "full") { if (listen === "full") {
@ -158,9 +145,6 @@ const utils = {
} else if (message.t === "CHANNEL_PINS_UPDATE") { } else if (message.t === "CHANNEL_PINS_UPDATE") {
await eventDispatcher.onChannelPinsUpdate(client, message.d) await eventDispatcher.onChannelPinsUpdate(client, message.d)
} else if (message.t === "CHANNEL_DELETE") {
await eventDispatcher.onChannelDelete(client, message.d)
} else if (message.t === "THREAD_CREATE") { } else if (message.t === "THREAD_CREATE") {
// @ts-ignore // @ts-ignore
await eventDispatcher.onThreadCreate(client, message.d) await eventDispatcher.onThreadCreate(client, message.d)
@ -188,14 +172,10 @@ const utils = {
} else if (message.t === "MESSAGE_REACTION_REMOVE" || message.t === "MESSAGE_REACTION_REMOVE_EMOJI" || message.t === "MESSAGE_REACTION_REMOVE_ALL") { } else if (message.t === "MESSAGE_REACTION_REMOVE" || message.t === "MESSAGE_REACTION_REMOVE_EMOJI" || message.t === "MESSAGE_REACTION_REMOVE_ALL") {
await eventDispatcher.onSomeReactionsRemoved(client, message.d) await eventDispatcher.onSomeReactionsRemoved(client, message.d)
} else if (message.t === "INTERACTION_CREATE") {
await interactions.dispatchInteraction(message.d)
} }
} catch (e) { } catch (e) {
// Let OOYE try to handle errors too // Let OOYE try to handle errors too
await eventDispatcher.onError(client, e, message) eventDispatcher.onError(client, e, message)
} }
} }
} }

View file

@ -27,12 +27,12 @@ const updatePins = sync.require("./actions/update-pins")
const api = sync.require("../matrix/api") const api = sync.require("../matrix/api")
/** @type {import("../discord/utils")} */ /** @type {import("../discord/utils")} */
const dUtils = sync.require("../discord/utils") const dUtils = sync.require("../discord/utils")
/** @type {import("../discord/discord-command-handler")}) */
const discordCommandHandler = sync.require("../discord/discord-command-handler")
/** @type {import("../m2d/converters/utils")} */ /** @type {import("../m2d/converters/utils")} */
const mxUtils = require("../m2d/converters/utils") const mxUtils = require("../m2d/converters/utils")
/** @type {import("./actions/speedbump")} */ /** @type {import("./actions/speedbump")} */
const speedbump = sync.require("./actions/speedbump") const speedbump = sync.require("./actions/speedbump")
/** @type {import("./actions/retrigger")} */
const retrigger = sync.require("./actions/retrigger")
/** @type {any} */ // @ts-ignore bad types from semaphore /** @type {any} */ // @ts-ignore bad types from semaphore
const Semaphore = require("@chriscdn/promise-semaphore") const Semaphore = require("@chriscdn/promise-semaphore")
@ -48,7 +48,7 @@ module.exports = {
* @param {Error} e * @param {Error} e
* @param {import("cloudstorm").IGatewayMessage} gatewayMessage * @param {import("cloudstorm").IGatewayMessage} gatewayMessage
*/ */
async onError(client, e, gatewayMessage) { onError(client, e, gatewayMessage) {
console.error("hit event-dispatcher's error handler with this exception:") console.error("hit event-dispatcher's error handler with this exception:")
console.error(e) // TODO: also log errors into a file or into the database, maybe use a library for this? or just wing it? definitely need to be able to store the formatted event body to load back in later console.error(e) // TODO: also log errors into a file or into the database, maybe use a library for this? or just wing it? definitely need to be able to store the formatted event body to load back in later
console.error(`while handling this ${gatewayMessage.t} gateway event:`) console.error(`while handling this ${gatewayMessage.t} gateway event:`)
@ -81,7 +81,7 @@ module.exports = {
builder.addLine(`Error trace:\n${stackLines.join("\n")}`, `<details><summary>Error trace</summary><pre>${stackLines.join("\n")}</pre></details>`) builder.addLine(`Error trace:\n${stackLines.join("\n")}`, `<details><summary>Error trace</summary><pre>${stackLines.join("\n")}</pre></details>`)
} }
builder.addLine("", `<details><summary>Original payload</summary><pre>${util.inspect(gatewayMessage.d, false, 4, false)}</pre></details>`) builder.addLine("", `<details><summary>Original payload</summary><pre>${util.inspect(gatewayMessage.d, false, 4, false)}</pre></details>`)
await api.sendEvent(roomID, "m.room.message", { api.sendEvent(roomID, "m.room.message", {
...builder.get(), ...builder.get(),
"moe.cadence.ooye.error": { "moe.cadence.ooye.error": {
source: "discord", source: "discord",
@ -115,7 +115,8 @@ module.exports = {
if (!member) return if (!member) return
if (!("permission_overwrites" in channel)) continue if (!("permission_overwrites" in channel)) continue
const permissions = dUtils.getPermissions(member.roles, guild.roles, client.user.id, channel.permission_overwrites) const permissions = dUtils.getPermissions(member.roles, guild.roles, client.user.id, channel.permission_overwrites)
if (!dUtils.hasAllPermissions(permissions, ["ViewChannel", "ReadMessageHistory"])) continue // We don't have permission to look back in this channel const wants = BigInt(1 << 10) | BigInt(1 << 16) // VIEW_CHANNEL + READ_MESSAGE_HISTORY
if ((permissions & wants) !== wants) continue // We don't have permission to look back in this channel
/** More recent messages come first. */ /** More recent messages come first. */
// console.log(`[check missed messages] in ${channel.id} (${guild.name} / ${channel.name}) because its last message ${channel.last_message_id} is not in the database`) // console.log(`[check missed messages] in ${channel.id} (${guild.name} / ${channel.name}) because its last message ${channel.last_message_id} is not in the database`)
@ -163,7 +164,8 @@ module.exports = {
// Permissions check // Permissions check
const permissions = dUtils.getPermissions(member.roles, guild.roles, client.user.id, channel.permission_overwrites) const permissions = dUtils.getPermissions(member.roles, guild.roles, client.user.id, channel.permission_overwrites)
if (!dUtils.hasAllPermissions(permissions, ["ViewChannel", "ReadMessageHistory"])) continue // We don't have permission to look up the pins in this channel const wants = BigInt(1 << 10) | BigInt(1 << 16) // VIEW_CHANNEL + READ_MESSAGE_HISTORY
if ((permissions & wants) !== wants) continue // We don't have permission to look up the pins in this channel
const row = select("channel_room", ["room_id", "last_bridged_pin_timestamp"], {channel_id: channel.id}).get() const row = select("channel_room", ["room_id", "last_bridged_pin_timestamp"], {channel_id: channel.id}).get()
if (!row) continue // Only care about already bridged channels if (!row) continue // Only care about already bridged channels
@ -191,7 +193,7 @@ module.exports = {
async onThreadCreate(client, thread) { async onThreadCreate(client, thread) {
const channelID = thread.parent_id || undefined const channelID = thread.parent_id || undefined
const parentRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get() const parentRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
if (!parentRoomID) return // Not interested in a thread if we aren't interested in its wider channel (won't autocreate) if (!parentRoomID) return // Not interested in a thread if we aren't interested in its wider channel
const threadRoomID = await createRoom.syncRoom(thread.id) // Create room (will share the same inflight as the initial message to the thread) const threadRoomID = await createRoom.syncRoom(thread.id) // Create room (will share the same inflight as the initial message to the thread)
await announceThread.announceThread(parentRoomID, threadRoomID, thread) await announceThread.announceThread(parentRoomID, threadRoomID, thread)
}, },
@ -228,19 +230,6 @@ module.exports = {
await updatePins.updatePins(data.channel_id, roomID, convertedTimestamp) await updatePins.updatePins(data.channel_id, roomID, convertedTimestamp)
}, },
/**
* @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayChannelDeleteDispatchData} channel
*/
async onChannelDelete(client, channel) {
const guildID = channel["guild_id"]
if (!guildID) return // channel must have been a DM channel or something
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
if (!roomID) return // channel wasn't being bridged in the first place
// @ts-ignore
await createRoom.unbridgeDeletedChannel(channel, guildID)
},
/** /**
* @param {import("./discord-client")} client * @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message * @param {DiscordTypes.GatewayMessageCreateDispatchData} message
@ -249,7 +238,6 @@ module.exports = {
if (message.author.username === "Deleted User") return // Nothing we can do for deleted users. if (message.author.username === "Deleted User") return // Nothing we can do for deleted users.
const channel = client.channels.get(message.channel_id) const channel = client.channels.get(message.channel_id)
if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages. if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages.
const guild = client.guilds.get(channel.guild_id) const guild = client.guilds.get(channel.guild_id)
assert(guild) assert(guild)
@ -258,17 +246,12 @@ module.exports = {
if (row) return // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it. if (row) return // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it.
} }
if (dUtils.isEphemeralMessage(message)) return // Ephemeral messages are for the eyes of the receiver only!
if (!createRoom.existsOrAutocreatable(channel, guild.id)) return // Check that the sending-to room exists or is autocreatable
const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id) const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id)
if (affected) return if (affected) return
// @ts-ignore // @ts-ignore
await sendMessage.sendMessage(message, channel, guild, row) await sendMessage.sendMessage(message, guild, row),
await discordCommandHandler.execute(message, channel, guild)
retrigger.messageFinishedBridging(message.id)
}, },
/** /**
@ -276,35 +259,29 @@ module.exports = {
* @param {DiscordTypes.GatewayMessageUpdateDispatchData} data * @param {DiscordTypes.GatewayMessageUpdateDispatchData} data
*/ */
async onMessageUpdate(client, data) { async onMessageUpdate(client, data) {
// Based on looking at data they've sent me over the gateway, this is the best way to check for meaningful changes.
// If the message content is a string then it includes all interesting fields and is meaningful.
// Otherwise, if there are embeds, then the system generated URL preview embeds.
if (!(typeof data.content === "string" || "embeds" in data)) return
if (data.webhook_id) { if (data.webhook_id) {
const row = select("webhook", "webhook_id", {webhook_id: data.webhook_id}).pluck().get() const row = select("webhook", "webhook_id", {webhook_id: data.webhook_id}).pluck().get()
if (row) return // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it. if (row) return // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it.
} }
if (dUtils.isEphemeralMessage(data)) return // Ephemeral messages are for the eyes of the receiver only!
// Edits need to go through the speedbump as well. If the message is delayed but the edit isn't, we don't have anything to edit from. // Edits need to go through the speedbump as well. If the message is delayed but the edit isn't, we don't have anything to edit from.
const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id) const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id)
if (affected) return if (affected) return
// Check that the sending-to room exists, and deal with Eventual Consistency(TM) // Based on looking at data they've sent me over the gateway, this is the best way to check for meaningful changes.
if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.onMessageUpdate, client, data)) return // If the message content is a string then it includes all interesting fields and is meaningful.
if (typeof data.content === "string") {
/** @type {DiscordTypes.GatewayMessageCreateDispatchData} */ /** @type {DiscordTypes.GatewayMessageCreateDispatchData} */
// @ts-ignore // @ts-ignore
const message = data const message = data
const channel = client.channels.get(message.channel_id) const channel = client.channels.get(message.channel_id)
if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages. if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages.
const guild = client.guilds.get(channel.guild_id) const guild = client.guilds.get(channel.guild_id)
assert(guild) assert(guild)
// @ts-ignore // @ts-ignore
await retrigger.pauseChanges(message.id, editMessage.editMessage(message, guild, row)) await editMessage.editMessage(message, guild, row)
}
}, },
/** /**
@ -313,6 +290,7 @@ module.exports = {
*/ */
async onReactionAdd(client, data) { async onReactionAdd(client, data) {
if (data.user_id === client.user.id) return // m2d reactions are added by the discord bot user - do not reflect them back to matrix. if (data.user_id === client.user.id) return // m2d reactions are added by the discord bot user - do not reflect them back to matrix.
discordCommandHandler.onReactionAdd(data)
await addReaction.addReaction(data) await addReaction.addReaction(data)
}, },
@ -330,7 +308,6 @@ module.exports = {
*/ */
async onMessageDelete(client, data) { async onMessageDelete(client, data) {
speedbump.onMessageDelete(data.id) speedbump.onMessageDelete(data.id)
if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.onMessageDelete, client, data)) return
await deleteMessage.deleteMessage(data) await deleteMessage.deleteMessage(data)
}, },

View file

@ -3,7 +3,6 @@ module.exports = async function(db) {
const contents = db.prepare("SELECT distinct hashed_profile_content FROM sim_member WHERE hashed_profile_content IS NOT NULL").pluck().all() const contents = db.prepare("SELECT distinct hashed_profile_content FROM sim_member WHERE hashed_profile_content IS NOT NULL").pluck().all()
const stmt = db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE hashed_profile_content = ?") const stmt = db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE hashed_profile_content = ?")
db.transaction(() => { db.transaction(() => {
/* c8 ignore next 6 */
for (let s of contents) { for (let s of contents) {
let b = Buffer.isBuffer(s) ? Uint8Array.from(s) : Uint8Array.from(Buffer.from(s)) let b = Buffer.isBuffer(s) ? Uint8Array.from(s) : Uint8Array.from(Buffer.from(s))
const unsignedHash = hasher.h64Raw(b) const unsignedHash = hasher.h64Raw(b)

View file

@ -33,11 +33,6 @@ export type Models = {
privacy_level: number privacy_level: number
} }
guild_active: {
guild_id: string
autocreate: 0 | 1
}
lottie: { lottie: {
sticker_id: string sticker_id: string
mxc_url: string mxc_url: string
@ -47,14 +42,7 @@ export type Models = {
room_id: string room_id: string
mxid: string mxid: string
displayname: string | null displayname: string | null
avatar_url: string | null, avatar_url: string | null
power_level: number
}
member_power: {
mxid: string
room_id: string
power_level: number
} }
message_channel: { message_channel: {
@ -62,6 +50,7 @@ export type Models = {
channel_id: string channel_id: string
} }
// A sim is an account that is being simulated by the bridge to copy events from the other side. See d2m\actions\register-user.js for relevant behavior and comments.
sim: { sim: {
user_id: string user_id: string
sim_name: string sim_name: string
@ -105,10 +94,6 @@ export type Models = {
emoji_id: string emoji_id: string
guild_id: string guild_id: string
} }
media_proxy: {
permitted_hash: number
}
} }
export type Prepared<Row> = { export type Prepared<Row> = {
@ -116,12 +101,10 @@ export type Prepared<Row> = {
safeIntegers: () => Prepared<{[K in keyof Row]: Row[K] extends number ? BigInt : Row[K]}> safeIntegers: () => Prepared<{[K in keyof Row]: Row[K] extends number ? BigInt : Row[K]}>
raw: () => Prepared<Row[keyof Row][]> raw: () => Prepared<Row[keyof Row][]>
all: (..._: any[]) => Row[] all: (..._: any[]) => Row[]
get: (..._: any[]) => Row | null | undefined get: (..._: any[]) => Row | null
} }
export type AllKeys<U> = U extends any ? keyof U : never export type AllKeys<U> = U extends any ? keyof U : never
export type PickTypeOf<T, K extends AllKeys<T>> = T extends { [k in K]?: any } ? T[K] : never export type PickTypeOf<T, K extends AllKeys<T>> = T extends { [k in K]?: any } ? T[K] : never
export type Merge<U> = {[x in AllKeys<U>]: PickTypeOf<U, x>} export type Merge<U> = {[x in AllKeys<U>]: PickTypeOf<U, x>}
export type Nullable<T> = {[k in keyof T]: T[k] | null} export type Nullable<T> = {[k in keyof T]: T[k] | null}
export type Numberish<T> = {[k in keyof T]: T[k] extends number ? (number | bigint) : T[k]}
export type ValueOrArray<T> = {[k in keyof T]: T[k][] | T[k]}

View file

@ -8,20 +8,15 @@ const U = require("./orm-defs")
* @template {keyof U.Models[Table]} Col * @template {keyof U.Models[Table]} Col
* @param {Table} table * @param {Table} table
* @param {Col[] | Col} cols * @param {Col[] | Col} cols
* @param {Partial<U.ValueOrArray<U.Numberish<U.Models[Table]>>>} where * @param {Partial<U.Models[Table]>} where
* @param {string} [e] * @param {string} [e]
*/ */
function select(table, cols, where = {}, e = "") { function select(table, cols, where = {}, e = "") {
if (!Array.isArray(cols)) cols = [cols] if (!Array.isArray(cols)) cols = [cols]
const parameters = [] const parameters = []
const wheres = Object.entries(where).map(([col, value]) => { const wheres = Object.entries(where).map(([col, value]) => {
if (Array.isArray(value)) {
parameters.push(...value)
return `"${col}" IN (` + Array(value.length).fill("?").join(", ") + ")"
} else {
parameters.push(value) parameters.push(value)
return `"${col}" = ?` return `"${col}" = ?`
}
}) })
const whereString = wheres.length ? " WHERE " + wheres.join(" AND ") : "" const whereString = wheres.length ? " WHERE " + wheres.join(" AND ") : ""
/** @type {U.Prepared<Pick<U.Models[Table], Col>>} */ /** @type {U.Prepared<Pick<U.Models[Table], Col>>} */
@ -49,8 +44,6 @@ class From {
/** @private */ /** @private */
this.cols = [] this.cols = []
/** @private */ /** @private */
this.makeColsSafe = true
/** @private */
this.using = [] this.using = []
/** @private */ /** @private */
this.isPluck = false this.isPluck = false
@ -85,12 +78,6 @@ class From {
return r return r
} }
selectUnsafe(...cols) {
this.cols = cols
this.makeColsSafe = false
return this
}
/** /**
* @template {Col} Select * @template {Col} Select
* @param {Select} col * @param {Select} col
@ -113,7 +100,7 @@ class From {
} }
/** /**
* @param {Partial<U.Numberish<U.Models[Table]>>} conditions * @param {Partial<U.Models[Table]>} conditions
*/ */
where(conditions) { where(conditions) {
const wheres = Object.entries(conditions).map(([col, value]) => { const wheres = Object.entries(conditions).map(([col, value]) => {
@ -125,8 +112,7 @@ class From {
} }
prepare() { prepare() {
if (this.makeColsSafe) this.cols = this.cols.map(k => `"${k}"`) let sql = `SELECT ${this.cols.map(k => `"${k}"`).join(", ")} FROM ${this.tables[0]} `
let sql = `SELECT ${this.cols.join(", ")} FROM ${this.tables[0]} `
for (let i = 1; i < this.tables.length; i++) { for (let i = 1; i < this.tables.length; i++) {
const table = this.tables[i] const table = this.tables[i]
const col = this.using[i-1] const col = this.using[i-1]

View file

@ -1,7 +1,7 @@
// @ts-check // @ts-check
const {test} = require("supertape") const {test} = require("supertape")
const data = require("../../test/data") const data = require("../test/data")
const {db, select, from} = require("../passthrough") const {db, select, from} = require("../passthrough")
@ -30,11 +30,6 @@ test("orm: select: all, where and pluck works on multiple columns", t => {
t.deepEqual(names, ["cadence [they]"]) t.deepEqual(names, ["cadence [they]"])
}) })
test("orm: select: in array works", t => {
const ids = select("emoji", "emoji_id", {name: ["online", "upstinky"]}).pluck().all()
t.deepEqual(ids, ["288858540888686602", "606664341298872324"])
})
test("orm: from: get pluck works", t => { test("orm: from: get pluck works", t => {
const guildID = from("guild_space").pluck("guild_id").and("WHERE space_id = ?").get("!jjWAGMeQdNrVZSSfvz:cadence.moe") const guildID = from("guild_space").pluck("guild_id").and("WHERE space_id = ?").get("!jjWAGMeQdNrVZSSfvz:cadence.moe")
t.equal(guildID, data.guild.general.id) t.equal(guildID, data.guild.general.id)
@ -56,15 +51,5 @@ test("orm: from: join direction works", t => {
const hasNoOwner = from("sim").join("sim_proxy", "user_id", "left").select("user_id", "proxy_owner_id").where({sim_name: "crunch_god"}).get() const hasNoOwner = from("sim").join("sim_proxy", "user_id", "left").select("user_id", "proxy_owner_id").where({sim_name: "crunch_god"}).get()
t.deepEqual(hasNoOwner, {user_id: "820865262526005258", proxy_owner_id: null}) t.deepEqual(hasNoOwner, {user_id: "820865262526005258", proxy_owner_id: null})
const hasNoOwnerInner = from("sim").join("sim_proxy", "user_id", "inner").select("user_id", "proxy_owner_id").where({sim_name: "crunch_god"}).get() const hasNoOwnerInner = from("sim").join("sim_proxy", "user_id", "inner").select("user_id", "proxy_owner_id").where({sim_name: "crunch_god"}).get()
t.deepEqual(hasNoOwnerInner, undefined) t.deepEqual(hasNoOwnerInner, null)
})
test("orm: select unsafe works (to select complex column names that can't be type verified)", t => {
const results = from("member_cache")
.join("member_power", "mxid")
.join("channel_room", "room_id") // only include rooms that are bridged
.and("where member_power.room_id = '*' and member_cache.power_level != member_power.power_level")
.selectUnsafe("mxid", "member_cache.room_id", "member_power.power_level")
.all()
t.equal(results[0].power_level, 100)
}) })

View file

@ -0,0 +1,273 @@
// @ts-check
const assert = require("assert").strict
const util = require("util")
const DiscordTypes = require("discord-api-types/v10")
const reg = require("../matrix/read-registration")
const {addbot} = require("../addbot")
const {discord, sync, db, select} = require("../passthrough")
/** @type {import("../matrix/api")}) */
const api = sync.require("../matrix/api")
/** @type {import("../matrix/file")} */
const file = sync.require("../matrix/file")
/** @type {import("../d2m/actions/create-space")} */
const createSpace = sync.require("../d2m/actions/create-space")
/** @type {import("./utils")} */
const utils = sync.require("./utils")
const PREFIX = "//"
let buttons = []
/**
* @param {string} channelID where to add the button
* @param {string} messageID where to add the button
* @param {string} emoji emoji to add as a button
* @param {string} userID only listen for responses from this user
* @returns {Promise<import("discord-api-types/v10").GatewayMessageReactionAddDispatchData>}
*/
async function addButton(channelID, messageID, emoji, userID) {
await discord.snow.channel.createReaction(channelID, messageID, emoji)
return new Promise(resolve => {
buttons.push({channelID, messageID, userID, resolve, created: Date.now()})
})
}
// Clear out old buttons every so often to free memory
setInterval(() => {
const now = Date.now()
buttons = buttons.filter(b => now - b.created < 2*60*60*1000)
}, 10*60*1000)
/** @param {import("discord-api-types/v10").GatewayMessageReactionAddDispatchData} data */
function onReactionAdd(data) {
const button = buttons.find(b => b.channelID === data.channel_id && b.messageID === data.message_id && b.userID === data.user_id)
if (button) {
buttons = buttons.filter(b => b !== button) // remove button data so it can't be clicked again
button.resolve(data)
}
}
/**
* @callback CommandExecute
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message
* @param {DiscordTypes.APIGuildTextChannel} channel
* @param {DiscordTypes.APIGuild} guild
* @param {Partial<DiscordTypes.RESTPostAPIChannelMessageJSONBody>} [ctx]
*/
/**
* @typedef Command
* @property {string[]} aliases
* @property {(message: DiscordTypes.GatewayMessageCreateDispatchData, channel: DiscordTypes.APIGuildTextChannel, guild: DiscordTypes.APIGuild) => Promise<any>} execute
*/
/** @param {CommandExecute} execute */
function replyctx(execute) {
/** @type {CommandExecute} */
return function(message, channel, guild, ctx = {}) {
ctx.message_reference = {
message_id: message.id,
channel_id: channel.id,
guild_id: guild.id,
fail_if_not_exists: false
}
return execute(message, channel, guild, ctx)
}
}
/** @type {Command[]} */
const commands = [{
aliases: ["icon", "avatar", "roomicon", "roomavatar", "channelicon", "channelavatar"],
execute: replyctx(
async (message, channel, guild, ctx) => {
// Guard
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
if (!roomID) return discord.snow.channel.createMessage(channel.id, {
...ctx,
content: "This channel isn't bridged to the other side."
})
// Current avatar
const avatarEvent = await api.getStateEvent(roomID, "m.room.avatar", "")
const avatarURLParts = avatarEvent?.url.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
let currentAvatarMessage =
( avatarURLParts ? `Current room-specific avatar: ${reg.ooye.server_origin}/_matrix/media/r0/download/${avatarURLParts[1]}/${avatarURLParts[2]}`
: "No avatar. Now's your time to strike. Use `//icon` again with a link or upload to set the room-specific avatar.")
// Next potential avatar
const nextAvatarURL = message.attachments.find(a => a.content_type?.startsWith("image/"))?.url || message.content.match(/https?:\/\/[^ ]+\.[^ ]+\.(?:png|jpg|jpeg|webp)\b/)?.[0]
let nextAvatarMessage =
( nextAvatarURL ? `\nYou want to set it to: ${nextAvatarURL}\nHit ✅ to make it happen.`
: "")
const sent = await discord.snow.channel.createMessage(channel.id, {
...ctx,
content: currentAvatarMessage + nextAvatarMessage
})
if (nextAvatarURL) {
addButton(channel.id, sent.id, "✅", message.author.id).then(async data => {
const mxcUrl = await file.uploadDiscordFileToMxc(nextAvatarURL)
await api.sendState(roomID, "m.room.avatar", "", {
url: mxcUrl
})
db.prepare("UPDATE channel_room SET custom_avatar = ? WHERE channel_id = ?").run(mxcUrl, channel.id)
await discord.snow.channel.createMessage(channel.id, {
...ctx,
content: "Your creation is unleashed. Any complaints will be redirected to Grelbo."
})
})
}
}
)
}, {
aliases: ["invite"],
execute: replyctx(
async (message, channel, guild, ctx) => {
// Check guild is bridged
const spaceID = select("guild_space", "space_id", {guild_id: guild.id}).pluck().get()
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
if (!spaceID || !roomID) return discord.snow.channel.createMessage(channel.id, {
...ctx,
content: "This server isn't bridged to Matrix, so you can't invite Matrix users."
})
// Check CREATE_INSTANT_INVITE permission
assert(message.member)
const guildPermissions = utils.getPermissions(message.member.roles, guild.roles)
if (!(guildPermissions & BigInt(1))) {
return discord.snow.channel.createMessage(channel.id, {
...ctx,
content: "You don't have permission to invite people to this Discord server."
})
}
// Guard against accidental mentions instead of the MXID
if (message.content.match(/<[@#:].*>/)) return discord.snow.channel.createMessage(channel.id, {
...ctx,
content: "You have to say the Matrix ID of the person you want to invite, but you mentioned a Discord user in your message.\nOne way to fix this is by writing `` ` `` backticks `` ` `` around the Matrix ID."
})
// Get named MXID
const mxid = message.content.match(/@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)/)?.[0]
if (!mxid) return discord.snow.channel.createMessage(channel.id, {
...ctx,
content: "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`"
})
// Check for existing invite to the space
let spaceMember
try {
spaceMember = await api.getStateEvent(spaceID, "m.room.member", mxid)
} catch (e) {}
if (spaceMember && spaceMember.membership === "invite") {
return discord.snow.channel.createMessage(channel.id, {
...ctx,
content: `\`${mxid}\` already has an invite, which they haven't accepted yet.`
})
}
// Invite Matrix user if not in space
if (!spaceMember || spaceMember.membership !== "join") {
await api.inviteToRoom(spaceID, mxid)
return discord.snow.channel.createMessage(channel.id, {
...ctx,
content: `You invited \`${mxid}\` to the server.`
})
}
// The Matrix user *is* in the space, maybe we want to invite them to this channel?
let roomMember
try {
roomMember = await api.getStateEvent(roomID, "m.room.member", mxid)
} catch (e) {}
if (!roomMember || (roomMember.membership !== "join" && roomMember.membership !== "invite")) {
const sent = await discord.snow.channel.createMessage(channel.id, {
...ctx,
content: `\`${mxid}\` is already in this server. Would you like to additionally invite them to this specific channel?\nHit ✅ to make it happen.`
})
return addButton(channel.id, sent.id, "✅", message.author.id).then(async data => {
await api.inviteToRoom(roomID, mxid)
await discord.snow.channel.createMessage(channel.id, {
...ctx,
content: `You invited \`${mxid}\` to the channel.`
})
})
}
// The Matrix user *is* in the space and in the channel.
await discord.snow.channel.createMessage(channel.id, {
...ctx,
content: `\`${mxid}\` is already in this server and this channel.`
})
}
)
}, {
aliases: ["addbot"],
execute: replyctx(
async (message, channel, guild, ctx) => {
return discord.snow.channel.createMessage(channel.id, {
...ctx,
content: addbot()
})
}
)
}, {
aliases: ["privacy", "discoverable", "publish", "published"],
execute: replyctx(
async (message, channel, guild, ctx) => {
const current = select("guild_space", "privacy_level", {guild_id: guild.id}).pluck().get()
if (current == null) {
return discord.snow.channel.createMessage(channel.id, {
...ctx,
content: "This server isn't bridged to the other side."
})
}
const levels = ["invite", "link", "directory"]
const level = levels.findIndex(x => message.content.includes(x))
if (level === -1) {
return discord.snow.channel.createMessage(channel.id, {
...ctx,
content: "**Usage: `//privacy <level>`**. This will set who can join the space on Matrix-side. There are three levels:"
+ "\n`invite`: Can only join with a direct in-app invite from another Matrix user, or the //invite command."
+ "\n`link`: Matrix links can be created and shared like Discord's invite links. `invite` features also work."
+ "\n`directory`: Publishes to the Matrix in-app directory, like Server Discovery. Preview enabled. `invite` and `link` also work."
+ `\n**Current privacy level: \`${levels[current]}\`**`
})
}
assert(message.member)
const guildPermissions = utils.getPermissions(message.member.roles, guild.roles)
if (guild.owner_id !== message.author.id && !(guildPermissions & BigInt(0x28))) { // MANAGE_GUILD | ADMINISTRATOR
return discord.snow.channel.createMessage(channel.id, {
...ctx,
content: "You don't have permission to change the privacy level. You need Manage Server or Administrator."
})
}
db.prepare("UPDATE guild_space SET privacy_level = ? WHERE guild_id = ?").run(level, guild.id)
discord.snow.channel.createMessage(channel.id, {
...ctx,
content: `Privacy level updated to \`${levels[level]}\`. Changes will apply shortly.`
})
await createSpace.syncSpaceFully(guild.id)
}
)
}]
/** @type {CommandExecute} */
async function execute(message, channel, guild) {
if (!message.content.startsWith(PREFIX)) return
const words = message.content.slice(PREFIX.length).split(" ")
const commandName = words[0]
const command = commands.find(c => c.aliases.includes(commandName))
if (!command) return
await command.execute(message, channel, guild)
}
module.exports.execute = execute
module.exports.onReactionAdd = onReactionAdd

74
discord/utils.js Normal file
View file

@ -0,0 +1,74 @@
// @ts-check
const DiscordTypes = require("discord-api-types/v10")
const EPOCH = 1420070400000
/**
* @param {string[]} userRoles
* @param {DiscordTypes.APIGuild["roles"]} guildRoles
* @param {string} [userID]
* @param {DiscordTypes.APIGuildChannel["permission_overwrites"]} [channelOverwrites]
*/
function getPermissions(userRoles, guildRoles, userID, channelOverwrites) {
let allowed = BigInt(0)
let everyoneID
// Guild allows
for (const role of guildRoles) {
if (role.name === "@everyone") {
allowed |= BigInt(role.permissions)
everyoneID = role.id
}
if (userRoles.includes(role.id)) {
allowed |= BigInt(role.permissions)
}
}
if (channelOverwrites) {
/** @type {((overwrite: Required<DiscordTypes.APIGuildChannel>["permission_overwrites"][0]) => any)[]} */
const actions = [
// Channel @everyone deny
overwrite => overwrite.id === everyoneID && (allowed &= ~BigInt(overwrite.deny)),
// Channel @everyone allow
overwrite => overwrite.id === everyoneID && (allowed |= BigInt(overwrite.allow)),
// Role deny
overwrite => userRoles.includes(overwrite.id) && (allowed &= ~BigInt(overwrite.deny)),
// Role allow
overwrite => userRoles.includes(overwrite.id) && (allowed |= BigInt(overwrite.allow)),
// User deny
overwrite => overwrite.id === userID && (allowed &= ~BigInt(overwrite.deny)),
// User allow
overwrite => overwrite.id === userID && (allowed |= BigInt(overwrite.allow))
]
for (let i = 0; i < actions.length; i++) {
for (const overwrite of channelOverwrites) {
actions[i](overwrite)
}
}
}
return allowed
}
/**
* Command interaction responses have a webhook_id for some reason, but still have real author info of a real bot user in the server.
* @param {DiscordTypes.APIMessage} message
*/
function isWebhookMessage(message) {
const isInteractionResponse = message.type === 20
return message.webhook_id && !isInteractionResponse
}
/** @param {string} snowflake */
function snowflakeToTimestampExact(snowflake) {
return Number(BigInt(snowflake) >> 22n) + EPOCH
}
/** @param {number} timestamp */
function timestampToSnowflakeInexact(timestamp) {
return String((timestamp - EPOCH) * 2**22)
}
module.exports.getPermissions = getPermissions
module.exports.isWebhookMessage = isWebhookMessage
module.exports.snowflakeToTimestampExact = snowflakeToTimestampExact
module.exports.timestampToSnowflakeInexact = timestampToSnowflakeInexact

84
discord/utils.test.js Normal file
View file

@ -0,0 +1,84 @@
const {test} = require("supertape")
const data = require("../test/data")
const utils = require("./utils")
test("is webhook message: identifies bot interaction response as not a message", t => {
t.equal(utils.isWebhookMessage(data.interaction_message.thinking_interaction), false)
})
test("is webhook message: identifies webhook interaction response as not a message", t => {
t.equal(utils.isWebhookMessage(data.interaction_message.thinking_interaction_without_bot_user), false)
})
test("is webhook message: identifies webhook message as a message", t => {
t.equal(utils.isWebhookMessage(data.special_message.bridge_echo_webhook), true)
})
test("discord utils: converts snowflake to timestamp", t => {
t.equal(utils.snowflakeToTimestampExact("86913608335773696"), 1440792219004)
})
test("discord utils: converts timestamp to snowflake", t => {
t.match(utils.timestampToSnowflakeInexact(1440792219004), /^869136083357.....$/)
})
test("getPermissions: channel overwrite to allow role works", t => {
const guildRoles = [
{
version: 1695412489043,
unicode_emoji: null,
tags: {},
position: 0,
permissions: "559623605571137",
name: "@everyone",
mentionable: false,
managed: false,
id: "1154868424724463687",
icon: null,
hoist: false,
flags: 0,
color: 0
},
{
version: 1695412604262,
unicode_emoji: null,
tags: { bot_id: "466378653216014359" },
position: 1,
permissions: "536995904",
name: "PluralKit",
mentionable: false,
managed: true,
id: "1154868908336099444",
icon: null,
hoist: false,
flags: 0,
color: 0
},
{
version: 1698778936921,
unicode_emoji: null,
tags: {},
position: 1,
permissions: "536870912",
name: "web hookers",
mentionable: false,
managed: false,
id: "1168988246680801360",
icon: null,
hoist: false,
flags: 0,
color: 0
}
]
const userRoles = [ "1168988246680801360" ]
const userID = "684280192553844747"
const overwrites = [
{ type: 0, id: "1154868908336099444", deny: "0", allow: "1024" },
{ type: 0, id: "1154868424724463687", deny: "1024", allow: "0" },
{ type: 0, id: "1168988246680801360", deny: "0", allow: "1024" },
{ type: 1, id: "353373325575323648", deny: "0", allow: "1024" }
]
const permissions = utils.getPermissions(userRoles, guildRoles, userID, overwrites)
const want = BigInt(1 << 10 | 1 << 16)
t.equal((permissions & want), want)
})

View file

@ -1,74 +0,0 @@
# Self-service room creation rules
Before version 3 of Out Of Your Element, new Matrix rooms would be created on-demand when a Discord channel is spoken in for the first time. This has worked pretty well.
This is done through functions like ensureRoom and ensureSpace in actions:
```js
async function sendMessage(message, channel, guild, row) {
const roomID = await createRoom.ensureRoom(message.channel_id)
...
}
/**
* Ensures the room exists. If it doesn't, creates the room with an accurate initial state.
* @param {string} channelID
* @returns {Promise<string>} Matrix room ID
*/
function ensureRoom(channelID) {
return _syncRoom(channelID, /* shouldActuallySync */ false) /* calls ensureSpace */
}
/**
* Ensures the space exists. If it doesn't, creates the space with an accurate initial state.
* @param {DiscordTypes.APIGuild} guild
* @returns {Promise<string>} Matrix space ID
*/
function ensureSpace(guild) {
return _syncSpace(guild, /* shouldActuallySync */ false)
}
```
With the introduction of self-service mode, we still want to retain this as a possible mode of operation, since some people prefer to have OOYE handle this administrative work. However, other people prefer to manage the links between channels and rooms themselves, and have control over what new rooms get linked up to.
Visibly, this is managed through the web interface. The web interface lets moderators enable/disable auto-creation of new rooms, as well as set which channels and rooms are linked together.
There is a small complication. Not only are Matrix rooms created automatically, their Matrix spaces are also created automatically during room sync: ensureRoom calls ensureSpace. If a user opts in to self-service mode by clicking the specific button in the web portal, we must ensure the _space is not created automatically either,_ because the Matrix user will provide a space to link to.
To solve this, we need a way to suppress specific guilds from having auto-created spaces. The natural way to represent this is a column on guild_space, but that doesn't work, because each guild_space row requires a guild and space to be linked, and we _don't want_ them to be linked.
So, internally, OOYE keeps track of this through a new table:
```sql
CREATE TABLE "guild_active" (
"guild_id" TEXT NOT NULL, -- only guilds that are bridged are present in this table
"autocreate" INTEGER NOT NULL, -- 0 or 1
PRIMARY KEY("guild_id")
) WITHOUT ROWID;
```
There is one more complication. When adding a Discord bot through web oauth with a redirect_uri, Discord adds the bot to the server normally, _then_ redirects back to OOYE, and only then does OOYE know which guild the bot was just added to. So, for a short time between the bot being added and the user being redirected, OOYE might receive Discord events in the server before it has the chance to create the guild_active database row.
So to prevent this, self-service behaviour needs to be an implicit default, and users must firmly choose one system or another to begin using OOYE. It is important for me to design this in a way that doesn't force users to do any extra work or make a choice they don't understand to keep the pre-v3 behaviour.
So there will be 3 states of whether a guild is self-service or not. At first, it could be absent from the table, in which case events for it will be dropped. Or it could be in the table with autocomplete = 0, in which case only rooms that already exist in channel_room will have messages bridged. Or it could have autocomplete = 1, in which case Matrix rooms will be created as needed, as per the pre-v3 behaviour.
| Auto-create | Meaning |
| -- | ------------ |
| 😶‍🌫️ | Unbridged - waiting |
| ❌ | Bridged - self-service |
| ✅ | Bridged - auto-create |
Pressing buttons on web or using the /invite command on a guild will insert a row into guild_active, allowing it to be bridged.
One more thing. Before v3, when a Matrix room was autocreated it would autocreate the space as well, if it needed to. But now, since nothing will be created until the user takes an action, the guild will always be created directly in response to a request. So room creation can now trust that the guild exists already.
So here's all the technical changes needed to support self-service in v3:
- New guild_active table showing whether, and how, a guild is bridged.
- When /invite command is used, INSERT OR IGNORE INTO state 1 and ensureRoom + ensureSpace.
- When bot is added through "easy mode" web button, REPLACE INTO state 1 and ensureSpace.
- When bot is added through "self-service" web button, REPLACE INTO state 0.
- Event dispatcher will only ensureRoom if the guild_active state is 1.
- createRoom can trust that the space exists because we check that in a calling function.
- createRoom will only create other dependencies if the guild is autocreate.

View file

@ -44,13 +44,8 @@ async function ensureWebhook(channelID, forceCreate = false) {
*/ */
async function withWebhook(channelID, callback) { async function withWebhook(channelID, callback) {
const webhook = await ensureWebhook(channelID, false) const webhook = await ensureWebhook(channelID, false)
return callback(webhook).catch(async e => { return callback(webhook).catch(e => {
if (e.message === `{"message": "Unknown Webhook", "code": 10015}`) { // pathetic error handling from SnowTransfer // TODO: check if the error was webhook-related and if webhook.created === false, then: const webhook = ensureWebhook(channelID, true); return callback(webhook)
// Our webhook is gone. Maybe somebody deleted it, or removed and re-added OOYE from the guild.
const newWebhook = await ensureWebhook(channelID, true)
return callback(newWebhook) // not caught; if the error happens again just throw it instead of looping
}
throw e throw e
}) })
} }
@ -62,7 +57,7 @@ async function withWebhook(channelID, callback) {
*/ */
async function sendMessageWithWebhook(channelID, data, threadID) { async function sendMessageWithWebhook(channelID, data, threadID) {
const result = await withWebhook(channelID, async webhook => { const result = await withWebhook(channelID, async webhook => {
return discord.snow.webhook.executeWebhook(webhook.id, webhook.token, data, {wait: true, thread_id: threadID}) return discord.snow.webhook.executeWebhook(webhook.id, webhook.token, data, {wait: true, thread_id: threadID, disableEveryone: true})
}) })
return result return result
} }

View file

@ -8,8 +8,6 @@ const {sync} = require("../../passthrough")
/** @type {import("../converters/emoji-sheet")} */ /** @type {import("../converters/emoji-sheet")} */
const emojiSheetConverter = sync.require("../converters/emoji-sheet") const emojiSheetConverter = sync.require("../converters/emoji-sheet")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** /**
* Downloads the emoji from the web and converts to uncompressed PNG data. * Downloads the emoji from the web and converts to uncompressed PNG data.
@ -18,12 +16,16 @@ const api = sync.require("../../matrix/api")
*/ */
async function getAndConvertEmoji(mxc) { async function getAndConvertEmoji(mxc) {
const abortController = new AbortController() const abortController = new AbortController()
const url = utils.getPublicUrlForMxc(mxc)
assert(url)
/** @type {import("node-fetch").Response} */ /** @type {import("node-fetch").Response} */
// If it turns out to be a GIF, we want to abandon the connection without downloading the whole thing. // If it turns out to be a GIF, we want to abandon the connection without downloading the whole thing.
// If we were using connection pooling, we would be forced to download the entire GIF. // If we were using connection pooling, we would be forced to download the entire GIF.
// So we set no agent to ensure we are not connection pooling. // So we set no agent to ensure we are not connection pooling.
// @ts-ignore the signal is slightly different from the type it wants (still works fine) // @ts-ignore the signal is slightly different from the type it wants (still works fine)
const res = await api.getMedia(mxc, {agent: false, signal: abortController.signal}) const res = await fetch(url, {agent: false, signal: abortController.signal})
return emojiSheetConverter.convertImageStream(res.body, () => { return emojiSheetConverter.convertImageStream(res.body, () => {
abortController.abort() abortController.abort()
res.body.pause() res.body.pause()

View file

@ -13,10 +13,10 @@ const utils = sync.require("../converters/utils")
*/ */
async function deleteMessage(event) { async function deleteMessage(event) {
const rows = from("event_message").join("message_channel", "message_id").select("channel_id", "message_id").where({event_id: event.redacts}).all() const rows = from("event_message").join("message_channel", "message_id").select("channel_id", "message_id").where({event_id: event.redacts}).all()
db.prepare("DELETE FROM event_message WHERE event_id = ?").run(event.redacts) db.prepare("DELETE FROM event_message WHERE event_id = ?").run(event.event_id)
for (const row of rows) { for (const row of rows) {
db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(row.message_id) db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(row.message_id)
await discord.snow.channel.deleteMessage(row.channel_id, row.message_id, event.content.reason) discord.snow.channel.deleteMessage(row.channel_id, row.message_id, event.content.reason)
} }
} }
@ -25,6 +25,7 @@ async function deleteMessage(event) {
*/ */
async function removeReaction(event) { async function removeReaction(event) {
const hash = utils.getEventIDHash(event.redacts) const hash = utils.getEventIDHash(event.redacts)
// TODO: this works but fix the type
const row = from("reaction").join("message_channel", "message_id").select("channel_id", "message_id", "encoded_emoji").where({hashed_event_id: hash}).get() const row = from("reaction").join("message_channel", "message_id").select("channel_id", "message_id", "encoded_emoji").where({hashed_event_id: hash}).get()
if (!row) return if (!row) return
await discord.snow.channel.deleteReactionSelf(row.channel_id, row.message_id, row.encoded_emoji) await discord.snow.channel.deleteReactionSelf(row.channel_id, row.message_id, row.encoded_emoji)

View file

@ -17,13 +17,11 @@ const eventToMessage = sync.require("../converters/event-to-message")
const api = sync.require("../../matrix/api") const api = sync.require("../../matrix/api")
/** @type {import("../../d2m/actions/register-user")} */ /** @type {import("../../d2m/actions/register-user")} */
const registerUser = sync.require("../../d2m/actions/register-user") const registerUser = sync.require("../../d2m/actions/register-user")
/** @type {import("../../d2m/actions/edit-message")} */
const editMessage = sync.require("../../d2m/actions/edit-message")
/** @type {import("../actions/emoji-sheet")} */ /** @type {import("../actions/emoji-sheet")} */
const emojiSheet = sync.require("../actions/emoji-sheet") const emojiSheet = sync.require("../actions/emoji-sheet")
/** /**
* @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[], pendingFiles?: ({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer | Readable})[]}} message * @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[], pendingFiles?: ({name: string, url: string} | {name: string, url: string, key: string, iv: string} | {name: string, buffer: Buffer | Readable})[]}} message
* @returns {Promise<DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]}>} * @returns {Promise<DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]}>}
*/ */
async function resolvePendingFiles(message) { async function resolvePendingFiles(message) {
@ -39,7 +37,7 @@ async function resolvePendingFiles(message) {
// Encrypted file // Encrypted file
const d = crypto.createDecipheriv("aes-256-ctr", Buffer.from(p.key, "base64url"), Buffer.from(p.iv, "base64url")) const d = crypto.createDecipheriv("aes-256-ctr", Buffer.from(p.key, "base64url"), Buffer.from(p.iv, "base64url"))
// @ts-ignore // @ts-ignore
await api.getMedia(p.mxc).then(res => res.body.pipe(d)) fetch(p.url).then(res => res.body.pipe(d))
return { return {
name: p.name, name: p.name,
file: d file: d
@ -47,7 +45,7 @@ async function resolvePendingFiles(message) {
} else { } else {
// Unencrypted file // Unencrypted file
/** @type {Readable} */ // @ts-ignore /** @type {Readable} */ // @ts-ignore
const body = await api.getMedia(p.mxc).then(res => res.body) const body = await fetch(p.url).then(res => res.body)
return { return {
name: p.name, name: p.name,
file: body file: body
@ -79,7 +77,7 @@ async function sendEvent(event) {
// no need to sync the matrix member to the other side. but if I did need to, this is where I'd do it // no need to sync the matrix member to the other side. but if I did need to, this is where I'd do it
let {messagesToEdit, messagesToSend, messagesToDelete, ensureJoined} = await eventToMessage.eventToMessage(event, guild, {api, snow: discord.snow, mxcDownloader: emojiSheet.getAndConvertEmoji}) let {messagesToEdit, messagesToSend, messagesToDelete, ensureJoined} = await eventToMessage.eventToMessage(event, guild, {api, snow: discord.snow, fetch, mxcDownloader: emojiSheet.getAndConvertEmoji})
messagesToEdit = await Promise.all(messagesToEdit.map(async e => { messagesToEdit = await Promise.all(messagesToEdit.map(async e => {
e.message = await resolvePendingFiles(e.message) e.message = await resolvePendingFiles(e.message)
@ -90,7 +88,6 @@ async function sendEvent(event) {
})) }))
let eventPart = 0 // 0 is primary, 1 is supporting let eventPart = 0 // 0 is primary, 1 is supporting
const pendingEdits = []
/** @type {DiscordTypes.APIMessage[]} */ /** @type {DiscordTypes.APIMessage[]} */
const messageResponses = [] const messageResponses = []
@ -114,33 +111,12 @@ async function sendEvent(event) {
eventPart = 1 eventPart = 1
messageResponses.push(messageResponse) messageResponses.push(messageResponse)
/*
If the Discord system has a cached link preview embed for one of the links just sent,
it will be instantly added as part of `embeds` and there won't be a MESSAGE_UPDATE.
To reflect the generated embed back to Matrix, we pretend the message was updated right away.
*/
const sentEmbedsCount = message.embeds?.length || 0
if (messageResponse.embeds.length > sentEmbedsCount) {
// not awaiting here because requests to Matrix shouldn't block requests to Discord
pendingEdits.push(() =>
// @ts-ignore this is a valid message edit payload
editMessage.editMessage({
id: messageResponse.id,
channel_id: messageResponse.channel_id,
guild_id: guild.id,
embeds: messageResponse.embeds
}, guild, null)
)
}
} }
for (const user of ensureJoined) { for (const user of ensureJoined) {
registerUser.ensureSimJoined(user, event.room_id) registerUser.ensureSimJoined(user, event.room_id)
} }
await Promise.all(pendingEdits.map(f => f())) // `await` will propagate any errors during editing
return messageResponses return messageResponses
} }

View file

@ -3,8 +3,8 @@
const assert = require("assert").strict const assert = require("assert").strict
const {pipeline} = require("stream").promises const {pipeline} = require("stream").promises
const sharp = require("sharp") const sharp = require("sharp")
const {GIFrame} = require("@cloudrac3r/giframe") const {GIFrame} = require("giframe")
const {PNG} = require("@cloudrac3r/pngjs") const {PNG} = require("pngjs")
const streamMimeType = require("stream-mime-type") const streamMimeType = require("stream-mime-type")
const SIZE = 48 const SIZE = 48
@ -81,10 +81,9 @@ async function convertImageStream(streamIn, stopStream) {
giframe.feed(chunk) giframe.feed(chunk)
}) })
const frame = await giframe.getFrame() const frame = await giframe.getFrame()
const pixels = Uint8Array.from(frame.pixels)
stopStream() stopStream()
const buffer = await sharp(pixels, {raw: {width: frame.width, height: frame.height, channels: 4}}) const buffer = await sharp(frame.pixels, {raw: {width: frame.width, height: frame.height, channels: 4}})
.resize(SIZE, SIZE, {fit: "contain", background: {r: 0, g: 0, b: 0, alpha: 0}}) .resize(SIZE, SIZE, {fit: "contain", background: {r: 0, g: 0, b: 0, alpha: 0}})
.png({compressionLevel: 0}) .png({compressionLevel: 0})
.toBuffer({resolveWithObject: true}) .toBuffer({resolveWithObject: true})

View file

@ -4,7 +4,7 @@ const Ty = require("../../types")
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const {Readable} = require("stream") const {Readable} = require("stream")
const chunk = require("chunk-text") const chunk = require("chunk-text")
const TurndownService = require("@cloudrac3r/turndown") const TurndownService = require("turndown")
const domino = require("domino") const domino = require("domino")
const assert = require("assert").strict const assert = require("assert").strict
const entities = require("entities") const entities = require("entities")
@ -15,8 +15,6 @@ const {sync, db, discord, select, from} = passthrough
const mxUtils = sync.require("../converters/utils") const mxUtils = sync.require("../converters/utils")
/** @type {import("../../discord/utils")} */ /** @type {import("../../discord/utils")} */
const dUtils = sync.require("../../discord/utils") const dUtils = sync.require("../../discord/utils")
/** @type {import("../../matrix/file")} */
const file = sync.require("../../matrix/file")
/** @type {import("./emoji-sheet")} */ /** @type {import("./emoji-sheet")} */
const emojiSheet = sync.require("./emoji-sheet") const emojiSheet = sync.require("./emoji-sheet")
@ -56,17 +54,16 @@ const turndownService = new TurndownService({
*/ */
// @ts-ignore bad type from turndown // @ts-ignore bad type from turndown
turndownService.escape = function (string) { turndownService.escape = function (string) {
return string.replace(/\s+|\S+/g, part => { // match chunks of spaces or non-spaces const escapedWords = string.split(" ").map(word => {
if (part.match(/\s/)) return part // don't process spaces if (word.match(/^https?:\/\//)) {
return word
if (part.match(/^https?:\/\//)) {
return part
} else { } else {
return markdownEscapes.reduce(function (accumulator, escape) { return markdownEscapes.reduce(function (accumulator, escape) {
return accumulator.replace(escape[0], escape[1]) return accumulator.replace(escape[0], escape[1])
}, part) }, word)
} }
}) })
return escapedWords.join(" ")
} }
turndownService.remove("mx-reply") turndownService.remove("mx-reply")
@ -131,7 +128,7 @@ turndownService.addRule("inlineLink", {
const href = node.getAttribute("href") const href = node.getAttribute("href")
content = content.replace(/ @.*/, "") content = content.replace(/ @.*/, "")
if (href === content) return href if (href === content) return href
if (decodeURIComponent(href).startsWith("https://matrix.to/#/@") && content[0] !== "@") content = "@" + content if (href.startsWith("https://matrix.to/#/@") && content[0] !== "@") content = "@" + content
return "[" + content + "](" + href + ")" return "[" + content + "](" + href + ")"
} }
}) })
@ -208,10 +205,11 @@ function getCodeContent(preCode) {
*/ */
function convertEmoji(mxcUrl, nameForGuess, allowSpriteSheetIndicator, allowLink) { function convertEmoji(mxcUrl, nameForGuess, allowSpriteSheetIndicator, allowLink) {
// Get the known emoji from the database. // Get the known emoji from the database.
if (mxcUrl) var row = select("emoji", ["emoji_id", "name", "animated"], {mxc_url: mxcUrl}).get() let row
if (mxcUrl) row = select("emoji", ["emoji_id", "name", "animated"], {mxc_url: mxcUrl}).get()
// Now we have to search all servers to see if we're able to send this emoji. // Now we have to search all servers to see if we're able to send this emoji.
if (row) { if (row) {
const found = [...discord.guilds.values()].find(g => g.emojis.find(e => e.id === row?.emoji_id)) const found = [...discord.guilds.values()].find(g => g.emojis.find(e => e.id === row.id))
if (!found) row = null if (!found) row = null
} }
// Or, if we don't have an emoji right now, we search for the name instead. // Or, if we don't have an emoji right now, we search for the name instead.
@ -221,7 +219,7 @@ function convertEmoji(mxcUrl, nameForGuess, allowSpriteSheetIndicator, allowLink
/** @type {{name: string, id: string, animated: number}[]} */ /** @type {{name: string, id: string, animated: number}[]} */
// @ts-ignore // @ts-ignore
const emojis = guild.emojis const emojis = guild.emojis
const found = emojis.find(e => e.name?.toLowerCase() === nameForGuessLower) const found = emojis.find(e => e.id === row?.id || e.name?.toLowerCase() === nameForGuessLower)
if (found) { if (found) {
row = { row = {
animated: found.animated, animated: found.animated,
@ -305,7 +303,7 @@ function getUserOrProxyOwnerID(mxid) {
* This function will strip them from the content and generate the correct pending file of the sprite sheet. * This function will strip them from the content and generate the correct pending file of the sprite sheet.
* @param {string} content * @param {string} content
* @param {{id: string, name: string}[]} attachments * @param {{id: string, name: string}[]} attachments
* @param {({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} pendingFiles * @param {({name: string, url: string} | {name: string, url: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} pendingFiles
* @param {(mxc: string) => Promise<Buffer | undefined>} mxcDownloader function that will download the mxc URLs and convert to uncompressed PNG data. use `getAndConvertEmoji` or a mock. * @param {(mxc: string) => Promise<Buffer | undefined>} mxcDownloader function that will download the mxc URLs and convert to uncompressed PNG data. use `getAndConvertEmoji` or a mock.
*/ */
async function uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles, mxcDownloader) { async function uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles, mxcDownloader) {
@ -330,7 +328,7 @@ async function uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles,
*/ */
async function handleRoomOrMessageLinks(input, di) { async function handleRoomOrMessageLinks(input, di) {
let offset = 0 let offset = 0
for (const match of [...input.matchAll(/("?https:\/\/matrix.to\/#\/(![^"/, ?)]+)(?:\/(\$[^"/ ?)]+))?(?:\?[^",:!? )]*?)?)(">|[,<\n )]|$)/g)]) { for (const match of [...input.matchAll(/("?https:\/\/matrix.to\/#\/(![^"/, ?)]+)(?:\/(\$[^"/ ?)]+))?(?:\?[^",:!? )]*)?)(">|[, )]|$)/g)]) {
assert(typeof match.index === "number") assert(typeof match.index === "number")
const [_, attributeValue, roomID, eventID, endMarker] = match const [_, attributeValue, roomID, eventID, endMarker] = match
let result let result
@ -386,35 +384,19 @@ async function handleRoomOrMessageLinks(input, di) {
/** /**
* @param {string} content * @param {string} content
* @param {string} senderMxid
* @param {string} roomID
* @param {DiscordTypes.APIGuild} guild * @param {DiscordTypes.APIGuild} guild
* @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer}} di * @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, fetch: import("node-fetch")["default"]}} di
*/ */
async function checkWrittenMentions(content, senderMxid, roomID, guild, di) { async function checkWrittenMentions(content, guild, di) {
let writtenMentionMatch = content.match(/(?:^|[^"[<>/A-Za-z0-9])@([A-Za-z][A-Za-z0-9._\[\]\(\)-]+):?/d) // /d flag for indices requires node.js 16+ let writtenMentionMatch = content.match(/(?:^|[^"[<>/A-Za-z0-9])@([A-Za-z][A-Za-z0-9._\[\]\(\)-]+):?/d) // /d flag for indices requires node.js 16+
if (writtenMentionMatch) { if (writtenMentionMatch) {
if (writtenMentionMatch[1] === "room") { // convert @room to @everyone
const powerLevels = await di.api.getStateEvent(roomID, "m.room.power_levels", "")
const userPower = powerLevels.users?.[senderMxid] || 0
if (userPower >= powerLevels.notifications?.room) {
return {
// @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]),
ensureJoined: [],
allowedMentionsParse: ["everyone"]
}
}
} else {
const results = await di.snow.guild.searchGuildMembers(guild.id, {query: writtenMentionMatch[1]}) const results = await di.snow.guild.searchGuildMembers(guild.id, {query: writtenMentionMatch[1]})
if (results[0]) { if (results[0]) {
assert(results[0].user) assert(results[0].user)
return { return {
// @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: []
}
} }
} }
} }
@ -440,12 +422,14 @@ const attachmentEmojis = new Map([
/** /**
* @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker | Ty.Event.Outer_M_Room_Message_Encrypted_File} event * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker | Ty.Event.Outer_M_Room_Message_Encrypted_File} event
* @param {import("discord-api-types/v10").APIGuild} guild * @param {import("discord-api-types/v10").APIGuild} guild
* @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, mxcDownloader: (mxc: string) => Promise<Buffer | undefined>}} di simple-as-nails dependency injection for the matrix API * @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, fetch: import("node-fetch")["default"], mxcDownloader: (mxc: string) => Promise<Buffer | undefined>}} di simple-as-nails dependency injection for the matrix API
*/ */
async function eventToMessage(event, guild, di) { async function eventToMessage(event, guild, di) {
/** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]})[]} */
let messages = []
let displayName = event.sender let displayName = event.sender
let avatarURL = undefined let avatarURL = undefined
const allowedMentionsParse = ["users", "roles"]
/** @type {string[]} */ /** @type {string[]} */
let messageIDsToEdit = [] let messageIDsToEdit = []
let replyLine = "" let replyLine = ""
@ -466,7 +450,7 @@ async function eventToMessage(event, guild, di) {
let content = event.content.body // ultimate fallback let content = event.content.body // ultimate fallback
const attachments = [] const attachments = []
/** @type {({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} */ /** @type {({name: string, url: string} | {name: string, url: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} */
const pendingFiles = [] const pendingFiles = []
/** @type {DiscordTypes.APIUser[]} */ /** @type {DiscordTypes.APIUser[]} */
const ensureJoined = [] const ensureJoined = []
@ -542,7 +526,7 @@ async function eventToMessage(event, guild, di) {
.replace(/<span [^>]*data-mx-spoiler\b[^>]*>.*?<\/span>/g, "[spoiler]") // Good enough method of removing spoiler content. (I don't want to break out the HTML parser unless I have to.) .replace(/<span [^>]*data-mx-spoiler\b[^>]*>.*?<\/span>/g, "[spoiler]") // Good enough method of removing spoiler content. (I don't want to break out the HTML parser unless I have to.)
.replace(/<[^>]+>/g, "") // Completely strip all HTML tags and formatting. .replace(/<[^>]+>/g, "") // Completely strip all HTML tags and formatting.
), 50) ), 50)
replyLine = "-# > " + contentPreviewChunks[0] replyLine = "> " + contentPreviewChunks[0]
if (contentPreviewChunks.length > 1) replyLine = replyLine.replace(/[,.']$/, "") + "..." if (contentPreviewChunks.length > 1) replyLine = replyLine.replace(/[,.']$/, "") + "..."
replyLine += "\n" replyLine += "\n"
return return
@ -566,7 +550,7 @@ async function eventToMessage(event, guild, di) {
assert(match) assert(match)
senderName = match[1] senderName = match[1]
} }
replyLine += `**${senderName}**` replyLine += `Ⓜ️**${senderName}**`
} }
// If the event has been edited, the homeserver will include the relation in `unsigned`. // If the event has been edited, the homeserver will include the relation in `unsigned`.
if (repliedToEvent.unsigned?.["m.relations"]?.["m.replace"]?.content?.["m.new_content"]) { if (repliedToEvent.unsigned?.["m.relations"]?.["m.replace"]?.content?.["m.new_content"]) {
@ -593,18 +577,17 @@ async function eventToMessage(event, guild, di) {
return convertEmoji(mxcUrlMatch?.[1], titleTextMatch?.[1], false, false) return convertEmoji(mxcUrlMatch?.[1], titleTextMatch?.[1], false, false)
}) })
repliedToContent = repliedToContent.replace(/<[^:>][^>]*>/g, "") // Completely strip all HTML tags and formatting. repliedToContent = repliedToContent.replace(/<[^:>][^>]*>/g, "") // Completely strip all HTML tags and formatting.
repliedToContent = repliedToContent.replace(/\bhttps?:\/\/[^ )]*/g, "<$&>")
repliedToContent = entities.decodeHTML5Strict(repliedToContent) // Remove entities like &amp; &quot; repliedToContent = entities.decodeHTML5Strict(repliedToContent) // Remove entities like &amp; &quot;
const contentPreviewChunks = chunk(repliedToContent, 50) const contentPreviewChunks = chunk(repliedToContent, 50)
if (contentPreviewChunks.length) { if (contentPreviewChunks.length) {
contentPreview = ": " + contentPreviewChunks[0] contentPreview = ":\n> " + contentPreviewChunks[0]
if (contentPreviewChunks.length > 1) contentPreview = contentPreview.replace(/[,.']$/, "") + "..." if (contentPreviewChunks.length > 1) contentPreview = contentPreview.replace(/[,.']$/, "") + "..."
} else { } else {
console.log("Unable to generate reply preview for this replied-to event because we stripped all of it:", repliedToEvent) console.log("Unable to generate reply preview for this replied-to event because we stripped all of it:", repliedToEvent)
contentPreview = "" contentPreview = ""
} }
} }
replyLine = `-# > ${replyLine}${contentPreview}\n` replyLine = `> ${replyLine}${contentPreview}\n`
})() })()
if (event.content.format === "org.matrix.custom.html" && event.content.formatted_body) { if (event.content.format === "org.matrix.custom.html" && event.content.formatted_body) {
@ -676,18 +659,17 @@ async function eventToMessage(event, guild, di) {
for (; node; node = node.nextSibling) { for (; node; node = node.nextSibling) {
// Check written mentions // Check written mentions
if (node.nodeType === 3 && node.nodeValue.includes("@") && !nodeIsChildOf(node, ["A", "CODE", "PRE"])) { 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) const result = await checkWrittenMentions(node.nodeValue, guild, di)
if (result) { if (result) {
node.nodeValue = result.content node.nodeValue = result.content
ensureJoined.push(...result.ensureJoined) ensureJoined.push(result.ensureJoined)
allowedMentionsParse.push(...result.allowedMentionsParse)
} }
} }
// Check for incompatible backticks in code blocks // Check for incompatible backticks in code blocks
let preNode let preNode
if (node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"]))) { if (node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"]))) {
if (preNode.firstChild?.nodeName === "CODE") { if (preNode.firstChild?.nodeName === "CODE") {
const ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1] || "txt" const ext = (preNode.firstChild.className.match(/language-(\S+)/) || [null, "txt"])[1]
const filename = `inline_code.${ext}` const filename = `inline_code.${ext}`
// Build the replacement <code> node // Build the replacement <code> node
const replacementCode = doc.createElement("code") const replacementCode = doc.createElement("code")
@ -727,7 +709,7 @@ async function eventToMessage(event, guild, di) {
content = turndownService.turndown(root) content = turndownService.turndown(root)
// Put < > around any surviving matrix.to links to hide the URL previews // Put < > around any surviving matrix.to links to hide the URL previews
content = content.replace(/\bhttps?:\/\/matrix\.to\/[^<>\n )]*/g, "<$&>") content = content.replace(/\bhttps?:\/\/matrix\.to\/[^ )]*/, "<$&>")
// It's designed for commonmark, we need to replace the space-space-newline with just newline // It's designed for commonmark, we need to replace the space-space-newline with just newline
content = content.replace(/ \n/g, "\n") content = content.replace(/ \n/g, "\n")
@ -746,13 +728,12 @@ async function eventToMessage(event, guild, di) {
} }
content = await handleRoomOrMessageLinks(content, di) // Replace matrix.to links with discord.com equivalents where possible 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 content = content.replace(/\bhttps?:\/\/matrix\.to\/[^ )]*/, "<$&>") // Put < > around any surviving matrix.to links to hide the URL previews
const result = await checkWrittenMentions(content, event.sender, event.room_id, guild, di) const result = await checkWrittenMentions(content, guild, di)
if (result) { if (result) {
content = result.content content = result.content
ensureJoined.push(...result.ensureJoined) ensureJoined.push(result.ensureJoined)
allowedMentionsParse.push(...result.allowedMentionsParse)
} }
// Markdown needs to be escaped, though take care not to escape the middle of links // Markdown needs to be escaped, though take care not to escape the middle of links
@ -767,23 +748,29 @@ async function eventToMessage(event, guild, di) {
const description = (event.content.body !== event.content.filename && event.content.filename && event.content.body) || undefined const description = (event.content.body !== event.content.filename && event.content.filename && event.content.body) || undefined
if ("url" in event.content) { if ("url" in event.content) {
// Unencrypted // Unencrypted
const url = mxUtils.getPublicUrlForMxc(event.content.url)
assert(url)
attachments.push({id: "0", description, filename}) attachments.push({id: "0", description, filename})
pendingFiles.push({name: filename, mxc: event.content.url}) pendingFiles.push({name: filename, url})
} else { } else {
// Encrypted // Encrypted
const url = mxUtils.getPublicUrlForMxc(event.content.file.url)
assert(url)
assert.equal(event.content.file.key.alg, "A256CTR") assert.equal(event.content.file.key.alg, "A256CTR")
attachments.push({id: "0", description, filename}) attachments.push({id: "0", description, filename})
pendingFiles.push({name: filename, mxc: event.content.file.url, key: event.content.file.key.k, iv: event.content.file.iv}) pendingFiles.push({name: filename, url, key: event.content.file.key.k, iv: event.content.file.iv})
} }
} else if (event.type === "m.sticker") { } else if (event.type === "m.sticker") {
content = "" content = ""
const url = mxUtils.getPublicUrlForMxc(event.content.url)
assert(url)
let filename = event.content.body let filename = event.content.body
if (event.type === "m.sticker") { if (event.type === "m.sticker") {
let mimetype let mimetype
if (event.content.info?.mimetype?.includes("/")) { if (event.content.info?.mimetype?.includes("/")) {
mimetype = event.content.info.mimetype mimetype = event.content.info.mimetype
} else { } else {
const res = await di.api.getMedia(event.content.url, {method: "HEAD"}) const res = await di.fetch(url, {method: "HEAD"})
if (res.status === 200) { if (res.status === 200) {
mimetype = res.headers.get("content-type") mimetype = res.headers.get("content-type")
} }
@ -792,22 +779,18 @@ async function eventToMessage(event, guild, di) {
filename += "." + mimetype.split("/")[1] filename += "." + mimetype.split("/")[1]
} }
attachments.push({id: "0", filename}) attachments.push({id: "0", filename})
pendingFiles.push({name: filename, mxc: event.content.url}) pendingFiles.push({name: filename, url})
} }
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)
/** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]})[]} */ messages = messages.concat(chunks.map(content => ({
const messages = chunks.map(content => ({
content, content,
allowed_mentions: {
parse: allowedMentionsParse
},
username: displayNameShortened, username: displayNameShortened,
avatar_url: avatarURL avatar_url: avatarURL
})) })))
if (attachments.length) { if (attachments.length) {
// If content is empty (should be the case when uploading a file) then chunk-text will create 0 messages. // If content is empty (should be the case when uploading a file) then chunk-text will create 0 messages.

View file

@ -1,13 +1,8 @@
// @ts-check // @ts-check
const assert = require("assert").strict const reg = require("../../matrix/read-registration")
const passthrough = require("../../passthrough")
const {db} = passthrough
const {reg} = require("../../matrix/read-registration")
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
const assert = require("assert").strict
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore /** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
let hasher = null let hasher = null
// @ts-ignore // @ts-ignore
@ -40,6 +35,16 @@ function eventSenderIsFromDiscord(sender) {
return false return false
} }
/**
* @param {string} mxc
* @returns {string?}
*/
function getPublicUrlForMxc(mxc) {
const avatarURLParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
if (avatarURLParts) return `${reg.ooye.server_origin}/_matrix/media/r0/download/${avatarURLParts[1]}/${avatarURLParts[2]}`
else return null
}
/** /**
* Event IDs are really big and have more entropy than we need. * Event IDs are really big and have more entropy than we need.
* If we want to store the event ID in the database, we can store a more compact version by hashing it with this. * If we want to store the event ID in the database, we can store a more compact version by hashing it with this.
@ -208,32 +213,6 @@ async function getViaServersQuery(roomID, api) {
return qs return qs
} }
/**
* Since the introduction of authenticated media, this can no longer just be the /_matrix/media/r0/download URL
* because Discord and Discord users cannot use those URLs. Media now has to be proxied through the bridge.
* To avoid the bridge acting as a proxy for *any* media, there is a list of permitted media stored in the database.
* (The other approach would be signing the URLs with a MAC (or similar) and adding the signature, but I'm not a
* cryptographer, so I don't want to.) To reduce database disk space usage, instead of storing each permitted URL,
* we just store its xxhash as a signed (as in +/-, not signature) 64-bit integer, which fits in an SQLite integer field.
* @see https://matrix.org/blog/2024/06/26/sunsetting-unauthenticated-media/ background
* @see https://matrix.org/blog/2024/06/20/matrix-v1.11-release/ implementation details
* @see https://www.sqlite.org/fileformat2.html#record_format SQLite integer field size
* @param {string} mxc
* @returns {string?}
*/
function getPublicUrlForMxc(mxc) {
assert(hasher, "xxhash is not ready yet")
const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
if (!mediaParts) return null
const serverAndMediaID = `${mediaParts[1]}/${mediaParts[2]}`
const unsignedHash = hasher.h64(serverAndMediaID)
const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
db.prepare("INSERT OR IGNORE INTO media_proxy (permitted_hash) VALUES (?)").run(signedHash)
return `${reg.ooye.bridge_origin}/download/matrix/${serverAndMediaID}`
}
module.exports.BLOCK_ELEMENTS = BLOCK_ELEMENTS module.exports.BLOCK_ELEMENTS = BLOCK_ELEMENTS
module.exports.eventSenderIsFromDiscord = eventSenderIsFromDiscord module.exports.eventSenderIsFromDiscord = eventSenderIsFromDiscord
module.exports.getPublicUrlForMxc = getPublicUrlForMxc module.exports.getPublicUrlForMxc = getPublicUrlForMxc

View file

@ -6,7 +6,7 @@
const util = require("util") const util = require("util")
const Ty = require("../types") const Ty = require("../types")
const {discord, db, sync, as, select} = require("../passthrough") const {discord, db, sync, as} = require("../passthrough")
/** @type {import("./actions/send-event")} */ /** @type {import("./actions/send-event")} */
const sendEvent = sync.require("./actions/send-event") const sendEvent = sync.require("./actions/send-event")
@ -20,7 +20,8 @@ const matrixCommandHandler = sync.require("../matrix/matrix-command-handler")
const utils = sync.require("./converters/utils") const utils = sync.require("./converters/utils")
/** @type {import("../matrix/api")}) */ /** @type {import("../matrix/api")}) */
const api = sync.require("../matrix/api") const api = sync.require("../matrix/api")
const {reg} = require("../matrix/read-registration") /** @type {import("../matrix/read-registration")}) */
const reg = sync.require("../matrix/read-registration")
let lastReportedEvent = 0 let lastReportedEvent = 0
@ -61,34 +62,15 @@ function guard(type, fn) {
} }
} }
/** async function retry(roomID, eventID) {
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>} reactionEvent const event = await api.getEvent(roomID, eventID)
*/
async function onRetryReactionAdd(reactionEvent) {
const roomID = reactionEvent.room_id
const event = await api.getEvent(roomID, reactionEvent.content["m.relates_to"]?.event_id)
// Check that it's a real error from OOYE
const error = event.content["moe.cadence.ooye.error"] const error = event.content["moe.cadence.ooye.error"]
if (event.sender !== `@${reg.sender_localpart}:${reg.ooye.server_name}` || !error) return if (event.sender !== `@${reg.sender_localpart}:${reg.ooye.server_name}` || !error) return
// To stop people injecting misleading messages, the reaction needs to come from either the original sender or a room moderator
if (reactionEvent.sender !== event.sender) {
// Check if it's a room moderator
const powerLevelsStateContent = await api.getStateEvent(roomID, "m.room.power_levels", "")
const powerLevel = powerLevelsStateContent.users?.[reactionEvent.sender] || 0
if (powerLevel < 50) return
}
// Retry
if (error.source === "matrix") { if (error.source === "matrix") {
as.emit(`type:${error.payload.type}`, error.payload) as.emit("type:" + error.payload.type, error.payload)
} else if (error.source === "discord") { } else if (error.source === "discord") {
discord.cloud.emit("event", error.payload) discord.cloud.emit("event", error.payload)
} }
// Redact the error to stop people from executing multiple retries
api.redactEvent(roomID, event.event_id)
} }
sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message", sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message",
@ -121,7 +103,7 @@ async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return if (utils.eventSenderIsFromDiscord(event.sender)) return
if (event.content["m.relates_to"].key === "🔁") { if (event.content["m.relates_to"].key === "🔁") {
// Try to bridge a failed event again? // Try to bridge a failed event again?
await onRetryReactionAdd(event) await retry(event.room_id, event.content["m.relates_to"].event_id)
} else { } else {
matrixCommandHandler.onReactionAdd(event) matrixCommandHandler.onReactionAdd(event)
await addReaction.addReaction(event) await addReaction.addReaction(event)
@ -166,29 +148,5 @@ sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member",
async event => { async event => {
if (event.state_key[0] !== "@") return if (event.state_key[0] !== "@") return
if (utils.eventSenderIsFromDiscord(event.state_key)) return if (utils.eventSenderIsFromDiscord(event.state_key)) return
if (event.content.membership === "leave" || event.content.membership === "ban") { db.prepare("REPLACE INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?)").run(event.room_id, event.state_key, event.content.displayname || null, event.content.avatar_url || null)
// Member is gone
db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key)
} else {
// Member is here
db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?")
.run(
event.room_id, event.state_key,
event.content.displayname || null, event.content.avatar_url || null,
event.content.displayname || null, event.content.avatar_url || null
)
}
}))
sync.addTemporaryListener(as, "type:m.room.power_levels", guard("m.room.power_levels",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Power_Levels>} event
*/
async event => {
if (event.state_key !== "") return
const existingPower = select("member_cache", "mxid", {room_id: event.room_id}).pluck().all()
const newPower = event.content.users || {}
for (const mxid of existingPower) {
db.prepare("UPDATE member_cache SET power_level = ? WHERE room_id = ? AND mxid = ?").run(newPower[mxid] || 0, event.room_id, mxid)
}
})) }))

View file

@ -3,15 +3,14 @@
const Ty = require("../types") const Ty = require("../types")
const assert = require("assert").strict const assert = require("assert").strict
const fetch = require("node-fetch").default
const passthrough = require("../passthrough") const passthrough = require("../passthrough")
const { discord, sync, db } = passthrough const { discord, sync, db } = passthrough
/** @type {import("./mreq")} */ /** @type {import("./mreq")} */
const mreq = sync.require("./mreq") const mreq = sync.require("./mreq")
/** @type {import("./file")} */
const file = sync.require("./file")
/** @type {import("./txnid")} */ /** @type {import("./txnid")} */
const makeTxnId = sync.require("./txnid") const makeTxnId = sync.require("./txnid")
const {reg} = require("./read-registration.js")
/** /**
* @param {string} p endpoint to access * @param {string} p endpoint to access
@ -60,7 +59,7 @@ async function createRoom(content) {
*/ */
async function joinRoom(roomIDOrAlias, mxid) { async function joinRoom(roomIDOrAlias, mxid) {
/** @type {Ty.R.RoomJoined} */ /** @type {Ty.R.RoomJoined} */
const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid), {}) const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid))
return root.room_id return root.room_id
} }
@ -71,7 +70,6 @@ async function inviteToRoom(roomID, mxidToInvite, mxid) {
} }
async function leaveRoom(roomID, mxid) { async function leaveRoom(roomID, mxid) {
console.log(`[api] leave: ${roomID}: ${mxid}`)
await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, mxid), {}) await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, mxid), {})
} }
@ -123,48 +121,6 @@ function getJoinedMembers(roomID) {
return mreq.mreq("GET", `/client/v3/rooms/${roomID}/joined_members`) return mreq.mreq("GET", `/client/v3/rooms/${roomID}/joined_members`)
} }
/**
* "Get the list of members for this room." This includes joined, invited, knocked, left, and banned members unless a filter is provided.
* The endpoint also supports `at` and `not_membership` URL parameters, but they are not exposed in this wrapper yet.
* @param {string} roomID
* @param {"join" | "invite" | "knock" | "leave" | "ban"} [membership] The kind of membership to filter for. Only one choice allowed.
* @returns {Promise<{chunk: Ty.Event.Outer<Ty.Event.M_Room_Member>[]}>}
*/
function getMembers(roomID, membership) {
return mreq.mreq("GET", `/client/v3/rooms/${roomID}/members`, undefined, {membership})
}
/**
* @param {string} roomID
* @param {{from?: string, limit?: any}} pagination
* @returns {Promise<Ty.HierarchyPagination<Ty.R.Hierarchy>>}
*/
function getHierarchy(roomID, pagination) {
let path = `/client/v1/rooms/${roomID}/hierarchy`
if (!pagination.from) delete pagination.from
if (!pagination.limit) pagination.limit = 50
path += `?${new URLSearchParams(pagination)}`
return mreq.mreq("GET", path)
}
/**
* Like `getHierarchy` but collects all pages for you.
* @param {string} roomID
*/
async function getFullHierarchy(roomID) {
/** @type {Ty.R.Hierarchy[]} */
let rooms = []
/** @type {string | undefined} */
let nextBatch = undefined
do {
/** @type {Ty.HierarchyPagination<Ty.R.Hierarchy>} */
const res = await getHierarchy(roomID, {from: nextBatch})
rooms.push(...res.rooms)
nextBatch = res.next_batch
} while (nextBatch)
return rooms
}
/** /**
* @param {string} roomID * @param {string} roomID
* @param {string} eventID * @param {string} eventID
@ -181,26 +137,6 @@ function getRelations(roomID, eventID, pagination, relType) {
return mreq.mreq("GET", path) return mreq.mreq("GET", path)
} }
/**
* Like `getRelations` but collects and filters all pages for you.
* @param {string} roomID
* @param {string} eventID
* @param {string?} [relType] type of relations to filter, e.g. "m.annotation" for reactions
*/
async function getFullRelations(roomID, eventID, relType) {
/** @type {Ty.Event.Outer<Ty.Event.M_Reaction>[]} */
let reactions = []
/** @type {string | undefined} */
let nextBatch = undefined
do {
/** @type {Ty.Pagination<Ty.Event.Outer<Ty.Event.M_Reaction>>} */
const res = await getRelations(roomID, eventID, {from: nextBatch}, relType)
reactions = reactions.concat(res.chunk)
nextBatch = res.next_batch
} while (nextBatch)
return reactions
}
/** /**
* @param {string} roomID * @param {string} roomID
* @param {string} type * @param {string} type
@ -292,53 +228,6 @@ async function setUserPower(roomID, mxid, power) {
return powerLevels return powerLevels
} }
/**
* Set a user's power level for a whole room hierarchy.
* @param {string} roomID
* @param {string} mxid
* @param {number} power
*/
async function setUserPowerCascade(roomID, mxid, power) {
assert(roomID[0] === "!")
assert(mxid[0] === "@")
const rooms = await getFullHierarchy(roomID)
for (const room of rooms) {
await setUserPower(room.room_id, mxid, power)
}
}
async function ping() {
// not using mreq so that we can read the status code
const res = await fetch(`${mreq.baseUrl}/client/v1/appservice/${reg.id}/ping`, {
method: "POST",
headers: {
Authorization: `Bearer ${reg.as_token}`
},
body: "{}"
})
const root = await res.json()
return {
ok: res.ok,
status: res.status,
root
}
}
/**
* @param {string} mxc
* @param {fetch.RequestInit} [init]
*/
function getMedia(mxc, init = {}) {
const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
assert(mediaParts)
return fetch(`${mreq.baseUrl}/client/v1/media/download/${mediaParts[1]}/${mediaParts[2]}`, {
headers: {
Authorization: `Bearer ${reg.as_token}`
},
...init
})
}
module.exports.path = path module.exports.path = path
module.exports.register = register module.exports.register = register
module.exports.createRoom = createRoom module.exports.createRoom = createRoom
@ -350,11 +239,7 @@ module.exports.getEventForTimestamp = getEventForTimestamp
module.exports.getAllState = getAllState module.exports.getAllState = getAllState
module.exports.getStateEvent = getStateEvent module.exports.getStateEvent = getStateEvent
module.exports.getJoinedMembers = getJoinedMembers module.exports.getJoinedMembers = getJoinedMembers
module.exports.getMembers = getMembers
module.exports.getHierarchy = getHierarchy
module.exports.getFullHierarchy = getFullHierarchy
module.exports.getRelations = getRelations module.exports.getRelations = getRelations
module.exports.getFullRelations = getFullRelations
module.exports.sendState = sendState module.exports.sendState = sendState
module.exports.sendEvent = sendEvent module.exports.sendEvent = sendEvent
module.exports.redactEvent = redactEvent module.exports.redactEvent = redactEvent
@ -362,6 +247,3 @@ module.exports.sendTyping = sendTyping
module.exports.profileSetDisplayname = profileSetDisplayname module.exports.profileSetDisplayname = profileSetDisplayname
module.exports.profileSetAvatarUrl = profileSetAvatarUrl module.exports.profileSetAvatarUrl = profileSetAvatarUrl
module.exports.setUserPower = setUserPower module.exports.setUserPower = setUserPower
module.exports.setUserPowerCascade = setUserPowerCascade
module.exports.ping = ping
module.exports.getMedia = getMedia

8
matrix/appservice.js Normal file
View file

@ -0,0 +1,8 @@
const reg = require("../matrix/read-registration")
const AppService = require("matrix-appservice").AppService
const as = new AppService({
homeserverToken: reg.hs_token
})
as.listen(+(new URL(reg.url).port))
module.exports = as

View file

@ -1,15 +1,10 @@
// @ts-check // @ts-check
const assert = require("assert").strict const assert = require("assert").strict
const mixin = require("@cloudrac3r/mixin-deep") const mixin = require("mixin-deep")
const {isDeepStrictEqual} = require("util") const deepEqual = require("deep-equal")
const passthrough = require("../passthrough") /** Mutates the input. */
const {sync} = passthrough
/** @type {import("./file")} */
const file = sync.require("./file")
/** Mutates the input. Not recursive - can only include or exclude entire state events. */
function kstateStripConditionals(kstate) { function kstateStripConditionals(kstate) {
for (const [k, content] of Object.entries(kstate)) { for (const [k, content] of Object.entries(kstate)) {
// conditional for whether a key is even part of the kstate (doing this declaratively on json is hard, so represent it as a property instead.) // conditional for whether a key is even part of the kstate (doing this declaratively on json is hard, so represent it as a property instead.)
@ -21,33 +16,9 @@ function kstateStripConditionals(kstate) {
return kstate return kstate
} }
/** Mutates the input. Works recursively through object tree. */ function kstateToState(kstate) {
async function kstateUploadMxc(obj) {
const promises = []
function inner(obj) {
for (const [k, v] of Object.entries(obj)) {
if (v == null || typeof v !== "object") continue
if (v.$url) {
promises.push(
file.uploadDiscordFileToMxc(v.$url)
.then(mxc => obj[k] = mxc)
)
}
inner(v)
}
}
inner(obj)
await Promise.all(promises)
return obj
}
/** Automatically strips conditionals and uploads URLs to mxc. */
async function kstateToState(kstate) {
const events = [] const events = []
kstateStripConditionals(kstate) kstateStripConditionals(kstate)
await kstateUploadMxc(kstate)
for (const [k, content] of Object.entries(kstate)) { for (const [k, content] of Object.entries(kstate)) {
const slashIndex = k.indexOf("/") const slashIndex = k.indexOf("/")
assert(slashIndex > 0) assert(slashIndex > 0)
@ -80,14 +51,14 @@ function diffKState(actual, target) {
// Special handling for power levels, we want to deep merge the actual and target into the final state. // Special handling for power levels, we want to deep merge the actual and target into the final state.
if (!(key in actual)) throw new Error(`want to apply a power levels diff, but original power level data is missing\nstarted with: ${JSON.stringify(actual)}\nwant to apply: ${JSON.stringify(target)}`) if (!(key in actual)) throw new Error(`want to apply a power levels diff, but original power level data is missing\nstarted with: ${JSON.stringify(actual)}\nwant to apply: ${JSON.stringify(target)}`)
const temp = mixin({}, actual[key], target[key]) const temp = mixin({}, actual[key], target[key])
if (!isDeepStrictEqual(actual[key], temp)) { if (!deepEqual(actual[key], temp, {strict: true})) {
// they differ. use the newly prepared object as the diff. // they differ. use the newly prepared object as the diff.
diff[key] = temp diff[key] = temp
} }
} else if (key in actual) { } else if (key in actual) {
// diff // diff
if (!isDeepStrictEqual(actual[key], target[key])) { if (!deepEqual(actual[key], target[key], {strict: true})) {
// they differ. use the target as the diff. // they differ. use the target as the diff.
diff[key] = target[key] diff[key] = target[key]
} }
@ -103,7 +74,6 @@ function diffKState(actual, target) {
} }
module.exports.kstateStripConditionals = kstateStripConditionals module.exports.kstateStripConditionals = kstateStripConditionals
module.exports.kstateUploadMxc = kstateUploadMxc
module.exports.kstateToState = kstateToState module.exports.kstateToState = kstateToState
module.exports.stateToKState = stateToKState module.exports.stateToKState = stateToKState
module.exports.diffKState = diffKState module.exports.diffKState = diffKState

View file

@ -1,5 +1,5 @@
const assert = require("assert") const assert = require("assert")
const {kstateToState, stateToKState, diffKState, kstateStripConditionals, kstateUploadMxc} = require("./kstate") const {kstateToState, stateToKState, diffKState, kstateStripConditionals} = require("./kstate")
const {test} = require("supertape") const {test} = require("supertape")
test("kstate strip: strips false conditions", t => { test("kstate strip: strips false conditions", t => {
@ -21,53 +21,8 @@ test("kstate strip: keeps true conditions while removing $if", t => {
}) })
}) })
test("kstateUploadMxc: sets the mxc", async t => { test("kstate2state: general", t => {
const input = { t.deepEqual(kstateToState({
"m.room.avatar/": {
url: {$url: "https://cdn.discordapp.com/guilds/112760669178241024/users/134826546694193153/avatars/38dd359aa12bcd52dd3164126c587f8c.png?size=1024"},
test1: {
test2: {
test3: {$url: "https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg"}
}
}
}
}
await kstateUploadMxc(input)
t.deepEqual(input, {
"m.room.avatar/": {
url: "mxc://cadence.moe/rfemHmAtcprjLEiPiEuzPhpl",
test1: {
test2: {
test3: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR"
}
}
}
})
})
test("kstateUploadMxc and strip: work together", async t => {
const input = {
"m.room.avatar/yes": {
$if: true,
url: {$url: "https://cdn.discordapp.com/guilds/112760669178241024/users/134826546694193153/avatars/38dd359aa12bcd52dd3164126c587f8c.png?size=1024"}
},
"m.room.avatar/no": {
$if: false,
url: {$url: "https://cdn.discordapp.com/avatars/320067006521147393/5fc4ad85c1ea876709e9a7d3374a78a1.png?size=1024"}
},
}
kstateStripConditionals(input)
await kstateUploadMxc(input)
t.deepEqual(input, {
"m.room.avatar/yes": {
url: "mxc://cadence.moe/rfemHmAtcprjLEiPiEuzPhpl"
}
})
})
test("kstate2state: general", async t => {
t.deepEqual(await kstateToState({
"m.room.name/": {name: "test name"}, "m.room.name/": {name: "test name"},
"m.room.member/@cadence:cadence.moe": {membership: "join"}, "m.room.member/@cadence:cadence.moe": {membership: "join"},
"uk.half-shot.bridge/org.matrix.appservice-irc://irc/epicord.net/#general": {creator: "@cadence:cadence.moe"} "uk.half-shot.bridge/org.matrix.appservice-irc://irc/epicord.net/#general": {creator: "@cadence:cadence.moe"}

View file

@ -14,7 +14,7 @@ const mxUtils = sync.require("../m2d/converters/utils")
const dUtils = sync.require("../discord/utils") const dUtils = sync.require("../discord/utils")
/** @type {import("./kstate")} */ /** @type {import("./kstate")} */
const ks = sync.require("./kstate") const ks = sync.require("./kstate")
const {reg} = require("./read-registration") const reg = require("./read-registration")
const PREFIXES = ["//", "/"] const PREFIXES = ["//", "/"]
@ -217,8 +217,9 @@ const commands = [{
} else { } else {
// Upload it to Discord and have the bridge sync it back to Matrix again // Upload it to Discord and have the bridge sync it back to Matrix again
for (const e of toUpload) { for (const e of toUpload) {
const publicUrl = mxUtils.getPublicUrlForMxc(e.url)
// @ts-ignore // @ts-ignore
const resizeInput = await api.getMedia(e.url, {agent: false}).then(res => res.arrayBuffer()) const resizeInput = await fetch(publicUrl, {agent: false}).then(res => res.arrayBuffer())
const resizeOutput = await sharp(resizeInput) const resizeOutput = await sharp(resizeInput)
.resize(EMOJI_SIZE, EMOJI_SIZE, {fit: "inside", withoutEnlargement: true, background: {r: 0, g: 0, b: 0, alpha: 0}}) .resize(EMOJI_SIZE, EMOJI_SIZE, {fit: "inside", withoutEnlargement: true, background: {r: 0, g: 0, b: 0, alpha: 0}})
.png() .png()

View file

@ -1,11 +1,14 @@
// @ts-check // @ts-check
const fetch = require("node-fetch").default const fetch = require("node-fetch").default
const mixin = require("@cloudrac3r/mixin-deep") const mixin = require("mixin-deep")
const stream = require("stream") const stream = require("stream")
const getStream = require("get-stream") const getStream = require("get-stream")
const {reg, writeRegistration} = require("./read-registration.js") const passthrough = require("../passthrough")
const { sync } = passthrough
/** @type {import("./read-registration")} */
const reg = sync.require("./read-registration.js")
const baseUrl = `${reg.ooye.server_origin}/_matrix` const baseUrl = `${reg.ooye.server_origin}/_matrix`
@ -45,15 +48,13 @@ async function mreq(method, url, body, extra = {}) {
const root = await res.json() const root = await res.json()
if (!res.ok || root.errcode) { if (!res.ok || root.errcode) {
if (root.error?.includes("Content-Length") && !reg.ooye.content_length_workaround) { if (root.error?.includes("Content-Length")) {
reg.ooye.content_length_workaround = true console.error(`OOYE cannot stream uploads to Synapse. Please choose one of these workarounds:`
const root = await mreq(method, url, body, extra) + `\n * Run an nginx reverse proxy to Synapse, and point registration.yaml's`
console.error("OOYE cannot stream uploads to Synapse. The `content_length_workaround` option" + `\n \`server_origin\` to nginx`
+ "\nhas been activated in registration.yaml, which works around the problem, but" + `\n * Set \`content_length_workaround: true\` in registration.yaml (this will`
+ "\nhalves the speed of bridging d->m files. A better way to resolve this problem" + `\n halve the speed of bridging d->m files)`)
+ "\nis to run an nginx reverse proxy to Synapse and re-run OOYE setup.") throw new Error("Synapse is not accepting stream uploads, see the message above.")
writeRegistration(reg)
return root
} }
delete opts.headers.Authorization delete opts.headers.Authorization
throw new MatrixServerError(root, {baseUrl, url, ...opts}) throw new MatrixServerError(root, {baseUrl, url, ...opts})
@ -80,6 +81,5 @@ async function withAccessToken(token, callback) {
} }
module.exports.MatrixServerError = MatrixServerError module.exports.MatrixServerError = MatrixServerError
module.exports.baseUrl = baseUrl
module.exports.mreq = mreq module.exports.mreq = mreq
module.exports.withAccessToken = withAccessToken module.exports.withAccessToken = withAccessToken

View file

@ -0,0 +1,14 @@
// @ts-check
const fs = require("fs")
const assert = require("assert").strict
const yaml = require("js-yaml")
/** @ts-ignore @type {import("../types").AppServiceRegistrationConfig} */
const reg = yaml.load(fs.readFileSync("registration.yaml", "utf8"))
reg["ooye"].invite = (reg.ooye.invite || []).filter(mxid => mxid.endsWith(`:${reg.ooye.server_name}`)) // one day I will understand why typescript disagrees with dot notation on this line
assert(reg.ooye.max_file_size)
assert(reg.ooye.namespace_prefix)
assert(reg.ooye.server_name)
module.exports = reg

View file

@ -0,0 +1,10 @@
const {test} = require("supertape")
const reg = require("./read-registration")
test("reg: has necessary parameters", t => {
const propertiesToCheck = ["sender_localpart", "id", "as_token", "ooye"]
t.deepEqual(
propertiesToCheck.filter(p => p in reg),
propertiesToCheck
)
})

2816
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -14,58 +14,46 @@
], ],
"author": "Cadence, PapiOphidian", "author": "Cadence, PapiOphidian",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"engines": {
"node": ">=20"
},
"dependencies": { "dependencies": {
"@chriscdn/promise-semaphore": "^2.0.1", "@chriscdn/promise-semaphore": "^2.0.1",
"@cloudrac3r/discord-markdown": "^2.6.3", "better-sqlite3": "^9.0.0",
"@cloudrac3r/giframe": "^0.4.3",
"@cloudrac3r/html-template-tag": "^5.0.1",
"@cloudrac3r/in-your-element": "^1.0.0",
"@cloudrac3r/mixin-deep": "^3.0.0",
"@cloudrac3r/pngjs": "^7.0.3",
"@cloudrac3r/pug": "^4.0.4",
"@cloudrac3r/turndown": "^7.1.4",
"@stackoverflow/stacks": "^2.5.7",
"@stackoverflow/stacks-icons": "^6.0.2",
"ansi-colors": "^4.1.3",
"better-sqlite3": "^11.1.2",
"chunk-text": "^2.0.1", "chunk-text": "^2.0.1",
"cloudstorm": "^0.10.10", "cloudstorm": "^0.10.8",
"domino": "^2.1.6", "deep-equal": "^2.2.3",
"enquirer": "^2.4.1", "discord-markdown": "git+https://git.sr.ht/~cadence/nodejs-discord-markdown#2881b447954fcea10510f212fa4c1dbbdc0a57a3",
"entities": "^5.0.0", "entities": "^4.5.0",
"get-stream": "^6.0.1", "get-stream": "^6.0.1",
"h3": "^1.12.0", "giframe": "github:cloudrac3r/giframe#v0.4.1",
"heatsync": "^2.5.5", "heatsync": "^2.4.1",
"lru-cache": "^10.4.3", "html-template-tag": "github:cloudrac3r/html-template-tag#v5.0",
"js-yaml": "^4.1.0",
"matrix-appservice": "^2.0.0",
"minimist": "^1.2.8", "minimist": "^1.2.8",
"mixin-deep": "github:cloudrac3r/mixin-deep#v3.0.0",
"node-fetch": "^2.6.7", "node-fetch": "^2.6.7",
"pngjs": "github:cloudrac3r/pngjs#v7.0.2",
"prettier-bytes": "^1.0.4", "prettier-bytes": "^1.0.4",
"sharp": "^0.33.4", "sharp": "^0.32.6",
"snowtransfer": "^0.10.5", "snowtransfer": "^0.10.5",
"stream-mime-type": "^1.0.2", "stream-mime-type": "^1.0.2",
"try-to-catch": "^3.0.1", "try-to-catch": "^3.0.1",
"uqr": "^0.1.2", "turndown": "^7.1.2",
"xxhash-wasm": "^1.0.2", "xxhash-wasm": "^1.0.2"
"zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@cloudrac3r/tap-dot": "^2.0.3",
"@types/node": "^18.16.0", "@types/node": "^18.16.0",
"@types/node-fetch": "^2.6.3", "@types/node-fetch": "^2.6.3",
"c8": "^10.1.2", "c8": "^8.0.1",
"colorette": "^1.4.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"discord-api-types": "^0.37.60", "discord-api-types": "^0.37.60",
"supertape": "^10.4.0" "supertape": "^10.3.0",
"tap-dot": "github:cloudrac3r/tap-dot#9dd7750ececeae3a96afba91905be812b6b2cc2d"
}, },
"scripts": { "scripts": {
"start": "node start.js",
"setup": "node scripts/setup.js",
"addbot": "node addbot.js", "addbot": "node addbot.js",
"test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap test/test.js | tap-dot", "test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap test/test.js | tap-dot",
"test-slow": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js -- --slow | tap-dot", "test-slow": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js -- --slow | tap-dot",
"cover": "c8 -o test/coverage --skip-full -x db/migrations -x src/matrix/file.js -x src/matrix/api.js -x src/matrix/mreq.js -x src/d2m/converters/rlottie-wasm.js -r html -r text supertape --no-check-assertions-count --format fail --no-worker test/test.js -- --slow" "cover": "c8 --skip-full -x db/migrations -x matrix/file.js -x matrix/api.js -x matrix/mreq.js -x d2m/converters/rlottie-wasm.js -r html -r text supertape --no-check-assertions-count --format fail --no-worker test/test.js -- --slow"
} }
} }

View file

@ -3,10 +3,11 @@
/** /**
* @typedef {Object} Passthrough * @typedef {Object} Passthrough
* @property {import("repl").REPLServer} repl * @property {import("repl").REPLServer} repl
* @property {typeof import("./config")} config
* @property {import("./d2m/discord-client")} discord * @property {import("./d2m/discord-client")} discord
* @property {import("heatsync").default} sync * @property {import("heatsync")} sync
* @property {import("better-sqlite3/lib/database")} db * @property {import("better-sqlite3/lib/database")} db
* @property {import("@cloudrac3r/in-your-element").AppService} as * @property {import("matrix-appservice").AppService} as
* @property {import("./db/orm").from} from * @property {import("./db/orm").from} from
* @property {import("./db/orm").select} select * @property {import("./db/orm").select} select
*/ */

100
readme.md
View file

@ -41,7 +41,7 @@ Most features you'd expect in both directions, plus a little extra spice:
* Custom emoji list syncing * Custom emoji list syncing
* Custom emojis in messages * Custom emojis in messages
* Custom room names/avatars can be applied on Matrix-side * Custom room names/avatars can be applied on Matrix-side
* Larger files from Discord are linked instead of reuploaded to Matrix (links don't expire) * Larger files from Discord are linked instead of reuploaded to Matrix
* Simulated user accounts are named @the_persons_username rather than @112233445566778899 * Simulated user accounts are named @the_persons_username rather than @112233445566778899
For more information about features, [see the user guide.](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/user-guide.md) For more information about features, [see the user guide.](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/user-guide.md)
@ -55,7 +55,7 @@ For more information about features, [see the user guide.](https://gitdab.com/ca
Using WeatherStack as a thin layer between the bridge application and the Discord API lets us control exactly what data is cached in memory. Only necessary information is cached. For example, member data, user data, message content, and past edits are never stored in memory. This keeps the memory usage low and also prevents it ballooning in size over the bridge's runtime. Using WeatherStack as a thin layer between the bridge application and the Discord API lets us control exactly what data is cached in memory. Only necessary information is cached. For example, member data, user data, message content, and past edits are never stored in memory. This keeps the memory usage low and also prevents it ballooning in size over the bridge's runtime.
The bridge uses a small SQLite database to store relationships like which Discord messages correspond to which Matrix messages. This is so the bridge knows what to edit when some message is edited on Discord. Using `without rowid` on the database tables stores the index and the data in the same B-tree. Since Matrix and Discord's internal IDs are quite long, this vastly reduces storage space because those IDs do not have to be stored twice separately. Some event IDs and URLs are actually stored as xxhash integers to reduce storage requirements even more. On my personal instance of OOYE, every 300,000 messages (representing a year of conversations) requires 47.3 MB of storage space in the SQLite database. The bridge uses a small SQLite database to store relationships like which Discord messages correspond to which Matrix messages. This is so the bridge knows what to edit when some message is edited on Discord. Using `without rowid` on the database tables stores the index and the data in the same B-tree. Since Matrix and Discord's internal IDs are quite long, this vastly reduces storage space because those IDs do not have to be stored twice separately. Some event IDs are actually stored as xxhash integers to reduce storage requirements even more. On my personal instance of OOYE, every 100,000 messages require 16.1 MB of storage space in the SQLite database.
Only necessary data and columns are queried from the database. We only contact the homeserver API if the database doesn't contain what we need. Only necessary data and columns are queried from the database. We only contact the homeserver API if the database doesn't contain what we need.
@ -74,50 +74,52 @@ You'll need:
Follow these steps: Follow these steps:
1. [Get Node.js version 20 or later](https://nodejs.org/en/download/prebuilt-installer) 1. [Get Node.js version 18 or later](https://nodejs.org/en/download/releases) (the version is required by the better-sqlite3 and matrix-appservice dependencies)
1. Clone this repo and checkout a specific tag. (Development happens on main. Stable versions are tagged.) 1. Clone this repo and checkout a specific tag. (Development happens on main. Stable versions are tagged.)
* The latest release tag is ![](https://img.shields.io/gitea/v/release/cadence/out-of-your-element?gitea_url=https%3A%2F%2Fgitdab.com&style=flat-square&label=%20&color=black). * The latest release tag is ![](https://img.shields.io/gitea/v/release/cadence/out-of-your-element?gitea_url=https%3A%2F%2Fgitdab.com&style=flat-square&label=%20&color=black).
1. Install dependencies: `npm install` 1. Install dependencies: `npm install --save-dev` (omit --save-dev if you will not run the automated tests)
1. Run `npm run setup` to check your setup and set the bot's initial state. It will prompt you for information. You only need to run this once ever. 1. Copy `config.example.js` to `config.js` and fill in Discord token.
1. Start the bridge: `npm run start` 1. Copy `registration.example.yaml` to `registration.yaml` and fill in bracketed values. You could generate each hex string with `dd if=/dev/urandom bs=32 count=1 2> /dev/null | basenc --base16 | dd conv=lcase 2> /dev/null`. Register the registration in Synapse's `homeserver.yaml` through the usual appservice installation process, then restart Synapse.
1. Run `node scripts/seed.js` to check your setup and set the bot's initial state. You only need to run this once ever.
1. Make sure the tests work by running `npm t`
1. Start the bridge: `node start.js`
1. Add the bot to a server - use any *one* of the following commands for an invite link: 1. Add the bot to a server - use any *one* of the following commands for an invite link:
* (in the REPL) `addbot` * (in the REPL) `addbot`
* (in a chat) `//addbot`
* $ `node addbot.js` * $ `node addbot.js`
* $ `npm run addbot` * $ `npm run addbot`
* $ `./addbot.sh` * $ `./addbot.sh`
Now any message on Discord will create the corresponding rooms on Matrix-side. After the rooms have been created, Matrix and Discord users can chat back and forth. Now any message on Discord will create the corresponding rooms on Matrix-side. After the rooms have been created, Matrix and Discord users can chat back and forth.
To get into the rooms on your Matrix account, use the `/invite [your mxid here]` command on Discord. To get into the rooms on your Matrix account, either add yourself to `invite` in `registration.yaml`, or use the `//invite [your mxid here]` command on Discord.
# Development setup # Development setup
* Install development dependencies with `npm install --save-dev` so you can run the tests. * Be sure to install dependencies with `--save-dev` so you can run the tests.
* Most files you change, such as actions, converters, and web, will automatically be reloaded. * Any files you change will automatically be reloaded, except for `stdin.js` and `d2m/discord-*.js`.
* If developing on a different computer to the one running the homeserver, use SSH port forwarding so that Synapse can connect on its `localhost:6693` to reach the running bridge on your computer. Example: `ssh -T -v -R 6693:localhost:6693 me@matrix.cadence.moe` * If developing on a different computer to the one running the homeserver, use SSH port forwarding so that Synapse can connect on its `localhost:6693` to reach the running bridge on your computer. Example: `ssh -T -v -R 6693:localhost:6693 me@matrix.cadence.moe`
* I recommend developing in Visual Studio Code so that the JSDoc x TypeScript annotation comments work. I don't know which other editors or language servers support annotations and type inference. * I recommend developing in Visual Studio Code so that the JSDoc x TypeScript annotation comments work. I don't know which other editors or language servers support annotations and type inference.
## Repository structure ## Repository structure
. .
* Run this to start the bridge:
├── start.js
* Runtime configuration, like tokens and user info: * Runtime configuration, like tokens and user info:
├── config.js
├── registration.yaml ├── registration.yaml
* You are here! :)
├── readme.md
* The bridge's SQLite database is stored here: * The bridge's SQLite database is stored here:
├── ooye.db*
* Source code
└── src
* Database schema:
├── db ├── db
│   ├── orm.js, orm-defs.d.ts │   ├── *.sql, *.db
│   * Migrations change the database schema when you update to a newer version of OOYE: │   * Migrations change the database schema when you update to a newer version of OOYE:
│   ├── migrate.js
│   └── migrations │   └── migrations
│       └── *.sql, *.js │       └── *.sql, *.js
* Discord-to-Matrix bridging: * Discord-to-Matrix bridging:
@ -132,10 +134,7 @@ To get into the rooms on your Matrix account, use the `/invite [your mxid here]`
│   ├── discord-*.js │   ├── discord-*.js
│   * Listening to events from Discord and dispatching them to the correct `action`: │   * Listening to events from Discord and dispatching them to the correct `action`:
│   └── event-dispatcher.js │   └── event-dispatcher.js
* Discord bot commands and menus:
├── discord ├── discord
│   ├── interactions
│   │   └── *.js
│   └── discord-command-handler.js │   └── discord-command-handler.js
* Matrix-to-Discord bridging: * Matrix-to-Discord bridging:
├── m2d ├── m2d
@ -151,50 +150,37 @@ To get into the rooms on your Matrix account, use the `/invite [your mxid here]`
├── matrix ├── matrix
│   └── *.js │   └── *.js
* Various files you can run once if you need them. * Various files you can run once if you need them.
└── scripts ├── scripts
* First time running a new bridge? Run this file to set up prerequisites on the Matrix server: │   * First time running a new bridge? Run this file to plant a seed, which will flourish into state for the bridge:
├── setup.js │   ├── seed.js
* Hopefully you won't need the rest of these. Code quality varies wildly. │   * Hopefully you won't need the rest of these. Code quality varies wildly.
└── *.js │   └── *.js
* You are here! :)
└── readme.md
## Dependency justification ## Dependency justification
Total transitive production dependencies: 147 (deduped transitive dependency count) dependency name: explanation
### <font size="+2">🦕</font> * (0) @chriscdn/promise-semaphore: It does what I want! I like it!
* (42) better-sqlite3: SQLite3 is the best database, and this is the best library for it. Really! I love it.
* (31) better-sqlite3: SQLite3 is the best database, and this is the best library for it.
* (27) @cloudrac3r/pug: Language for dynamic web pages. This is my fork. (I released code that hadn't made it to npm, and removed the heavy pug-filters feature.)
* (16) stream-mime-type@1: This seems like the best option. Version 1 is used because version 2 is ESM-only.
* (14) h3: Web server. OOYE needs this for the appservice listener, authmedia proxy, and more. 14 transitive dependencies is on the low end for a web server.
* (11) sharp: Image resizing and compositing. OOYE needs this for the emoji sprite sheets.
### <font size="-1">🪱</font>
* (0) @chriscdn/promise-semaphore: It does what I want.
* (1) @cloudrac3r/discord-markdown: This is my fork.
* (0) @cloudrac3r/giframe: This is my fork.
* (1) @cloudrac3r/html-template-tag: This is my fork.
* (0) @cloudrac3r/in-your-element: This is my Matrix Appservice API library. It depends on h3 and zod, which are already pulled in by OOYE.
* (0) @cloudrac3r/mixin-deep: This is my fork. (It fixes a bug in regular mixin-deep.)
* (0) @cloudrac3r/pngjs: Lottie stickers are converted to bitmaps with the vendored Rlottie WASM build, then the bitmaps are converted to PNG with pngjs.
* (0) @cloudrac3r/turndown: This HTML-to-Markdown converter looked the most suitable. I forked it to change the escaping logic to match the way Discord works.
* (3) @stackoverflow/stacks: Stack Overflow design language and icons.
* (0) ansi-colors: Helps with interactive prompting for the initial setup, and it's already pulled in by enquirer.
* (1) chunk-text: It does what I want. * (1) chunk-text: It does what I want.
* (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust. * (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust.
* (0) domino: DOM implementation that's already pulled in by turndown. * (8) snowtransfer: Discord API library with bring-your-own-caching that I trust.
* (1) enquirer: Interactive prompting for the initial setup rather than forcing users to edit YAML non-interactively. * (0) deep-equal: It's already pulled in by supertape.
* (0) entities: Looks fine. No dependencies. * (1) discord-markdown: This is my fork!
* (0) get-stream: Only needed if content_length_workaround is true. * (0) get-stream: Only needed if content_length_workaround is true.
* (0) giframe: This is my fork!
* (1) heatsync: Module hot-reloader that I trust. * (1) heatsync: Module hot-reloader that I trust.
* (1) js-yaml: Will be removed in the future after registration.yaml is converted to JSON. * (0) entities: Looks fine. No dependencies.
* (0) lru-cache: For holding unused nonce in memory and letting them be overwritten later if never used. * (1) html-template-tag: This is my fork!
* (1) js-yaml: It seems to do what I want, and it's already pulled in by matrix-appservice.
* (70) matrix-appservice: I wish it didn't pull in express :(
* (0) minimist: It's already pulled in by better-sqlite3->prebuild-install. * (0) minimist: It's already pulled in by better-sqlite3->prebuild-install.
* (3) node-fetch@2: I like it and it does what I want. Version 2 is used because version 3 is ESM-only. * (0) mixin-deep: This is my fork! (It fixes a bug in regular mixin-deep.)
* (3) node-fetch@2: I like it and it does what I want.
* (0) pngjs: Lottie stickers are converted to bitmaps with the vendored Rlottie WASM build, then the bitmaps are converted to PNG with pngjs.
* (0) prettier-bytes: It does what I want and has no dependencies. * (0) prettier-bytes: It does what I want and has no dependencies.
* (2) snowtransfer: Discord API library with bring-your-own-caching that I trust. * (51) sharp: Jimp has fewer dependencies, but sharp is faster.
* (0) try-to-catch: Not strictly necessary, but it's already pulled in by supertape, so I may as well. * (0) try-to-catch: Not strictly necessary, but it does what I want and has no dependencies.
* (0) uqr: QR code SVG generator. Used on the website to scan in an invite link. * (1) turndown: I need an HTML-to-Markdown converter and this one looked suitable enough. It has some bugs that I've worked around, so I might switch away from it later.
* (0) xxhash-wasm: Used where cryptographically secure hashing is not required.
* (0) zod: Input validation for the web server. It's popular and easy to use.

24
registration.example.yaml Normal file
View file

@ -0,0 +1,24 @@
id: de8c56117637cb5d9f4ac216f612dc2adb1de4c09ae8d13553f28c33a28147c7
hs_token: [a unique 64 character hex string]
as_token: [a unique 64 character hex string]
url: http://localhost:6693
sender_localpart: _ooye_bot
protocols:
- discord
namespaces:
users:
- exclusive: true
regex: '@_ooye_.*'
aliases:
- exclusive: true
regex: '#_ooye_.*'
rate_limited: false
ooye:
namespace_prefix: _ooye_
max_file_size: 5000000
server_name: [the part after the colon in your matrix id, like cadence.moe]
server_origin: [the full protocol and domain of your actual matrix server's location, with no trailing slash, like https://matrix.cadence.moe]
content_length_workaround: false
invite:
# uncomment this to auto-invite the named user to newly created spaces and mark them as admin (PL 100) everywhere
# - '@cadence:cadence.moe'

13
scripts/capture-message-update-events.js Executable file → Normal file
View file

@ -1,4 +1,3 @@
#!/usr/bin/env node
// @ts-check // @ts-check
// **** // ****
@ -17,16 +16,16 @@ function fieldToPresenceValue(field) {
const sqlite = require("better-sqlite3") const sqlite = require("better-sqlite3")
const HeatSync = require("heatsync") const HeatSync = require("heatsync")
const {reg} = require("../src/matrix/read-registration") const config = require("../config")
const passthrough = require("../src/passthrough") const passthrough = require("../passthrough")
const sync = new HeatSync({watchFS: false}) const sync = new HeatSync({watchFS: false})
Object.assign(passthrough, {sync}) Object.assign(passthrough, {config, sync})
const DiscordClient = require("../src/d2m/discord-client") const DiscordClient = require("../d2m/discord-client")
const discord = new DiscordClient(reg.ooye.discord_token, "no") const discord = new DiscordClient(config.discordToken, "no")
passthrough.discord = discord passthrough.discord = discord
;(async () => { ;(async () => {
@ -38,7 +37,7 @@ passthrough.discord = discord
})() })()
const events = new sqlite("scripts/events.db") const events = new sqlite("scripts/events.db")
const sql = "INSERT INTO update_event (json, " + interestingFields.join(", ") + ") VALUES (" + "?".repeat(interestingFields.length + 1).split("").join(", ") + ")" const sql = "INSERT INTO \"update\" (json, " + interestingFields.join(", ") + ") VALUES (" + "?".repeat(interestingFields.length + 1).split("").join(", ") + ")"
console.log(sql) console.log(sql)
const prepared = events.prepare(sql) const prepared = events.prepare(sql)

10
scripts/check-migrate.js Executable file → Normal file
View file

@ -1,4 +1,3 @@
#!/usr/bin/env node
// @ts-check // @ts-check
// Trigger the database migration flow and exit after committing. // Trigger the database migration flow and exit after committing.
@ -6,10 +5,11 @@
const sqlite = require("better-sqlite3") const sqlite = require("better-sqlite3")
const passthrough = require("../src/passthrough") const config = require("../config")
const db = new sqlite("ooye.db") const passthrough = require("../passthrough")
const migrate = require("../src/db/migrate") const db = new sqlite("db/ooye.db")
const migrate = require("../db/migrate")
Object.assign(passthrough, {db}) Object.assign(passthrough, {config, db })
migrate.migrate(db) migrate.migrate(db)

23
scripts/migrate-from-old-bridge.js Executable file → Normal file
View file

@ -1,4 +1,3 @@
#!/usr/bin/env node
// @ts-check // @ts-check
const assert = require("assert").strict const assert = require("assert").strict
@ -7,17 +6,19 @@ const Semaphore = require("@chriscdn/promise-semaphore")
const sqlite = require("better-sqlite3") const sqlite = require("better-sqlite3")
const HeatSync = require("heatsync") const HeatSync = require("heatsync")
const passthrough = require("../src/passthrough") const config = require("../config")
const passthrough = require("../passthrough")
const sync = new HeatSync({watchFS: false}) const sync = new HeatSync({watchFS: false})
const {reg} = require("../src/matrix/read-registration") /** @type {import("../matrix/read-registration")} */
const reg = sync.require("../matrix/read-registration")
assert(reg.old_bridge) assert(reg.old_bridge)
const oldAT = reg.old_bridge.as_token const oldAT = reg.old_bridge.as_token
const newAT = reg.as_token const newAT = reg.as_token
const oldDB = new sqlite(reg.old_bridge.database) const oldDB = new sqlite(reg.old_bridge.database)
const db = new sqlite("ooye.db") const db = new sqlite("db/ooye.db")
db.exec(`CREATE TABLE IF NOT EXISTS half_shot_migration ( db.exec(`CREATE TABLE IF NOT EXISTS half_shot_migration (
discord_channel TEXT NOT NULL, discord_channel TEXT NOT NULL,
@ -25,19 +26,19 @@ db.exec(`CREATE TABLE IF NOT EXISTS half_shot_migration (
PRIMARY KEY("discord_channel") PRIMARY KEY("discord_channel")
) WITHOUT ROWID;`) ) WITHOUT ROWID;`)
Object.assign(passthrough, {sync, db}) Object.assign(passthrough, {config, sync, db})
const DiscordClient = require("../src/d2m/discord-client") const DiscordClient = require("../d2m/discord-client")
const discord = new DiscordClient(reg.ooye.discord_token, "half") const discord = new DiscordClient(config.discordToken, "half")
passthrough.discord = discord passthrough.discord = discord
/** @type {import("../src/d2m/actions/create-space")} */ /** @type {import("../d2m/actions/create-space")} */
const createSpace = sync.require("../d2m/actions/create-space") const createSpace = sync.require("../d2m/actions/create-space")
/** @type {import("../src/d2m/actions/create-room")} */ /** @type {import("../d2m/actions/create-room")} */
const createRoom = sync.require("../d2m/actions/create-room") const createRoom = sync.require("../d2m/actions/create-room")
/** @type {import("../src/matrix/mreq")} */ /** @type {import("../matrix/mreq")} */
const mreq = sync.require("../matrix/mreq") const mreq = sync.require("../matrix/mreq")
/** @type {import("../src/matrix/api")} */ /** @type {import("../matrix/api")} */
const api = sync.require("../matrix/api") const api = sync.require("../matrix/api")
const sema = new Semaphore() const sema = new Semaphore()

22
scripts/register.js Normal file
View file

@ -0,0 +1,22 @@
// @ts-check
const { AppServiceRegistration } = require("matrix-appservice");
let id = AppServiceRegistration.generateToken()
try {
const reg = require("../matrix/read-registration")
if (reg.id) id = reg.id
} catch (e) {}
// creating registration files
const newReg = new AppServiceRegistration(null);
newReg.setAppServiceUrl("http://localhost:6693");
newReg.setId(id);
newReg.setHomeserverToken(AppServiceRegistration.generateToken());
newReg.setAppServiceToken(AppServiceRegistration.generateToken());
newReg.setSenderLocalpart("_ooye_bot");
newReg.addRegexPattern("users", "@_ooye_.*", true);
newReg.addRegexPattern("aliases", "#_ooye_.*", true);
newReg.setProtocols(["discord"]); // For 3PID lookups
newReg.setRateLimited(false);
newReg.outputAsYaml("registration.yaml");

Some files were not shown because too many files have changed in this diff Show more