Compare commits
No commits in common. "main" and "v2.2" have entirely different histories.
184 changed files with 2737 additions and 22864 deletions
13
.gitignore
vendored
13
.gitignore
vendored
|
@ -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
9
addbot.js
Executable file → Normal 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
|
||||
|
|
|
@ -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
3
config.example.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
module.exports = {
|
||||
discordToken: "yes"
|
||||
}
|
|
@ -2,25 +2,20 @@
|
|||
|
||||
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, from} = passthrough
|
||||
const {discord, sync, db, select} = passthrough
|
||||
/** @type {import("../../matrix/file")} */
|
||||
const file = sync.require("../../matrix/file")
|
||||
/** @type {import("../../matrix/api")} */
|
||||
const api = sync.require("../../matrix/api")
|
||||
/** @type {import("../../matrix/mreq")} */
|
||||
const mreq = sync.require("../../matrix/mreq")
|
||||
/** @type {import("../../matrix/kstate")} */
|
||||
const ks = sync.require("../../matrix/kstate")
|
||||
/** @type {import("../../discord/utils")} */
|
||||
const dUtils = sync.require("../../discord/utils")
|
||||
/** @type {import("../../m2d/converters/utils")} */
|
||||
const mUtils = sync.require("../../m2d/converters/utils")
|
||||
/** @type {import("./create-space")} */
|
||||
const createSpace = sync.require("./create-space")
|
||||
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:
|
||||
|
@ -30,7 +25,7 @@ const createSpace = sync.require("./create-space")
|
|||
*/
|
||||
const PRIVACY_ENUMS = {
|
||||
PRESET: ["private_chat", "public_chat", "public_chat"],
|
||||
VISIBILITY: ["private", "private", "private"],
|
||||
VISIBILITY: ["private", "private", "public"],
|
||||
SPACE_HISTORY_VISIBILITY: ["invited", "world_readable", "world_readable"], // copying from element client
|
||||
ROOM_HISTORY_VISIBILITY: ["shared", "shared", "world_readable"], // any events sent after <value> are visible, but for world_readable anybody can read without even joining
|
||||
GUEST_ACCESS: ["can_join", "forbidden", "forbidden"], // whether guests can join space if other conditions are met
|
||||
|
@ -43,6 +38,26 @@ const DEFAULT_PRIVACY_LEVEL = 0
|
|||
/** @type {Map<string, Promise<string>>} channel ID -> Promise<room ID> */
|
||||
const inflightRoomCreate = new Map()
|
||||
|
||||
/**
|
||||
* Async because it gets all room state from the homeserver.
|
||||
* @param {string} roomID
|
||||
*/
|
||||
async function roomToKState(roomID) {
|
||||
const root = await api.getAllState(roomID)
|
||||
return ks.stateToKState(root)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} roomID
|
||||
* @param {any} kstate
|
||||
*/
|
||||
async function applyKStateDiffToRoom(roomID, kstate) {
|
||||
const events = await ks.kstateToState(kstate)
|
||||
return Promise.all(events.map(({type, state_key, content}) =>
|
||||
api.sendState(roomID, type, state_key, content)
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{id: string, name: string, topic?: string?, type: number, parent_id?: string?}} channel
|
||||
* @param {{id: string}} guild
|
||||
|
@ -74,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)
|
||||
|
||||
/** Used for membership/permission checks. */
|
||||
const guildSpaceID = await createSpace.ensureSpace(guild)
|
||||
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", "custom_topic"], {channel_id: channel.id}).get()
|
||||
const customName = channelRow?.nick
|
||||
const customAvatar = channelRow?.custom_avatar
|
||||
const hasCustomTopic = channelRow?.custom_topic
|
||||
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 = select("guild_space", "privacy_level", {guild_id: guild.id}).pluck().get()
|
||||
assert(privacyLevel != null) // already ensured the space exists
|
||||
let history_visibility = PRIVACY_ENUMS.ROOM_HISTORY_VISIBILITY[privacyLevel]
|
||||
if (channel["thread_metadata"]) history_visibility = "world_readable"
|
||||
|
||||
|
@ -119,18 +139,9 @@ async function channelToKState(channel, guild, di) {
|
|||
join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]}
|
||||
}
|
||||
|
||||
const everyonePermissions = dUtils.getPermissions([], guild.roles, undefined, channel.permission_overwrites)
|
||||
const everyoneCanSend = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendMessages)
|
||||
const everyoneCanMentionEveryone = dUtils.hasAllPermissions(everyonePermissions, ["MentionEveryone"])
|
||||
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
|
||||
|
||||
/** @type {any} */
|
||||
const channelKState = {
|
||||
"m.room.name/": {name: convertedName},
|
||||
"m.room.topic/": {topic: convertedTopic},
|
||||
|
@ -143,15 +154,17 @@ async function channelToKState(channel, guild, di) {
|
|||
},
|
||||
/** @type {{join_rule: string, [x: string]: any}} */
|
||||
"m.room.join_rules/": join_rules,
|
||||
/** @type {Ty.Event.M_Power_Levels} */
|
||||
"m.room.power_levels/": {
|
||||
events_default: everyoneCanSend ? 0 : 50,
|
||||
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
|
||||
},
|
||||
[`uk.half-shot.bridge/moe.cadence.ooye://discord/${guild.id}/${channel.id}`]: {
|
||||
bridgebot: `@${reg.sender_localpart}:${reg.ooye.server_name}`,
|
||||
|
@ -162,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,
|
||||
|
@ -172,8 +185,6 @@ async function channelToKState(channel, guild, di) {
|
|||
}
|
||||
}
|
||||
|
||||
if (hasCustomTopic) delete channelKState["m.room.topic/"]
|
||||
|
||||
return {spaceID: parentSpaceID, privacyLevel, channelKState}
|
||||
}
|
||||
|
||||
|
@ -238,16 +249,15 @@ async function postApplyPowerLevels(kstate, callback) {
|
|||
const powerLevelContent = kstate["m.room.power_levels/"]
|
||||
const kstateWithoutPowerLevels = {...kstate}
|
||||
delete kstateWithoutPowerLevels["m.room.power_levels/"]
|
||||
delete kstateWithoutPowerLevels["chat.schildi.hide_ui/read_receipts"]
|
||||
|
||||
/** @type {string} */
|
||||
const roomID = await callback(kstateWithoutPowerLevels)
|
||||
|
||||
// Now *really* apply the power level overrides on top of what Synapse *really* set
|
||||
if (powerLevelContent) {
|
||||
const newRoomKState = await ks.roomToKState(roomID)
|
||||
const newRoomKState = await roomToKState(roomID)
|
||||
const newRoomPowerLevelsDiff = ks.diffKState(newRoomKState, {"m.room.power_levels/": powerLevelContent})
|
||||
await ks.applyKStateDiffToRoom(roomID, newRoomPowerLevelsDiff)
|
||||
await applyKStateDiffToRoom(roomID, newRoomPowerLevelsDiff)
|
||||
}
|
||||
|
||||
return roomID
|
||||
|
@ -264,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
|
||||
|
@ -337,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
|
||||
|
@ -352,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
|
||||
|
@ -373,10 +327,10 @@ 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 ks.roomToKState(roomID)
|
||||
const roomKState = await roomToKState(roomID)
|
||||
if (+roomKState["m.room.create/"].room_version <= 8) {
|
||||
// join_rule `restricted` is not available in room version < 8 and not working properly in version == 8
|
||||
// read more: https://spec.matrix.org/v1.8/rooms/v9/
|
||||
|
@ -384,7 +338,7 @@ async function _syncRoom(channelID, shouldActuallySync) {
|
|||
channelKState["m.room.join_rules/"] = {join_rule: "public"}
|
||||
}
|
||||
const roomDiff = ks.diffKState(roomKState, channelKState)
|
||||
const roomApply = ks.applyKStateDiffToRoom(roomID, roomDiff)
|
||||
const roomApply = applyKStateDiffToRoom(roomID, roomDiff)
|
||||
db.prepare("UPDATE channel_room SET name = ? WHERE room_id = ?").run(channel.name, roomID)
|
||||
|
||||
// sync room as space member
|
||||
|
@ -394,76 +348,35 @@ 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)
|
||||
}
|
||||
|
||||
async function unbridgeChannel(channelID) {
|
||||
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 channel-ish (just needs an id, topic is optional)
|
||||
* @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 row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").get()
|
||||
assert.ok(row)
|
||||
const spaceID = select("guild_space", "space_id", {guild_id: guildID}).pluck().get()
|
||||
assert.ok(spaceID)
|
||||
|
||||
let botInRoom = true
|
||||
// remove room from being a space member
|
||||
await api.sendState(roomID, "m.space.parent", spaceID, {})
|
||||
await api.sendState(spaceID, "m.space.child", roomID, {})
|
||||
|
||||
// remove declaration that the room is bridged
|
||||
try {
|
||||
await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channel.id}`, {})
|
||||
} catch (e) {
|
||||
if (String(e).includes("not in room")) {
|
||||
botInRoom = false
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
if (botInRoom && "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 || ""})
|
||||
}
|
||||
|
||||
// delete webhook on discord
|
||||
const webhook = select("webhook", ["webhook_id", "webhook_token"], {channel_id: channel.id}).get()
|
||||
if (webhook) {
|
||||
await discord.snow.webhook.deleteWebhook(webhook.webhook_id, webhook.webhook_token)
|
||||
db.prepare("DELETE FROM webhook WHERE channel_id = ?").run(channel.id)
|
||||
}
|
||||
|
||||
// delete room from database
|
||||
db.prepare("DELETE FROM member_cache WHERE room_id = ?").run(roomID)
|
||||
db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channel.id) // cascades to most other tables, like messages
|
||||
|
||||
if (!botInRoom) return
|
||||
|
||||
// demote admins in room
|
||||
/** @type {Ty.Event.M_Power_Levels} */
|
||||
const powerLevelContent = await api.getStateEvent(roomID, "m.room.power_levels", "")
|
||||
powerLevelContent.users ??= {}
|
||||
const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}`
|
||||
for (const mxid of Object.keys(powerLevelContent.users)) {
|
||||
if (powerLevelContent.users[mxid] >= 100 && mUtils.eventSenderIsFromDiscord(mxid) && mxid !== bot) {
|
||||
delete powerLevelContent.users[mxid]
|
||||
await api.sendState(roomID, "m.room.power_levels", "", powerLevelContent, mxid)
|
||||
}
|
||||
}
|
||||
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", {
|
||||
|
@ -471,28 +384,12 @@ async function unbridgeDeletedChannel(channel, guildID) {
|
|||
body: "⚠️ This room was removed from the bridge."
|
||||
})
|
||||
|
||||
// if it is an easy mode room, clean up the room from the managed space and make it clear it's not being bridged
|
||||
// (don't do this for self-service rooms, because they might continue to be used on Matrix or linked somewhere else later)
|
||||
if (row.autocreate === 1) {
|
||||
// remove room from being a space member
|
||||
await api.sendState(roomID, "m.space.parent", row.space_id, {})
|
||||
await api.sendState(row.space_id, "m.space.child", roomID, {})
|
||||
// leave room
|
||||
await api.leaveRoom(roomID)
|
||||
|
||||
// leave room
|
||||
await api.leaveRoom(roomID)
|
||||
}
|
||||
|
||||
// if it is a self-service room, remove sim members
|
||||
// (the room can be used with less clutter and the member list makes sense if it's bridged somewhere else)
|
||||
if (row.autocreate === 0) {
|
||||
// remove sim members
|
||||
const members = select("sim_member", "mxid", {room_id: roomID}).pluck().all()
|
||||
const preparedDelete = db.prepare("DELETE FROM sim_member WHERE room_id = ? AND mxid = ?")
|
||||
for (const mxid of members) {
|
||||
await api.leaveRoom(roomID, mxid)
|
||||
preparedDelete.run(roomID, mxid)
|
||||
}
|
||||
}
|
||||
// delete room from database
|
||||
const {changes} = db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channelID)
|
||||
assert.equal(changes, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -503,7 +400,7 @@ async function unbridgeDeletedChannel(channel, guildID) {
|
|||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
async function _syncSpaceMember(channel, spaceID, roomID) {
|
||||
const spaceKState = await ks.roomToKState(spaceID)
|
||||
const spaceKState = await roomToKState(spaceID)
|
||||
let spaceEventContent = {}
|
||||
if (
|
||||
channel.type !== DiscordTypes.ChannelType.PrivateThread // private threads do not belong in the space (don't offer people something they can't join)
|
||||
|
@ -516,7 +413,7 @@ async function _syncSpaceMember(channel, spaceID, roomID) {
|
|||
const spaceDiff = ks.diffKState(spaceKState, {
|
||||
[`m.space.child/${roomID}`]: spaceEventContent
|
||||
})
|
||||
return ks.applyKStateDiffToRoom(spaceID, spaceDiff)
|
||||
return applyKStateDiffToRoom(spaceID, spaceDiff)
|
||||
}
|
||||
|
||||
async function createAllForGuild(guildID) {
|
||||
|
@ -539,9 +436,9 @@ module.exports.ensureRoom = ensureRoom
|
|||
module.exports.syncRoom = syncRoom
|
||||
module.exports.createAllForGuild = createAllForGuild
|
||||
module.exports.channelToKState = channelToKState
|
||||
module.exports.roomToKState = roomToKState
|
||||
module.exports.applyKStateDiffToRoom = applyKStateDiffToRoom
|
||||
module.exports.postApplyPowerLevels = postApplyPowerLevels
|
||||
module.exports._convertNameAndTopic = convertNameAndTopic
|
||||
module.exports.unbridgeChannel = unbridgeChannel
|
||||
module.exports._unbridgeRoom = _unbridgeRoom
|
||||
module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel
|
||||
module.exports.existsOrAutocreatable = existsOrAutocreatable
|
||||
module.exports.assertExistsOrAutocreatable = assertExistsOrAutocreatable
|
100
d2m/actions/create-room.test.js
Normal file
100
d2m/actions/create-room.test.js
Normal file
|
@ -0,0 +1,100 @@
|
|||
// @ts-check
|
||||
|
||||
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 passthrough = require("../../passthrough")
|
||||
const {db} = passthrough
|
||||
|
||||
test("channel2room: discoverable privacy room", async t => {
|
||||
db.prepare("UPDATE guild_space SET privacy_level = 2").run()
|
||||
t.deepEqual(
|
||||
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"}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
test("channel2room: linkable privacy room", async t => {
|
||||
db.prepare("UPDATE guild_space SET privacy_level = 1").run()
|
||||
t.deepEqual(
|
||||
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"}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
test("channel2room: invite-only privacy room", async t => {
|
||||
db.prepare("UPDATE guild_space SET privacy_level = 0").run()
|
||||
t.deepEqual(
|
||||
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general).then(x => x.channelKState)),
|
||||
testData.room.general
|
||||
)
|
||||
})
|
||||
|
||||
test("channel2room: room where limited people can mention everyone", async t => {
|
||||
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}}})
|
||||
t.deepEqual(
|
||||
kstateStripConditionals(await channelToKState(testData.channel.general, limitedGuild).then(x => x.channelKState)),
|
||||
limitedRoom
|
||||
)
|
||||
})
|
||||
|
||||
test("convertNameAndTopic: custom name and topic", t => {
|
||||
t.deepEqual(
|
||||
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, "hauntings"),
|
||||
["hauntings", "#the-twilight-zone | Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"]
|
||||
)
|
||||
})
|
||||
|
||||
test("convertNameAndTopic: custom name, no topic", t => {
|
||||
t.deepEqual(
|
||||
_convertNameAndTopic({id: "123", name: "the-twilight-zone", type: 0}, {id: "456"}, "hauntings"),
|
||||
["hauntings", "#the-twilight-zone\n\nChannel ID: 123\nGuild ID: 456"]
|
||||
)
|
||||
})
|
||||
|
||||
test("convertNameAndTopic: original name and topic", t => {
|
||||
t.deepEqual(
|
||||
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, null),
|
||||
["the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"]
|
||||
)
|
||||
})
|
||||
|
||||
test("convertNameAndTopic: original name, no topic", t => {
|
||||
t.deepEqual(
|
||||
_convertNameAndTopic({id: "123", name: "the-twilight-zone", type: 0}, {id: "456"}, null),
|
||||
["the-twilight-zone", "Channel ID: 123\nGuild ID: 456"]
|
||||
)
|
||||
})
|
||||
|
||||
test("convertNameAndTopic: public thread icon", t => {
|
||||
t.deepEqual(
|
||||
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 11}, {id: "456"}, null),
|
||||
["[⛓️] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"]
|
||||
)
|
||||
})
|
||||
|
||||
test("convertNameAndTopic: private thread icon", t => {
|
||||
t.deepEqual(
|
||||
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 12}, {id: "456"}, null),
|
||||
["[🔒⛓️] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"]
|
||||
)
|
||||
})
|
||||
|
||||
test("convertNameAndTopic: voice channel icon", t => {
|
||||
t.deepEqual(
|
||||
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 2}, {id: "456"}, null),
|
||||
["[🔊] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"]
|
||||
)
|
||||
})
|
|
@ -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,10 +31,6 @@ async function createSpace(guild, kstate) {
|
|||
const topic = kstate["m.room.topic/"]?.topic || undefined
|
||||
assert(name)
|
||||
|
||||
const memberCount = guild["member_count"] ?? guild.approximate_member_count ?? 0
|
||||
const enablePresenceByDefault = +(memberCount < 50) // scary! all active users in a presence-enabled guild will be pinging the server every <30 seconds to stay online
|
||||
const globalAdmins = select("member_power", "mxid", {room_id: "*"}).pluck().all()
|
||||
|
||||
const roomID = await createRoom.postApplyPowerLevels(kstate, async kstate => {
|
||||
return api.createRoom({
|
||||
name,
|
||||
|
@ -44,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"
|
||||
|
@ -52,7 +48,7 @@ async function createSpace(guild, kstate) {
|
|||
initial_state: await ks.kstateToState(kstate)
|
||||
})
|
||||
})
|
||||
db.prepare("INSERT INTO guild_space (guild_id, space_id, presence) VALUES (?, ?, ?)").run(guild.id, roomID, enablePresenceByDefault)
|
||||
db.prepare("INSERT INTO guild_space (guild_id, space_id) VALUES (?, ?)").run(guild.id, roomID)
|
||||
return roomID
|
||||
}
|
||||
|
||||
|
@ -62,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
|
||||
|
@ -94,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 a space for 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)
|
||||
|
@ -118,9 +111,9 @@ async function _syncSpace(guild, shouldActuallySync) {
|
|||
const guildKState = await guildToKState(guild, privacy_level) // calling this in both branches because we don't want to calculate this if not syncing
|
||||
|
||||
// sync guild state to space
|
||||
const spaceKState = await ks.roomToKState(spaceID)
|
||||
const spaceKState = await createRoom.roomToKState(spaceID)
|
||||
const spaceDiff = ks.diffKState(spaceKState, guildKState)
|
||||
await ks.applyKStateDiffToRoom(spaceID, spaceDiff)
|
||||
await createRoom.applyKStateDiffToRoom(spaceID, spaceDiff)
|
||||
|
||||
// guild icon was changed, so room avatars need to be updated as well as the space ones
|
||||
// doing it this way rather than calling syncRoom for great efficiency gains
|
||||
|
@ -185,19 +178,28 @@ async function syncSpaceFully(guildID) {
|
|||
const guildKState = await guildToKState(guild, privacy_level)
|
||||
|
||||
// sync guild state to space
|
||||
const spaceKState = await ks.roomToKState(spaceID)
|
||||
const spaceKState = await createRoom.roomToKState(spaceID)
|
||||
const spaceDiff = ks.diffKState(spaceKState, guildKState)
|
||||
await ks.applyKStateDiffToRoom(spaceID, spaceDiff)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -234,11 +236,11 @@ async function syncSpaceExpressions(data, checkBeforeSync) {
|
|||
}
|
||||
if (isDeepStrictEqual(existing, content)) return
|
||||
}
|
||||
await api.sendState(spaceID, "im.ponies.room_emotes", eventKey, content)
|
||||
api.sendState(spaceID, "im.ponies.room_emotes", eventKey, content)
|
||||
}
|
||||
|
||||
await update(spaceID, "emojis", "moe.cadence.ooye.pack.emojis", expression.emojisToState)
|
||||
await update(spaceID, "stickers", "moe.cadence.ooye.pack.stickers", expression.stickersToState)
|
||||
update(spaceID, "emojis", "moe.cadence.ooye.pack.emojis", expression.emojisToState)
|
||||
update(spaceID, "stickers", "moe.cadence.ooye.pack.stickers", expression.stickersToState)
|
||||
}
|
||||
|
||||
module.exports.createSpace = createSpace
|
|
@ -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/": {
|
|
@ -16,6 +16,7 @@ async function deleteMessage(data) {
|
|||
|
||||
const eventsToRedact = select("event_message", "event_id", {message_id: data.id}).pluck().all()
|
||||
db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(data.id)
|
||||
db.prepare("DELETE FROM event_message WHERE message_id = ?").run(data.id)
|
||||
for (const eventID of eventsToRedact) {
|
||||
// Unfortunately, we can't specify a sender to do the redaction as, unless we find out that info via the audit logs
|
||||
await api.redactEvent(row.room_id, eventID)
|
||||
|
@ -34,6 +35,7 @@ async function deleteMessageBulk(data) {
|
|||
const sids = JSON.stringify(data.ids)
|
||||
const eventsToRedact = from("event_message").pluck("event_id").and("WHERE message_id IN (SELECT value FROM json_each(?))").all(sids)
|
||||
db.prepare("DELETE FROM message_channel WHERE message_id IN (SELECT value FROM json_each(?))").run(sids)
|
||||
db.prepare("DELETE FROM event_message WHERE message_id IN (SELECT value FROM json_each(?))").run(sids)
|
||||
for (const eventID of eventsToRedact) {
|
||||
// Awaiting will make it go slower, but since this could be a long-running operation either way, we want to leave rate limit capacity for other operations
|
||||
await api.redactEvent(roomID, eventID)
|
|
@ -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)
|
||||
}
|
||||
|
@ -61,7 +61,7 @@ async function editMessage(message, guild, row) {
|
|||
|
||||
// 4. Send all the things.
|
||||
if (eventsToSend.length) {
|
||||
db.prepare("INSERT OR IGNORE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id)
|
||||
db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id)
|
||||
}
|
||||
for (const content of eventsToSend) {
|
||||
const eventType = content.$type
|
|
@ -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"
|
||||
},
|
|
@ -33,7 +33,7 @@ async function convert(stickerItem) {
|
|||
if (res.status !== 200) throw new Error("Sticker data file not found.")
|
||||
const text = await res.text()
|
||||
|
||||
// Convert to PNG (stream.Readable)
|
||||
// Convert to PNG (readable stream)
|
||||
const readablePng = await convertLottie.convert(text)
|
||||
|
||||
// Upload to MXC
|
|
@ -1,11 +1,12 @@
|
|||
// @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
|
||||
|
||||
const passthrough = require("../../passthrough")
|
||||
const {sync, db, select} = passthrough
|
||||
const {discord, sync, db, select} = passthrough
|
||||
/** @type {import("../../matrix/api")} */
|
||||
const api = sync.require("../../matrix/api")
|
||||
/** @type {import("../../matrix/file")} */
|
||||
|
@ -32,7 +33,7 @@ async function createSim(pkMessage) {
|
|||
const mxid = `@${localpart}:${reg.ooye.server_name}`
|
||||
|
||||
// Save chosen name in the database forever
|
||||
db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?)").run(pkMessage.member.uuid, simName, simName, mxid)
|
||||
db.prepare("INSERT INTO sim (user_id, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(pkMessage.member.uuid, simName, localpart, mxid)
|
||||
|
||||
// Register matrix user with that name
|
||||
try {
|
||||
|
@ -145,20 +146,15 @@ async function fetchMessage(messageID) {
|
|||
// Their backend is weird. Sometimes it says "message not found" (code 20006) on the first try, so we make multiple attempts.
|
||||
let attempts = 0
|
||||
do {
|
||||
try {
|
||||
var res = await fetch(`https://api.pluralkit.me/v2/messages/${messageID}`)
|
||||
if (res.ok) return res.json()
|
||||
var errorGetter = res.json
|
||||
} catch (e) {
|
||||
// Catch any network issues too.
|
||||
errorGetter = e.toString
|
||||
}
|
||||
var res = await fetch(`https://api.pluralkit.me/v2/messages/${messageID}`)
|
||||
if (res.ok) return res.json()
|
||||
|
||||
// I think the backend needs some time to update.
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
} while (++attempts < 3)
|
||||
|
||||
throw new Error(`PK API returned an error after ${attempts} tries: ${JSON.stringify(await errorGetter())}`)
|
||||
const errorMessage = await res.json()
|
||||
throw new Error(`PK API returned an error after ${attempts} tries: ${JSON.stringify(errorMessage)}`)
|
||||
}
|
||||
|
||||
module.exports._memberToStateContent = memberToStateContent
|
|
@ -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")
|
||||
|
||||
|
@ -33,7 +33,7 @@ async function createSim(user) {
|
|||
|
||||
// Save chosen name in the database forever
|
||||
// Making this database change right away so that in a concurrent registration, the 2nd registration will already have generated a different localpart because it can see this row when it generates
|
||||
db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?)").run(user.id, user.username, simName, mxid)
|
||||
db.prepare("INSERT INTO sim (user_id, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(user.id, simName, localpart, mxid)
|
||||
|
||||
// Register matrix user with that name
|
||||
try {
|
|
@ -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(
|
|
@ -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
|
||||
|
@ -53,7 +62,7 @@ async function removeReaction(data, reactions) {
|
|||
*/
|
||||
async function removeEmojiReaction(data, reactions) {
|
||||
const key = await emojiToKey.emojiToKey(data.emoji)
|
||||
const discordPreferredEncoding = await emoji.encodeEmoji(key, undefined)
|
||||
const discordPreferredEncoding = emoji.encodeEmoji(key, undefined)
|
||||
db.prepare("DELETE FROM reaction WHERE message_id = ? AND encoded_emoji = ?").run(data.message_id, discordPreferredEncoding)
|
||||
|
||||
return converter.removeEmojiReaction(data, reactions, key)
|
|
@ -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
|
|
@ -47,8 +47,8 @@ async function sendMessage(message, channel, guild, row) {
|
|||
const events = await messageToEvent.messageToEvent(message, guild, {}, {api})
|
||||
const eventIDs = []
|
||||
if (events.length) {
|
||||
db.prepare("INSERT OR IGNORE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id)
|
||||
if (senderMxid) api.sendTyping(roomID, false, senderMxid).catch(() => {})
|
||||
db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id)
|
||||
if (senderMxid) api.sendTyping(roomID, false, senderMxid)
|
||||
}
|
||||
for (const event of events) {
|
||||
const part = event === events[0] ? 0 : 1
|
|
@ -6,8 +6,6 @@ const {discord, sync, db} = passthrough
|
|||
const pinsToList = sync.require("../converters/pins-to-list")
|
||||
/** @type {import("../../matrix/api")} */
|
||||
const api = sync.require("../../matrix/api")
|
||||
/** @type {import("../../matrix/kstate")} */
|
||||
const ks = sync.require("../../matrix/kstate")
|
||||
|
||||
/**
|
||||
* @template {string | null | undefined} T
|
||||
|
@ -25,13 +23,13 @@ function convertTimestamp(timestamp) {
|
|||
* @param {number?} convertedTimestamp
|
||||
*/
|
||||
async function updatePins(channelID, roomID, convertedTimestamp) {
|
||||
const discordPins = await discord.snow.channel.getChannelPinnedMessages(channelID)
|
||||
const pinned = pinsToList.pinsToList(discordPins)
|
||||
|
||||
const kstate = await ks.roomToKState(roomID)
|
||||
const diff = ks.diffKState(kstate, {"m.room.pinned_events/": {pinned}})
|
||||
await ks.applyKStateDiffToRoom(roomID, diff)
|
||||
|
||||
const pins = await discord.snow.channel.getChannelPinnedMessages(channelID)
|
||||
const eventIDs = pinsToList.pinsToList(pins)
|
||||
if (pins.length === eventIDs.length || eventIDs.length) {
|
||||
await api.sendState(roomID, "m.room.pinned_events", "", {
|
||||
pinned: eventIDs
|
||||
})
|
||||
}
|
||||
db.prepare("UPDATE channel_room SET last_bridged_pin_timestamp = ? WHERE channel_id = ?").run(convertedTimestamp || 0, channelID)
|
||||
}
|
||||
|
|
@ -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)}))
|
|
@ -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")
|
||||
})
|
|
@ -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 => {
|
|
@ -21,7 +21,7 @@ const Rlottie = (async () => {
|
|||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @returns {Promise<stream.Readable>}
|
||||
* @returns {Promise<import("stream").Readable>}
|
||||
*/
|
||||
async function convert(text) {
|
||||
const r = await Rlottie
|
||||
|
@ -41,7 +41,6 @@ async function convert(text) {
|
|||
png.data = Buffer.from(rendered)
|
||||
// png.pack() is a bad stream and will throw away any data it sends if it's not connected to a destination straight away.
|
||||
// We use Duplex.from to convert it into a good stream.
|
||||
// @ts-ignore
|
||||
return stream.Duplex.from(png.pack())
|
||||
}
|
||||
|
|
@ -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!",
|
|
@ -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 {
|
||||
|
@ -61,7 +58,7 @@ function getDiscordParseCallbacks(message, guild, useHTML) {
|
|||
emoji: node => {
|
||||
if (useHTML) {
|
||||
const mxc = select("emoji", "mxc_url", {emoji_id: node.id}).pluck().get()
|
||||
assert(mxc, `Emoji consistency assertion failed for ${node.name}:${node.id}`) // All emojis should have been added ahead of time in the messageToEvent function.
|
||||
assert(mxc) // All emojis should have been added ahead of time in the messageToEvent function.
|
||||
return `<img data-mx-emoticon height="32" src="${mxc}" title=":${node.name}:" alt=":${node.name}:">`
|
||||
} else {
|
||||
return `:${node.name}:`
|
||||
|
@ -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,13 +166,13 @@ 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: {
|
||||
mimetype: attachment.content_type,
|
||||
size: attachment.size,
|
||||
duration: attachment.duration_secs ? Math.round(attachment.duration_secs * 1000) : undefined
|
||||
duration: attachment.duration_secs ? attachment.duration_secs * 1000 : undefined
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
@ -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,13 +193,11 @@ async function attachmentToEvent(mentions, attachment) {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {DiscordTypes.APIMessage} message
|
||||
* @param {DiscordTypes.APIGuild} guild
|
||||
* @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean, alwaysReturnFormattedBody?: boolean, scanTextForMentions?: 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.
|
||||
* - scanTextForMentions: true - needs to be set to false when converting forwarded messages etc which may be from a different channel that can't be scanned.
|
||||
* @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API
|
||||
*/
|
||||
async function messageToEvent(message, guild, options = {}, di) {
|
||||
|
@ -235,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:
|
||||
|
@ -354,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]`
|
||||
|
@ -372,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
|
||||
|
@ -387,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]
|
||||
|
@ -430,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
|
||||
|
@ -444,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
|
||||
}
|
||||
|
@ -464,12 +430,10 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
repliedToUserHtml = repliedToDisplayName
|
||||
}
|
||||
let repliedToContent = message.referenced_message?.content
|
||||
if (repliedToContent?.match(/^(-# )?> (-# )?<:L1:/)) {
|
||||
if (repliedToContent?.startsWith("> <:L1:")) {
|
||||
// If the Discord user is replying to a Matrix user's reply, the fallback is going to contain the emojis and stuff from the bridged rep of the Matrix user's reply quote.
|
||||
// Need to remove that previous reply rep from this fallback body. The fallbody body should only contain the Matrix user's actual message.
|
||||
// ┌──────A─────┐ A reply rep starting with >quote or -#smalltext >quote. Match until the end of the line.
|
||||
// ┆ ┆┌─B─┐ There may be up to 2 reply rep lines in a row if it was created in the old format. Match all lines.
|
||||
repliedToContent = repliedToContent.replace(/^((-# )?> .*\n){1,2}/, "")
|
||||
repliedToContent = repliedToContent.split("\n").slice(2).join("\n")
|
||||
}
|
||||
if (repliedToContent == "") repliedToContent = "[Media]"
|
||||
else if (!repliedToContent) repliedToContent = "[Replied-to message content wasn't provided by Discord]"
|
||||
|
@ -498,7 +462,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
|
||||
|
@ -516,62 +480,11 @@ 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, scanTextForMentions: false}, 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)]
|
||||
if (options.scanTextForMentions !== false && matches.length && matches.some(m => m[1].match(/[a-z]/i) && m[1] !== "everyone" && m[1] !== "here")) {
|
||||
if (matches.length && matches.some(m => m[1].match(/[a-z]/i) && m[1] !== "everyone" && m[1] !== "here")) {
|
||||
const writtenMentionsText = matches.map(m => m[1].toLowerCase())
|
||||
const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
|
||||
assert(roomID)
|
||||
|
@ -586,8 +499,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
|
||||
|
@ -659,9 +573,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()
|
||||
|
@ -669,7 +583,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
|
|
@ -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")
|
||||
|
||||
/**
|
|
@ -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&via=matrix.org">https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&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,10 +485,10 @@ 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,
|
||||
duration: 3960.0000381469727,
|
||||
mimetype: "audio/ogg",
|
||||
size: 10584,
|
||||
},
|
||||
|
@ -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",
|
||||
|
@ -646,84 +559,6 @@ test("message2event: simple reply in thread to a matrix user's reply", async t =
|
|||
}])
|
||||
})
|
||||
|
||||
test("message2event: infinidoge's reply to ami's matrix smalltext reply to infinidoge", async t => {
|
||||
const events = await messageToEvent(data.message.infinidoge_reply_to_ami_matrix_smalltext_reply_to_infinidoge, data.guild.general, {}, {
|
||||
api: {
|
||||
getEvent: mockGetEvent(t, "!BnKuBPCvyfOkhcUjEu:cadence.moe", "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4", {
|
||||
type: "m.room.message",
|
||||
sender: "@ami:the-apothecary.club",
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: `> <@_ooye_infinidoge:cadence.moe> Neat that they thought of that\n\nlet me guess they got a lot of bug reports like "empty chest with no loot?"`,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$baby?via=cadence.moe">In reply to</a> <a href="https://matrix.to/#/@_ooye_infinidoge:cadence.moe">@_ooye_infinidoge:cadence.moe</a><br>Neat that they thought of that</blockquote></mx-reply>let me guess they got a lot of bug reports like "empty chest with no loot?"`,
|
||||
"m.relates_to": {
|
||||
"m.in_reply_to": {
|
||||
event_id: "$baby"
|
||||
}
|
||||
}
|
||||
},
|
||||
event_id: "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4",
|
||||
room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe"
|
||||
})
|
||||
}
|
||||
})
|
||||
t.deepEqual(events, [{
|
||||
$type: "m.room.message",
|
||||
"m.relates_to": {
|
||||
"m.in_reply_to": {
|
||||
event_id: "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4"
|
||||
}
|
||||
},
|
||||
"m.mentions": {
|
||||
user_ids: ["@ami:the-apothecary.club"]
|
||||
},
|
||||
msgtype: "m.text",
|
||||
body: `> Ami (she/her): let me guess they got a lot of bug reports like "empty chest with no loot?"\n\nMost likely`,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4">In reply to</a> <a href="https://matrix.to/#/@ami:the-apothecary.club">Ami (she/her)</a><br>let me guess they got a lot of bug reports like "empty chest with no loot?"</blockquote></mx-reply>Most likely`,
|
||||
}])
|
||||
})
|
||||
|
||||
test("message2event: infinidoge's reply to ami's matrix smalltext singleline reply to infinidoge", async t => {
|
||||
const events = await messageToEvent(data.message.infinidoge_reply_to_ami_matrix_smalltext_singleline_reply_to_infinidoge, data.guild.general, {}, {
|
||||
api: {
|
||||
getEvent: mockGetEvent(t, "!BnKuBPCvyfOkhcUjEu:cadence.moe", "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4", {
|
||||
type: "m.room.message",
|
||||
sender: "@ami:the-apothecary.club",
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: `> <@_ooye_infinidoge:cadence.moe> Neat that they thought of that\n\nlet me guess they got a lot of bug reports like "empty chest with no loot?"`,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$baby?via=cadence.moe">In reply to</a> <a href="https://matrix.to/#/@_ooye_infinidoge:cadence.moe">@_ooye_infinidoge:cadence.moe</a><br>Neat that they thought of that</blockquote></mx-reply>let me guess they got a lot of bug reports like "empty chest with no loot?"`,
|
||||
"m.relates_to": {
|
||||
"m.in_reply_to": {
|
||||
event_id: "$baby"
|
||||
}
|
||||
}
|
||||
},
|
||||
event_id: "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4",
|
||||
room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe"
|
||||
})
|
||||
}
|
||||
})
|
||||
t.deepEqual(events, [{
|
||||
$type: "m.room.message",
|
||||
"m.relates_to": {
|
||||
"m.in_reply_to": {
|
||||
event_id: "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4"
|
||||
}
|
||||
},
|
||||
"m.mentions": {
|
||||
user_ids: ["@ami:the-apothecary.club"]
|
||||
},
|
||||
msgtype: "m.text",
|
||||
body: `> Ami (she/her): let me guess they got a lot of bug reports like "empty chest with no loot?"\n\nMost likely`,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4">In reply to</a> <a href="https://matrix.to/#/@ami:the-apothecary.club">Ami (she/her)</a><br>let me guess they got a lot of bug reports like "empty chest with no loot?"</blockquote></mx-reply>Most likely`,
|
||||
}])
|
||||
})
|
||||
|
||||
test("message2event: simple written @mention for matrix user", async t => {
|
||||
const events = await messageToEvent(data.message.simple_written_at_mention_for_matrix, data.guild.general, {}, {
|
||||
api: {
|
||||
|
@ -837,7 +672,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 +686,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,140 +849,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&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&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",
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
test("message2event: don't scan forwarded messages for mentions", async t => {
|
||||
const events = await messageToEvent(data.message.forwarded_dont_scan_for_mentions, {}, {}, {})
|
||||
t.deepEqual(events, [
|
||||
{
|
||||
$type: "m.room.message",
|
||||
body: "[🔀 Forwarded message]"
|
||||
+ "\n» If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile https://social.luca.run/@luca/113950834185678114",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `🔀 <em>Forwarded message</em>`
|
||||
+ `<br><blockquote>If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile <a href="https://social.luca.run/@luca/113950834185678114">https://social.luca.run/@luca/113950834185678114</a></blockquote>`,
|
||||
"m.mentions": {},
|
||||
msgtype: "m.notice"
|
||||
}
|
||||
])
|
||||
})
|
|
@ -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 => {
|
|
@ -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}
|
||||
}
|
||||
}
|
||||
|
1
d2m/converters/rlottie-wasm.js
Normal file
1
d2m/converters/rlottie-wasm.js
Normal file
File diff suppressed because one or more lines are too long
BIN
src/d2m/converters/rlottie-wasm.wasm → d2m/converters/rlottie-wasm.wasm
Normal file → Executable file
BIN
src/d2m/converters/rlottie-wasm.wasm → d2m/converters/rlottie-wasm.wasm
Normal file → Executable file
Binary file not shown.
|
@ -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))
|
||||
|
|
@ -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")
|
||||
|
||||
/**
|
|
@ -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)
|
||||
|
@ -64,7 +64,7 @@ function userToSimName(user) {
|
|||
}
|
||||
|
||||
// 1. Is sim user already registered?
|
||||
const existing = select("sim", "user_id", {user_id: user.id}).pluck().get()
|
||||
const existing = select("sim", "sim_name", {user_id: user.id}).pluck().get()
|
||||
assert.equal(existing, null, "Shouldn't try to create a new name for an existing sim")
|
||||
|
||||
// 2. Register based on username (could be new or old format)
|
|
@ -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")
|
|
@ -1,16 +1,12 @@
|
|||
// @ts-check
|
||||
|
||||
const {Endpoints, SnowTransfer} = require("snowtransfer")
|
||||
const {reg} = require("../matrix/read-registration")
|
||||
const {Client: CloudStorm} = require("cloudstorm")
|
||||
|
||||
// @ts-ignore
|
||||
Endpoints.BASE_HOST = reg.ooye.discord_origin || "https://discord.com"; Endpoints.CDN_URL = reg.ooye.discord_cdn_origin || "https://cdn.discordapp.com"
|
||||
const { SnowTransfer } = require("snowtransfer")
|
||||
const { Client: CloudStorm } = require("cloudstorm")
|
||||
|
||||
const passthrough = require("../passthrough")
|
||||
const {sync} = passthrough
|
||||
const { sync } = passthrough
|
||||
|
||||
/** @type {import("./discord-packets")} */
|
||||
/** @type {typeof import("./discord-packets")} */
|
||||
const discordPackets = sync.require("./discord-packets")
|
||||
|
||||
class DiscordClient {
|
||||
|
@ -28,7 +24,7 @@ class DiscordClient {
|
|||
intents: [
|
||||
"DIRECT_MESSAGES", "DIRECT_MESSAGE_REACTIONS", "DIRECT_MESSAGE_TYPING",
|
||||
"GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS",
|
||||
"MESSAGE_CONTENT", "GUILD_PRESENCES"
|
||||
"MESSAGE_CONTENT"
|
||||
],
|
||||
ws: {
|
||||
compress: false,
|
||||
|
@ -61,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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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,25 +34,17 @@ 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") {
|
||||
try {
|
||||
await eventDispatcher.checkMissedExpressions(message.d)
|
||||
await eventDispatcher.checkMissedPins(client, message.d)
|
||||
await eventDispatcher.checkMissedMessages(client, message.d)
|
||||
} catch (e) {
|
||||
console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:")
|
||||
console.error(e)
|
||||
}
|
||||
eventDispatcher.checkMissedExpressions(message.d)
|
||||
eventDispatcher.checkMissedPins(client, message.d)
|
||||
eventDispatcher.checkMissedMessages(client, message.d)
|
||||
}
|
||||
|
||||
} else if (message.t === "GUILD_UPDATE") {
|
||||
|
@ -103,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)
|
||||
|
@ -129,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -163,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)
|
||||
|
@ -193,17 +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)
|
||||
|
||||
} else if (message.t === "PRESENCE_UPDATE") {
|
||||
eventDispatcher.onPresenceUpdate(client, message.d)
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
// Let OOYE try to handle errors too
|
||||
await eventDispatcher.onError(client, e, message)
|
||||
eventDispatcher.onError(client, e, message)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -27,14 +27,14 @@ 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")} */
|
||||
const speedbump = sync.require("./actions/speedbump")
|
||||
/** @type {import("./actions/retrigger")} */
|
||||
const retrigger = sync.require("./actions/retrigger")
|
||||
/** @type {import("./actions/set-presence")} */
|
||||
const setPresence = sync.require("./actions/set-presence")
|
||||
|
||||
/** @type {any} */ // @ts-ignore bad types from semaphore
|
||||
const Semaphore = require("@chriscdn/promise-semaphore")
|
||||
|
@ -50,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:`)
|
||||
|
@ -83,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",
|
||||
|
@ -105,19 +105,13 @@ module.exports = {
|
|||
async checkMissedMessages(client, guild) {
|
||||
if (guild.unavailable) return
|
||||
const bridgedChannels = select("channel_room", "channel_id").pluck().all()
|
||||
const preparedExists = db.prepare("SELECT channel_id FROM message_channel WHERE channel_id = ? LIMIT 1")
|
||||
const preparedGet = select("event_message", "event_id", {}, "WHERE message_id = ?").pluck()
|
||||
const prepared = select("event_message", "event_id", {}, "WHERE message_id = ?").pluck()
|
||||
for (const channel of guild.channels.concat(guild.threads)) {
|
||||
if (!bridgedChannels.includes(channel.id)) continue
|
||||
if (!("last_message_id" in channel) || !channel.last_message_id) continue
|
||||
|
||||
// Skip if channel is already up-to-date
|
||||
const latestWasBridged = preparedGet.get(channel.last_message_id)
|
||||
const latestWasBridged = prepared.get(channel.last_message_id)
|
||||
if (latestWasBridged) continue
|
||||
|
||||
// Skip if channel was just added to the bridge (there's no place to resume from if it's brand new)
|
||||
if (!preparedExists.get(channel.id)) continue
|
||||
|
||||
// Permissions check
|
||||
const member = guild.members.find(m => m.user?.id === client.user.id)
|
||||
if (!member) return
|
||||
|
@ -139,7 +133,7 @@ module.exports = {
|
|||
}
|
||||
}
|
||||
let latestBridgedMessageIndex = messages.findIndex(m => {
|
||||
return preparedGet.get(m.id)
|
||||
return prepared.get(m.id)
|
||||
})
|
||||
// console.log(`[check missed messages] got ${messages.length} messages; last message that IS bridged is at position ${latestBridgedMessageIndex} in the channel`)
|
||||
if (latestBridgedMessageIndex === -1) latestBridgedMessageIndex = 1 // rather than crawling the ENTIRE channel history, let's just bridge the most recent 1 message to make it up to date.
|
||||
|
@ -187,7 +181,7 @@ module.exports = {
|
|||
*/
|
||||
async checkMissedExpressions(guild) {
|
||||
const data = {guild_id: guild.id, ...guild}
|
||||
await createSpace.syncSpaceExpressions(data, true)
|
||||
createSpace.syncSpaceExpressions(data, true)
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -199,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)
|
||||
},
|
||||
|
@ -236,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
|
||||
|
@ -257,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)
|
||||
|
||||
|
@ -268,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)
|
||||
},
|
||||
|
@ -289,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.
|
||||
|
@ -300,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)
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -321,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)
|
||||
},
|
||||
|
||||
|
@ -371,15 +351,5 @@ module.exports = {
|
|||
*/
|
||||
async onExpressionsUpdate(client, data) {
|
||||
await createSpace.syncSpaceExpressions(data, false)
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {import("./discord-client")} client
|
||||
* @param {DiscordTypes.GatewayPresenceUpdateDispatchData} data
|
||||
*/
|
||||
async onPresenceUpdate(client, data) {
|
||||
const status = data.status
|
||||
if (!status) return
|
||||
setPresence.presenceTracker.incomingPresence(data.user.id, data.guild_id, status)
|
||||
}
|
||||
}
|
|
@ -6,8 +6,7 @@ const {join} = require("path")
|
|||
async function migrate(db) {
|
||||
let files = fs.readdirSync(join(__dirname, "migrations"))
|
||||
files = files.sort()
|
||||
db.prepare("CREATE TABLE IF NOT EXISTS migration (filename TEXT NOT NULL, PRIMARY KEY (filename)) WITHOUT ROWID").run()
|
||||
/** @type {string} */
|
||||
db.prepare("CREATE TABLE IF NOT EXISTS migration (filename TEXT NOT NULL)").run()
|
||||
let progress = db.prepare("SELECT * FROM migration").pluck().get()
|
||||
if (!progress) {
|
||||
progress = ""
|
||||
|
@ -38,8 +37,6 @@ async function migrate(db) {
|
|||
if (migrationRan) {
|
||||
console.log("Database migrations all done.")
|
||||
}
|
||||
|
||||
db.pragma("foreign_keys = on")
|
||||
}
|
||||
|
||||
module.exports.migrate = migrate
|
|
@ -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)
|
|
@ -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)
|
||||
}
|
31
src/db/orm-defs.d.ts → db/orm-defs.d.ts
vendored
31
src/db/orm-defs.d.ts → db/orm-defs.d.ts
vendored
|
@ -10,8 +10,6 @@ export type Models = {
|
|||
speedbump_id: string | null
|
||||
speedbump_webhook_id: string | null
|
||||
speedbump_checked: number | null
|
||||
guild_id: string | null
|
||||
custom_topic: number
|
||||
}
|
||||
|
||||
event_message: {
|
||||
|
@ -33,20 +31,6 @@ export type Models = {
|
|||
guild_id: string
|
||||
space_id: string
|
||||
privacy_level: number
|
||||
presence: 0 | 1
|
||||
}
|
||||
|
||||
guild_active: {
|
||||
guild_id: string
|
||||
autocreate: 0 | 1
|
||||
}
|
||||
|
||||
invite: {
|
||||
mxid: string
|
||||
room_id: string
|
||||
type: string | null
|
||||
name: string | null
|
||||
avatar: string | null
|
||||
}
|
||||
|
||||
lottie: {
|
||||
|
@ -58,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: {
|
||||
|
@ -116,10 +93,6 @@ export type Models = {
|
|||
emoji_id: string
|
||||
guild_id: string
|
||||
}
|
||||
|
||||
media_proxy: {
|
||||
permitted_hash: number
|
||||
}
|
||||
}
|
||||
|
||||
export type Prepared<Row> = {
|
||||
|
@ -134,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]}
|
|
@ -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]
|
|
@ -1,22 +1,22 @@
|
|||
// @ts-check
|
||||
|
||||
const {test} = require("supertape")
|
||||
const data = require("../../test/data")
|
||||
const data = require("../test/data")
|
||||
|
||||
const {db, select, from} = require("../passthrough")
|
||||
|
||||
test("orm: select: get works", t => {
|
||||
const row = select("guild_space", "guild_id", {}, "WHERE space_id = ?").get("!jjmvBegULiLucuWEHU:cadence.moe")
|
||||
const row = select("guild_space", "guild_id", {}, "WHERE space_id = ?").get("!jjWAGMeQdNrVZSSfvz:cadence.moe")
|
||||
t.equal(row?.guild_id, data.guild.general.id)
|
||||
})
|
||||
|
||||
test("orm: from: get works", t => {
|
||||
const row = from("guild_space").select("guild_id").and("WHERE space_id = ?").get("!jjmvBegULiLucuWEHU:cadence.moe")
|
||||
const row = from("guild_space").select("guild_id").and("WHERE space_id = ?").get("!jjWAGMeQdNrVZSSfvz:cadence.moe")
|
||||
t.equal(row?.guild_id, data.guild.general.id)
|
||||
})
|
||||
|
||||
test("orm: select: get pluck works", t => {
|
||||
const guildID = select("guild_space", "guild_id", {}, "WHERE space_id = ?").pluck().get("!jjmvBegULiLucuWEHU:cadence.moe")
|
||||
const guildID = select("guild_space", "guild_id", {}, "WHERE space_id = ?").pluck().get("!jjWAGMeQdNrVZSSfvz:cadence.moe")
|
||||
t.equal(guildID, data.guild.general.id)
|
||||
})
|
||||
|
||||
|
@ -30,13 +30,8 @@ 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("!jjmvBegULiLucuWEHU:cadence.moe")
|
||||
const guildID = from("guild_space").pluck("guild_id").and("WHERE space_id = ?").get("!jjWAGMeQdNrVZSSfvz:cadence.moe")
|
||||
t.equal(guildID, data.guild.general.id)
|
||||
})
|
||||
|
||||
|
@ -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)
|
||||
})
|
273
discord/discord-command-handler.js
Normal file
273
discord/discord-command-handler.js
Normal 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
|
|
@ -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
|
|
@ -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")
|
||||
})
|
|
@ -1,98 +0,0 @@
|
|||
# Foreign keys in the Out Of Your Element database
|
||||
|
||||
Historically, Out Of Your Element did not use foreign keys in the database, but since I found a need for them, I have decided to add them. Referential integrity is probably valuable as well.
|
||||
|
||||
The need is that unlinking a channel and room using the web interface should clear up all related entries from `message_channel`, `event_message`, `reaction`, etc. Without foreign keys, this requires multiple DELETEs with tricky queries. With foreign keys and ON DELETE CASCADE, this just works.
|
||||
|
||||
## Quirks
|
||||
|
||||
* **REPLACE INTO** internally causes a DELETE followed by an INSERT, and the DELETE part **will trigger any ON DELETE CASCADE** foreign key conditions on the table, even when the primary key being replaced is the same.
|
||||
* ```sql
|
||||
CREATE TABLE discord_channel (channel_id TEXT NOT NULL, name TEXT NOT NULL, PRIMARY KEY (channel_id));
|
||||
CREATE TABLE discord_message (message_id TEXT NOT NULL, channel_id TEXT NOT NULL, PRIMARY KEY (message_id),
|
||||
FOREIGN KEY (channel_id) REFERENCES discord_channel (channel_id) ON DELETE CASCADE);
|
||||
INSERT INTO discord_channel (channel_id, name) VALUES ("c_1", "place");
|
||||
INSERT INTO discord_message (message_id, channel_id) VALUES ("m_2", "c_1"); -- i love my message
|
||||
REPLACE INTO discord_channel (channel_id, name) VALUES ("c_1", "new place"); -- replace into time
|
||||
-- i love my message
|
||||
SELECT * FROM discord_message; -- where is my message
|
||||
```
|
||||
* In SQLite, `pragma foreign_keys = on` must be set **for each connection** after it's established. I've added this at the start of `migrate.js`, which is called by all database connections.
|
||||
* Pragma? Pragma keys
|
||||
* Whenever a child row is inserted, SQLite will look up a row from the parent table to ensure referential integrity. This means **the parent table should be sufficiently keyed or indexed on columns referenced by foreign keys**, or SQLite won't let you do it, with a cryptic error message later on during DML. Due to normal forms, foreign keys naturally tend to reference the parent table's primary key, which is indexed, so that's okay. But still keep this in mind, since many of OOYE's tables effectively have two primary keys, for the Discord and Matrix IDs. A composite primary key doesn't count, even when it's the first column. A unique index counts.
|
||||
|
||||
## Where keys
|
||||
|
||||
Here are some tables that could potentially have foreign keys added between them, and my thought process of whether foreign keys would be a good idea:
|
||||
|
||||
* `guild_active` <--(PK guild_id FK)-- `channel_room` ✅
|
||||
* Could be good for referential integrity.
|
||||
* Linking to guild_space would be pretty scary in case the guild was being relinked to a different space - since rooms aren't tied to a space, this wouldn't actually disturb anything. So I pick guild_active instead.
|
||||
* `channel_room` <--(PK channel_id FK)-- `message_channel` ✅
|
||||
* Seems useful as we want message records to be deleted when a channel is unlinked.
|
||||
* `message_channel` <--(PK message_id PK)-- `event_message` ✅
|
||||
* Seems useful as we want event information to be deleted when a channel is unlinked.
|
||||
* `guild_active` <--(PK guild_id PK)-- `guild_space` ✅
|
||||
* All bridged guilds should have a corresponding guild_active entry, so referential integrity would be useful here to make sure we haven't got any weird states.
|
||||
* `channel_room` <--(**C** room_id PK)-- `member_cache` ✅
|
||||
* Seems useful as we want to clear the member cache when a channel is unlinked.
|
||||
* There is no index on `channel_room.room_id` right now. It would be good to create this index. Will just make it UNIQUE in the table definition.
|
||||
* `message_channel` <--(PK message_id FK)-- `reaction` ✅
|
||||
* Seems useful as we want to clear the reactions cache when a channel is unlinked.
|
||||
* `sim` <--(**C** mxid FK)-- `sim_member`
|
||||
* OOYE inner joins on this.
|
||||
* Sims are never deleted so if this was added it would only be used for enforcing referential integrity.
|
||||
* The storage cost of the additional index on `sim` would not be worth the benefits.
|
||||
* `channel_room` <--(**C** room_id PK)-- `sim_member`
|
||||
* If a room is being permanently unlinked, it may be useful to see a populated member list. If it's about to be relinked to another channel, we want to keep the sims in the room for more speed and to avoid spamming state events into the timeline.
|
||||
* Either way, the sims could remain in the room even after it's been unlinked. So no referential integrity is desirable here.
|
||||
* `sim` <--(PK user_id PK)-- `sim_proxy`
|
||||
* OOYE left joins on this. In normal operation, this relationship might not exist.
|
||||
* `channel_room` <--(PK channel_id PK)-- `webhook` ✅
|
||||
* Seems useful. Webhooks should be deleted from Discord just before the channel is unlinked. That should be mirrored in the database too.
|
||||
|
||||
## Occurrences of REPLACE INTO/DELETE FROM
|
||||
|
||||
* `edit-message.js` — `REPLACE INTO message_channel`
|
||||
* Scary! Changed to INSERT OR IGNORE
|
||||
* `send-message.js` — `REPLACE INTO message_channel`
|
||||
* Changed to INSERT OR IGNORE
|
||||
* `add-reaction.js` — `REPLACE INTO reaction`
|
||||
* `channel-webhook.js` — `REPLACE INTO webhook`
|
||||
* `send-event.js` — `REPLACE INTO message_channel`
|
||||
* Seems incorrect? Maybe?? Originally added in fcbb045. Changed to INSERT
|
||||
* `event-to-message.js` — `REPLACE INTO member_cache`
|
||||
* `oauth.js` — `REPLACE INTO guild_active`
|
||||
* Very scary!! Changed to INSERT .. ON CONFLICT DO UPDATE
|
||||
* `create-room.js` — `DELETE FROM channel_room`
|
||||
* Please cascade
|
||||
* `delete-message.js`
|
||||
* Removed redundant DELETEs
|
||||
* `edit-message.js` — `DELETE FROM event_message`
|
||||
* `register-pk-user.js` — `DELETE FROM sim`
|
||||
* It's a failsafe during creation
|
||||
* `register-user.js` — `DELETE FROM sim`
|
||||
* It's a failsafe during creation
|
||||
* `remove-reaction.js` — `DELETE FROM reaction`
|
||||
* `event-dispatcher.js` — `DELETE FROM member_cache`
|
||||
* `redact.js` — `DELETE FROM event_message`
|
||||
* Removed this redundant DELETE
|
||||
* `send-event.js` — `DELETE FROM event_message`
|
||||
* Removed this redundant DELETE
|
||||
|
||||
## How keys
|
||||
|
||||
SQLite does not have a complete ALTER TABLE command, so I have to DROP and CREATE. According to [the docs](https://www.sqlite.org/lang_altertable.html), the correct strategy is:
|
||||
|
||||
1. (Not applicable) *If foreign key constraints are enabled, disable them using PRAGMA foreign_keys=OFF.*
|
||||
2. Start a transaction.
|
||||
3. (Not applicable) *Remember the format of all indexes, triggers, and views associated with table X. This information will be needed in step 8 below. One way to do this is to run a query like the following: SELECT type, sql FROM sqlite_schema WHERE tbl_name='X'.*
|
||||
4. Use CREATE TABLE to construct a new table "new_X" that is in the desired revised format of table X. Make sure that the name "new_X" does not collide with any existing table name, of course.
|
||||
5. Transfer content from X into new_X using a statement like: INSERT INTO new_X SELECT ... FROM X.
|
||||
6. Drop the old table X: DROP TABLE X.
|
||||
7. Change the name of new_X to X using: ALTER TABLE new_X RENAME TO X.
|
||||
8. (Not applicable) *Use CREATE INDEX, CREATE TRIGGER, and CREATE VIEW to reconstruct indexes, triggers, and views associated with table X. Perhaps use the old format of the triggers, indexes, and views saved from step 3 above as a guide, making changes as appropriate for the alteration.*
|
||||
9. (Not applicable) *If any views refer to table X in a way that is affected by the schema change, then drop those views using DROP VIEW and recreate them with whatever changes are necessary to accommodate the schema change using CREATE VIEW.*
|
||||
10. If foreign key constraints were originally enabled then run PRAGMA foreign_key_check to verify that the schema change did not break any foreign key constraints.
|
||||
11. Commit the transaction started in step 2.
|
||||
12. (Not applicable) *If foreign keys constraints were originally enabled, reenable them now.*
|
|
@ -1,77 +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.
|
||||
|
||||
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 will only create other dependencies if the guild is autocreate.
|
||||
|
||||
## Enough with your theory. How do rooms actually get bridged now?
|
||||
|
||||
After clicking the easy mode button on web and adding the bot to a server, it will create new Matrix rooms on-demand when any invite features are used (web or command) OR just when any message is sent on Discord.
|
||||
|
||||
Alternatively, pressing the self-service mode button and adding the bot to a server will prompt the web user to link it with a space. After doing so, they'll be on the standard guild management page where they can invite to the space and manually link rooms. Nothing will be autocreated.
|
|
@ -1,103 +0,0 @@
|
|||
# Why does the bridge have a website?
|
||||
|
||||
## It's essential for making images work
|
||||
|
||||
Matrix has a feature called [Authenticated Media](https://matrix.org/blog/2024/06/26/sunsetting-unauthenticated-media/), where uploaded media (like user avatars and uploaded files) is restricted to Matrix users only. This means Discord users wouldn't be able to see important parts of the conversation.
|
||||
|
||||
To keep things working for Discord users, OOYE's web server can act as a proxy files that were uploaded on Matrix-side. This will automatically take effect when needed, so Discord users shouldn't notice any issues.
|
||||
|
||||
## Why now?
|
||||
|
||||
I knew a web interface had a lot of potential, but I was reluctant to add one because it would make the initial setup more complicated. However, when authenticated media forced my hand, I saw an opportunity to introduce new useful features. Hopefully you'll agree that it's worth it!
|
||||
|
||||
# What else does it do?
|
||||
|
||||
## Makes it easy to invite the bot
|
||||
|
||||
The home page of the website has buttons to add the bridge bot to a Discord server. If you are primarly a Matrix user and you want somebody else (who may be less technical) to add the bot to their own Discord server, this should make it a lot more intuitive for them.
|
||||
|
||||
## Makes it easy to invite yourself or others
|
||||
|
||||
After your hypothetical less-technical friend adds the bot to their Discord server, you need to generate an invite for your Matrix account on Matrix-side. Without the website, you might need to guide them through running a /invite command with your user ID. With the website, they don't have to do anything extra. You can use your phone to scan the QR code on their screen, which lets you invite your user ID in your own time.
|
||||
|
||||
You can also set the person's permissions when you invite them, so you can easily bootstrap the Matrix side with your trusted ones as moderators.
|
||||
|
||||
## To link channels and rooms
|
||||
|
||||
Without a website, to link Discord channels to existing Matrix rooms, you'd need to run a /link command with the internal IDs of each room. This is tedious and error-prone, especially if you want to set up a lot of channels. With the web interface, you can see a list of all the available rooms and click them to link them together.
|
||||
|
||||
## To change settings
|
||||
|
||||
Important settings, like whether the Matrix rooms should be private or publicly accessible, can be configured by clicking buttons rather than memorising commands. Changes take effect immediately.
|
||||
|
||||
# Permissions
|
||||
|
||||
## Bot invites
|
||||
|
||||
Anybody who can access the home page can use the buttons to add your bot - but even without the website, they can already do this by manually constructing a URL. If you want to make it so _only you_ can add your bot to servers, you need to edit [your Discord application](https://discord.com/developers/applications), go to the Bot section, and turn off the switch that says Public Bot.
|
||||
|
||||
## Server settings
|
||||
|
||||
If you have either the Administrator or Manage Server permissions in a Discord server, you can use the website to manage that server, including linking channels and changing settings.
|
||||
|
||||
# Initial setup
|
||||
|
||||
The website is built in to OOYE and is always running as part of the bridge. For authenticated media proxy to work, you'll need to make the web server accessible on the public internet over HTTPS, presumably using a reverse proxy.
|
||||
|
||||
When you use `npm run setup` as part of OOYE's initial setup, it will guide you through this process, and it will do a thorough self-test to make sure it's configured correctly. If you get stuck or want a configuration template, check the notes below.
|
||||
|
||||
## Reverse proxy
|
||||
|
||||
When OOYE is running, the web server runs on port 6693. (To use a different port or a UNIX socket, edit registration.yaml's `socket` setting and restart.)
|
||||
|
||||
It doesn't have to have its own dedicated domain name, you can also use a sub-path on an existing domain, like the domain of your Matrix homeserver. See the end of this document for more information.
|
||||
|
||||
You are likely already using a reverse proxy for running your homeserver, so this should just be a configuration change.
|
||||
|
||||
## Example configuration for nginx, dedicated domain name
|
||||
|
||||
Replace `bridge.cadence.moe` with the hostname you're using.
|
||||
|
||||
```nix
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name bridge.cadence.moe;
|
||||
|
||||
return 301 https://bridge.cadence.moe$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name bridge.cadence.moe;
|
||||
|
||||
# ssl parameters here...
|
||||
client_max_body_size 5M;
|
||||
|
||||
location / {
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
||||
proxy_pass http://127.0.0.1:6693;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Example configuration for nginx, sharing a domain name
|
||||
|
||||
Same as above, but change the following:
|
||||
|
||||
- `location / {` -> `location /ooye/ {` (any sub-path you want; you MUST use a trailing slash or it won't work)
|
||||
- `proxy_pass http://127.0.0.1:6693;` -> `proxy_pass http://127.0.0.1:6693/;` (you MUST use a trailing slash on this too or it won't work)
|
||||
|
||||
## Example configuration for Caddy, dedicated domain name
|
||||
|
||||
```nix
|
||||
bridge.cadence.moe {
|
||||
log {
|
||||
output file /var/log/caddy/access.log
|
||||
format console
|
||||
}
|
||||
encode gzip
|
||||
reverse_proxy 127.0.0.1:6693
|
||||
}
|
||||
```
|
|
@ -20,7 +20,7 @@ async function addReaction(event) {
|
|||
if (!messageID) return // Nothing can be done if the parent message was never bridged.
|
||||
|
||||
const key = event.content["m.relates_to"].key
|
||||
const discordPreferredEncoding = await emoji.encodeEmoji(key, event.content.shortcode)
|
||||
const discordPreferredEncoding = emoji.encodeEmoji(key, event.content.shortcode)
|
||||
if (!discordPreferredEncoding) return
|
||||
|
||||
await discord.snow.channel.createReaction(channelID, messageID, discordPreferredEncoding) // acting as the discord bot itself
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
const assert = require("assert").strict
|
||||
const DiscordTypes = require("discord-api-types/v10")
|
||||
const stream = require("stream")
|
||||
const {Readable} = require("stream")
|
||||
const passthrough = require("../../passthrough")
|
||||
const {discord, db, select} = passthrough
|
||||
|
||||
|
@ -57,7 +57,7 @@ async function withWebhook(channelID, callback) {
|
|||
|
||||
/**
|
||||
* @param {string} channelID
|
||||
* @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]}} data
|
||||
* @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]}} data
|
||||
* @param {string} [threadID]
|
||||
*/
|
||||
async function sendMessageWithWebhook(channelID, data, threadID) {
|
||||
|
@ -70,7 +70,7 @@ async function sendMessageWithWebhook(channelID, data, threadID) {
|
|||
/**
|
||||
* @param {string} channelID
|
||||
* @param {string} messageID
|
||||
* @param {DiscordTypes.RESTPatchAPIWebhookWithTokenMessageJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]}} data
|
||||
* @param {DiscordTypes.RESTPatchAPIWebhookWithTokenMessageJSONBody & {files?: {name: string, file: Buffer | Readable}[]}} data
|
||||
* @param {string} [threadID]
|
||||
*/
|
||||
async function editMessageWithWebhook(channelID, messageID, data, threadID) {
|
|
@ -1,12 +1,13 @@
|
|||
// @ts-check
|
||||
|
||||
const stream = require("stream")
|
||||
const assert = require("assert")
|
||||
const fetch = require("node-fetch").default
|
||||
|
||||
const utils = require("../converters/utils")
|
||||
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.
|
||||
|
@ -15,15 +16,20 @@ 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.
|
||||
const res = await api.getMedia(mxc, {signal: abortController.signal})
|
||||
const readable = stream.Readable.fromWeb(res.body)
|
||||
return emojiSheetConverter.convertImageStream(readable, () => {
|
||||
// @ts-ignore the signal is slightly different from the type it wants (still works fine)
|
||||
const res = await fetch(url, {agent: false, signal: abortController.signal})
|
||||
return emojiSheetConverter.convertImageStream(res.body, () => {
|
||||
abortController.abort()
|
||||
readable.emit("end")
|
||||
readable.on("error", () => {}) // DOMException [AbortError]: This operation was aborted
|
||||
res.body.pause()
|
||||
res.body.emit("end")
|
||||
})
|
||||
}
|
||||
|
|
@ -13,6 +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.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)
|
||||
|
@ -24,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)
|
|
@ -2,9 +2,10 @@
|
|||
|
||||
const Ty = require("../../types")
|
||||
const DiscordTypes = require("discord-api-types/v10")
|
||||
const stream = require("stream")
|
||||
const {Readable} = require("stream")
|
||||
const assert = require("assert").strict
|
||||
const crypto = require("crypto")
|
||||
const fetch = require("node-fetch").default
|
||||
const passthrough = require("../../passthrough")
|
||||
const {sync, discord, db, select} = passthrough
|
||||
|
||||
|
@ -22,8 +23,8 @@ const editMessage = sync.require("../../d2m/actions/edit-message")
|
|||
const emojiSheet = sync.require("../actions/emoji-sheet")
|
||||
|
||||
/**
|
||||
* @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[], pendingFiles?: ({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer | stream.Readable})[]}} message
|
||||
* @returns {Promise<DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]}>}
|
||||
* @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) {
|
||||
if (!message.pendingFiles) return message
|
||||
|
@ -37,14 +38,16 @@ async function resolvePendingFiles(message) {
|
|||
if ("key" in p) {
|
||||
// Encrypted file
|
||||
const d = crypto.createDecipheriv("aes-256-ctr", Buffer.from(p.key, "base64url"), Buffer.from(p.iv, "base64url"))
|
||||
await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb(res.body).pipe(d))
|
||||
// @ts-ignore
|
||||
fetch(p.url).then(res => res.body.pipe(d))
|
||||
return {
|
||||
name: p.name,
|
||||
file: d
|
||||
}
|
||||
} else {
|
||||
// Unencrypted file
|
||||
const body = await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb(res.body))
|
||||
/** @type {Readable} */ // @ts-ignore
|
||||
const body = await fetch(p.url).then(res => res.body)
|
||||
return {
|
||||
name: p.name,
|
||||
file: body
|
||||
|
@ -76,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)
|
||||
|
@ -99,13 +102,14 @@ async function sendEvent(event) {
|
|||
|
||||
for (const id of messagesToDelete) {
|
||||
db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(id)
|
||||
db.prepare("DELETE FROM event_message WHERE message_id = ?").run(id)
|
||||
await channelWebhook.deleteMessageWithWebhook(channelID, id, threadID)
|
||||
}
|
||||
|
||||
for (const message of messagesToSend) {
|
||||
const reactionPart = messagesToEdit.length === 0 && message === messagesToSend[messagesToSend.length - 1] ? 0 : 1
|
||||
const messageResponse = await channelWebhook.sendMessageWithWebhook(channelID, message, threadID)
|
||||
db.prepare("INSERT INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(messageResponse.id, threadID || channelID)
|
||||
db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(messageResponse.id, threadID || channelID)
|
||||
db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 0)").run(event.event_id, event.type, event.content["msgtype"] || null, messageResponse.id, eventPart, reactionPart) // source 0 = matrix
|
||||
|
||||
eventPart = 1
|
|
@ -1,7 +1,6 @@
|
|||
// @ts-check
|
||||
|
||||
const assert = require("assert").strict
|
||||
const stream = require("stream")
|
||||
const {pipeline} = require("stream").promises
|
||||
const sharp = require("sharp")
|
||||
const {GIFrame} = require("@cloudrac3r/giframe")
|
||||
|
@ -49,7 +48,7 @@ async function compositeMatrixEmojis(mxcs, mxcDownloader) {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {stream.Readable} streamIn
|
||||
* @param {import("node-fetch").Response["body"]} streamIn
|
||||
* @param {() => any} stopStream
|
||||
* @returns {Promise<Buffer | undefined>} Uncompressed PNG image
|
||||
*/
|
||||
|
@ -82,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})
|
57
m2d/converters/emoji.js
Normal file
57
m2d/converters/emoji.js
Normal file
|
@ -0,0 +1,57 @@
|
|||
// @ts-check
|
||||
|
||||
const assert = require("assert").strict
|
||||
const Ty = require("../../types")
|
||||
|
||||
const passthrough = require("../../passthrough")
|
||||
const {sync, select} = passthrough
|
||||
|
||||
/**
|
||||
* @param {string} input
|
||||
* @param {string | null | undefined} shortcode
|
||||
* @returns {string?}
|
||||
*/
|
||||
function encodeEmoji(input, shortcode) {
|
||||
let discordPreferredEncoding
|
||||
if (input.startsWith("mxc://")) {
|
||||
// Custom emoji
|
||||
let row = select("emoji", ["emoji_id", "name"], {mxc_url: input}).get()
|
||||
if (!row && shortcode) {
|
||||
// Use the name to try to find a known emoji with the same name.
|
||||
const name = shortcode.replace(/^:|:$/g, "")
|
||||
row = select("emoji", ["emoji_id", "name"], {name: name}).get()
|
||||
}
|
||||
if (!row) {
|
||||
// We don't have this emoji and there's no realistic way to just-in-time upload a new emoji somewhere.
|
||||
// Sucks!
|
||||
return null
|
||||
}
|
||||
// Cool, we got an exact or a candidate emoji.
|
||||
discordPreferredEncoding = encodeURIComponent(`${row.name}:${row.emoji_id}`)
|
||||
} else {
|
||||
// Default emoji
|
||||
// https://github.com/discord/discord-api-docs/issues/2723#issuecomment-807022205 ????????????
|
||||
const encoded = encodeURIComponent(input)
|
||||
const encodedTrimmed = encoded.replace(/%EF%B8%8F/g, "")
|
||||
|
||||
const forceTrimmedList = [
|
||||
"%F0%9F%91%8D", // 👍
|
||||
"%F0%9F%91%8E", // 👎️
|
||||
"%E2%AD%90", // ⭐
|
||||
"%F0%9F%90%88", // 🐈
|
||||
"%E2%9D%93", // ❓
|
||||
"%F0%9F%8F%86", // 🏆️
|
||||
"%F0%9F%93%9A", // 📚️
|
||||
]
|
||||
|
||||
discordPreferredEncoding =
|
||||
( forceTrimmedList.includes(encodedTrimmed) ? encodedTrimmed
|
||||
: encodedTrimmed !== encoded && [...input].length === 2 ? encoded
|
||||
: encodedTrimmed)
|
||||
|
||||
console.log("add reaction from matrix:", input, encoded, encodedTrimmed, "chosen:", discordPreferredEncoding)
|
||||
}
|
||||
return discordPreferredEncoding
|
||||
}
|
||||
|
||||
module.exports.encodeEmoji = encodeEmoji
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
const Ty = require("../../types")
|
||||
const DiscordTypes = require("discord-api-types/v10")
|
||||
const stream = require("stream")
|
||||
const {Readable} = require("stream")
|
||||
const chunk = require("chunk-text")
|
||||
const TurndownService = require("@cloudrac3r/turndown")
|
||||
const TurndownService = require("turndown")
|
||||
const domino = require("domino")
|
||||
const assert = require("assert").strict
|
||||
const entities = require("entities")
|
||||
|
@ -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")
|
||||
|
||||
|
@ -131,7 +129,7 @@ turndownService.addRule("inlineLink", {
|
|||
const href = node.getAttribute("href")
|
||||
content = content.replace(/ @.*/, "")
|
||||
if (href === content) return href
|
||||
if (decodeURIComponent(href).startsWith("https://matrix.to/#/@") && content[0] !== "@") content = "@" + content
|
||||
if (href.startsWith("https://matrix.to/#/@") && content[0] !== "@") content = "@" + content
|
||||
return "[" + content + "](" + href + ")"
|
||||
}
|
||||
})
|
||||
|
@ -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,
|
||||
|
@ -258,18 +257,7 @@ async function getMemberFromCacheOrHomeserver(roomID, mxid, api) {
|
|||
const row = select("member_cache", ["displayname", "avatar_url"], {room_id: roomID, mxid}).get()
|
||||
if (row) return row
|
||||
return api.getStateEvent(roomID, "m.room.member", mxid).then(event => {
|
||||
const room = select("channel_room", "room_id", {room_id: roomID}).get()
|
||||
if (room) {
|
||||
// save the member to the cache so we don't have to check with the homeserver next time
|
||||
// the cache will be kept in sync by the `m.room.member` event listener
|
||||
const displayname = event?.displayname || null
|
||||
const avatar_url = event?.avatar_url || null
|
||||
db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?").run(
|
||||
roomID, mxid,
|
||||
displayname, avatar_url,
|
||||
displayname, avatar_url
|
||||
)
|
||||
}
|
||||
db.prepare("REPLACE INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?)").run(roomID, mxid, event?.displayname || null, event?.avatar_url || null)
|
||||
return event
|
||||
}).catch(() => {
|
||||
return {displayname: null, avatar_url: null}
|
||||
|
@ -316,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) {
|
||||
|
@ -341,9 +329,9 @@ async function uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles,
|
|||
*/
|
||||
async function handleRoomOrMessageLinks(input, di) {
|
||||
let offset = 0
|
||||
for (const match of [...input.matchAll(/("?https:\/\/matrix.to\/#\/((?:#|%23|!)[^"/, ?)]+)(?:\/(\$[^"/ ?)]+))?(?:\?[^",:!? )]*?)?)(">|[,<\n )]|$)/g)]) {
|
||||
for (const match of [...input.matchAll(/("?https:\/\/matrix.to\/#\/(![^"/, ?)]+)(?:\/(\$[^"/ ?)]+))?(?:\?[^",:!? )]*)?)(">|[, )]|$)/g)]) {
|
||||
assert(typeof match.index === "number")
|
||||
let [_, attributeValue, roomID, eventID, endMarker] = match
|
||||
const [_, attributeValue, roomID, eventID, endMarker] = match
|
||||
let result
|
||||
|
||||
const resultType = endMarker === '">' ? "html" : "plain"
|
||||
|
@ -361,16 +349,6 @@ async function handleRoomOrMessageLinks(input, di) {
|
|||
// Don't process links that are part of the reply fallback, they'll be removed entirely by turndown
|
||||
if (input.slice(match.index + match[0].length + offset).startsWith("In reply to")) continue
|
||||
|
||||
// Resolve room alias to room ID if needed
|
||||
roomID = decodeURIComponent(roomID)
|
||||
if (roomID[0] === "#") {
|
||||
try {
|
||||
roomID = await di.api.getAlias(roomID)
|
||||
} catch (e) {
|
||||
continue // Room alias is unresolvable, so it can't be converted
|
||||
}
|
||||
}
|
||||
|
||||
const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get()
|
||||
if (!channelID) continue
|
||||
if (!eventID) {
|
||||
|
@ -410,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+
|
||||
|
@ -461,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
|
||||
|
@ -476,7 +454,7 @@ async function eventToMessage(event, guild, di) {
|
|||
// Try to extract an accurate display name and avatar URL from the member event
|
||||
const member = await getMemberFromCacheOrHomeserver(event.room_id, event.sender, di?.api)
|
||||
if (member.displayname) displayName = member.displayname
|
||||
if (member.avatar_url) avatarURL = mxUtils.getPublicUrlForMxc(member.avatar_url)
|
||||
if (member.avatar_url) avatarURL = mxUtils.getPublicUrlForMxc(member.avatar_url) || undefined
|
||||
// If the display name is too long to be put into the webhook (80 characters is the maximum),
|
||||
// put the excess characters into displayNameRunoff, later to be put at the top of the message
|
||||
let [displayNameShortened, displayNameRunoff] = splitDisplayName(displayName)
|
||||
|
@ -487,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 = []
|
||||
|
@ -512,7 +490,8 @@ async function eventToMessage(event, guild, di) {
|
|||
// Is it editing a reply? We need special handling if it is.
|
||||
// Get the original event, then check if it was a reply
|
||||
const originalEvent = await di.api.getEvent(event.room_id, originalEventId)
|
||||
const repliedToEventId = originalEvent?.content?.["m.relates_to"]?.["m.in_reply_to"]?.event_id
|
||||
if (!originalEvent) return
|
||||
const repliedToEventId = originalEvent.content["m.relates_to"]?.["m.in_reply_to"]?.event_id
|
||||
if (!repliedToEventId) return
|
||||
|
||||
// After all that, it's an edit of a reply.
|
||||
|
@ -562,7 +541,7 @@ async function eventToMessage(event, guild, di) {
|
|||
.replace(/<span [^>]*data-mx-spoiler\b[^>]*>.*?<\/span>/g, "[spoiler]") // Good enough method of removing spoiler content. (I don't want to break out the HTML parser unless I have to.)
|
||||
.replace(/<[^>]+>/g, "") // Completely strip all HTML tags and formatting.
|
||||
), 50)
|
||||
replyLine = "-# > " + contentPreviewChunks[0]
|
||||
replyLine = "> " + contentPreviewChunks[0]
|
||||
if (contentPreviewChunks.length > 1) replyLine = replyLine.replace(/[,.']$/, "") + "..."
|
||||
replyLine += "\n"
|
||||
return
|
||||
|
@ -575,27 +554,34 @@ async function eventToMessage(event, guild, di) {
|
|||
if (row) {
|
||||
replyLine += `https://discord.com/channels/${guild.id}/${row.channel_id}/${row.message_id} `
|
||||
}
|
||||
const sender = repliedToEvent.sender
|
||||
const authorID = getUserOrProxyOwnerID(sender)
|
||||
if (authorID) {
|
||||
replyLine += `<@${authorID}>`
|
||||
} else {
|
||||
let senderName = select("member_cache", "displayname", {mxid: sender}).pluck().get()
|
||||
if (!senderName) {
|
||||
const match = sender.match(/@([^:]*)/)
|
||||
assert(match)
|
||||
senderName = match[1]
|
||||
}
|
||||
replyLine += `Ⓜ️**${senderName}**`
|
||||
}
|
||||
// If the event has been edited, the homeserver will include the relation in `unsigned`.
|
||||
if (repliedToEvent.unsigned?.["m.relations"]?.["m.replace"]?.content?.["m.new_content"]) {
|
||||
repliedToEvent = repliedToEvent.unsigned["m.relations"]["m.replace"] // Note: this changes which event_id is in repliedToEvent.
|
||||
repliedToEvent.content = repliedToEvent.content["m.new_content"]
|
||||
}
|
||||
/** @type {string} */
|
||||
let repliedToContent = repliedToEvent.content.formatted_body || repliedToEvent.content.body
|
||||
const fileReplyContentAlternative = attachmentEmojis.get(repliedToEvent.content.msgtype)
|
||||
let contentPreview
|
||||
const fileReplyContentAlternative = attachmentEmojis.get(repliedToEvent.content.msgtype)
|
||||
if (fileReplyContentAlternative) {
|
||||
contentPreview = " " + fileReplyContentAlternative
|
||||
} else if (repliedToEvent.unsigned?.redacted_because) {
|
||||
contentPreview = " (in reply to a deleted message)"
|
||||
} else if (typeof repliedToContent !== "string") {
|
||||
// in reply to a weird metadata event like m.room.name, m.room.member...
|
||||
// I'm not implementing text fallbacks for arbitrary room events. this should cover most cases
|
||||
// this has never ever happened in the wild anyway
|
||||
repliedToEvent.sender = ""
|
||||
contentPreview = " (channel details edited)"
|
||||
} else {
|
||||
// Generate a reply preview for a standard message
|
||||
/** @type {string} */
|
||||
let repliedToContent = repliedToEvent.content.formatted_body || repliedToEvent.content.body
|
||||
repliedToContent = repliedToContent.replace(/.*<\/mx-reply>/s, "") // Remove everything before replies, so just use the actual message body
|
||||
repliedToContent = repliedToContent.replace(/^\s*<blockquote>.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards
|
||||
repliedToContent = repliedToContent.replace(/(?:\n|<br>)+/g, " ") // Should all be on one line
|
||||
|
@ -606,26 +592,17 @@ async function eventToMessage(event, guild, di) {
|
|||
return convertEmoji(mxcUrlMatch?.[1], titleTextMatch?.[1], false, false)
|
||||
})
|
||||
repliedToContent = repliedToContent.replace(/<[^:>][^>]*>/g, "") // Completely strip all HTML tags and formatting.
|
||||
repliedToContent = repliedToContent.replace(/\bhttps?:\/\/[^ )]*/g, "<$&>")
|
||||
repliedToContent = entities.decodeHTML5Strict(repliedToContent) // Remove entities like & "
|
||||
const contentPreviewChunks = chunk(repliedToContent, 50)
|
||||
if (contentPreviewChunks.length) {
|
||||
contentPreview = ": " + contentPreviewChunks[0]
|
||||
contentPreview = ":\n> " + contentPreviewChunks[0]
|
||||
if (contentPreviewChunks.length > 1) contentPreview = contentPreview.replace(/[,.']$/, "") + "..."
|
||||
} else {
|
||||
console.log("Unable to generate reply preview for this replied-to event because we stripped all of it:", repliedToEvent)
|
||||
contentPreview = ""
|
||||
}
|
||||
}
|
||||
const sender = repliedToEvent.sender
|
||||
const authorID = getUserOrProxyOwnerID(sender)
|
||||
if (authorID) {
|
||||
replyLine += `<@${authorID}>`
|
||||
} else {
|
||||
let senderName = select("member_cache", "displayname", {mxid: sender}).pluck().get()
|
||||
if (!senderName) senderName = sender.match(/@([^:]*)/)?.[1]
|
||||
if (senderName) replyLine += `**Ⓜ${senderName}**`
|
||||
}
|
||||
replyLine = `-# > ${replyLine}${contentPreview}\n`
|
||||
replyLine = `> ${replyLine}${contentPreview}\n`
|
||||
})()
|
||||
|
||||
if (event.content.format === "org.matrix.custom.html" && event.content.formatted_body) {
|
||||
|
@ -708,7 +685,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")
|
||||
|
@ -748,7 +725,7 @@ async function eventToMessage(event, guild, di) {
|
|||
content = turndownService.turndown(root)
|
||||
|
||||
// Put < > around any surviving matrix.to links to hide the URL previews
|
||||
content = content.replace(/\bhttps?:\/\/matrix\.to\/[^<>\n )]*/g, "<$&>")
|
||||
content = content.replace(/\bhttps?:\/\/matrix\.to\/[^ )]*/g, "<$&>")
|
||||
|
||||
// It's designed for commonmark, we need to replace the space-space-newline with just newline
|
||||
content = content.replace(/ \n/g, "\n")
|
||||
|
@ -767,7 +744,7 @@ async function eventToMessage(event, guild, di) {
|
|||
}
|
||||
|
||||
content = await handleRoomOrMessageLinks(content, di) // Replace matrix.to links with discord.com equivalents where possible
|
||||
content = content.replace(/\bhttps?:\/\/matrix\.to\/[^<>\n )]*/, "<$&>") // Put < > around any surviving matrix.to links to hide the URL previews
|
||||
content = content.replace(/\bhttps?:\/\/matrix\.to\/[^ )]*/, "<$&>") // Put < > around any surviving matrix.to links to hide the URL previews
|
||||
|
||||
const result = await checkWrittenMentions(content, event.sender, event.room_id, guild, di)
|
||||
if (result) {
|
||||
|
@ -788,23 +765,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")
|
||||
}
|
||||
|
@ -813,14 +796,14 @@ 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
|
||||
|
||||
// Split into 2000 character chunks
|
||||
const chunks = chunk(content, 2000)
|
||||
/** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]})[]} */
|
||||
/** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]})[]} */
|
||||
const messages = chunks.map(content => ({
|
||||
content,
|
||||
allowed_mentions: {
|
File diff suppressed because it is too large
Load diff
|
@ -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
|
||||
|
@ -30,7 +25,9 @@ const NEWLINE_ELEMENTS = BLOCK_ELEMENTS.concat(["BR"])
|
|||
*/
|
||||
function eventSenderIsFromDiscord(sender) {
|
||||
// If it's from a user in the bridge's namespace, then it originated from discord
|
||||
// This could include messages sent by the appservice's bot user, because that is what's used for webhooks
|
||||
// This includes messages sent by the appservice's bot user, because that is what's used for webhooks
|
||||
// TODO: It would be nice if bridge system messages wouldn't trigger this check and could be bridged from matrix to discord, while webhook reflections would remain ignored...
|
||||
// TODO that only applies to the above todo: But you'd have to watch out for the /icon command, where the bridge bot would set the room avatar, and that shouldn't be reflected into the room a second time.
|
||||
if (userRegex.some(x => sender.match(x))) {
|
||||
return true
|
||||
}
|
||||
|
@ -38,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.
|
||||
|
@ -206,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 | undefined}
|
||||
*/
|
||||
function getPublicUrlForMxc(mxc) {
|
||||
assert(hasher, "xxhash is not ready yet")
|
||||
const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
|
||||
if (!mediaParts) return undefined
|
||||
|
||||
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
|
152
m2d/event-dispatcher.js
Normal file
152
m2d/event-dispatcher.js
Normal file
|
@ -0,0 +1,152 @@
|
|||
// @ts-check
|
||||
|
||||
/*
|
||||
* Grab Matrix events we care about, check them, and bridge them.
|
||||
*/
|
||||
|
||||
const util = require("util")
|
||||
const Ty = require("../types")
|
||||
const {discord, db, sync, as} = require("../passthrough")
|
||||
|
||||
/** @type {import("./actions/send-event")} */
|
||||
const sendEvent = sync.require("./actions/send-event")
|
||||
/** @type {import("./actions/add-reaction")} */
|
||||
const addReaction = sync.require("./actions/add-reaction")
|
||||
/** @type {import("./actions/redact")} */
|
||||
const redact = sync.require("./actions/redact")
|
||||
/** @type {import("../matrix/matrix-command-handler")} */
|
||||
const matrixCommandHandler = sync.require("../matrix/matrix-command-handler")
|
||||
/** @type {import("./converters/utils")} */
|
||||
const utils = sync.require("./converters/utils")
|
||||
/** @type {import("../matrix/api")}) */
|
||||
const api = sync.require("../matrix/api")
|
||||
/** @type {import("../matrix/read-registration")}) */
|
||||
const reg = sync.require("../matrix/read-registration")
|
||||
|
||||
let lastReportedEvent = 0
|
||||
|
||||
function guard(type, fn) {
|
||||
return async function(event, ...args) {
|
||||
try {
|
||||
return await fn(event, ...args)
|
||||
} catch (e) {
|
||||
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?
|
||||
console.error(`while handling this ${type} gateway event:`)
|
||||
console.dir(event, {depth: null})
|
||||
|
||||
if (Date.now() - lastReportedEvent < 5000) return
|
||||
lastReportedEvent = Date.now()
|
||||
|
||||
let stackLines = e.stack.split("\n")
|
||||
api.sendEvent(event.room_id, "m.room.message", {
|
||||
msgtype: "m.text",
|
||||
body: "\u26a0 Matrix event not delivered to Discord. See formatted content for full details.",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: "\u26a0 <strong>Matrix event not delivered to Discord</strong>"
|
||||
+ `<br>Event type: ${type}`
|
||||
+ `<br>${e.toString()}`
|
||||
+ `<br><details><summary>Error trace</summary>`
|
||||
+ `<pre>${stackLines.join("\n")}</pre></details>`
|
||||
+ `<details><summary>Original payload</summary>`
|
||||
+ `<pre>${util.inspect(event, false, 4, false)}</pre></details>`,
|
||||
"moe.cadence.ooye.error": {
|
||||
source: "matrix",
|
||||
payload: event
|
||||
},
|
||||
"m.mentions": {
|
||||
user_ids: ["@cadence:cadence.moe"]
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function retry(roomID, eventID) {
|
||||
const event = await api.getEvent(roomID, eventID)
|
||||
const error = event.content["moe.cadence.ooye.error"]
|
||||
if (event.sender !== `@${reg.sender_localpart}:${reg.ooye.server_name}` || !error) return
|
||||
if (error.source === "matrix") {
|
||||
as.emit("type:" + error.payload.type, error.payload)
|
||||
} else if (error.source === "discord") {
|
||||
discord.cloud.emit("event", error.payload)
|
||||
}
|
||||
}
|
||||
|
||||
sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message",
|
||||
/**
|
||||
* @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} event it is a m.room.message because that's what this listener is filtering for
|
||||
*/
|
||||
async event => {
|
||||
if (utils.eventSenderIsFromDiscord(event.sender)) return
|
||||
const messageResponses = await sendEvent.sendEvent(event)
|
||||
if (event.type === "m.room.message" && event.content.msgtype === "m.text") {
|
||||
// @ts-ignore
|
||||
await matrixCommandHandler.execute(event)
|
||||
}
|
||||
}))
|
||||
|
||||
sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker",
|
||||
/**
|
||||
* @param {Ty.Event.Outer_M_Sticker} event it is a m.sticker because that's what this listener is filtering for
|
||||
*/
|
||||
async event => {
|
||||
if (utils.eventSenderIsFromDiscord(event.sender)) return
|
||||
const messageResponses = await sendEvent.sendEvent(event)
|
||||
}))
|
||||
|
||||
sync.addTemporaryListener(as, "type:m.reaction", guard("m.reaction",
|
||||
/**
|
||||
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>} event it is a m.reaction because that's what this listener is filtering for
|
||||
*/
|
||||
async event => {
|
||||
if (utils.eventSenderIsFromDiscord(event.sender)) return
|
||||
if (event.content["m.relates_to"].key === "🔁") {
|
||||
// Try to bridge a failed event again?
|
||||
await retry(event.room_id, event.content["m.relates_to"].event_id)
|
||||
} else {
|
||||
matrixCommandHandler.onReactionAdd(event)
|
||||
await addReaction.addReaction(event)
|
||||
}
|
||||
}))
|
||||
|
||||
sync.addTemporaryListener(as, "type:m.room.redaction", guard("m.room.redaction",
|
||||
/**
|
||||
* @param {Ty.Event.Outer_M_Room_Redaction} event it is a m.room.redaction because that's what this listener is filtering for
|
||||
*/
|
||||
async event => {
|
||||
if (utils.eventSenderIsFromDiscord(event.sender)) return
|
||||
await redact.handle(event)
|
||||
}))
|
||||
|
||||
sync.addTemporaryListener(as, "type:m.room.avatar", guard("m.room.avatar",
|
||||
/**
|
||||
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Avatar>} event
|
||||
*/
|
||||
async event => {
|
||||
if (event.state_key !== "") return
|
||||
if (utils.eventSenderIsFromDiscord(event.sender)) return
|
||||
const url = event.content.url || null
|
||||
db.prepare("UPDATE channel_room SET custom_avatar = ? WHERE room_id = ?").run(url, event.room_id)
|
||||
}))
|
||||
|
||||
sync.addTemporaryListener(as, "type:m.room.name", guard("m.room.name",
|
||||
/**
|
||||
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Name>} event
|
||||
*/
|
||||
async event => {
|
||||
if (event.state_key !== "") return
|
||||
if (utils.eventSenderIsFromDiscord(event.sender)) return
|
||||
const name = event.content.name || null
|
||||
db.prepare("UPDATE channel_room SET nick = ? WHERE room_id = ?").run(name, event.room_id)
|
||||
}))
|
||||
|
||||
sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member",
|
||||
/**
|
||||
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Member>} event
|
||||
*/
|
||||
async event => {
|
||||
if (event.state_key[0] !== "@") return
|
||||
if (utils.eventSenderIsFromDiscord(event.state_key)) return
|
||||
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)
|
||||
}))
|
|
@ -2,15 +2,15 @@
|
|||
|
||||
const Ty = require("../types")
|
||||
const assert = require("assert").strict
|
||||
const streamWeb = require("stream/web")
|
||||
|
||||
const passthrough = require("../passthrough")
|
||||
const {sync} = 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
|
||||
|
@ -34,21 +34,14 @@ function path(p, mxid, otherParams = {}) {
|
|||
|
||||
/**
|
||||
* @param {string} username
|
||||
* @returns {Promise<Ty.R.Registered>}
|
||||
*/
|
||||
async function register(username) {
|
||||
function register(username) {
|
||||
console.log(`[api] register: ${username}`)
|
||||
try {
|
||||
await mreq.mreq("POST", "/client/v3/register", {
|
||||
type: "m.login.application_service",
|
||||
username
|
||||
})
|
||||
} catch (e) {
|
||||
if (e.errcode === "M_USER_IN_USE" || e.data?.error === "Internal server error") {
|
||||
// "Internal server error" is the only OK error because older versions of Synapse say this if you try to register the same username twice.
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
return mreq.mreq("POST", "/client/v3/register", {
|
||||
type: "m.login.application_service",
|
||||
username
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -66,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
|
||||
}
|
||||
|
||||
|
@ -77,20 +70,9 @@ 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), {})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} roomID
|
||||
* @param {string} reason
|
||||
* @param {string} [mxid]
|
||||
*/
|
||||
async function leaveRoomWithReason(roomID, reason, mxid) {
|
||||
console.log(`[api] leave: ${roomID}: ${mxid}, because ${reason}`)
|
||||
await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, mxid), {reason})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} roomID
|
||||
* @param {string} eventID
|
||||
|
@ -139,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
|
||||
|
@ -163,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
|
||||
|
@ -197,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
|
||||
|
@ -308,134 +241,19 @@ async function setUserPower(roomID, mxid, power) {
|
|||
return powerLevels
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a user's power level for a whole room hierarchy.
|
||||
* @param {string} spaceID
|
||||
* @param {string} mxid
|
||||
* @param {number} power
|
||||
*/
|
||||
async function setUserPowerCascade(spaceID, mxid, power) {
|
||||
assert(spaceID[0] === "!")
|
||||
assert(mxid[0] === "@")
|
||||
const rooms = await getFullHierarchy(spaceID)
|
||||
await setUserPower(spaceID, mxid, power)
|
||||
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 {RequestInit} [init]
|
||||
* @return {Promise<Response & {body: streamWeb.ReadableStream<Uint8Array>}>}
|
||||
*/
|
||||
async function getMedia(mxc, init = {}) {
|
||||
const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
|
||||
assert(mediaParts)
|
||||
const res = await fetch(`${mreq.baseUrl}/client/v1/media/download/${mediaParts[1]}/${mediaParts[2]}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${reg.as_token}`
|
||||
},
|
||||
...init
|
||||
})
|
||||
assert(res.body)
|
||||
// @ts-ignore
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the m.read receipt in roomID to point to eventID.
|
||||
* This doesn't modify m.fully_read, which matches [the behaviour of matrix-bot-sdk.](https://github.com/element-hq/matrix-bot-sdk/blob/e72a4c498e00c6c339a791630c45d00a351f56a8/src/MatrixClient.ts#L1227)
|
||||
* @param {string} roomID
|
||||
* @param {string} eventID
|
||||
* @param {string?} [mxid]
|
||||
*/
|
||||
async function sendReadReceipt(roomID, eventID, mxid) {
|
||||
await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/receipt/m.read/${eventID}`, mxid), {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Acknowledge an event as read by calling api.sendReadReceipt on it.
|
||||
* @param {Ty.Event.Outer<any>} event
|
||||
* @param {string?} [mxid]
|
||||
*/
|
||||
async function ackEvent(event, mxid) {
|
||||
await sendReadReceipt(event.room_id, event.event_id, mxid)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a room alias to a room ID.
|
||||
* @param {string} alias
|
||||
*/
|
||||
async function getAlias(alias) {
|
||||
/** @type {Ty.R.ResolvedRoom} */
|
||||
const root = await mreq.mreq("GET", `/client/v3/directory/room/${encodeURIComponent(alias)}`)
|
||||
return root.room_id
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} type namespaced event type, e.g. m.direct
|
||||
* @param {string} [mxid] you
|
||||
* @returns the *content* of the account data "event"
|
||||
*/
|
||||
async function getAccountData(type, mxid) {
|
||||
if (!mxid) mxid = `@${reg.sender_localpart}:${reg.ooye.server_name}`
|
||||
const root = await mreq.mreq("GET", `/client/v3/user/${mxid}/account_data/${type}`)
|
||||
return root
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} type namespaced event type, e.g. m.direct
|
||||
* @param {any} content whatever you want
|
||||
* @param {string} [mxid] you
|
||||
*/
|
||||
async function setAccountData(type, content, mxid) {
|
||||
if (!mxid) mxid = `@${reg.sender_localpart}:${reg.ooye.server_name}`
|
||||
await mreq.mreq("PUT", `/client/v3/user/${mxid}/account_data/${type}`, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{presence: "online" | "offline" | "unavailable", status_msg?: string}} data
|
||||
* @param {string} mxid
|
||||
*/
|
||||
async function setPresence(data, mxid) {
|
||||
await mreq.mreq("PUT", path(`/client/v3/presence/${mxid}/status`, mxid), data)
|
||||
}
|
||||
|
||||
module.exports.path = path
|
||||
module.exports.register = register
|
||||
module.exports.createRoom = createRoom
|
||||
module.exports.joinRoom = joinRoom
|
||||
module.exports.inviteToRoom = inviteToRoom
|
||||
module.exports.leaveRoom = leaveRoom
|
||||
module.exports.leaveRoomWithReason = leaveRoomWithReason
|
||||
module.exports.getEvent = getEvent
|
||||
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
|
||||
|
@ -443,12 +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
|
||||
module.exports.sendReadReceipt = sendReadReceipt
|
||||
module.exports.ackEvent = ackEvent
|
||||
module.exports.getAlias = getAlias
|
||||
module.exports.getAccountData = getAccountData
|
||||
module.exports.setAccountData = setAccountData
|
||||
module.exports.setPresence = setPresence
|
8
matrix/appservice.js
Normal file
8
matrix/appservice.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
const reg = require("../matrix/read-registration")
|
||||
const AppService = require("matrix-appservice").AppService
|
||||
const as = new AppService({
|
||||
homeserverToken: reg.hs_token
|
||||
})
|
||||
as.listen(+(new URL(reg.url).port))
|
||||
|
||||
module.exports = as
|
|
@ -1,5 +1,7 @@
|
|||
// @ts-check
|
||||
|
||||
const fetch = require("node-fetch").default
|
||||
|
||||
const passthrough = require("../passthrough")
|
||||
const {sync, db, select} = passthrough
|
||||
/** @type {import("./mreq")} */
|
||||
|
@ -45,7 +47,7 @@ async function uploadDiscordFileToMxc(path) {
|
|||
}
|
||||
|
||||
// Download from Discord
|
||||
const promise = fetch(url, {}).then(async res => {
|
||||
const promise = fetch(url, {}).then(/** @param {import("node-fetch").Response} res */ async res => {
|
||||
// Upload to Matrix
|
||||
const root = await module.exports._actuallyUploadDiscordFileToMxc(urlNoExpiry, res)
|
||||
|
||||
|
@ -60,10 +62,6 @@ async function uploadDiscordFileToMxc(path) {
|
|||
return promise
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {Response} res
|
||||
*/
|
||||
async function _actuallyUploadDiscordFileToMxc(url, res) {
|
||||
const body = res.body
|
||||
/** @type {import("../types").R.FileUploaded} */
|
|
@ -8,8 +8,6 @@ const passthrough = require("../passthrough")
|
|||
const {sync} = passthrough
|
||||
/** @type {import("./file")} */
|
||||
const file = sync.require("./file")
|
||||
/** @type {import("./api")} */
|
||||
const api = sync.require("./api")
|
||||
|
||||
/** Mutates the input. Not recursive - can only include or exclude entire state events. */
|
||||
function kstateStripConditionals(kstate) {
|
||||
|
@ -87,12 +85,6 @@ function diffKState(actual, target) {
|
|||
diff[key] = temp
|
||||
}
|
||||
|
||||
} else if (key === "chat.schildi.hide_ui/read_receipts") {
|
||||
// Special handling: don't add this key if it's new. Do overwrite if already present.
|
||||
if (key in actual) {
|
||||
diff[key] = target[key]
|
||||
}
|
||||
|
||||
} else if (key in actual) {
|
||||
// diff
|
||||
if (!isDeepStrictEqual(actual[key], target[key])) {
|
||||
|
@ -110,32 +102,8 @@ function diffKState(actual, target) {
|
|||
return diff
|
||||
}
|
||||
|
||||
/* c8 ignore start */
|
||||
|
||||
/**
|
||||
* Async because it gets all room state from the homeserver.
|
||||
* @param {string} roomID
|
||||
*/
|
||||
async function roomToKState(roomID) {
|
||||
const root = await api.getAllState(roomID)
|
||||
return stateToKState(root)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} roomID
|
||||
* @param {any} kstate
|
||||
*/
|
||||
async function applyKStateDiffToRoom(roomID, kstate) {
|
||||
const events = await kstateToState(kstate)
|
||||
return Promise.all(events.map(({type, state_key, content}) =>
|
||||
api.sendState(roomID, type, state_key, content)
|
||||
))
|
||||
}
|
||||
|
||||
module.exports.kstateStripConditionals = kstateStripConditionals
|
||||
module.exports.kstateUploadMxc = kstateUploadMxc
|
||||
module.exports.kstateToState = kstateToState
|
||||
module.exports.stateToKState = stateToKState
|
||||
module.exports.diffKState = diffKState
|
||||
module.exports.roomToKState = roomToKState
|
||||
module.exports.applyKStateDiffToRoom = applyKStateDiffToRoom
|
|
@ -234,31 +234,3 @@ test("diffKState: kstate keys must contain a slash separator", t => {
|
|||
, /does not contain a slash separator/)
|
||||
t.pass()
|
||||
})
|
||||
|
||||
test("diffKState: don't add hide_ui when not present", t => {
|
||||
test("diffKState: detects new properties", t => {
|
||||
t.deepEqual(
|
||||
diffKState({
|
||||
}, {
|
||||
"chat.schildi.hide_ui/read_receipts/": {}
|
||||
}),
|
||||
{
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("diffKState: overwriten hide_ui when present", t => {
|
||||
test("diffKState: detects new properties", t => {
|
||||
t.deepEqual(
|
||||
diffKState({
|
||||
"chat.schildi.hide_ui/read_receipts/": {hidden: true}
|
||||
}, {
|
||||
"chat.schildi.hide_ui/read_receipts/": {}
|
||||
}),
|
||||
{
|
||||
"chat.schildi.hide_ui/read_receipts/": {}
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
|
@ -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()
|
|
@ -1,11 +1,14 @@
|
|||
// @ts-check
|
||||
|
||||
const fetch = require("node-fetch").default
|
||||
const mixin = require("@cloudrac3r/mixin-deep")
|
||||
const stream = require("stream")
|
||||
const streamWeb = require("stream/web")
|
||||
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`
|
||||
|
||||
|
@ -22,7 +25,7 @@ class MatrixServerError extends Error {
|
|||
/**
|
||||
* @param {string} method
|
||||
* @param {string} url
|
||||
* @param {string | object | streamWeb.ReadableStream | stream.Readable} [body]
|
||||
* @param {any} [body]
|
||||
* @param {any} [extra]
|
||||
*/
|
||||
async function mreq(method, url, body, extra = {}) {
|
||||
|
@ -30,35 +33,30 @@ async function mreq(method, url, body, extra = {}) {
|
|||
body = JSON.stringify(body)
|
||||
} else if (body instanceof stream.Readable && reg.ooye.content_length_workaround) {
|
||||
body = await getStream.buffer(body)
|
||||
} else if (body instanceof streamWeb.ReadableStream && reg.ooye.content_length_workaround) {
|
||||
body = await stream.consumers.buffer(stream.Readable.fromWeb(body))
|
||||
}
|
||||
|
||||
/** @type {RequestInit} */
|
||||
const opts = mixin({
|
||||
method,
|
||||
body,
|
||||
headers: {
|
||||
Authorization: `Bearer ${reg.as_token}`
|
||||
},
|
||||
...(body && {duplex: "half"}), // https://github.com/octokit/request.js/pull/571/files
|
||||
}
|
||||
}, extra)
|
||||
|
||||
// console.log(baseUrl + url, opts)
|
||||
const res = await fetch(baseUrl + url, opts)
|
||||
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"]
|
||||
delete opts.headers.Authorization
|
||||
throw new MatrixServerError(root, {baseUrl, url, ...opts})
|
||||
}
|
||||
return root
|
||||
|
@ -83,6 +81,5 @@ async function withAccessToken(token, callback) {
|
|||
}
|
||||
|
||||
module.exports.MatrixServerError = MatrixServerError
|
||||
module.exports.baseUrl = baseUrl
|
||||
module.exports.mreq = mreq
|
||||
module.exports.withAccessToken = withAccessToken
|
14
matrix/read-registration.js
Normal file
14
matrix/read-registration.js
Normal 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
|
10
matrix/read-registration.test.js
Normal file
10
matrix/read-registration.test.js
Normal 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
|
||||
)
|
||||
})
|
2283
package-lock.json
generated
2283
package-lock.json
generated
File diff suppressed because it is too large
Load diff
52
package.json
52
package.json
|
@ -14,57 +14,45 @@
|
|||
],
|
||||
"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",
|
||||
"@cloudrac3r/discord-markdown": "^2.6.2",
|
||||
"@cloudrac3r/giframe": "^0.4.3",
|
||||
"@cloudrac3r/html-template-tag": "^5.0.1",
|
||||
"@cloudrac3r/in-your-element": "^1.0.0",
|
||||
"@cloudrac3r/mixin-deep": "^3.0.1",
|
||||
"@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",
|
||||
"better-sqlite3": "^9.0.0",
|
||||
"chunk-text": "^2.0.1",
|
||||
"cloudstorm": "^0.11.2",
|
||||
"discord-api-types": "^0.37.119",
|
||||
"domino": "^2.1.6",
|
||||
"enquirer": "^2.4.1",
|
||||
"entities": "^5.0.0",
|
||||
"get-relative-path": "^1.0.2",
|
||||
"cloudstorm": "^0.10.8",
|
||||
"entities": "^4.5.0",
|
||||
"get-stream": "^6.0.1",
|
||||
"h3": "^1.12.0",
|
||||
"heatsync": "^2.7.0",
|
||||
"lru-cache": "^10.4.3",
|
||||
"heatsync": "^2.5.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"matrix-appservice": "^2.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
"node-fetch": "^2.6.7",
|
||||
"prettier-bytes": "^1.0.4",
|
||||
"sharp": "^0.33.4",
|
||||
"snowtransfer": "^0.11.0",
|
||||
"sharp": "^0.32.6",
|
||||
"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"
|
||||
"turndown": "^7.1.2",
|
||||
"xxhash-wasm": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudrac3r/tap-dot": "^2.0.3",
|
||||
"@cloudrac3r/tap-dot": "^2.0.2",
|
||||
"@types/node": "^18.16.0",
|
||||
"c8": "^10.1.2",
|
||||
"@types/node-fetch": "^2.6.3",
|
||||
"c8": "^8.0.1",
|
||||
"colorette": "^1.4.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"discord-api-types": "^0.37.60",
|
||||
"supertape": "^10.4.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node --enable-source-maps start.js",
|
||||
"setup": "node --enable-source-maps scripts/setup.js",
|
||||
"addbot": "node addbot.js",
|
||||
"test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js | tap-dot",
|
||||
"test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap test/test.js | tap-dot",
|
||||
"test-slow": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js -- --slow | tap-dot",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,10 +3,11 @@
|
|||
/**
|
||||
* @typedef {Object} Passthrough
|
||||
* @property {import("repl").REPLServer} repl
|
||||
* @property {typeof import("./config")} config
|
||||
* @property {import("./d2m/discord-client")} discord
|
||||
* @property {import("heatsync")} sync
|
||||
* @property {import("heatsync").default} sync
|
||||
* @property {import("better-sqlite3/lib/database")} db
|
||||
* @property {import("@cloudrac3r/in-your-element").AppService} as
|
||||
* @property {import("matrix-appservice").AppService} as
|
||||
* @property {import("./db/orm").from} from
|
||||
* @property {import("./db/orm").select} select
|
||||
*/
|
171
readme.md
171
readme.md
|
@ -36,14 +36,12 @@ Most features you'd expect in both directions, plus a little extra spice:
|
|||
* Attachments
|
||||
* Spoiler attachments
|
||||
* Embeds
|
||||
* Presence
|
||||
* Guild-Space details syncing
|
||||
* Channel-Room details syncing
|
||||
* Custom emoji list syncing
|
||||
* Custom emojis in messages
|
||||
* Custom room names/avatars can be applied on Matrix-side
|
||||
* PluralKit members have persistent user accounts
|
||||
* 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)
|
||||
|
@ -57,13 +55,13 @@ 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.
|
||||
|
||||
File uploads (like avatars from bridged members) are checked locally and deduplicated. Only brand new files are uploaded to the homeserver. This saves loads of space in the homeserver's media repo, especially for Synapse.
|
||||
|
||||
Switching to [WAL mode](https://www.sqlite.org/wal.html) could improve your database access speed even more. Run `node scripts/wal.js` if you want to switch to WAL mode. (This will also enable `synchronous = NORMAL`.)
|
||||
Switching to [WAL mode](https://www.sqlite.org/wal.html) could improve your database access speed even more. Run `node scripts/wal.js` if you want to switch to WAL mode. This will also enable `synchronous = NORMAL`.
|
||||
|
||||
# Setup
|
||||
|
||||
|
@ -73,134 +71,115 @@ You'll need:
|
|||
|
||||
* Administrative access to a homeserver
|
||||
* Discord bot
|
||||
* Domain name for the bridge's website ([more info](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/why-does-the-bridge-have-a-website.md)
|
||||
|
||||
Follow these steps:
|
||||
|
||||
1. [Get Node.js version 20 or later](https://nodejs.org/en/download/prebuilt-installer)
|
||||
|
||||
1. Switch to a normal user account. (i.e. do not run any of the following commands as root or sudo.)
|
||||
1. [Get Node.js version 18 or later](https://nodejs.org/en/download/releases) (the version is required by the better-sqlite3 and matrix-appservice dependencies)
|
||||
|
||||
1. Clone this repo and checkout a specific tag. (Development happens on main. Stable versions are tagged.)
|
||||
* The latest release tag is ![](https://img.shields.io/gitea/v/release/cadence/out-of-your-element?gitea_url=https%3A%2F%2Fgitdab.com&style=flat-square&label=%20&color=black).
|
||||
|
||||
1. Install dependencies: `npm install`
|
||||
1. Install dependencies: `npm install --save-dev` (omit --save-dev if you will not run the automated tests)
|
||||
|
||||
1. Run `npm run setup` to check your setup and set the bot's initial state. It will prompt you for information. You only need to run this once ever.
|
||||
1. Copy `config.example.js` to `config.js` and fill in Discord token.
|
||||
|
||||
1. Start the bridge: `npm run start`
|
||||
1. Copy `registration.example.yaml` to `registration.yaml` and fill in bracketed values. You could generate each hex string with `dd if=/dev/urandom bs=32 count=1 2> /dev/null | basenc --base16 | dd conv=lcase 2> /dev/null`. Register the registration in Synapse's `homeserver.yaml` through the usual appservice installation process, then restart Synapse.
|
||||
|
||||
1. Run `node scripts/seed.js` to check your setup and set the bot's initial state. You only need to run this once ever.
|
||||
1. Make sure the tests work by running `npm t`
|
||||
|
||||
1. Start the bridge: `node start.js`
|
||||
|
||||
1. Add the bot to a server - use any *one* of the following commands for an invite link:
|
||||
* (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.
|
||||
* Be sure to install dependencies with `--save-dev` so you can run the tests.
|
||||
* 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: 139
|
||||
(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.
|
||||
* (10) h3: Web server. OOYE needs this for the appservice listener, authmedia proxy, and more. 14 transitive dependencies is on the low end for a web server.
|
||||
* (11) sharp: Image resizing and compositing. OOYE needs this for the emoji sprite sheets.
|
||||
|
||||
### <font size="-1">🪱</font>
|
||||
|
||||
* (0) @chriscdn/promise-semaphore: It does what I want.
|
||||
* (1) @cloudrac3r/discord-markdown: This is my fork.
|
||||
* (0) @cloudrac3r/giframe: This is my fork.
|
||||
* (1) @cloudrac3r/html-template-tag: This is my fork.
|
||||
* (0) @cloudrac3r/in-your-element: This is my Matrix Appservice API library. It depends on h3 and zod, which are already pulled in by OOYE.
|
||||
* (0) @cloudrac3r/mixin-deep: This is my fork. (It fixes a bug in regular mixin-deep.)
|
||||
* (0) @cloudrac3r/pngjs: Lottie stickers are converted to bitmaps with the vendored Rlottie WASM build, then the bitmaps are converted to PNG with pngjs.
|
||||
* (0) @cloudrac3r/turndown: This HTML-to-Markdown converter looked the most suitable. I forked it to change the escaping logic to match the way Discord works.
|
||||
* (3) @stackoverflow/stacks: Stack Overflow design language and icons.
|
||||
* (0) ansi-colors: Helps with interactive prompting for the initial setup, and it's already pulled in by enquirer.
|
||||
* (0) @chriscdn/promise-semaphore: It does what I want! I like it!
|
||||
* (42) better-sqlite3: SQLite3 is the best database, and this is the best library for it. Really! I love it.
|
||||
* (1) chunk-text: It does what I want.
|
||||
* (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust.
|
||||
* (0) discord-api-types: Bitfields needed at runtime and types needed for development.
|
||||
* (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-relative-path: Looks fine. No dependencies.
|
||||
* (8) snowtransfer: Discord API library with bring-your-own-caching that I trust.
|
||||
* (1) discord-markdown: This is my fork!
|
||||
* (0) get-stream: Only needed if content_length_workaround is true.
|
||||
* (0) giframe: This is my fork!
|
||||
* (1) heatsync: Module hot-reloader that I trust.
|
||||
* (1) 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) entities: Looks fine. No dependencies.
|
||||
* (1) html-template-tag: This is my fork!
|
||||
* (1) js-yaml: It seems to do what I want, and it's already pulled in by matrix-appservice.
|
||||
* (70) matrix-appservice: I wish it didn't pull in express :(
|
||||
* (0) minimist: It's already pulled in by better-sqlite3->prebuild-install.
|
||||
* (0) mixin-deep: This is my fork! (It fixes a bug in regular mixin-deep.)
|
||||
* (3) node-fetch@2: I like it and it does what I want.
|
||||
* (0) pngjs: Lottie stickers are converted to bitmaps with the vendored Rlottie WASM build, then the bitmaps are converted to PNG with pngjs.
|
||||
* (0) prettier-bytes: It does what I want and has no dependencies.
|
||||
* (0) snowtransfer: Discord API library with bring-your-own-caching that I trust.
|
||||
* (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.
|
||||
* (51) sharp: Jimp has fewer dependencies, but sharp is faster.
|
||||
* (0) try-to-catch: Not strictly necessary, but it does what I want and has no dependencies.
|
||||
* (1) turndown: I need an HTML-to-Markdown converter and this one looked suitable enough. It has some bugs that I've worked around, so I might switch away from it later.
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue