Compare commits

..

No commits in common. "main" and "v2.3" have entirely different histories.
main ... v2.3

151 changed files with 1146 additions and 5506 deletions

13
.gitignore vendored
View file

@ -1,16 +1,7 @@
# Secrets
node_modules
config.js
registration.yaml
ooye.db*
events.db*
# Automatically generated
node_modules
coverage
db/ooye.db*
test/res/*
!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
const {reg} = require("./src/matrix/read-registration")
const token = reg.ooye.discord_token
const id = Buffer.from(token.split(".")[0], "base64").toString()
const config = require("./config")
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 `
}
/* c8 ignore next 3 */
if (process.argv.find(a => a.endsWith("addbot") || a.endsWith("addbot.js"))) {
console.log(addbot())
}
module.exports.id = id
module.exports.addbot = addbot

View file

@ -1,3 +1,3 @@
#!/usr/bin/env sh
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 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 {discord, sync, db, select} = passthrough
@ -15,6 +14,8 @@ const api = sync.require("../../matrix/api")
const ks = sync.require("../../matrix/kstate")
/** @type {import("../../discord/utils")} */
const utils = sync.require("../../discord/utils")
/** @type {import("./create-space")}) */
const createSpace = sync.require("./create-space") // watch out for the require loop
/**
* There are 3 levels of room privacy:
@ -88,36 +89,41 @@ function convertNameAndTopic(channel, guild, customName) {
* Async because it may create the guild and/or upload the guild icon to mxc.
* @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel
* @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 parentChannel = discord.channels.get(channel.parent_id)
const guildRow = select("guild_space", ["space_id", "privacy_level"], {guild_id: guild.id}).get()
assert(guildRow)
/** Used for membership/permission checks. */
let guildSpaceID = guildRow.space_id
let guildSpaceID
/** 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. */
let parentSpaceID = guildSpaceID
if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum) {
let parentSpaceID
let privacyLevel
if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum) { // it's a forum channel's thread, so use a different space to group those threads
guildSpaceID = await createSpace.ensureSpace(guild)
parentSpaceID = await ensureRoom(channel.parent_id)
assert(typeof parentSpaceID === "string")
privacyLevel = select("guild_space", "privacy_level", {space_id: guildSpaceID}).pluck().get()
} else { // otherwise use the guild's space like usual
parentSpaceID = await createSpace.ensureSpace(guild)
guildSpaceID = parentSpaceID
privacyLevel = select("guild_space", "privacy_level", {space_id: parentSpaceID}).pluck().get()
}
assert(typeof parentSpaceID === "string")
assert(typeof guildSpaceID === "string")
assert(typeof privacyLevel === "number")
const channelRow = select("channel_room", ["nick", "custom_avatar"], {channel_id: channel.id}).get()
const customName = channelRow?.nick
const customAvatar = channelRow?.custom_avatar
const row = select("channel_room", ["nick", "custom_avatar"], {channel_id: channel.id}).get()
const customName = row?.nick
const customAvatar = row?.custom_avatar
const [convertedName, convertedTopic] = convertNameAndTopic(channel, guild, customName)
const avatarEventContent = {}
if (customAvatar) {
avatarEventContent.url = customAvatar
} 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]
if (channel["thread_metadata"]) history_visibility = "world_readable"
@ -136,13 +142,6 @@ async function channelToKState(channel, guild, di) {
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 = {
"m.room.name/": {name: convertedName},
"m.room.topic/": {topic: convertedTopic},
@ -156,10 +155,13 @@ async function channelToKState(channel, guild, di) {
/** @type {{join_rule: string, [x: string]: any}} */
"m.room.join_rules/": join_rules,
"m.room.power_levels/": {
events: {
"m.room.avatar": 0
},
notifications: {
room: everyoneCanMentionEveryone ? 0 : 20
},
users: {...spacePower, ...globalAdminPower}
users: reg.ooye.invite.reduce((a, c) => (a[c] = 100, a), {})
},
"chat.schildi.hide_ui/read_receipts": {
hidden: true
@ -173,7 +175,7 @@ async function channelToKState(channel, guild, di) {
network: {
id: guild.id,
displayname: guild.name,
avatar_url: {$url: file.guildIcon(guild)}
avatar_url: await file.uploadDiscordFileToMxc(file.guildIcon(guild))
},
channel: {
id: channel.id,
@ -272,61 +274,6 @@ function channelToGuild(channel) {
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:
1. Get IDs
@ -345,7 +292,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 {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
@ -360,11 +306,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
}
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 {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)
inflightRoomCreate.delete(channelID) // OK to release inflight waiters now. they will read the correct `existing` row
return roomID
@ -381,7 +327,7 @@ async function _syncRoom(channelID, shouldActuallySync) {
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
const roomKState = await roomToKState(roomID)
@ -402,12 +348,12 @@ async function _syncRoom(channelID, shouldActuallySync) {
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) {
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) {
return _syncRoom(channelID, true)
}
@ -416,16 +362,11 @@ async function _unbridgeRoom(channelID) {
/** @ts-ignore @type {DiscordTypes.APIGuildChannel} */
const channel = discord.channels.get(channelID)
assert.ok(channel)
assert.ok(channel.guild_id)
return unbridgeDeletedChannel(channel, channel.guild_id)
return unbridgeDeletedChannel(channel.id, channel.guild_id)
}
/**
* @param {{id: string, topic?: string?}} channel
* @param {string} guildID
*/
async function unbridgeDeletedChannel(channel, guildID) {
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
async function unbridgeDeletedChannel(channelID, guildID) {
const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
assert.ok(roomID)
const spaceID = select("guild_space", "space_id", {guild_id: guildID}).pluck().get()
assert.ok(spaceID)
@ -435,11 +376,7 @@ async function unbridgeDeletedChannel(channel, guildID) {
await api.sendState(spaceID, "m.space.child", roomID, {})
// remove declaration that the room is bridged
await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channel.id}`, {})
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 || ""})
}
await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channelID}`, {})
// send a notification in the room
await api.sendEvent(roomID, "m.room.message", {
@ -451,7 +388,8 @@ async function unbridgeDeletedChannel(channel, guildID) {
await api.leaveRoom(roomID)
// 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 +442,3 @@ module.exports.postApplyPowerLevels = postApplyPowerLevels
module.exports._convertNameAndTopic = convertNameAndTopic
module.exports._unbridgeRoom = _unbridgeRoom
module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel
module.exports.existsOrAutocreatable = existsOrAutocreatable
module.exports.assertExistsOrAutocreatable = assertExistsOrAutocreatable

View file

@ -4,94 +4,50 @@ const mixin = require("@cloudrac3r/mixin-deep")
const {channelToKState, _convertNameAndTopic} = require("./create-room")
const {kstateStripConditionals} = require("../../matrix/kstate")
const {test} = require("supertape")
const testData = require("../../../test/data")
const testData = require("../../test/data")
const passthrough = require("../../passthrough")
const {db} = passthrough
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()
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, {
"m.room.guest_access/": {guest_access: "forbidden"},
"m.room.join_rules/": {join_rule: "public"},
"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/"])
"m.room.history_visibility/": {history_visibility: "world_readable"}
})
)
t.equal(called, 1)
})
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()
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, {
"m.room.guest_access/": {guest_access: "forbidden"},
"m.room.join_rules/": {join_rule: "public"},
"m.room.power_levels/": mixin({users: {"@example:matrix.org": 50}}, testData.room.general["m.room.power_levels/"])
"m.room.join_rules/": {join_rule: "public"}
})
)
t.equal(called, 1)
})
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()
t.deepEqual(
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)),
Object.assign({}, testData.room.general, {
"m.room.power_levels/": mixin({users: {"@example:matrix.org": 50}}, testData.room.general["m.room.power_levels/"])
})
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general).then(x => x.channelKState)),
testData.room.general
)
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}
}})
const limitedRoom = mixin({}, testData.room.general, {"m.room.power_levels/": {notifications: {room: 20}}})
t.deepEqual(
kstateStripConditionals(await channelToKState(testData.channel.general, limitedGuild, {api: {getStateEvent}}).then(x => x.channelKState)),
kstateStripConditionals(await channelToKState(testData.channel.general, limitedGuild).then(x => x.channelKState)),
limitedRoom
)
t.equal(called, 1)
})
test("convertNameAndTopic: custom name and topic", t => {

View file

@ -4,7 +4,7 @@ const assert = require("assert").strict
const {isDeepStrictEqual} = require("util")
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 {discord, sync, db, select} = passthrough
@ -31,8 +31,6 @@ async function createSpace(guild, kstate) {
const topic = kstate["m.room.topic/"]?.topic || undefined
assert(name)
const globalAdmins = select("member_power", "mxid", {room_id: "*"}).pluck().all()
const roomID = await createRoom.postApplyPowerLevels(kstate, async kstate => {
return api.createRoom({
name,
@ -42,7 +40,7 @@ async function createSpace(guild, kstate) {
events_default: 100, // space can only be managed by bridge
invite: 0 // any existing member can invite others
},
invite: globalAdmins,
invite: reg.ooye.invite,
topic,
creation_content: {
type: "m.space"
@ -60,17 +58,17 @@ async function createSpace(guild, kstate) {
*/
async function guildToKState(guild, privacyLevel) {
assert.equal(typeof privacyLevel, "number")
const globalAdmins = select("member_power", ["mxid", "power_level"], {room_id: "*"}).all()
const guildKState = {
"m.room.name/": {name: guild.name},
"m.room.avatar/": {
$if: guild.icon,
discord_path: file.guildIcon(guild),
url: {$url: file.guildIcon(guild)}
},
"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.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
@ -92,9 +90,6 @@ async function _syncSpace(guild, shouldActuallySync) {
const row = select("guild_space", ["space_id", "privacy_level"], {guild_id: guild.id}).get()
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 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)
@ -187,15 +182,24 @@ async function syncSpaceFully(guildID) {
const spaceDiff = ks.diffKState(spaceKState, guildKState)
await createRoom.applyKStateDiffToRoom(spaceID, spaceDiff)
const childRooms = await api.getFullHierarchy(spaceID)
/** @type {string[]} room IDs */
let childRooms = []
/** @type {string | undefined} */
let nextBatch = undefined
do {
/** @type {Ty.HierarchyPagination<Ty.R.Hierarchy>} */
const res = await api.getHierarchy(spaceID, {from: nextBatch})
childRooms.push(...res.rooms.map(room => room.room_id))
nextBatch = res.next_batch
} while (nextBatch)
for (const {room_id} of childRooms) {
const channelID = select("channel_room", "channel_id", {room_id}).pluck().get()
for (const roomID of childRooms) {
const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get()
if (!channelID) continue
if (discord.channels.has(channelID)) {
await createRoom.syncRoom(channelID)
} else {
await createRoom.unbridgeDeletedChannel({id: channelID}, guildID)
await createRoom.unbridgeDeletedChannel(channelID, guildID)
}
}

View file

@ -4,7 +4,7 @@ const mixin = require("@cloudrac3r/mixin-deep")
const {guildToKState, ensureSpace} = require("./create-space")
const {kstateStripConditionals, kstateUploadMxc} = require("../../matrix/kstate")
const {test} = require("supertape")
const testData = require("../../../test/data")
const testData = require("../../test/data")
const passthrough = require("../../passthrough")
const {db} = passthrough
@ -14,6 +14,7 @@ test("guild2space: can generate kstate for a guild, passing privacy level 0", as
await kstateUploadMxc(kstateStripConditionals(await guildToKState(testData.guild.general, 0))),
{
"m.room.avatar/": {
discord_path: "/icons/112760669178241024/a_f83622e09ead74f0c5c527fe241f8f8c.png?size=1024",
url: "mxc://cadence.moe/zKXGZhmImMHuGQZWJEFKJbsF"
},
"m.room.guest_access/": {

View file

@ -53,7 +53,7 @@ async function editMessage(message, guild, row) {
const sendNewEventParts = new Set()
for (const promotion of promotions) {
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) {
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)
}).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
}
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 + "~"
shortcodes.push(shortcode)
result.images[shortcode] = {
result.images[shortcodes] = {
info: {
mimetype: file.stickerFormat.get(sticker.format_type)?.mime || "image/png"
},

View file

@ -1,7 +1,7 @@
// @ts-check
const assert = require("assert")
const {reg} = require("../../matrix/read-registration")
const reg = require("../../matrix/read-registration")
const Ty = require("../../types")
const fetch = require("node-fetch").default

View file

@ -1,7 +1,7 @@
// @ts-check
const assert = require("assert").strict
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")

View file

@ -1,6 +1,6 @@
const {_memberToStateContent} = require("./register-user")
const {test} = require("supertape")
const testData = require("../../../test/data")
const testData = require("../../test/data")
test("member2state: without member nick or avatar", async t => {
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()
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
const removals = await

View file

@ -12,7 +12,6 @@ function debugRetrigger(message) {
}
}
const paused = new Set()
const emitter = new EventEmitter()
/**
@ -26,15 +25,13 @@ const emitter = new EventEmitter()
* @returns {boolean} false if the event was found and the function will be ignored, true if the event was not found and the function will be retriggered
*/
function eventNotFoundThenRetrigger(messageID, fn, ...rest) {
if (!paused.has(messageID)) {
const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get()
if (eventID) {
debugRetrigger(`[retrigger] OK mid <-> eid = ${messageID} <-> ${eventID}`)
return false // event was found so don't retrigger
}
const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get()
if (eventID) {
debugRetrigger(`[retrigger] OK mid <-> eid = ${messageID} <-> ${eventID}`)
return false // event was found so don't retrigger
}
debugRetrigger(`[retrigger] WAIT mid = ${messageID}`)
debugRetrigger(`[retrigger] WAIT mid <-> eid = ${messageID} <-> ${eventID}`)
emitter.once(messageID, () => {
debugRetrigger(`[retrigger] TRIGGER mid = ${messageID}`)
fn(...rest)
@ -49,25 +46,6 @@ function eventNotFoundThenRetrigger(messageID, fn, ...rest) {
return true // event was not found, then retrigger
}
/**
* Anything calling retrigger during the callback will be paused and retriggered after the callback resolves.
* @template T
* @param {string} messageID
* @param {Promise<T>} promise
* @returns {Promise<T>}
*/
async function pauseChanges(messageID, promise) {
try {
debugRetrigger(`[retrigger] PAUSE mid = ${messageID}`)
paused.add(messageID)
return await promise
} finally {
debugRetrigger(`[retrigger] RESUME mid = ${messageID}`)
paused.delete(messageID)
messageFinishedBridging(messageID)
}
}
/**
* Triggers any pending operations that were waiting on the corresponding event ID.
* @param {string} messageID
@ -81,4 +59,3 @@ function messageFinishedBridging(messageID) {
module.exports.eventNotFoundThenRetrigger = eventNotFoundThenRetrigger
module.exports.messageFinishedBridging = messageFinishedBridging
module.exports.pauseChanges = pauseChanges

View file

@ -24,13 +24,14 @@ function eventCanBeEdited(ev) {
/**
* @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("../../matrix/api")} api simple-as-nails dependency injection for the matrix 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)
@ -122,44 +123,28 @@ async function editToChanges(message, guild, api) {
eventsToReplace = eventsToReplace.filter(eventCanBeEdited)
// 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.
// 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})[]} */
/** @type {({column: string, eventID: string} | {column: string, nextEvent: true})[]} */
const promotions = []
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 (!candidatesForParts.some(e => e.old[column] === 0)) {
// Try to find an existing event to promote. Bigger order is better.
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")
candidatesForParts.sort((a, b) => order(b) - order(a))
if (column === "part") {
promotions.push({column, eventID: candidatesForParts[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 {
promotions.push({column, eventID: candidatesForParts[candidatesForParts.length - 1].old.event_id}) // reaction_part should be the last one
}
}
// Or, if there are no existing events to promote and new events will be sent, whatever gets sent will be the next part = 0.
else {
} else {
// No existing events to promote, but new events are being sent. Whatever gets sent will be the next part = 0.
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)
if (eventsToSend.length && !promotions.length) {
const existingReaction = select("reaction", "message_id", {message_id: message.id}).pluck().get()
if (!existingReaction) {
const existingPartZero = unchangedEvents.concat(eventsToReplace).find(p => p.old.reaction_part === 0)
assert(existingPartZero) // will exist because a reaction_part=0 always exists and no events are being removed
promotions.push({column: "reaction_part", eventID: existingPartZero.old.event_id, value: 1}) // update the current reaction_part to 1
promotions.push({column: "reaction_part", nextEvent: true}) // the newly created event will have reaction_part = 0
}
}
// Removing unnecessary properties before returning
eventsToRedact = eventsToRedact.map(e => e.old.event_id)
eventsToReplace = eventsToReplace.map(e => ({oldID: e.old.event_id, newContent: makeReplacementEventContent(e.old.event_id, e.newFallbackContent, e.newInnerContent)}))

View file

@ -1,6 +1,6 @@
const {test} = require("supertape")
const {editToChanges} = require("./edit-to-changes")
const data = require("../../../test/data")
const data = require("../../test/data")
const Ty = require("../../types")
test("edit2changes: edit by webhook", async t => {
@ -99,9 +99,9 @@ test("edit2changes: change file type", async t => {
t.deepEqual(eventsToRedact, ["$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCJ"])
t.deepEqual(eventsToSend, [{
$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",
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": {},
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}])
})
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, {})
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, [{
@ -266,62 +266,7 @@ test("edit2changes: generated embed", async t => {
+ `</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.deepEqual(promotions, []) // TODO: it would be ideal to promote this to reaction_part = 0. this is OK to do because the main message won't have had any reactions yet.
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 {emojiToKey} = require("./emoji-to-key")
const data = require("../../../test/data")
const data = require("../../test/data")
const Ty = require("../../types")
test("emoji2key: unicode emoji works", async t => {

View file

@ -1,18 +1,11 @@
const {test} = require("supertape")
const {messageToEvent} = require("./message-to-event")
const data = require("../../../test/data")
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",
body: "> ↪️ @papiophidian: used `/stats`",
format: "org.matrix.custom.html",
formatted_body: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
"m.mentions": {},
msgtype: "m.text",
}, {
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.notice",
@ -67,7 +60,7 @@ test("message2event embeds: image embed and attachment", async t => {
msgtype: "m.image",
url: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR",
body: "Screenshot_20231001_034036.jpg",
external_url: "https://bridge.example.org/download/discordcdn/176333891320283136/1157854643037163610/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,
@ -150,13 +143,6 @@ test("message2event embeds: crazy html is all escaped", async t => {
test("message2event embeds: title without url", async t => {
const events = await messageToEvent(data.message_with_embeds.title_without_url, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
body: "> ↪️ @papiophidian: used `/stats`",
format: "org.matrix.custom.html",
formatted_body: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
"m.mentions": {},
msgtype: "m.text",
}, {
$type: "m.room.message",
msgtype: "m.notice",
body: "| ## Hi, I'm Amanda!\n| \n| I condone pirating music!",
@ -169,13 +155,6 @@ test("message2event embeds: title without url", async t => {
test("message2event embeds: url without title", async t => {
const events = await messageToEvent(data.message_with_embeds.url_without_title, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
body: "> ↪️ @papiophidian: used `/stats`",
format: "org.matrix.custom.html",
formatted_body: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
"m.mentions": {},
msgtype: "m.text",
}, {
$type: "m.room.message",
msgtype: "m.notice",
body: "| I condone pirating music!",
@ -188,13 +167,6 @@ test("message2event embeds: url without title", async t => {
test("message2event embeds: author without url", async t => {
const events = await messageToEvent(data.message_with_embeds.author_without_url, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
body: "> ↪️ @papiophidian: used `/stats`",
format: "org.matrix.custom.html",
formatted_body: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
"m.mentions": {},
msgtype: "m.text",
}, {
$type: "m.room.message",
msgtype: "m.notice",
body: "| ## Amanda\n| \n| I condone pirating music!",
@ -207,13 +179,6 @@ test("message2event embeds: author without url", async t => {
test("message2event embeds: author url without name", async t => {
const events = await messageToEvent(data.message_with_embeds.author_url_without_name, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
body: "> ↪️ @papiophidian: used `/stats`",
format: "org.matrix.custom.html",
formatted_body: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
"m.mentions": {},
msgtype: "m.text",
}, {
$type: "m.room.message",
msgtype: "m.notice",
body: "| I condone pirating music!",

View file

@ -18,7 +18,7 @@ const lottie = sync.require("../actions/lottie")
const mxUtils = sync.require("../../m2d/converters/utils")
/** @type {import("../../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))
@ -32,10 +32,7 @@ function getDiscordParseCallbacks(message, guild, useHTML) {
/** @param {{id: string, type: "discordUser"}} node */
user: node => {
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
|| (interaction?.user.id === node.id ? interaction.user.username : null)
|| node.id
const username = message.mentions.find(ment => ment.id === node.id)?.username || node.id
if (mxid && useHTML) {
return `<a href="https://matrix.to/#/${mxid}">@${username}</a>`
} else {
@ -103,7 +100,6 @@ const embedTitleParser = markdown.markdownEngine.parserFor({
* @param {DiscordTypes.APIAttachment} attachment
*/
async function attachmentToEvent(mentions, attachment) {
const external_url = dUtils.getPublicUrlForCdn(attachment.url)
const emoji =
attachment.content_type?.startsWith("image/jp") ? "📸"
: attachment.content_type?.startsWith("image/") ? "🖼️"
@ -117,9 +113,9 @@ async function attachmentToEvent(mentions, attachment) {
$type: "m.room.message",
"m.mentions": mentions,
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",
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
@ -128,9 +124,9 @@ async function attachmentToEvent(mentions, attachment) {
$type: "m.room.message",
"m.mentions": mentions,
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",
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) {
return {
@ -138,7 +134,7 @@ async function attachmentToEvent(mentions, attachment) {
"m.mentions": mentions,
msgtype: "m.image",
url: await file.uploadDiscordFileToMxc(attachment.url),
external_url,
external_url: attachment.url,
body: attachment.description || attachment.filename,
filename: attachment.filename,
info: {
@ -154,7 +150,7 @@ async function attachmentToEvent(mentions, attachment) {
"m.mentions": mentions,
msgtype: "m.video",
url: await file.uploadDiscordFileToMxc(attachment.url),
external_url,
external_url: attachment.url,
body: attachment.description || attachment.filename,
filename: attachment.filename,
info: {
@ -170,7 +166,7 @@ async function attachmentToEvent(mentions, attachment) {
"m.mentions": mentions,
msgtype: "m.audio",
url: await file.uploadDiscordFileToMxc(attachment.url),
external_url,
external_url: attachment.url,
body: attachment.description || attachment.filename,
filename: attachment.filename,
info: {
@ -185,7 +181,7 @@ async function attachmentToEvent(mentions, attachment) {
"m.mentions": mentions,
msgtype: "m.file",
url: await file.uploadDiscordFileToMxc(attachment.url),
external_url,
external_url: attachment.url,
body: attachment.description || attachment.filename,
filename: attachment.filename,
info: {
@ -197,12 +193,11 @@ async function attachmentToEvent(mentions, attachment) {
}
/**
* @param {DiscordTypes.APIMessage} message
* @param {DiscordTypes.APIGuild} guild
* @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean, alwaysReturnFormattedBody?: boolean}} options default values:
* @param {import("discord-api-types/v10").APIMessage} message
* @param {import("discord-api-types/v10").APIGuild} guild
* @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean}} options default values:
* - includeReplyFallback: true
* - 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
*/
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[]}}
We should consider the following scenarios for mentions:
@ -353,13 +338,8 @@ async function messageToEvent(message, guild, options = {}, di) {
result = `https://matrix.to/#/${roomID}/${eventID}?${via}`
} else {
const ts = dUtils.snowflakeToTimestampExact(messageID)
try {
const {event_id} = await di.api.getEventForTimestamp(roomID, ts)
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}]`
}
const {event_id} = await di.api.getEventForTimestamp(roomID, ts)
result = `https://matrix.to/#/${roomID}/${event_id}?${via}`
}
} else {
result = `${match[0]} [event is from another server]`
@ -371,13 +351,6 @@ async function messageToEvent(message, guild, options = {}, di) {
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.
* @param {string} content Partial or complete Discord message content
@ -386,12 +359,11 @@ async function messageToEvent(message, guild, options = {}, di) {
* @param {any} customHtmlOutput
*/
async function transformContent(content, customOptions = {}, customParser = null, customHtmlOutput = null) {
content = transformAttachmentLinks(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.
// 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 => {
const id = match[3]
const name = match[2]
@ -429,13 +401,8 @@ async function messageToEvent(message, guild, options = {}, di) {
return {body, html}
}
/**
* After converting Discord content to Matrix plaintext and HTML content, post-process the bodies and push the resulting text event
* @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) {
// FIXME: What was the scanMentions parameter supposed to activate? It's unused.
async function addTextEvent(body, html, msgtype, {scanMentions}) {
// Star * prefix for fallback edits
if (options.includeEditFallbackStar) {
body = "* " + body
@ -443,7 +410,7 @@ async function messageToEvent(message, guild, options = {}, di) {
}
const flags = message.flags || 0
if (flags & DiscordTypes.MessageFlags.IsCrosspost) {
if (flags & 2) {
body = `[🔀 ${message.author.username}]\n` + body
html = `🔀 <strong>${message.author.username}</strong><br>` + html
}
@ -497,7 +464,7 @@ async function messageToEvent(message, guild, options = {}, di) {
const isPlaintext = body === html
if (!isPlaintext || options.alwaysReturnFormattedBody) {
if (!isPlaintext) {
Object.assign(newTextMessageEvent, {
format: "org.matrix.custom.html",
formatted_body: html
@ -515,58 +482,7 @@ async function messageToEvent(message, guild, options = {}, di) {
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.
const matches = [...message.content.matchAll(/@ ?([a-z0-9._]+)\b/gi)]
@ -585,8 +501,9 @@ async function messageToEvent(message, guild, options = {}, di) {
}
}
// Text content appears first
const {body, html} = await transformContent(message.content)
await addTextEvent(body, html, msgtype)
await addTextEvent(body, html, msgtype, {scanMentions: true})
}
// Then attachments
@ -658,9 +575,9 @@ async function messageToEvent(message, guild, options = {}, di) {
let chosenImage = embed.image?.url
// the thumbnail seems to be used for "article" type but displayed big at the bottom by discord
if (embed.type === "article" && embed.thumbnail?.url && !chosenImage) chosenImage = embed.thumbnail.url
if (chosenImage) rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`)
if (chosenImage) rep.addParagraph(`📸 ${chosenImage}`)
if (embed.video?.url) rep.addParagraph(`🎞️ ${dUtils.getPublicUrlForCdn(embed.video.url)}`)
if (embed.video?.url) rep.addParagraph(`🎞️ ${embed.video.url}`)
if (embed.footer?.text) rep.addLine(`${embed.footer.text}`, tag`${embed.footer.text}`)
let {body, formatted_body: html} = rep.get()
@ -668,7 +585,7 @@ async function messageToEvent(message, guild, options = {}, di) {
html = `<blockquote>${html}</blockquote>`
// 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

View file

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

View file

@ -1,7 +1,6 @@
const {test} = require("supertape")
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")
/**
@ -65,44 +64,6 @@ test("message2event: simple user mention", async t => {
test("message2event: simple room mention", async t => {
let called = 0
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: {
async getStateEvent(roomID, type, key) {
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")
})
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 => {
const events = await messageToEvent(data.message.message_link_from_another_server, data.guild.general)
t.deepEqual(events, [{
@ -337,7 +250,7 @@ test("message2event: attachment with no content", async t => {
msgtype: "m.image",
url: "mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM",
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",
info: {
mimetype: "image/png",
@ -354,9 +267,9 @@ test("message2event: spoiler attachment", async t => {
$type: "m.room.message",
"m.mentions": {},
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",
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",
url: "mxc://cadence.moe/ZDCNYnkPszxGKgObUIFmvjus",
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",
info: {
mimetype: "image/png",
@ -427,7 +340,7 @@ test("message2event: skull webp attachment with content", async t => {
mimetype: "image/webp",
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",
url: "mxc://cadence.moe/sDxWmDErBhYBxtDcJQgBETes"
}])
@ -461,7 +374,7 @@ test("message2event: reply to skull webp attachment with content", async t => {
mimetype: "image/jpeg",
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",
url: "mxc://cadence.moe/WlAbFSiNRIHPDEwKdyPeGywa"
}])
@ -551,7 +464,7 @@ test("message2event: reply with a video", async t => {
body: "Ins_1960637570.mp4",
filename: "Ins_1960637570.mp4",
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: {
h: 854,
mimetype: "video/mp4",
@ -572,7 +485,7 @@ test("message2event: voice message", async t => {
t.deepEqual(events, [{
$type: "m.room.message",
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",
info: {
duration: 3960.0000381469727,
@ -595,7 +508,7 @@ test("message2event: misc file", async t => {
}, {
$type: "m.room.message",
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",
info: {
mimetype: "text/plain; charset=utf-8",
@ -837,7 +750,7 @@ test("message2event: very large attachment is linked instead of being uploaded",
content: "hey",
attachments: [{
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",
size: 100e6
}]
@ -851,9 +764,9 @@ test("message2event: very large attachment is linked instead of being uploaded",
$type: "m.room.message",
"m.mentions": {},
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",
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)'
}])
})
@ -1014,123 +927,3 @@ test("message2event: @everyone within a link", async t => {
"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 data = require("../../../test/data")
const data = require("../../test/data")
const {pinsToList} = require("./pins-to-list")
test("pins2list: converts known IDs, ignores unknown IDs", t => {

View file

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

View file

@ -4,9 +4,10 @@ const assert = require("assert").strict
const passthrough = require("../../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")} */
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))

View file

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

View file

@ -1,7 +1,7 @@
// @ts-check
const assert = require("assert")
const {reg} = require("../../matrix/read-registration")
const registration = require("../../matrix/read-registration")
const passthrough = require("../../passthrough")
const {select} = passthrough
@ -26,7 +26,7 @@ function downcaseUsername(user) {
// remove leading and trailing dashes and underscores...
.replace(/(?:^[_-]*|[_-]*$)/g, "")
// If requested, also make the Discord user ID part of the username
if (reg.ooye.include_user_id_in_mxid) {
if (registration.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)

View file

@ -1,7 +1,7 @@
const {test} = require("supertape")
const tryToCatch = require("try-to-catch")
const assert = require("assert")
const data = require("../../../test/data")
const data = require("../../test/data")
const {userToSimName} = require("./user-to-mxid")
test("user2name: cannot create user for a webhook", async t => {
@ -46,7 +46,7 @@ test("user2name: works on special user", t => {
})
test("user2name: includes ID if requested in config", t => {
const {reg} = require("../../matrix/read-registration")
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")

View file

@ -6,7 +6,7 @@ const { Client: CloudStorm } = require("cloudstorm")
const passthrough = require("../passthrough")
const { sync } = passthrough
/** @type {import("./discord-packets")} */
/** @type {typeof import("./discord-packets")} */
const discordPackets = sync.require("./discord-packets")
class DiscordClient {
@ -57,9 +57,6 @@ class DiscordClient {
addEventLogger("error", "Error")
addEventLogger("disconnected", "Disconnected")
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 passthrough = require("../passthrough")
const {sync, db} = passthrough
function populateGuildID(guildID, channelID) {
db.prepare("UPDATE channel_room SET guild_id = ? WHERE channel_id = ?").run(guildID, channelID)
}
const { sync } = passthrough
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
/** @type {typeof import("./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
if (message.t === "READY") {
@ -40,16 +34,13 @@ const utils = {
channel.guild_id = message.d.id
arr.push(channel.id)
client.channels.set(channel.id, channel)
populateGuildID(message.d.id, channel.id)
}
for (const thread of message.d.threads || []) {
// @ts-ignore
thread.guild_id = message.d.id
arr.push(thread.id)
client.channels.set(thread.id, thread)
populateGuildID(message.d.id, thread.id)
}
if (listen === "full") {
eventDispatcher.checkMissedExpressions(message.d)
eventDispatcher.checkMissedPins(client, message.d)
@ -98,11 +89,7 @@ const utils = {
} else if (message.t === "THREAD_CREATE") {
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") {
client.channels.set(message.d.id, message.d)
@ -124,21 +111,21 @@ const utils = {
client.guildChannelMap.delete(message.d.id)
} else if (message.t === "CHANNEL_CREATE") {
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
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_DELETE") {
client.channels.delete(message.d.id)
if (message.d["guild_id"]) {
const channels = client.guildChannelMap.get(message.d["guild_id"])
if (channels) {
const previous = channels.indexOf(message.d.id)
if (previous !== -1) channels.splice(previous, 1)
} else if (message.t === "CHANNEL_CREATE" || message.t === "CHANNEL_DELETE") {
if (message.t === "CHANNEL_CREATE") {
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
const channels = client.guildChannelMap.get(message.d["guild_id"])
if (channels && !channels.includes(message.d.id)) channels.push(message.d.id)
}
} else {
client.channels.delete(message.d.id)
if (message.d["guild_id"]) {
const channels = client.guildChannelMap.get(message.d["guild_id"])
if (channels) {
const previous = channels.indexOf(message.d.id)
if (previous !== -1) channels.splice(previous, 1)
}
}
}
}
@ -158,9 +145,6 @@ const utils = {
} else if (message.t === "CHANNEL_PINS_UPDATE") {
await eventDispatcher.onChannelPinsUpdate(client, message.d)
} else if (message.t === "CHANNEL_DELETE") {
await eventDispatcher.onChannelDelete(client, message.d)
} else if (message.t === "THREAD_CREATE") {
// @ts-ignore
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") {
await eventDispatcher.onSomeReactionsRemoved(client, message.d)
} else if (message.t === "INTERACTION_CREATE") {
await interactions.dispatchInteraction(message.d)
}
} catch (e) {
// Let OOYE try to handle errors too
await eventDispatcher.onError(client, e, message)
eventDispatcher.onError(client, e, message)
}
}
}

View file

@ -27,6 +27,8 @@ const updatePins = sync.require("./actions/update-pins")
const api = sync.require("../matrix/api")
/** @type {import("../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")} */
const mxUtils = require("../m2d/converters/utils")
/** @type {import("./actions/speedbump")} */
@ -48,7 +50,7 @@ module.exports = {
* @param {Error} e
* @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(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:`)
@ -81,7 +83,7 @@ module.exports = {
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>`)
await api.sendEvent(roomID, "m.room.message", {
api.sendEvent(roomID, "m.room.message", {
...builder.get(),
"moe.cadence.ooye.error": {
source: "discord",
@ -191,7 +193,7 @@ module.exports = {
async onThreadCreate(client, thread) {
const channelID = thread.parent_id || undefined
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)
await announceThread.announceThread(parentRoomID, threadRoomID, thread)
},
@ -228,19 +230,6 @@ module.exports = {
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 {DiscordTypes.GatewayMessageCreateDispatchData} message
@ -249,7 +238,6 @@ module.exports = {
if (message.author.username === "Deleted User") return // Nothing we can do for deleted users.
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.
const guild = client.guilds.get(channel.guild_id)
assert(guild)
@ -260,13 +248,12 @@ module.exports = {
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)
if (affected) return
// @ts-ignore
await sendMessage.sendMessage(message, channel, guild, row)
await sendMessage.sendMessage(message, channel, guild, row),
await discordCommandHandler.execute(message, channel, guild)
retrigger.messageFinishedBridging(message.id)
},
@ -281,6 +268,9 @@ module.exports = {
// Otherwise, if there are embeds, then the system generated URL preview embeds.
if (!(typeof data.content === "string" || "embeds" in data)) return
// Deal with Eventual Consistency(TM)
if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.onMessageUpdate, client, data)) return
if (data.webhook_id) {
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.
@ -292,19 +282,16 @@ module.exports = {
const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id)
if (affected) return
// Check that the sending-to room exists, and deal with Eventual Consistency(TM)
if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.onMessageUpdate, client, data)) return
/** @type {DiscordTypes.GatewayMessageCreateDispatchData} */
// @ts-ignore
const message = data
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.
const guild = client.guilds.get(channel.guild_id)
assert(guild)
// @ts-ignore
await retrigger.pauseChanges(message.id, editMessage.editMessage(message, guild, row))
await editMessage.editMessage(message, guild, row)
},
/**
@ -313,6 +300,7 @@ module.exports = {
*/
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.
discordCommandHandler.onReactionAdd(data)
await addReaction.addReaction(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 stmt = db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE hashed_profile_content = ?")
db.transaction(() => {
/* c8 ignore next 6 */
for (let s of contents) {
let b = Buffer.isBuffer(s) ? Uint8Array.from(s) : Uint8Array.from(Buffer.from(s))
const unsignedHash = hasher.h64Raw(b)

View file

@ -10,6 +10,7 @@
*/
module.exports = async function(db) {
const id = require("../../../addbot").id
const config = require("../../config")
const id = Buffer.from(config.discordToken.split(".")[0], "base64").toString()
db.prepare("UPDATE OR REPLACE sim SET user_id = ? WHERE user_id = '0'").run(id)
}

View file

@ -33,11 +33,6 @@ export type Models = {
privacy_level: number
}
guild_active: {
guild_id: string
autocreate: 0 | 1
}
lottie: {
sticker_id: string
mxc_url: string
@ -47,14 +42,7 @@ export type Models = {
room_id: string
mxid: string
displayname: string | null
avatar_url: string | null,
power_level: number
}
member_power: {
mxid: string
room_id: string
power_level: number
avatar_url: string | null
}
message_channel: {
@ -105,10 +93,6 @@ export type Models = {
emoji_id: string
guild_id: string
}
media_proxy: {
permitted_hash: number
}
}
export type Prepared<Row> = {
@ -123,5 +107,3 @@ 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 Merge<U> = {[x in AllKeys<U>]: PickTypeOf<U, x>}
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
* @param {Table} table
* @param {Col[] | Col} cols
* @param {Partial<U.ValueOrArray<U.Numberish<U.Models[Table]>>>} where
* @param {Partial<U.Models[Table]>} where
* @param {string} [e]
*/
function select(table, cols, where = {}, e = "") {
if (!Array.isArray(cols)) cols = [cols]
const parameters = []
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)
return `"${col}" = ?`
}
parameters.push(value)
return `"${col}" = ?`
})
const whereString = wheres.length ? " WHERE " + wheres.join(" AND ") : ""
/** @type {U.Prepared<Pick<U.Models[Table], Col>>} */
@ -49,8 +44,6 @@ class From {
/** @private */
this.cols = []
/** @private */
this.makeColsSafe = true
/** @private */
this.using = []
/** @private */
this.isPluck = false
@ -85,12 +78,6 @@ class From {
return r
}
selectUnsafe(...cols) {
this.cols = cols
this.makeColsSafe = false
return this
}
/**
* @template {Col} Select
* @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) {
const wheres = Object.entries(conditions).map(([col, value]) => {
@ -125,8 +112,7 @@ class From {
}
prepare() {
if (this.makeColsSafe) this.cols = this.cols.map(k => `"${k}"`)
let sql = `SELECT ${this.cols.join(", ")} FROM ${this.tables[0]} `
let sql = `SELECT ${this.cols.map(k => `"${k}"`).join(", ")} FROM ${this.tables[0]} `
for (let i = 1; i < this.tables.length; i++) {
const table = this.tables[i]
const col = this.using[i-1]

View file

@ -1,7 +1,7 @@
// @ts-check
const {test} = require("supertape")
const data = require("../../test/data")
const data = require("../test/data")
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]"])
})
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 => {
const guildID = from("guild_space").pluck("guild_id").and("WHERE space_id = ?").get("!jjWAGMeQdNrVZSSfvz:cadence.moe")
t.equal(guildID, data.guild.general.id)
@ -58,13 +53,3 @@ test("orm: from: join direction works", t => {
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)
})
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 & DiscordTypes.PermissionFlagsBits.CreateInstantInvite)) {
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

View file

@ -3,15 +3,6 @@
const DiscordTypes = require("discord-api-types/v10")
const assert = require("assert").strict
const {reg} = require("../matrix/read-registration")
const {db} = require("../passthrough")
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
let hasher = null
// @ts-ignore
require("xxhash-wasm")().then(h => hasher = h)
const EPOCH = 1420070400000
/**
@ -106,14 +97,16 @@ function hasAllPermissions(resolvedPermissions, permissionsToCheckFor) {
* @param {DiscordTypes.APIMessage} message
*/
function isWebhookMessage(message) {
return message.webhook_id && message.type !== DiscordTypes.MessageType.ChatInputCommand
const isInteractionResponse = message.type === 20
return message.webhook_id && !isInteractionResponse
}
/**
* Ephemeral messages can be generated if a slash command is attached to the same bot that OOYE is running on
* @param {Pick<DiscordTypes.APIMessage, "flags">} message
*/
function isEphemeralMessage(message) {
return Boolean(message.flags && (message.flags & DiscordTypes.MessageFlags.Ephemeral))
return message.flags && (message.flags & (1 << 6));
}
/** @param {string} snowflake */
@ -126,16 +119,6 @@ function timestampToSnowflakeInexact(timestamp) {
return String((timestamp - EPOCH) * 2**22)
}
/** @param {string} url */
function getPublicUrlForCdn(url) {
const match = url.match(/https:\/\/(cdn|media)\.discordapp\.(?:com|net)\/attachments\/([0-9]+)\/([0-9]+)\/([-A-Za-z0-9_.,]+)/)
if (!match) return url
const unsignedHash = hasher.h64(match[3]) // attachment ID
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/discord${match[1]}/${match[2]}/${match[3]}/${match[4]}`
}
module.exports.getPermissions = getPermissions
module.exports.hasPermission = hasPermission
module.exports.hasSomePermissions = hasSomePermissions
@ -144,4 +127,3 @@ module.exports.isWebhookMessage = isWebhookMessage
module.exports.isEphemeralMessage = isEphemeralMessage
module.exports.snowflakeToTimestampExact = snowflakeToTimestampExact
module.exports.timestampToSnowflakeInexact = timestampToSnowflakeInexact
module.exports.getPublicUrlForCdn = getPublicUrlForCdn

View file

@ -1,6 +1,6 @@
const DiscordTypes = require("discord-api-types/v10")
const {test} = require("supertape")
const data = require("../../test/data")
const data = require("../test/data")
const utils = require("./utils")
test("is webhook message: identifies bot interaction response as not a message", t => {
@ -84,67 +84,6 @@ test("getPermissions: channel overwrite to allow role works", t => {
t.equal((permissions & want), want)
})
test("getPermissions: channel overwrite to allow user 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 = []
const userID = "353373325575323648"
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)
})
test("hasSomePermissions: detects the permission", t => {
const userPermissions = DiscordTypes.PermissionFlagsBits.MentionEveryone | DiscordTypes.PermissionFlagsBits.BanMembers
const canRemoveMembers = utils.hasSomePermissions(userPermissions, ["KickMembers", "BanMembers"])
@ -168,15 +107,3 @@ test("hasAllPermissions: doesn't detect not the permissions", t => {
const canRemoveMembers = utils.hasAllPermissions(userPermissions, ["KickMembers", "BanMembers"])
t.equal(canRemoveMembers, false)
})
test("isEphemeralMessage: detects ephemeral message", t => {
t.equal(utils.isEphemeralMessage(data.special_message.ephemeral_message), true)
})
test("isEphemeralMessage: doesn't detect normal message", t => {
t.equal(utils.isEphemeralMessage(data.message.simple_plaintext), false)
})
test("getPublicUrlForCdn: no-op on non-discord URL", t => {
t.equal(utils.getPublicUrlForCdn("https://cadence.moe"), "https://cadence.moe")
})

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

@ -8,8 +8,6 @@ const {sync} = require("../../passthrough")
/** @type {import("../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.
@ -18,12 +16,16 @@ const api = sync.require("../../matrix/api")
*/
async function getAndConvertEmoji(mxc) {
const abortController = new AbortController()
const url = utils.getPublicUrlForMxc(mxc)
assert(url)
/** @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 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.
// @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, () => {
abortController.abort()
res.body.pause()

View file

@ -13,7 +13,7 @@ const utils = sync.require("../converters/utils")
*/
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()
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) {
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)
@ -25,6 +25,7 @@ async function deleteMessage(event) {
*/
async function removeReaction(event) {
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()
if (!row) return
await discord.snow.channel.deleteReactionSelf(row.channel_id, row.message_id, row.encoded_emoji)

View file

@ -23,7 +23,7 @@ const editMessage = sync.require("../../d2m/actions/edit-message")
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}[]}>}
*/
async function resolvePendingFiles(message) {
@ -39,7 +39,7 @@ async function resolvePendingFiles(message) {
// Encrypted file
const d = crypto.createDecipheriv("aes-256-ctr", Buffer.from(p.key, "base64url"), Buffer.from(p.iv, "base64url"))
// @ts-ignore
await api.getMedia(p.mxc).then(res => res.body.pipe(d))
fetch(p.url).then(res => res.body.pipe(d))
return {
name: p.name,
file: d
@ -47,7 +47,7 @@ async function resolvePendingFiles(message) {
} else {
// Unencrypted file
/** @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 {
name: p.name,
file: body
@ -79,7 +79,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
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 => {
e.message = await resolvePendingFiles(e.message)

View file

@ -81,10 +81,9 @@ async function convertImageStream(streamIn, stopStream) {
giframe.feed(chunk)
})
const frame = await giframe.getFrame()
const pixels = Uint8Array.from(frame.pixels)
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}})
.png({compressionLevel: 0})
.toBuffer({resolveWithObject: true})

View file

@ -15,8 +15,6 @@ const {sync, db, discord, select, from} = passthrough
const mxUtils = sync.require("../converters/utils")
/** @type {import("../../discord/utils")} */
const dUtils = sync.require("../../discord/utils")
/** @type {import("../../matrix/file")} */
const file = sync.require("../../matrix/file")
/** @type {import("./emoji-sheet")} */
const emojiSheet = sync.require("./emoji-sheet")
@ -208,10 +206,11 @@ function getCodeContent(preCode) {
*/
function convertEmoji(mxcUrl, nameForGuess, allowSpriteSheetIndicator, allowLink) {
// 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.
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
}
// Or, if we don't have an emoji right now, we search for the name instead.
@ -221,7 +220,7 @@ function convertEmoji(mxcUrl, nameForGuess, allowSpriteSheetIndicator, allowLink
/** @type {{name: string, id: string, animated: number}[]} */
// @ts-ignore
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) {
row = {
animated: found.animated,
@ -305,7 +304,7 @@ function getUserOrProxyOwnerID(mxid) {
* This function will strip them from the content and generate the correct pending file of the sprite sheet.
* @param {string} content
* @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.
*/
async function uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles, mxcDownloader) {
@ -389,7 +388,7 @@ async function handleRoomOrMessageLinks(input, di) {
* @param {string} senderMxid
* @param {string} roomID
* @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) {
let writtenMentionMatch = content.match(/(?:^|[^"[<>/A-Za-z0-9])@([A-Za-z][A-Za-z0-9._\[\]\(\)-]+):?/d) // /d flag for indices requires node.js 16+
@ -440,7 +439,7 @@ 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 {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) {
let displayName = event.sender
@ -466,7 +465,7 @@ async function eventToMessage(event, guild, di) {
let content = event.content.body // ultimate fallback
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 = []
/** @type {DiscordTypes.APIUser[]} */
const ensureJoined = []
@ -687,7 +686,7 @@ async function eventToMessage(event, guild, di) {
let preNode
if (node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"]))) {
if (preNode.firstChild?.nodeName === "CODE") {
const ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1] || "txt"
const ext = (preNode.firstChild.className.match(/language-(\S+)/) || [null, "txt"])[1]
const filename = `inline_code.${ext}`
// Build the replacement <code> node
const replacementCode = doc.createElement("code")
@ -767,23 +766,29 @@ async function eventToMessage(event, guild, di) {
const description = (event.content.body !== event.content.filename && event.content.filename && event.content.body) || undefined
if ("url" in event.content) {
// Unencrypted
const url = mxUtils.getPublicUrlForMxc(event.content.url)
assert(url)
attachments.push({id: "0", description, filename})
pendingFiles.push({name: filename, mxc: event.content.url})
pendingFiles.push({name: filename, url})
} else {
// Encrypted
const url = mxUtils.getPublicUrlForMxc(event.content.file.url)
assert(url)
assert.equal(event.content.file.key.alg, "A256CTR")
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") {
content = ""
const url = mxUtils.getPublicUrlForMxc(event.content.url)
assert(url)
let filename = event.content.body
if (event.type === "m.sticker") {
let mimetype
if (event.content.info?.mimetype?.includes("/")) {
mimetype = event.content.info.mimetype
} else {
const res = await di.api.getMedia(event.content.url, {method: "HEAD"})
const res = await di.fetch(url, {method: "HEAD"})
if (res.status === 200) {
mimetype = res.headers.get("content-type")
}
@ -792,7 +797,7 @@ async function eventToMessage(event, guild, di) {
filename += "." + mimetype.split("/")[1]
}
attachments.push({id: "0", filename})
pendingFiles.push({name: filename, mxc: event.content.url})
pendingFiles.push({name: filename, url})
}
content = displayNameRunoff + replyLine + content

View file

@ -3,7 +3,7 @@ const fs = require("fs")
const {test} = require("supertape")
const {eventToMessage} = require("./event-to-message")
const {convertImageStream} = require("./emoji-sheet")
const data = require("../../../test/data")
const data = require("../../test/data")
const {MatrixServerError} = require("../../matrix/mreq")
const {select, discord} = require("../../passthrough")
@ -772,38 +772,6 @@ test("event2message: code blocks are uploaded as attachments instead if they con
)
})
test("event2message: code blocks are uploaded as attachments instead if they contain incompatible backticks (default to txt file extension)", async t => {
t.deepEqual(
await eventToMessage({
type: "m.room.message",
sender: "@cadence:cadence.moe",
content: {
msgtype: "m.text",
body: "wrong body",
format: "org.matrix.custom.html",
formatted_body: 'So if you run code like this<pre><code>System.out.println("```");</code></pre>it should print a markdown formatted code block'
},
event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s",
room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe"
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "cadence [they]",
content: "So if you run code like this `[inline_code.txt]` it should print a markdown formatted code block",
attachments: [{id: "0", filename: "inline_code.txt"}],
pendingFiles: [{name: "inline_code.txt", buffer: Buffer.from('System.out.println("```");')}],
avatar_url: undefined,
allowed_mentions: {
parse: ["users", "roles"]
}
}]
}
)
})
test("event2message: characters are encoded properly in code blocks", async t => {
t.deepEqual(
await eventToMessage({
@ -1073,7 +1041,7 @@ test("event2message: rich reply to a sim user", async t => {
content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:"
+ " Slow news day."
+ "\nTesting this reply, ignore",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
allowed_mentions: {
parse: ["users", "roles"]
}
@ -1225,7 +1193,7 @@ test("event2message: rich reply to an already-edited message will quote the new
content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647><@111604486476181504>:"
+ " this is the new content. heya!"
+ "\nhiiiii....",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
allowed_mentions: {
parse: ["users", "roles"]
}
@ -1278,7 +1246,7 @@ test("event2message: rich reply to a missing event will quote from formatted_bod
username: "cadence [they]",
content: "-# > But who sees the seashells she sells sitting..."
+ "\nWhat a tongue-bender...",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
allowed_mentions: {
parse: ["users", "roles"]
}
@ -1327,7 +1295,7 @@ test("event2message: rich reply to a missing event without formatted_body will u
messagesToSend: [{
username: "cadence [they]",
content: "Testing this reply, ignore",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
allowed_mentions: {
parse: ["users", "roles"]
}
@ -1378,7 +1346,7 @@ test("event2message: rich reply to a missing event and no reply fallback will no
messagesToSend: [{
username: "cadence [they]",
content: "Testing this reply, ignore.",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
allowed_mentions: {
parse: ["users", "roles"]
}
@ -1430,7 +1398,7 @@ test("event2message: should avoid using blockquote contents as reply preview in
content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:"
+ " that can't be true! there's no way :o"
+ "\nI agree!",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
allowed_mentions: {
parse: ["users", "roles"]
}
@ -1568,7 +1536,7 @@ test("event2message: should include a reply preview when message ends with a blo
content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**Ⓜ_ooye_cookie**:"
+ " <https://tootsuite.net/Warp-Gate2.gif> tanget: @..."
+ "\naichmophobia",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
allowed_mentions: {
parse: ["users", "roles"]
}
@ -1652,7 +1620,7 @@ test("event2message: should include a reply preview when replying to a descripti
content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/497161350934560778/1162625810109317170 <@1109360903096369153>:"
+ " It looks like this queue has ended."
+ `\nso you're saying on matrix side I would have to edit ^this^ to add "Timed out" before the blockquote?`,
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
allowed_mentions: {
parse: ["users", "roles"]
}
@ -1705,7 +1673,7 @@ test("event2message: entities are not escaped in main message or reply preview",
content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**Ⓜcadence [they]**:"
+ " Testing? \"':.`[]&things"
+ "\n_Testing?_ \"':.\\`\\[\\]&things",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
allowed_mentions: {
parse: ["users", "roles"]
}
@ -2037,7 +2005,7 @@ test("event2message: editing a rich reply to a sim user", async t => {
content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:"
+ " Slow news day."
+ "\nEditing this reply, which is also a test",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
allowed_mentions: {
parse: ["users", "roles"]
}
@ -2093,7 +2061,7 @@ test("event2message: editing a plaintext body message", async t => {
message: {
username: "cadence [they]",
content: "well, I guess it's no longer brand new... it's existed for mere seconds...",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
allowed_mentions: {
parse: ["users", "roles"]
}
@ -2148,7 +2116,7 @@ test("event2message: editing a plaintext message to be longer", async t => {
message: {
content: "aaaaaaaaa ".repeat(198) + "well, I guess it's",
username: "cadence [they]",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
allowed_mentions: {
parse: ["users", "roles"]
}
@ -2157,7 +2125,7 @@ test("event2message: editing a plaintext message to be longer", async t => {
messagesToSend: [{
content: "no longer brand new... it's existed for mere seconds..." + ("aaaaaaaaa ".repeat(20)).slice(0, -1),
username: "cadence [they]",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
allowed_mentions: {
parse: ["users", "roles"]
}
@ -2210,7 +2178,7 @@ test("event2message: editing a plaintext message to be shorter", async t => {
message: {
username: "cadence [they]",
content: "well, I guess it's no longer brand new... it's existed for mere seconds...",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
allowed_mentions: {
parse: ["users", "roles"]
}
@ -2271,7 +2239,7 @@ test("event2message: editing a formatted body message", async t => {
message: {
username: "cadence [they]",
content: "**well, I guess it's no longer brand new... it's existed for mere seconds...**",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
allowed_mentions: {
parse: ["users", "roles"]
}
@ -2328,7 +2296,7 @@ test("event2message: rich reply to a matrix user's long message with formatting"
content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 **Ⓜcadence [they]**:"
+ " i should have a little happy test list bold em..."
+ "\n**no you can't!!!**",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
allowed_mentions: {
parse: ["users", "roles"]
}
@ -2390,7 +2358,7 @@ test("event2message: rich reply to an image", async t => {
username: "cadence [they]",
content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504> 🖼️"
+ "\nCaught in 8K UHD VR QLED Epic Edition",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
allowed_mentions: {
parse: ["users", "roles"]
}
@ -2446,7 +2414,7 @@ test("event2message: rich reply to a spoiler should ensure the spoiler is hidden
content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:"
+ " [spoiler] cw crossword spoilers you'll never..."
+ "\nomg NO WAY!!",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
allowed_mentions: {
parse: ["users", "roles"]
}
@ -2501,7 +2469,7 @@ test("event2message: with layered rich replies, the preview should only be the r
content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 **Ⓜcadence [they]**:"
+ " two"
+ "\nthree",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
allowed_mentions: {
parse: ["users", "roles"]
}
@ -2558,7 +2526,7 @@ test("event2message: if event is a reply and starts with a quote, they should be
+ " i have a feeling that clients are meant to strip..."
+ "\n"
+ "\n> To strip the fallback on the `body`, the client should iterate over each line of the string, removing any lines that start with the fallback prefix (\"> “, including the space, without quotes) and stopping when a line is encountered without the prefix. This prefix is known as the “fallback prefix sequence”.",
avatar_url: "https://bridge.example.org/download/matrix/syndicated.gay/ZkBUPXCiXTjdJvONpLJmcbKP",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/syndicated.gay/ZkBUPXCiXTjdJvONpLJmcbKP",
allowed_mentions: {
parse: ["users", "roles"]
}
@ -2616,7 +2584,7 @@ test("event2message: rich reply to a deleted event", async t => {
username: "Ampflower 🌺",
content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**ⓂAmpflower 🌺** (in reply to a deleted message)"
+ "\nHuh it did the same thing here too",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/PRfhXYBTOalvgQYtmCLeUXko",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/PRfhXYBTOalvgQYtmCLeUXko",
allowed_mentions: {
parse: ["users", "roles"]
}
@ -3384,7 +3352,7 @@ test("event2message: caches the member if the member is not known", async t => {
messagesToSend: [{
username: "should_be_newly_cached",
content: "testing the member state cache",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/this_is_the_avatar",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/this_is_the_avatar",
allowed_mentions: {
parse: ["users", "roles"]
}
@ -3548,9 +3516,9 @@ test("event2message: text attachments work", async t => {
messagesToSend: [{
username: "cadence [they]",
content: "",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
attachments: [{id: "0", description: undefined, filename: "chiki-powerups.txt"}],
pendingFiles: [{name: "chiki-powerups.txt", mxc: "mxc://cadence.moe/zyThGlYQxvlvBVbVgKDDbiHH"}]
pendingFiles: [{name: "chiki-powerups.txt", url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/zyThGlYQxvlvBVbVgKDDbiHH"}]
}]
}
)
@ -3584,9 +3552,9 @@ test("event2message: image attachments work", async t => {
messagesToSend: [{
username: "cadence [they]",
content: "",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
attachments: [{id: "0", description: undefined, filename: "cool cat.png"}],
pendingFiles: [{name: "cool cat.png", mxc: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}]
pendingFiles: [{name: "cool cat.png", url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}]
}]
}
)
@ -3620,9 +3588,9 @@ test("event2message: image attachments can have a custom description", async t =
messagesToSend: [{
username: "cadence [they]",
content: "",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
attachments: [{id: "0", description: "Cat emoji surrounded by pink hearts", filename: "cool cat.png"}],
pendingFiles: [{name: "cool cat.png", mxc: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}]
pendingFiles: [{name: "cool cat.png", url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}]
}]
}
)
@ -3670,11 +3638,11 @@ test("event2message: encrypted image attachments work", async t => {
messagesToSend: [{
username: "cadence [they]",
content: "",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
attachments: [{id: "0", description: undefined, filename: "image.png"}],
pendingFiles: [{
name: "image.png",
mxc: "mxc://heyquark.com/LOGkUTlVFrqfiExlGZNgCJJX",
url: "https://matrix.cadence.moe/_matrix/media/r0/download/heyquark.com/LOGkUTlVFrqfiExlGZNgCJJX",
key: "QTo-oMPnN1Rbc7vBFg9WXMgoctscdyxdFEIYm8NYceo",
iv: "Va9SHZpIn5kAAAAAAAAAAA"
}]
@ -3715,9 +3683,9 @@ test("event2message: stickers work", async t => {
messagesToSend: [{
username: "cadence [they]",
content: "",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
attachments: [{id: "0", filename: "get_real2.gif"}],
pendingFiles: [{name: "get_real2.gif", mxc: "mxc://cadence.moe/NyMXQFAAdniImbHzsygScbmN"}]
pendingFiles: [{name: "get_real2.gif", url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/NyMXQFAAdniImbHzsygScbmN"}]
}]
}
)
@ -3736,17 +3704,15 @@ test("event2message: stickers fetch mimetype from server when mimetype not provi
event_id: "$mL-eEVWCwOvFtoOiivDP7gepvf-fTYH6_ioK82bWDI0",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}, {}, {
api: {
async getMedia(mxc, options) {
called++
t.equal(mxc, "mxc://cadence.moe/ybOWQCaXysnyUGuUCaQlTGJf")
t.equal(options.method, "HEAD")
return {
status: 200,
headers: new Map([
["content-type", "image/gif"]
])
}
async fetch(url, options) {
called++
t.equal(url, "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/ybOWQCaXysnyUGuUCaQlTGJf")
t.equal(options.method, "HEAD")
return {
status: 200,
headers: new Map([
["content-type", "image/gif"]
])
}
}
}),
@ -3759,7 +3725,7 @@ test("event2message: stickers fetch mimetype from server when mimetype not provi
content: "",
avatar_url: undefined,
attachments: [{id: "0", filename: "YESYESYES.gif"}],
pendingFiles: [{name: "YESYESYES.gif", mxc: "mxc://cadence.moe/ybOWQCaXysnyUGuUCaQlTGJf"}]
pendingFiles: [{name: "YESYESYES.gif", url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/ybOWQCaXysnyUGuUCaQlTGJf"}]
}]
}
)
@ -3779,17 +3745,15 @@ test("event2message: stickers with unknown mimetype are not allowed", async t =>
event_id: "$mL-eEVWCwOvFtoOiivDP7gepvf-fTYH6_ioK82bWDI0",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}, {}, {
api: {
async getMedia(mxc, options) {
called++
t.equal(mxc, "mxc://cadence.moe/ybOWQCaXysnyUGuUCaQlTGJe")
t.equal(options.method, "HEAD")
return {
status: 404,
headers: new Map([
["content-type", "application/json"]
])
}
async fetch(url, options) {
called++
t.equal(url, "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/ybOWQCaXysnyUGuUCaQlTGJe")
t.equal(options.method, "HEAD")
return {
status: 404,
headers: new Map([
["content-type", "application/json"]
])
}
}
})
@ -3830,36 +3794,6 @@ test("event2message: static emojis work", async t => {
)
})
test("event2message: emojis in other servers are reused if they have the same title text", async t => {
t.deepEqual(
await eventToMessage({
type: "m.room.message",
sender: "@cadence:cadence.moe",
content: {
msgtype: "m.text",
body: ":hippo:",
format: "org.matrix.custom.html",
formatted_body: '<img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/123456\" title=\":hippo:\" alt=\":hippo:\">'
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
room_id: "!CzvdIdUQXgUjDVKxeU:cadence.moe"
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "cadence [they]",
content: "<:hippo:230201364309868544>",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
allowed_mentions: {
parse: ["users", "roles"]
}
}]
}
)
})
test("event2message: animated emojis work", async t => {
t.deepEqual(
await eventToMessage({
@ -3910,7 +3844,7 @@ test("event2message: unknown emojis in the middle are linked", async t => {
messagesToEdit: [],
messagesToSend: [{
username: "cadence [they]",
content: "a [:ms_robot_grin:](https://bridge.example.org/download/matrix/cadence.moe/RLMgJGfgTPjIQtvvWZsYjhjy) b",
content: "a [:ms_robot_grin:](https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/RLMgJGfgTPjIQtvvWZsYjhjy) b",
avatar_url: undefined,
allowed_mentions: {
parse: ["users", "roles"]
@ -4333,7 +4267,7 @@ slow()("event2message: known and unknown emojis in the end are reuploaded as a s
fileContentStart: messages.messagesToSend[0].pendingFiles[0].buffer.subarray(0, 90).toString("base64")
}
t.deepEqual(testResult, {
content: "known unknown: <:hippo:230201364309868544> [:ms_robot_dress:](https://bridge.example.org/download/matrix/cadence.moe/wcouHVjbKJJYajkhJLsyeJAA) and known unknown:",
content: "known unknown: <:hippo:230201364309868544> [:ms_robot_dress:](https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/wcouHVjbKJJYajkhJLsyeJAA) and known unknown:",
fileName: "emojis.png",
fileContentStart: "iVBORw0KGgoAAAANSUhEUgAAAGAAAAAwCAYAAADuFn/PAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAeXRFWHRSYXcACklQVEMgcHJvZmlsZQogICAgICA0Ngoz"
})

View file

@ -1,13 +1,8 @@
// @ts-check
const assert = require("assert").strict
const passthrough = require("../../passthrough")
const {db} = passthrough
const {reg} = require("../../matrix/read-registration")
const reg = require("../../matrix/read-registration")
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
const assert = require("assert").strict
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
let hasher = null
// @ts-ignore
@ -40,6 +35,16 @@ function eventSenderIsFromDiscord(sender) {
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.
* 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
}
/**
* 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.eventSenderIsFromDiscord = eventSenderIsFromDiscord
module.exports.getPublicUrlForMxc = getPublicUrlForMxc

View file

@ -6,7 +6,7 @@
const util = require("util")
const Ty = require("../types")
const {discord, db, sync, as, select} = require("../passthrough")
const {discord, db, sync, as} = require("../passthrough")
/** @type {import("./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")
/** @type {import("../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
@ -166,29 +167,5 @@ sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member",
async event => {
if (event.state_key[0] !== "@") return
if (utils.eventSenderIsFromDiscord(event.state_key)) return
if (event.content.membership === "leave" || event.content.membership === "ban") {
// 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)
}
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)
}))

View file

@ -3,15 +3,14 @@
const Ty = require("../types")
const assert = require("assert").strict
const fetch = require("node-fetch").default
const passthrough = require("../passthrough")
const { discord, sync, db } = passthrough
/** @type {import("./mreq")} */
const mreq = sync.require("./mreq")
/** @type {import("./file")} */
const file = sync.require("./file")
/** @type {import("./txnid")} */
const makeTxnId = sync.require("./txnid")
const {reg} = require("./read-registration.js")
/**
* @param {string} p endpoint to access
@ -60,7 +59,7 @@ async function createRoom(content) {
*/
async function joinRoom(roomIDOrAlias, mxid) {
/** @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
}
@ -71,7 +70,6 @@ async function inviteToRoom(roomID, mxidToInvite, mxid) {
}
async function leaveRoom(roomID, mxid) {
console.log(`[api] leave: ${roomID}: ${mxid}`)
await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, mxid), {})
}
@ -123,17 +121,6 @@ function getJoinedMembers(roomID) {
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
@ -147,24 +134,6 @@ function getHierarchy(roomID, 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} eventID
@ -181,26 +150,6 @@ function getRelations(roomID, eventID, pagination, relType) {
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} type
@ -292,53 +241,6 @@ async function setUserPower(roomID, mxid, power) {
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.register = register
module.exports.createRoom = createRoom
@ -350,11 +252,8 @@ module.exports.getEventForTimestamp = getEventForTimestamp
module.exports.getAllState = getAllState
module.exports.getStateEvent = getStateEvent
module.exports.getJoinedMembers = getJoinedMembers
module.exports.getMembers = getMembers
module.exports.getHierarchy = getHierarchy
module.exports.getFullHierarchy = getFullHierarchy
module.exports.getRelations = getRelations
module.exports.getFullRelations = getFullRelations
module.exports.sendState = sendState
module.exports.sendEvent = sendEvent
module.exports.redactEvent = redactEvent
@ -362,6 +261,3 @@ module.exports.sendTyping = sendTyping
module.exports.profileSetDisplayname = profileSetDisplayname
module.exports.profileSetAvatarUrl = profileSetAvatarUrl
module.exports.setUserPower = setUserPower
module.exports.setUserPowerCascade = setUserPowerCascade
module.exports.ping = ping
module.exports.getMedia = getMedia

View file

@ -1,6 +1,6 @@
// @ts-check
const {reg} = require("../matrix/read-registration")
const reg = require("../matrix/read-registration")
const {AppService} = require("@cloudrac3r/in-your-element")
const as = new AppService(reg)
as.listen()

View file

@ -14,7 +14,7 @@ const mxUtils = sync.require("../m2d/converters/utils")
const dUtils = sync.require("../discord/utils")
/** @type {import("./kstate")} */
const ks = sync.require("./kstate")
const {reg} = require("./read-registration")
const reg = require("./read-registration")
const PREFIXES = ["//", "/"]
@ -217,8 +217,9 @@ const commands = [{
} else {
// Upload it to Discord and have the bridge sync it back to Matrix again
for (const e of toUpload) {
const publicUrl = mxUtils.getPublicUrlForMxc(e.url)
// @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)
.resize(EMOJI_SIZE, EMOJI_SIZE, {fit: "inside", withoutEnlargement: true, background: {r: 0, g: 0, b: 0, alpha: 0}})
.png()

View file

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

600
package-lock.json generated
View file

@ -16,21 +16,15 @@
"@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",
"cloudstorm": "^0.10.10",
"domino": "^2.1.6",
"enquirer": "^2.4.1",
"entities": "^5.0.0",
"get-stream": "^6.0.1",
"h3": "^1.12.0",
"heatsync": "^2.5.5",
"lru-cache": "^10.4.3",
"heatsync": "^2.5.3",
"js-yaml": "^4.1.0",
"minimist": "^1.2.8",
"node-fetch": "^2.6.7",
"prettier-bytes": "^1.0.4",
@ -38,21 +32,17 @@
"snowtransfer": "^0.10.5",
"stream-mime-type": "^1.0.2",
"try-to-catch": "^3.0.1",
"uqr": "^0.1.2",
"xxhash-wasm": "^1.0.2",
"zod": "^3.23.8"
"xxhash-wasm": "^1.0.2"
},
"devDependencies": {
"@cloudrac3r/tap-dot": "^2.0.3",
"@cloudrac3r/tap-dot": "^2.0.2",
"@types/node": "^18.16.0",
"@types/node-fetch": "^2.6.3",
"c8": "^10.1.2",
"colorette": "^1.4.0",
"cross-env": "^7.0.3",
"discord-api-types": "^0.37.60",
"supertape": "^10.4.0"
},
"engines": {
"node": ">=20"
}
},
"../in-your-element": {
@ -78,62 +68,6 @@
"node": ">=18"
}
},
"../tap-dot": {
"name": "@cloudrac3r/tap-dot",
"version": "2.0.0",
"extraneous": true,
"license": "MIT",
"dependencies": {
"@cloudrac3r/tap-out": "^3.2.3",
"ansi-colors": "^4.1.3"
},
"bin": {
"tap-dot": "bin/dot"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.24.8",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz",
"integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz",
"integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.25.6",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.6.tgz",
"integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==",
"dependencies": {
"@babel/types": "^7.25.6"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.25.6",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.6.tgz",
"integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==",
"dependencies": {
"@babel/helper-string-parser": "^7.24.8",
"@babel/helper-validator-identifier": "^7.24.7",
"to-fast-properties": "^2.0.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
@ -295,54 +229,14 @@
"node": ">=14.19.0"
}
},
"node_modules/@cloudrac3r/pug": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@cloudrac3r/pug/-/pug-4.0.4.tgz",
"integrity": "sha512-RZhxM/WfSHT0n39URlwDdugBfGfwEWmr+w+mCyiT9jaiqCjeZPpXkps/cWLA1XRLo7fzq0+9THtGzVKXS487/A==",
"dependencies": {
"@cloudrac3r/pug-code-gen": "3.0.5",
"@cloudrac3r/pug-lexer": "5.0.3",
"pug-linker": "^4.0.0",
"pug-load": "^3.0.0",
"pug-parser": "^6.0.0",
"pug-runtime": "^3.0.1",
"pug-strip-comments": "^2.0.0"
}
},
"node_modules/@cloudrac3r/pug-code-gen": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@cloudrac3r/pug-code-gen/-/pug-code-gen-3.0.5.tgz",
"integrity": "sha512-dKKpy3i9YlVa3lBgu5Jds513c7AtzmmsR2/lGhY2NOODSpIiTcbWLw1obA9YEmmH1tAJny+J6ePYN1N1RgjjQA==",
"dependencies": {
"constantinople": "^4.0.1",
"doctypes": "^1.1.0",
"js-stringify": "^1.0.2",
"pug-attrs": "^3.0.0",
"pug-error": "^2.1.0",
"pug-runtime": "^3.0.1",
"void-elements": "^3.1.0",
"with": "^7.0.0"
}
},
"node_modules/@cloudrac3r/pug-lexer": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/@cloudrac3r/pug-lexer/-/pug-lexer-5.0.3.tgz",
"integrity": "sha512-ym4g4q+l9IC2H1wXCDnF79AQZ48xtxO675JOT316e17W2wHWtgRccXpT6DkBAaRDZycmkGzSxID1S15T2lZj+g==",
"dependencies": {
"character-parser": "^4.0.0",
"is-expression": "^4.0.0",
"pug-error": "^2.1.0"
}
},
"node_modules/@cloudrac3r/tap-dot": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@cloudrac3r/tap-dot/-/tap-dot-2.0.3.tgz",
"integrity": "sha512-GmdDvc9LCdD4IvKf5DhTTSywGzIT8KbxjhNFSewnaUEfiGDer65CK62KzdciZgWBZfow8xaHISGfXgThn5/rqg==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@cloudrac3r/tap-dot/-/tap-dot-2.0.2.tgz",
"integrity": "sha512-q2IzqZvFIIjy8dBnuC5+baZ6KHroxOjWzaaBd+FGPk2NvPpsRdf8dFv0rTTntco8Z62grmCuMPc4/Nblx+Ot3A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cloudrac3r/tap-out": "^3.2.3",
"ansi-colors": "^4.1.3"
"colorette": "^1.0.5"
},
"bin": {
"tap-dot": "bin/dot"
@ -353,7 +247,6 @@
"resolved": "https://registry.npmjs.org/@cloudrac3r/tap-out/-/tap-out-3.2.3.tgz",
"integrity": "sha512-WS89ziuTMLZlOVTXArkok32HcpWWxejQmZAUOl6nbB2Y114M8LfSOQ7t/rmgVsYB4oc6Ehe/nzevUMt4GGbCsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"re-emitter": "1.1.4",
"split2": "^4.2.0"
@ -383,11 +276,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/@hotwired/stimulus": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.2.tgz",
"integrity": "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A=="
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
@ -754,7 +642,6 @@
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0",
@ -772,7 +659,6 @@
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
@ -784,15 +670,13 @@
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true,
"license": "MIT"
"dev": true
},
"node_modules/@isaacs/cliui/node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2",
@ -810,7 +694,6 @@
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.1.0",
"string-width": "^5.0.1",
@ -874,21 +757,11 @@
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=14"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@putout/cli-keypress": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@putout/cli-keypress/-/cli-keypress-2.0.0.tgz",
@ -921,20 +794,6 @@
"integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==",
"dev": true
},
"node_modules/@stackoverflow/stacks": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@stackoverflow/stacks/-/stacks-2.5.7.tgz",
"integrity": "sha512-1ipTt7jqUszyd78Gn9TADT22PL0yXe14iEfgZyvJlDvrNrmyJLoGsFMRMwcduPol6/C/zkFt2dmfph/5vFDcYA==",
"dependencies": {
"@hotwired/stimulus": "^3.2.2",
"@popperjs/core": "^2.11.8"
}
},
"node_modules/@stackoverflow/stacks-icons": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@stackoverflow/stacks-icons/-/stacks-icons-6.0.2.tgz",
"integrity": "sha512-NDXV/0w6on9fJBfaLrBtPSXTbGyitD+mBTmIpLmDWbVgZo3EJgZBdPdElO0nO7K0WR2Yee1nKhC8euRhd9nicg=="
},
"node_modules/@supertape/engine-loader": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@supertape/engine-loader/-/engine-loader-2.0.0.tgz",
@ -1071,11 +930,10 @@
"dev": true
},
"node_modules/@types/node": {
"version": "18.19.46",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.46.tgz",
"integrity": "sha512-vnRgMS7W6cKa1/0G3/DTtQYpVrZ8c0Xm6UkLaVFrb9jtcVC3okokW09Ki1Qdrj9ISokszD69nY4WDLRlvHlhAA==",
"version": "18.19.42",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.42.tgz",
"integrity": "sha512-d2ZFc/3lnK2YCYhos8iaNIYu9Vfhr92nHiyJHRltXWjXUBjEE+A4I58Tdbnw4VhggSW+2j5y5gTrLs4biNnubg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
@ -1110,25 +968,6 @@
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
"integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A=="
},
"node_modules/acorn": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
"integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/ansi-colors": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
"integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
"engines": {
"node": ">=6"
}
},
"node_modules/ansi-regex": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
@ -1156,6 +995,11 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
},
"node_modules/as-table": {
"version": "1.0.55",
"resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz",
@ -1165,28 +1009,12 @@
"printable-characters": "^1.0.42"
}
},
"node_modules/assert-never": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.3.0.tgz",
"integrity": "sha512-9Z3vxQ+berkL/JJo0dK+EY3Lp0s3NtSnP3VCLsh5HDcZPrh0M+KQRK5sWhUeyPPH+/RCxZqOxLMR+YC6vlviEQ=="
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true
},
"node_modules/babel-walk": {
"version": "3.0.0-canary-5",
"resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz",
"integrity": "sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==",
"dependencies": {
"@babel/types": "^7.9.6"
},
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/backtracker": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/backtracker/-/backtracker-4.0.0.tgz",
@ -1218,11 +1046,10 @@
]
},
"node_modules/better-sqlite3": {
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.3.0.tgz",
"integrity": "sha512-iHt9j8NPYF3oKCNOO5ZI4JwThjt3Z6J6XrcwG85VNMVzv1ByqrHWv5VILEbCMFWDsoHhXvQ7oC8vgRXFAKgl9w==",
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.1.2.tgz",
"integrity": "sha512-gujtFwavWU4MSPT+h9B+4pkvZdyOUkH54zgLdIrMmmmd4ZqiBIrRNBzNzYVFO417xo882uP5HBu4GjOfaSrIQw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
@ -1340,11 +1167,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/character-parser": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/character-parser/-/character-parser-4.0.0.tgz",
"integrity": "sha512-jWburCrDpd+aPopB7esjh/gLyZoHZS4C2xwwJlkTPyhhJdXG+FCG0P4qCOInvOd9yhiuAEJYlZsUMQ0JSK4ykw=="
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
@ -1425,13 +1247,13 @@
}
},
"node_modules/cloudstorm": {
"version": "0.10.11",
"resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.10.11.tgz",
"integrity": "sha512-A3lN0o404la7ryWIxN73gW2ehC0RO4h0yCA2grtOtPh8rNTd6+R2U4llyJlb61HlyOFrEVJ7AbOoFblVSmkrtw==",
"version": "0.10.10",
"resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.10.10.tgz",
"integrity": "sha512-WcxpGc8lTQ24nn9L8WflyrU+sHE7StTXu8NeEYzBevMThbYRWVDQOuzca7iiGyaNWZmFu3Iv0nLbUjFFIOQ5CQ==",
"license": "MIT",
"dependencies": {
"discord-api-types": "^0.37.98",
"snowtransfer": "^0.10.7"
"discord-api-types": "^0.37.93",
"snowtransfer": "^0.10.6"
},
"engines": {
"node": ">=14.8.0"
@ -1474,6 +1296,12 @@
"simple-swizzle": "^0.2.2"
}
},
"node_modules/colorette": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
"dev": true
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@ -1495,15 +1323,6 @@
"node": "^14.18.0 || >=16.10.0"
}
},
"node_modules/constantinople": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/constantinople/-/constantinople-4.0.1.tgz",
"integrity": "sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==",
"dependencies": {
"@babel/parser": "^7.6.0",
"@babel/types": "^7.6.1"
}
},
"node_modules/convert-source-map": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
@ -1634,16 +1453,11 @@
}
},
"node_modules/discord-api-types": {
"version": "0.37.101",
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.101.tgz",
"integrity": "sha512-2wizd94t7G3A8U5Phr3AiuL4gSvhqistDwWnlk1VLTit8BI1jWUncFqFQNdPbHqS3661+Nx/iEyIwtVjPuBP3w==",
"version": "0.37.95",
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.95.tgz",
"integrity": "sha512-EuEO4vu8+rtWbrVufDtYFH5dm40Wo55jBWEdwyvav1r3XiM51PzAnZ/BUaHmfqYLEooJs+3Tn56o/Vnp1qLMJg==",
"license": "MIT"
},
"node_modules/doctypes": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/doctypes/-/doctypes-1.1.0.tgz",
"integrity": "sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ=="
},
"node_modules/domino": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/domino/-/domino-2.1.6.tgz",
@ -1653,8 +1467,7 @@
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true,
"license": "MIT"
"dev": true
},
"node_modules/emoji-regex": {
"version": "8.0.0",
@ -1670,37 +1483,6 @@
"once": "^1.4.0"
}
},
"node_modules/enquirer": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz",
"integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==",
"dependencies": {
"ansi-colors": "^4.1.1",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/enquirer/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/enquirer/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/entities": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-5.0.0.tgz",
@ -1870,22 +1652,24 @@
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="
},
"node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.0.0.tgz",
"integrity": "sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==",
"dev": true,
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"jackspeak": "^4.0.1",
"minimatch": "^10.0.0",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
"path-scurry": "^2.0.0"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
@ -1930,9 +1714,9 @@
}
},
"node_modules/heatsync": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/heatsync/-/heatsync-2.5.5.tgz",
"integrity": "sha512-Sy2/X2a69W2W1xgp7GBY81naHtWXxwV8N6uzPTJLQXgq4oTMJeL6F/AUlGS+fUa/Pt5ioxzi7gvd8THMJ3GpyA==",
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/heatsync/-/heatsync-2.5.4.tgz",
"integrity": "sha512-KzsM+wR0MIykD80kCHNZCpNvFY4uC1Yze8R37eehJyGIvEepJd+7ubczh6FVoBFtK0nVEszt5Hl8AbzUvb+vMQ==",
"dependencies": {
"backtracker": "^4.0.0"
}
@ -2003,15 +1787,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-expression": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-expression/-/is-expression-4.0.0.tgz",
"integrity": "sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==",
"dependencies": {
"acorn": "^7.1.1",
"object-assign": "^4.1.1"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@ -2064,14 +1839,16 @@
}
},
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.0.1.tgz",
"integrity": "sha512-cub8rahkh0Q/bw1+GxP7aeSe29hHHn2V4m29nnDlvCdlgU+3UGxkZp7Z53jLUdpX3jdTO0nJZUDl3xvbWc2Xog==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
@ -2103,10 +1880,16 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/js-stringify": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz",
"integrity": "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g=="
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/just-kebab-case": {
"version": "4.2.0",
@ -2129,12 +1912,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
@ -2193,16 +1970,15 @@
}
},
"node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz",
"integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@ -2271,14 +2047,6 @@
"integrity": "sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==",
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ohash": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-1.1.3.tgz",
@ -2354,22 +2122,30 @@
"dev": true
},
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz",
"integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": ">=16 || 14 >=14.18"
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-scurry/node_modules/lru-cache": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.0.tgz",
"integrity": "sha512-Qv32eSV1RSCfhY3fpPE2GNZ8jgM9X7rdAfemLWqTUxwiyIC4jJ6Sy0fZ8H+oLWevO6i4/bizg7c8d8i6bxrzbA==",
"dev": true,
"engines": {
"node": "20 || >=22"
}
},
"node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
@ -2450,66 +2226,6 @@
"integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==",
"dev": true
},
"node_modules/pug-attrs": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pug-attrs/-/pug-attrs-3.0.0.tgz",
"integrity": "sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==",
"dependencies": {
"constantinople": "^4.0.1",
"js-stringify": "^1.0.2",
"pug-runtime": "^3.0.0"
}
},
"node_modules/pug-error": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pug-error/-/pug-error-2.1.0.tgz",
"integrity": "sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg=="
},
"node_modules/pug-linker": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/pug-linker/-/pug-linker-4.0.0.tgz",
"integrity": "sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==",
"dependencies": {
"pug-error": "^2.0.0",
"pug-walk": "^2.0.0"
}
},
"node_modules/pug-load": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pug-load/-/pug-load-3.0.0.tgz",
"integrity": "sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==",
"dependencies": {
"object-assign": "^4.1.1",
"pug-walk": "^2.0.0"
}
},
"node_modules/pug-parser": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/pug-parser/-/pug-parser-6.0.0.tgz",
"integrity": "sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==",
"dependencies": {
"pug-error": "^2.0.0",
"token-stream": "1.0.0"
}
},
"node_modules/pug-runtime": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/pug-runtime/-/pug-runtime-3.0.1.tgz",
"integrity": "sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg=="
},
"node_modules/pug-strip-comments": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/pug-strip-comments/-/pug-strip-comments-2.0.0.tgz",
"integrity": "sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==",
"dependencies": {
"pug-error": "^2.0.0"
}
},
"node_modules/pug-walk": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/pug-walk/-/pug-walk-2.0.0.tgz",
"integrity": "sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ=="
},
"node_modules/pump": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
@ -2543,8 +2259,7 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/re-emitter/-/re-emitter-1.1.4.tgz",
"integrity": "sha512-C0SIXdXDSus2yqqvV7qifnb4NoWP7mEBXJq3axci301mXHCZb8Djwm4hrEZo4UeXRaEnfjH98uQ8EBppk2oNWA==",
"dev": true,
"license": "MIT"
"dev": true
},
"node_modules/react-is": {
"version": "18.2.0",
@ -2776,13 +2491,13 @@
}
},
"node_modules/snowtransfer": {
"version": "0.10.7",
"resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.10.7.tgz",
"integrity": "sha512-lXUYp6jOou0DI8uFl3Dh78KD1gVa3dNbUt2TK6RW39mHenAR6XpoPoydUNXCWvdxi6uGU6zQ1yNICZpKjF6wMA==",
"version": "0.10.6",
"resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.10.6.tgz",
"integrity": "sha512-vRIHhV0YXeQ7LdnU+gwaX6ovoUbh5ajAA5etQY5lguySGZlYc3S2+GJ+EOTkVYDHtzGNOiBAUxR4Kn4iSHp+QQ==",
"license": "MIT",
"dependencies": {
"discord-api-types": "^0.37.98",
"undici": "^6.19.8"
"discord-api-types": "^0.37.93",
"undici": "^6.19.5"
},
"engines": {
"node": ">=14.18.0"
@ -2802,7 +2517,6 @@
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
@ -2869,7 +2583,6 @@
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
@ -2884,7 +2597,6 @@
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
@ -2894,7 +2606,6 @@
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
@ -2944,7 +2655,6 @@
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
@ -2957,7 +2667,6 @@
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
@ -2987,11 +2696,10 @@
}
},
"node_modules/supertape": {
"version": "10.7.3",
"resolved": "https://registry.npmjs.org/supertape/-/supertape-10.7.3.tgz",
"integrity": "sha512-t/zv0sev+5261g9KampNVL7io8UQ7zmouRWt9/UU+Yr7Ap0MqBKlyDFFvkzcfADT+O6bXZMW5x3nzYzSU+LAYg==",
"version": "10.7.2",
"resolved": "https://registry.npmjs.org/supertape/-/supertape-10.7.2.tgz",
"integrity": "sha512-pBWvzBtefVgokjJRk2ks+6q8/Sy1fIIvMBL8nXYaXk3100cgcPsw4iYhFSW41tg/cEe4IOlV/G+FZFN4kSMtOg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cloudcmd/stub": "^4.0.0",
"@putout/cli-keypress": "^2.0.0",
@ -3007,7 +2715,7 @@
"cli-progress": "^3.8.2",
"flatted": "^3.3.1",
"fullstore": "^3.0.0",
"glob": "^10.0.0",
"glob": "^11.0.0",
"jest-diff": "^29.0.1",
"once": "^1.4.0",
"resolve": "^1.17.0",
@ -3102,6 +2810,78 @@
"node": ">=18"
}
},
"node_modules/test-exclude/node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"dev": true,
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/test-exclude/node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"dev": true,
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"optionalDependencies": {
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/test-exclude/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true
},
"node_modules/test-exclude/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/test-exclude/node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/through2": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz",
@ -3129,19 +2909,6 @@
"integrity": "sha512-M1aP6ASmuVD0PSxl5fqjCAGY9WyND3DHZ8RwT5I8o7469XE53Lb5zbPai20Dhj7TProyaapfVj3TaT0P+LoSEA==",
"dev": true
},
"node_modules/to-fast-properties": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
"integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
"engines": {
"node": ">=4"
}
},
"node_modules/token-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/token-stream/-/token-stream-1.0.0.tgz",
"integrity": "sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg=="
},
"node_modules/token-types": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz",
@ -3211,9 +2978,9 @@
"license": "MIT"
},
"node_modules/undici": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.19.8.tgz",
"integrity": "sha512-U8uCCl2x9TK3WANvmBavymRzxbfFYG+tAu+fgx3zxQy3qdagQqBLwJVrdyO1TBfUXvfKveMKJZhpvUYoOjM+4g==",
"version": "6.19.7",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.19.7.tgz",
"integrity": "sha512-HR3W/bMGPSr90i8AAp2C4DM3wChFdJPLrWYpIS++LxS8K+W535qftjt+4MyjNYHeWabMj1nvtmLIi7l++iq91A==",
"license": "MIT",
"engines": {
"node": ">=18.17"
@ -3238,12 +3005,6 @@
"pathe": "^1.1.2"
}
},
"node_modules/uqr": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.2.tgz",
"integrity": "sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==",
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@ -3263,14 +3024,6 @@
"node": ">=10.12.0"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
@ -3300,20 +3053,6 @@
"node": ">= 8"
}
},
"node_modules/with": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/with/-/with-7.0.2.tgz",
"integrity": "sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==",
"dependencies": {
"@babel/parser": "^7.9.6",
"@babel/types": "^7.9.6",
"assert-never": "^1.2.1",
"babel-walk": "3.0.0-canary-5"
},
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@ -3337,7 +3076,6 @@
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
@ -3355,7 +3093,6 @@
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
@ -3365,7 +3102,6 @@
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},

View file

@ -14,9 +14,6 @@
],
"author": "Cadence, PapiOphidian",
"license": "AGPL-3.0-or-later",
"engines": {
"node": ">=20"
},
"dependencies": {
"@chriscdn/promise-semaphore": "^2.0.1",
"@cloudrac3r/discord-markdown": "^2.6.3",
@ -25,21 +22,15 @@
"@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",
"cloudstorm": "^0.10.10",
"domino": "^2.1.6",
"enquirer": "^2.4.1",
"entities": "^5.0.0",
"get-stream": "^6.0.1",
"h3": "^1.12.0",
"heatsync": "^2.5.5",
"lru-cache": "^10.4.3",
"heatsync": "^2.5.3",
"js-yaml": "^4.1.0",
"minimist": "^1.2.8",
"node-fetch": "^2.6.7",
"prettier-bytes": "^1.0.4",
@ -47,25 +38,22 @@
"snowtransfer": "^0.10.5",
"stream-mime-type": "^1.0.2",
"try-to-catch": "^3.0.1",
"uqr": "^0.1.2",
"xxhash-wasm": "^1.0.2",
"zod": "^3.23.8"
"xxhash-wasm": "^1.0.2"
},
"devDependencies": {
"@cloudrac3r/tap-dot": "^2.0.3",
"@cloudrac3r/tap-dot": "^2.0.2",
"@types/node": "^18.16.0",
"@types/node-fetch": "^2.6.3",
"c8": "^10.1.2",
"colorette": "^1.4.0",
"cross-env": "^7.0.3",
"discord-api-types": "^0.37.60",
"supertape": "^10.4.0"
},
"scripts": {
"start": "node start.js",
"setup": "node scripts/setup.js",
"addbot": "node addbot.js",
"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",
"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,6 +3,7 @@
/**
* @typedef {Object} Passthrough
* @property {import("repl").REPLServer} repl
* @property {typeof import("./config")} config
* @property {import("./d2m/discord-client")} discord
* @property {import("heatsync").default} sync
* @property {import("better-sqlite3/lib/database")} db

147
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 emojis in messages
* 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
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.
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.
@ -74,127 +74,116 @@ You'll need:
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/prebuilt-installer)
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).
1. Install dependencies: `npm install`
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. Start the bridge: `node start.js`
1. Add the bot to a server - use any *one* of the following commands for an invite link:
* (in the REPL) `addbot`
* (in a chat) `//addbot`
* $ `node addbot.js`
* $ `npm run addbot`
* $ `./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.
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
* Install development dependencies with `npm install --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`
* 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
.
* Run this to start the bridge:
├── start.js
* Runtime configuration, like tokens and user info:
├── config.js
├── registration.yaml
* The bridge's SQLite database is stored here:
├── db
│   ├── *.sql, *.db
│   * Migrations change the database schema when you update to a newer version of OOYE:
│   └── migrations
│       └── *.sql, *.js
* Discord-to-Matrix bridging:
├── d2m
│   * Execute actions through the whole flow, like sending a Discord message to Matrix:
│   ├── actions
│   │   └── *.js
│   * Convert data from one form to another without depending on bridge state. Called by actions:
│   ├── converters
│   │   └── *.js
│   * Making Discord work:
│   ├── discord-*.js
│   * Listening to events from Discord and dispatching them to the correct `action`:
│   └── event-dispatcher.js
├── discord
│   └── discord-command-handler.js
* Matrix-to-Discord bridging:
├── m2d
│   * Execute actions through the whole flow, like sending a Matrix message to Discord:
│   ├── actions
│   │   └── *.js
│   * Convert data from one form to another without depending on bridge state. Called by actions:
│   ├── converters
│   │   └── *.js
│   * Listening to events from Matrix and dispatching them to the correct `action`:
│   └── event-dispatcher.js
* We aren't using the matrix-js-sdk, so here are all the functions for the Matrix C-S and Appservice APIs:
├── matrix
│   └── *.js
* Various files you can run once if you need them.
├── scripts
│   * First time running a new bridge? Run this file to plant a seed, which will flourish into state for the bridge:
│   ├── seed.js
│   * Hopefully you won't need the rest of these. Code quality varies wildly.
│   └── *.js
* You are here! :)
├── readme.md
* The bridge's SQLite database is stored here:
├── ooye.db*
* Source code
└── src
* Database schema:
├── db
│   ├── orm.js, orm-defs.d.ts
│   * Migrations change the database schema when you update to a newer version of OOYE:
│   ├── migrate.js
│   └── migrations
│       └── *.sql, *.js
* Discord-to-Matrix bridging:
├── d2m
│   * Execute actions through the whole flow, like sending a Discord message to Matrix:
│   ├── actions
│   │   └── *.js
│   * Convert data from one form to another without depending on bridge state. Called by actions:
│   ├── converters
│   │   └── *.js
│   * Making Discord work:
│   ├── discord-*.js
│   * Listening to events from Discord and dispatching them to the correct `action`:
│   └── event-dispatcher.js
* Discord bot commands and menus:
├── discord
│   ├── interactions
│   │   └── *.js
│   └── discord-command-handler.js
* Matrix-to-Discord bridging:
├── m2d
│   * Execute actions through the whole flow, like sending a Matrix message to Discord:
│   ├── actions
│   │   └── *.js
│   * Convert data from one form to another without depending on bridge state. Called by actions:
│   ├── converters
│   │   └── *.js
│   * Listening to events from Matrix and dispatching them to the correct `action`:
│   └── event-dispatcher.js
* We aren't using the matrix-js-sdk, so here are all the functions for the Matrix C-S and Appservice APIs:
├── matrix
│   └── *.js
* Various files you can run once if you need them.
└── scripts
* First time running a new bridge? Run this file to set up prerequisites on the Matrix server:
├── setup.js
* Hopefully you won't need the rest of these. Code quality varies wildly.
└── *.js
└── readme.md
## Dependency justification
Total transitive production dependencies: 147
(deduped transitive dependency count) dependency name: explanation
### <font size="+2">🦕</font>
* (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) @chriscdn/promise-semaphore: It does what I want! I like it!
* (1) @cloudrac3r/discord-markdown: This is my fork!
* (0) @cloudrac3r/giframe: This is my fork!
* (1) @cloudrac3r/html-template-tag: This is my fork!
* (16) @cloudrac3r/in-your-element: This is my Matrix Appservice API library.
* (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.
* (42) better-sqlite3: SQLite3 is the best database, and this is the best library for it. Really! I love it.
* (1) chunk-text: It does what I want.
* (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust.
* (0) domino: DOM implementation that's already pulled in by turndown.
* (1) enquirer: Interactive prompting for the initial setup rather than forcing users to edit YAML non-interactively.
* (0) entities: Looks fine. No dependencies.
* (0) get-stream: Only needed if content_length_workaround is true.
* (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) lru-cache: For holding unused nonce in memory and letting them be overwritten later if never used.
* (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) 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: Image compositing and processing. Jimp has fewer dependencies, but sharp is faster.
* (8) snowtransfer: Discord API library with bring-your-own-caching that I trust.
* (10) stream-mime-type@1: This seems like the best option. Version 1 is used because version 2 is ESM-only.
* (0) try-to-catch: Not strictly necessary, but it's already pulled in by supertape, so I may as well.
* (0) uqr: QR code SVG generator. Used on the website to scan in an invite link.
* (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.
Total transitive production dependencies: 113

25
registration.example.yaml Normal file
View file

@ -0,0 +1,25 @@
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
include_user_id_in_mxid: 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'

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

@ -1,4 +1,3 @@
#!/usr/bin/env node
// @ts-check
// ****
@ -17,16 +16,16 @@ function fieldToPresenceValue(field) {
const sqlite = require("better-sqlite3")
const HeatSync = require("heatsync")
const {reg} = require("../src/matrix/read-registration")
const passthrough = require("../src/passthrough")
const config = require("../config")
const passthrough = require("../passthrough")
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
;(async () => {

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