1
0
Fork 0

Compare commits

..

1 commit

Author SHA1 Message Date
1d7ec4315e Support alternate Discord hosts 2023-10-16 00:12:00 +13:00
70 changed files with 866 additions and 5978 deletions

View file

@ -1,8 +0,0 @@
{
"watermarks": {
"statements": [60, 100],
"lines": [60, 100],
"functions": [60, 100],
"branches": [60, 100]
}
}

2
.gitignore vendored
View file

@ -3,5 +3,3 @@ config.js
registration.yaml
coverage
db/ooye.db*
test/res/*
!test/res/lottie*

View file

@ -8,8 +8,6 @@ const {discord, sync, db, select} = passthrough
const threadToAnnouncement = sync.require("../converters/thread-to-announcement")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("./register-user")} */
const registerUser = sync.require("./register-user")
/**
* @param {string} parentRoomID
@ -17,10 +15,10 @@ const registerUser = sync.require("./register-user")
* @param {import("discord-api-types/v10").APIThreadChannel} thread
*/
async function announceThread(parentRoomID, threadRoomID, thread) {
assert(thread.owner_id)
// @ts-ignore
const creatorMxid = await registerUser.ensureSimJoined({id: thread.owner_id}, parentRoomID)
const creatorMxid = select("sim", "mxid", {user_id: thread.owner_id}).pluck().get()
const content = await threadToAnnouncement.threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, {api})
await api.sendEvent(parentRoomID, "m.room.message", content, creatorMxid)
}

View file

@ -2,7 +2,6 @@
const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10")
const deepEqual = require("deep-equal")
const reg = require("../../matrix/read-registration")
const passthrough = require("../../passthrough")
@ -13,8 +12,8 @@ const api = sync.require("../../matrix/api")
const file = sync.require("../../matrix/file")
/** @type {import("./create-room")} */
const createRoom = sync.require("./create-room")
/** @type {import("./expression")} */
const expression = sync.require("./expression")
/** @type {import("../converters/expression")} */
const expression = sync.require("../converters/expression")
/** @type {import("../../matrix/kstate")} */
const ks = sync.require("../../matrix/kstate")
@ -22,7 +21,7 @@ const ks = sync.require("../../matrix/kstate")
const inflightSpaceCreate = new Map()
/**
* @param {DiscordTypes.RESTGetAPIGuildResult} guild
* @param {import("discord-api-types/v10").RESTGetAPIGuildResult} guild
* @param {any} kstate
*/
async function createSpace(guild, kstate) {
@ -199,40 +198,23 @@ async function syncSpaceFully(guildID) {
}
/**
* @param {DiscordTypes.GatewayGuildEmojisUpdateDispatchData | DiscordTypes.GatewayGuildStickersUpdateDispatchData} data
* @param {boolean} checkBeforeSync false to always send new state, true to check the current state and only apply if state would change
* @param {import("discord-api-types/v10").GatewayGuildEmojisUpdateDispatchData | import("discord-api-types/v10").GatewayGuildStickersUpdateDispatchData} data
*/
async function syncSpaceExpressions(data, checkBeforeSync) {
async function syncSpaceExpressions(data) {
// No need for kstate here. Each of these maps to a single state event, which will always overwrite what was there before. I can just send the state event.
const spaceID = select("guild_space", "space_id", {guild_id: data.guild_id}).pluck().get()
if (!spaceID) return
/**
* @typedef {DiscordTypes.GatewayGuildEmojisUpdateDispatchData & DiscordTypes.GatewayGuildStickersUpdateDispatchData} Expressions
* @param {string} spaceID
* @param {Expressions extends any ? keyof Expressions : never} key
* @param {string} eventKey
* @param {typeof expression["emojisToState"] | typeof expression["stickersToState"]} fn
*/
async function update(spaceID, key, eventKey, fn) {
if (!(key in data) || !data[key].length) return
const content = await fn(data[key])
if (checkBeforeSync) {
let existing
try {
existing = await api.getStateEvent(spaceID, "im.ponies.room_emotes", eventKey)
} catch (e) {
// State event not found. This space doesn't have any existing emojis. We create a dummy empty event for comparison's sake.
existing = fn([])
}
if (deepEqual(existing, content, {strict: true})) return
}
api.sendState(spaceID, "im.ponies.room_emotes", eventKey, content)
if ("emojis" in data && data.emojis.length) {
const content = await expression.emojisToState(data.emojis)
api.sendState(spaceID, "im.ponies.room_emotes", "moe.cadence.ooye.pack.emojis", content)
}
update(spaceID, "emojis", "moe.cadence.ooye.pack.emojis", expression.emojisToState)
update(spaceID, "stickers", "moe.cadence.ooye.pack.stickers", expression.stickersToState)
if ("stickers" in data && data.stickers.length) {
const content = await expression.stickersToState(data.stickers)
api.sendState(spaceID, "im.ponies.room_emotes", "moe.cadence.ooye.pack.stickers", content)
}
}
module.exports.createSpace = createSpace

View file

@ -1,46 +1,23 @@
// @ts-check
const passthrough = require("../../passthrough")
const {sync, db, select, from} = passthrough
const {sync, db, select} = passthrough
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("./speedbump")} */
const speedbump = sync.require("./speedbump")
/**
* @param {import("discord-api-types/v10").GatewayMessageDeleteDispatchData} data
*/
async function deleteMessage(data) {
const row = select("channel_room", ["room_id", "speedbump_checked", "thread_parent"], {channel_id: data.channel_id}).get()
if (!row) return
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)
}
await speedbump.updateCache(row.thread_parent || data.channel_id, row.speedbump_checked)
}
/**
* @param {import("discord-api-types/v10").GatewayMessageDeleteBulkDispatchData} data
*/
async function deleteMessageBulk(data) {
const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get()
if (!roomID) return
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)
const eventsToRedact = select("event_message", "event_id", {message_id: data.id}).pluck().all()
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
// 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(roomID, eventID)
db.prepare("DELETE FROM event_message WHERE event_id = ?").run(eventID)
}
}
module.exports.deleteMessage = deleteMessage
module.exports.deleteMessageBulk = deleteMessageBulk

View file

@ -1,32 +1,18 @@
// @ts-check
const assert = require("assert").strict
const passthrough = require("../../passthrough")
const {sync, db, select} = passthrough
/** @type {import("../converters/edit-to-changes")} */
const editToChanges = sync.require("../converters/edit-to-changes")
/** @type {import("./register-pk-user")} */
const registerPkUser = sync.require("./register-pk-user")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/**
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
* @param {import("discord-api-types/v10").APIGuild} guild
* @param {{speedbump_id: string, speedbump_webhook_id: string} | null} row data about the webhook which is proxying messages in this channel
*/
async function editMessage(message, guild, row) {
let {roomID, eventsToRedact, eventsToReplace, eventsToSend, senderMxid, promotions} = await editToChanges.editToChanges(message, guild, api)
if (row && row.speedbump_webhook_id === message.webhook_id) {
// Handle the PluralKit public instance
if (row.speedbump_id === "466378653216014359") {
const root = await registerPkUser.fetchMessage(message.id)
assert(root.member)
senderMxid = await registerPkUser.ensureSimJoined(root, roomID)
}
}
async function editMessage(message, guild) {
const {roomID, eventsToRedact, eventsToReplace, eventsToSend, senderMxid, promotions} = await editToChanges.editToChanges(message, guild, api)
// 1. Replace all the things.
for (const {oldID, newContent} of eventsToReplace) {

View file

@ -1,53 +0,0 @@
// @ts-check
const DiscordTypes = require("discord-api-types/v10")
const Ty = require("../../types")
const assert = require("assert").strict
const passthrough = require("../../passthrough")
const {sync, db, select} = passthrough
/** @type {import("../../matrix/file")} */
const file = sync.require("../../matrix/file")
/** @type {import("../../matrix/mreq")} */
const mreq = sync.require("../../matrix/mreq")
/** @type {import("../converters/lottie")} */
const convertLottie = sync.require("../converters/lottie")
const INFO = {
mimetype: "image/png",
w: convertLottie.SIZE,
h: convertLottie.SIZE
}
/**
* @param {DiscordTypes.APIStickerItem} stickerItem
* @returns {Promise<{mxc_url: string, info: typeof INFO}>}
*/
async function convert(stickerItem) {
// Reuse sticker if already converted and uploaded
const existingMxc = select("lottie", "mxc_url", {sticker_id: stickerItem.id}).pluck().get()
if (existingMxc) return {mxc_url: existingMxc, info: INFO}
// Fetch sticker data from Discord
const res = await fetch(file.DISCORD_IMAGES_BASE + file.sticker(stickerItem))
if (res.status !== 200) throw new Error("Sticker data file not found.")
const text = await res.text()
// Convert to PNG (readable stream)
const readablePng = await convertLottie.convert(text)
// Upload to MXC
/** @type {Ty.R.FileUploaded} */
const root = await mreq.mreq("POST", "/media/v3/upload", readablePng, {
headers: {
"Content-Type": INFO.mimetype
}
})
assert(root.content_uri)
// Save the link for next time
db.prepare("INSERT INTO lottie (sticker_id, mxc_url) VALUES (?, ?)").run(stickerItem.id, root.content_uri)
return {mxc_url: root.content_uri, info: INFO}
}
module.exports.convert = convert

View file

@ -1,153 +0,0 @@
// @ts-check
const assert = require("assert")
const reg = require("../../matrix/read-registration")
const Ty = require("../../types")
const fetch = require("node-fetch").default
const passthrough = require("../../passthrough")
const {discord, sync, db, select} = passthrough
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/file")} */
const file = sync.require("../../matrix/file")
/** @type {import("./register-user")} */
const registerUser = sync.require("./register-user")
/**
* @typedef WebhookAuthor Discord API message->author. A webhook as an author.
* @prop {string} username
* @prop {string?} avatar
* @prop {string} id
*/
/**
* A sim is an account that is being simulated by the bridge to copy events from the other side.
* @param {Ty.PkMessage} pkMessage
* @returns mxid
*/
async function createSim(pkMessage) {
// Choose sim name
const simName = "_pk_" + pkMessage.member.id
const localpart = reg.ooye.namespace_prefix + simName
const mxid = `@${localpart}:${reg.ooye.server_name}`
// Save chosen name in the database forever
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 {
await api.register(localpart)
} catch (e) {
// If user creation fails, manually undo the database change. Still isn't perfect, but should help.
// (I would prefer a transaction, but it's not safe to leave transactions open across event loop ticks.)
db.prepare("DELETE FROM sim WHERE user_id = ?").run(pkMessage.member.uuid)
throw e
}
return mxid
}
/**
* Ensure a sim is registered for the user.
* If there is already a sim, use that one. If there isn't one yet, register a new sim.
* @param {Ty.PkMessage} pkMessage
* @returns {Promise<string>} mxid
*/
async function ensureSim(pkMessage) {
let mxid = null
const existing = select("sim", "mxid", {user_id: pkMessage.member.uuid}).pluck().get()
if (existing) {
mxid = existing
} else {
mxid = await createSim(pkMessage)
}
return mxid
}
/**
* Ensure a sim is registered for the user and is joined to the room.
* @param {Ty.PkMessage} pkMessage
* @param {string} roomID
* @returns {Promise<string>} mxid
*/
async function ensureSimJoined(pkMessage, roomID) {
// Ensure room ID is really an ID, not an alias
assert.ok(roomID[0] === "!")
// Ensure user
const mxid = await ensureSim(pkMessage)
// Ensure joined
const existing = select("sim_member", "mxid", {room_id: roomID, mxid}).pluck().get()
if (!existing) {
try {
await api.inviteToRoom(roomID, mxid)
await api.joinRoom(roomID, mxid)
} catch (e) {
if (e.message.includes("is already in the room.")) {
// Sweet!
} else {
throw e
}
}
db.prepare("INSERT OR IGNORE INTO sim_member (room_id, mxid) VALUES (?, ?)").run(roomID, mxid)
}
return mxid
}
/**
* @param {Ty.PkMessage} pkMessage
* @param {WebhookAuthor} author
*/
async function memberToStateContent(pkMessage, author) {
// We prefer to use the member's avatar URL data since the image upload can be cached across channels,
// unlike the userAvatar URL which is unique per channel, due to the webhook ID being in the URL.
const avatar = pkMessage.member.avatar_url || pkMessage.member.webhook_avatar_url || pkMessage.system.avatar_url || file.userAvatar(author)
const content = {
displayname: author.username,
membership: "join",
"moe.cadence.ooye.pk_member": pkMessage.member
}
if (avatar) content.avatar_url = await file.uploadDiscordFileToMxc(avatar)
return content
}
/**
* Sync profile data for a sim user. This function follows the following process:
* 1. Join the sim to the room if needed
* 2. Make an object of what the new room member state content would be, including uploading the profile picture if it hasn't been done before
* 3. Compare against the previously known state content, which is helpfully stored in the database
* 4. If the state content has changed, send it to Matrix and update it in the database for next time
* @param {WebhookAuthor} author
* @param {Ty.PkMessage} pkMessage
* @param {string} roomID
* @returns {Promise<string>} mxid of the updated sim
*/
async function syncUser(author, pkMessage, roomID) {
const mxid = await ensureSimJoined(pkMessage, roomID)
// Update the sim_proxy table, so mentions can look up the original sender later
db.prepare("INSERT OR IGNORE INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES (?, ?, ?)").run(pkMessage.member.uuid, pkMessage.sender, author.username)
// Sync the member state
const content = await memberToStateContent(pkMessage, author)
const currentHash = registerUser._hashProfileContent(content)
const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get()
// only do the actual sync if the hash has changed since we last looked
if (existingHash !== currentHash) {
await api.sendState(roomID, "m.room.member", mxid, content, mxid)
db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid)
}
return mxid
}
/** @returns {Promise<Ty.PkMessage>} */
function fetchMessage(messageID) {
return fetch(`https://api.pluralkit.me/v2/messages/${messageID}`).then(res => res.json())
}
module.exports._memberToStateContent = memberToStateContent
module.exports.ensureSim = ensureSim
module.exports.ensureSimJoined = ensureSimJoined
module.exports.syncUser = syncUser
module.exports.fetchMessage = fetchMessage

View file

@ -36,7 +36,7 @@ async function createSim(user) {
await api.register(localpart)
} catch (e) {
// If user creation fails, manually undo the database change. Still isn't perfect, but should help.
// (I would prefer a transaction, but it's not safe to leave transactions open across event loop ticks.)
// (A transaction would be preferable, but I don't think it's safe to leave transaction open across event loop ticks.)
db.prepare("DELETE FROM sim WHERE user_id = ?").run(user.id)
throw e
}
@ -123,7 +123,7 @@ async function memberToStateContent(user, member, guildID) {
return content
}
function _hashProfileContent(content) {
function hashProfileContent(content) {
const unsignedHash = hasher.h64(`${content.displayname}\u0000${content.avatar_url}`)
const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
return signedHash
@ -142,7 +142,7 @@ function _hashProfileContent(content) {
async function syncUser(user, member, guildID, roomID) {
const mxid = await ensureSimJoined(user, roomID)
const content = await memberToStateContent(user, member, guildID)
const currentHash = _hashProfileContent(content)
const currentHash = hashProfileContent(content)
const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get()
// only do the actual sync if the hash has changed since we last looked
if (existingHash !== currentHash) {
@ -179,7 +179,6 @@ async function syncAllUsersInRoom(roomID) {
}
module.exports._memberToStateContent = memberToStateContent
module.exports._hashProfileContent = _hashProfileContent
module.exports.ensureSim = ensureSim
module.exports.ensureSimJoined = ensureSimJoined
module.exports.syncUser = syncUser

View file

@ -10,8 +10,6 @@ const messageToEvent = sync.require("../converters/message-to-event")
const api = sync.require("../../matrix/api")
/** @type {import("./register-user")} */
const registerUser = sync.require("./register-user")
/** @type {import("./register-pk-user")} */
const registerPkUser = sync.require("./register-pk-user")
/** @type {import("../actions/create-room")} */
const createRoom = sync.require("../actions/create-room")
/** @type {import("../../discord/utils")} */
@ -20,9 +18,8 @@ const dUtils = sync.require("../../discord/utils")
/**
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
* @param {import("discord-api-types/v10").APIGuild} guild
* @param {{speedbump_id: string, speedbump_webhook_id: string} | null} row data about the webhook which is proxying messages in this channel
*/
async function sendMessage(message, guild, row) {
async function sendMessage(message, guild) {
const roomID = await createRoom.ensureRoom(message.channel_id)
let senderMxid = null
@ -32,19 +29,6 @@ async function sendMessage(message, guild, row) {
} else { // well, good enough...
senderMxid = await registerUser.ensureSimJoined(message.author, roomID)
}
} else if (row && row.speedbump_webhook_id === message.webhook_id) {
// Handle the PluralKit public instance
if (row.speedbump_id === "466378653216014359") {
const root = await registerPkUser.fetchMessage(message.id)
// Member is null if member was deleted. We just got this message, so member surely exists.
if (!root.member) {
const e = new Error("PK API did not return a member")
message["__pk_response__"] = root
console.error(root)
throw e
}
senderMxid = await registerPkUser.syncUser(message.author, root, roomID)
}
}
const events = await messageToEvent.messageToEvent(message, guild, {}, {api})

View file

@ -1,68 +0,0 @@
// @ts-check
const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough")
const {discord, select, db} = passthrough
const SPEEDBUMP_SPEED = 4000 // 4 seconds delay
const SPEEDBUMP_UPDATE_FREQUENCY = 2 * 60 * 60 // 2 hours
/** @type {Set<any>} */
const KNOWN_BOTS = new Set([
"466378653216014359" // PluralKit
])
/**
* Fetch new speedbump data for the channel and put it in the database as cache
* @param {string} channelID
* @param {number?} lastChecked
*/
async function updateCache(channelID, lastChecked) {
const now = Math.floor(Date.now() / 1000)
if (lastChecked && now - lastChecked < SPEEDBUMP_UPDATE_FREQUENCY) return
const webhooks = await discord.snow.webhook.getChannelWebhooks(channelID)
const found = webhooks.find(b => KNOWN_BOTS.has(b.application_id))
const foundApplication = found?.application_id
const foundWebhook = found?.id
db.prepare("UPDATE channel_room SET speedbump_id = ?, speedbump_webhook_id = ?, speedbump_checked = ? WHERE channel_id = ?").run(foundApplication, foundWebhook, now, channelID)
}
/** @type {Set<string>} set of messageID */
const bumping = new Set()
/**
* Slow down a message. After it passes the speedbump, return whether it's okay or if it's been deleted.
* @param {string} messageID
* @returns whether it was deleted
*/
async function doSpeedbump(messageID) {
bumping.add(messageID)
await new Promise(resolve => setTimeout(resolve, SPEEDBUMP_SPEED))
return !bumping.delete(messageID)
}
/**
* Check whether to slow down a message, and do it. After it passes the speedbump, return whether it's okay or if it's been deleted.
* @param {string} channelID
* @param {string} messageID
* @returns whether it was deleted, and data about the channel's (not thread's) speedbump
*/
async function maybeDoSpeedbump(channelID, messageID) {
let row = select("channel_room", ["thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: channelID}).get()
if (row?.thread_parent) row = select("channel_room", ["thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: row.thread_parent}).get() // webhooks belong to the channel, not the thread
if (!row?.speedbump_webhook_id) return {affected: false, row: null} // not affected, no speedbump
const affected = await doSpeedbump(messageID)
return {affected, row} // maybe affected, and there is a speedbump
}
/**
* @param {string} messageID
*/
function onMessageDelete(messageID) {
bumping.delete(messageID)
}
module.exports.updateCache = updateCache
module.exports.doSpeedbump = doSpeedbump
module.exports.maybeDoSpeedbump = maybeDoSpeedbump
module.exports.onMessageDelete = onMessageDelete

View file

@ -1,37 +1,22 @@
// @ts-check
const passthrough = require("../../passthrough")
const {discord, sync, db} = passthrough
const {discord, sync} = passthrough
/** @type {import("../converters/pins-to-list")} */
const pinsToList = sync.require("../converters/pins-to-list")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/**
* @template {string | null | undefined} T
* @param {T} timestamp
* @returns {T extends string ? number : null}
*/
function convertTimestamp(timestamp) {
// @ts-ignore
return typeof timestamp === "string" ? Math.floor(new Date(timestamp).getTime() / 1000) : null
}
/**
* @param {string} channelID
* @param {string} roomID
* @param {number?} convertedTimestamp
*/
async function updatePins(channelID, roomID, convertedTimestamp) {
async function updatePins(channelID, roomID) {
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)
}
module.exports.convertTimestamp = convertTimestamp
module.exports.updatePins = updatePins

View file

@ -93,11 +93,7 @@ async function editToChanges(message, guild, api) {
// We can choose an existing event to promote. Bigger order is better.
const order = e => 2*+(e.event_type === "m.room.message") + 1*+(e.event_subtype === "m.text")
eventsToReplace.sort((a, b) => order(b) - order(a))
if (column === "part") {
promotions.push({column, eventID: eventsToReplace[0].old.event_id}) // part should be the first one
} else {
promotions.push({column, eventID: eventsToReplace[eventsToReplace.length - 1].old.event_id}) // reaction_part should be the last one
}
promotions.push({column, eventID: eventsToReplace[0].old.event_id})
} 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})

View file

@ -175,63 +175,3 @@ test("edit2changes: edit of reply to skull webp attachment with content", async
}
}])
})
test("edit2changes: edits the text event when multiple rows have part = 0 (should never happen in real life, but make sure the safety net works)", async t => {
const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments_but_all_parts_equal_0, data.guild.general, {})
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, [])
t.deepEqual(eventsToReplace, [{
oldID: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd999",
newContent: {
$type: "m.room.message",
msgtype: "m.text",
body: "* only the content can be edited",
"m.mentions": {},
// *** Replaced With: ***
"m.new_content": {
msgtype: "m.text",
body: "only the content can be edited",
"m.mentions": {}
},
"m.relates_to": {
rel_type: "m.replace",
event_id: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd999"
}
}
}])
})
test("edit2changes: promotes the text event when multiple rows have part = 1 (should never happen in real life, but make sure the safety net works)", async t => {
const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments_but_all_parts_equal_1, data.guild.general, {})
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, [])
t.deepEqual(eventsToReplace, [{
oldID: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd111",
newContent: {
$type: "m.room.message",
msgtype: "m.text",
body: "* only the content can be edited",
"m.mentions": {},
// *** Replaced With: ***
"m.new_content": {
msgtype: "m.text",
body: "only the content can be edited",
"m.mentions": {}
},
"m.relates_to": {
rel_type: "m.replace",
event_id: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd111"
}
}
}])
t.deepEqual(promotions, [
{
column: "part",
eventID: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd111"
},
{
column: "reaction_part",
eventID: "$f9cjKiacXI9qPF_nUAckzbiKnJEi0LM399kOkhdd111"
}
])
})

View file

@ -1,9 +1,10 @@
// @ts-check
const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough")
const {sync, db} = passthrough
const {discord, sync, db, select} = passthrough
/** @type {import("../../matrix/file")} */
const file = sync.require("../../matrix/file")

View file

@ -1,11 +1,25 @@
// @ts-check
const assert = require("assert")
const stream = require("stream")
const DiscordTypes = require("discord-api-types/v10")
const Ty = require("../../types")
const assert = require("assert").strict
const {PNG} = require("pngjs")
const passthrough = require("../../passthrough")
const {sync, db, discord, select} = passthrough
/** @type {import("../../matrix/file")} */
const file = sync.require("../../matrix/file")
//** @type {import("../../matrix/mreq")} */
const mreq = sync.require("../../matrix/mreq")
const SIZE = 160 // Discord's display size on 1x displays is 160
const INFO = {
mimetype: "image/png",
w: SIZE,
h: SIZE
}
/**
* @typedef RlottieWasm
* @prop {(string) => boolean} load load lottie data from string of json
@ -20,15 +34,20 @@ const Rlottie = (async () => {
})()
/**
* @param {string} text
* @returns {Promise<import("stream").Readable>}
* @param {DiscordTypes.APIStickerItem} stickerItem
* @returns {Promise<{mxc_url: string, info: typeof INFO}>}
*/
async function convert(text) {
async function convert(stickerItem) {
const existingMxc = select("lottie", "mxc_url", {sticker_id: stickerItem.id}).pluck().get()
if (existingMxc) return {mxc_url: existingMxc, info: INFO}
const r = await Rlottie
const res = await fetch(file.DISCORD_IMAGES_BASE + file.sticker(stickerItem))
if (res.status !== 200) throw new Error("Sticker data file not found.")
const text = await res.text()
/** @type RlottieWasm */
const rh = new r.RlottieWasm()
const status = rh.load(text)
assert(status, `Rlottie unable to load ${text.length} byte data file.`)
if (!status) throw new Error(`Rlottie unable to load ${text.length} byte data file.`)
const rendered = rh.render(0, SIZE, SIZE)
let png = new PNG({
width: SIZE,
@ -39,10 +58,17 @@ async function convert(text) {
inputHasAlpha: true,
})
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.
return stream.Duplex.from(png.pack())
// @ts-ignore wrong type from pngjs
const readablePng = png.pack()
/** @type {Ty.R.FileUploaded} */
const root = await mreq.mreq("POST", "/media/v3/upload", readablePng, {
headers: {
"Content-Type": INFO.mimetype
}
})
assert(root.content_uri)
db.prepare("INSERT INTO lottie (sticker_id, mxc_url) VALUES (?, ?)").run(stickerItem.id, root.content_uri)
return {mxc_url: root.content_uri, info: INFO}
}
module.exports.convert = convert
module.exports.SIZE = SIZE

View file

@ -1,34 +0,0 @@
// @ts-check
const fs = require("fs")
const stream = require("stream")
const {test} = require("supertape")
const {convert} = require("./lottie")
const WRITE_PNG = false
test("lottie: can convert and save PNG", async t => {
const input = await fs.promises.readFile("test/res/lottie-bee.json", "utf8")
const resultStream = await convert(input)
/* c8 ignore next 3 */
if (WRITE_PNG) {
resultStream.pipe(fs.createWriteStream("test/res/lottie-bee.png"))
t.fail("PNG written to /test/res/lottie-bee.png, please manually check it")
} else {
const expected = await fs.promises.readFile("test/res/lottie-bee.png")
const actual = Buffer.alloc(expected.length)
let i = 0
await stream.promises.pipeline(
resultStream,
async function* (source) {
for await (const chunk of source) {
chunk.copy(actual, i)
i += chunk.length
}
},
new stream.PassThrough()
)
t.equal(i, actual.length, `allocated ${actual.length} bytes, but wrote ${i}`)
t.deepEqual(actual, expected)
}
})

View file

@ -3,20 +3,46 @@ const {messageToEvent} = require("./message-to-event")
const data = require("../../test/data")
const Ty = require("../../types")
/**
* @param {string} roomID
* @param {string} eventID
* @returns {(roomID: string, eventID: string) => Promise<Ty.Event.Outer<Ty.Event.M_Room_Message>>}
*/
function mockGetEvent(t, roomID_in, eventID_in, outer) {
return async function(roomID, eventID) {
t.equal(roomID, roomID_in)
t.equal(eventID, eventID_in)
return new Promise(resolve => {
setTimeout(() => {
resolve({
event_id: eventID_in,
room_id: roomID_in,
origin_server_ts: 1680000000000,
unsigned: {
age: 2245,
transaction_id: "$local.whatever"
},
...outer
})
})
})
}
}
test("message2event embeds: nothing but a field", async t => {
const events = await messageToEvent(data.message_with_embeds.nothing_but_a_field, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.notice",
body: "| ### Amanda 🎵#2192 :online:"
+ "\n| willow tree, branch 0"
+ "\n| ** Uptime:**\n| 3m 55s\n| ** Memory:**\n| 64.45MB",
body: "> **Amanda 🎵#2192 :online:"
+ "\n> willow tree, branch 0**"
+ "\n> ** Uptime:**\n> 3m 55s\n> ** Memory:**\n> 64.45MB",
format: "org.matrix.custom.html",
formatted_body: '<blockquote><p><strong>Amanda 🎵#2192 <img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/LCEqjStXCxvRQccEkuslXEyZ\" title=\":online:\" alt=\":online:\">'
formatted_body: '<blockquote><strong>Amanda 🎵#2192 <img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/LCEqjStXCxvRQccEkuslXEyZ\" title=\":online:\" alt=\":online:\">'
+ '<br>willow tree, branch 0</strong>'
+ '<br><strong> Uptime:</strong><br>3m 55s'
+ '<br><strong> Memory:</strong><br>64.45MB</p></blockquote>'
+ '<br><strong> Memory:</strong><br>64.45MB</blockquote>'
}])
})
@ -26,19 +52,19 @@ test("message2event embeds: reply with just an embed", async t => {
$type: "m.room.message",
msgtype: "m.notice",
"m.mentions": {},
body: "| ## ⏺️ dynastic (@dynastic) https://twitter.com/i/user/719631291747078145"
+ "\n| \n| ## https://twitter.com/i/status/1707484191963648161"
+ "\n| \n| does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?"
+ "\n| \n| ### Retweets"
+ "\n| 119"
+ "\n| \n| ### Likes"
+ "\n| 5581"
+ "\n| — Twitter",
body: "> [**⏺️ dynastic (@dynastic)**](https://twitter.com/i/user/719631291747078145)"
+ "\n> \n> **https://twitter.com/i/status/1707484191963648161**"
+ "\n> \n> does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?"
+ "\n> \n> **Retweets**"
+ "\n> 119"
+ "\n> \n> **Likes**"
+ "\n> 5581"
+ "\n> \n> — Twitter",
format: "org.matrix.custom.html",
formatted_body: '<blockquote><p><strong><a href="https://twitter.com/i/user/719631291747078145">⏺️ dynastic (@dynastic)</a></strong></p>'
+ '<p><strong><a href="https://twitter.com/i/status/1707484191963648161">https://twitter.com/i/status/1707484191963648161</a></strong>'
+ '</p><p>does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?'
+ '</p><p><strong>Retweets</strong><br>119</p><p><strong>Likes</strong><br>5581</p>— Twitter</blockquote>'
formatted_body: '<blockquote><a href="https://twitter.com/i/user/719631291747078145"><strong>⏺️ dynastic (@dynastic)</strong></a>'
+ '<br><br><strong><a href="https://twitter.com/i/status/1707484191963648161">https://twitter.com/i/status/1707484191963648161</a></strong>'
+ '<br><br>does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?'
+ '<br><br><strong>Retweets</strong><br>119<br><br><strong>Likes</strong><br>5581<br><br>— Twitter</blockquote>'
}])
})
@ -73,71 +99,3 @@ test("message2event embeds: image embed and attachment", async t => {
"m.mentions": {}
}])
})
test("message2event embeds: blockquote in embed", async t => {
let called = 0
const events = await messageToEvent(data.message_with_embeds.blockquote_in_embed, data.guild.general, {}, {
api: {
async getStateEvent(roomID, type, key) {
called++
t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {
users: {
"@_ooye_bot:cadence.moe": 100
}
}
},
async getJoinedMembers(roomID) {
called++
t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe")
return {
joined: {
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
"@user:example.invalid": {display_name: null, avatar_url: null}
}
}
}
}
})
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: ":emoji: **4 |** #wonderland",
format: "org.matrix.custom.html",
formatted_body: `<img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/mwZaCtRGAQQyOItagDeCocEO\" title=\":emoji:\" alt=\":emoji:\"> <strong>4 |</strong> <a href=\"https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe?via=cadence.moe&via=example.invalid\">#wonderland</a>`,
"m.mentions": {}
}, {
$type: "m.room.message",
msgtype: "m.notice",
body: "| ## ⏺️ minimus https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo?via=cadence.moe&via=example.invalid\n| \n| reply draft\n| > The following is a message composed via consensus of the Stinker Council.\n| > \n| > For those who are not currently aware of our existence, we represent the organization known as Wonderland. Our previous mission centered around the assortment and study of puzzling objects, entities and other assorted phenomena. This mission was the focus of our organization for more than 28 years.\n| > \n| > Due to circumstances outside of our control, this directive has now changed. Our new mission will be the extermination of the stinker race.\n| > \n| > There will be no further communication.\n| \n| [Go to Message](https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo?via=cadence.moe&via=example.invalid)",
format: "org.matrix.custom.html",
formatted_body: "<blockquote><p><strong><a href=\"https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo?via=cadence.moe&amp;via=example.invalid\">⏺️ minimus</a></strong></p><p>reply draft<br><blockquote>The following is a message composed via consensus of the Stinker Council.<br><br>For those who are not currently aware of our existence, we represent the organization known as Wonderland. Our previous mission centered around the assortment and study of puzzling objects, entities and other assorted phenomena. This mission was the focus of our organization for more than 28 years.<br><br>Due to circumstances outside of our control, this directive has now changed. Our new mission will be the extermination of the stinker race.<br><br>There will be no further communication.</blockquote></p><p><a href=\"https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo?via=cadence.moe&amp;via=example.invalid\">Go to Message</a></p></blockquote>",
"m.mentions": {}
}])
t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each")
})
test("message2event embeds: crazy html is all escaped", async t => {
const events = await messageToEvent(data.message_with_embeds.escaping_crazy_html_tags, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.notice",
body: "| ## ⏺️ <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;) https://a.co/&amp;<script>"
+ "\n| \n| ## <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;) https://a.co/&amp;<script>"
+ "\n| \n| <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;)"
+ "\n| \n| ### <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;)"
+ "\n| <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;)"
+ "\n| — <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;)",
format: "org.matrix.custom.html",
formatted_body: `<blockquote>`
+ `<p><strong><a href="https://a.co/&amp;amp;&lt;script&gt;">⏺️ &lt;strong&gt;[&lt;span data-mx-color=&#39;#123456&#39;&gt;Hey&lt;script&gt;](https://a.co/&amp;amp;)</a></strong></p>`
+ `<p><strong><a href=\"https://a.co/&amp;amp;&lt;script&gt;">&lt;strong&gt;[&lt;span data-mx-color='#123456'&gt;Hey&lt;script&gt;](<a href="https://a.co/&amp;amp">https://a.co/&amp;amp</a>;)</a></strong></p>`
+ `<p>&lt;strong&gt;<a href="https://a.co/&amp;amp;">&lt;span data-mx-color='#123456'&gt;Hey&lt;script&gt;</a></p>`
+ `<p><strong>&lt;strong&gt;[&lt;span data-mx-color='#123456'&gt;Hey&lt;script&gt;](<a href=\"https://a.co/&amp;amp\">https://a.co/&amp;amp</a>;)</strong>`
+ `<br>&lt;strong&gt;<a href="https://a.co/&amp;amp;">&lt;span data-mx-color='#123456'&gt;Hey&lt;script&gt;</a></p>`
+ `— &lt;strong&gt;[&lt;span data-mx-color=&#39;#123456&#39;&gt;Hey&lt;script&gt;](https://a.co/&amp;amp;)</blockquote>`,
"m.mentions": {}
}])
})

View file

@ -4,7 +4,6 @@ const assert = require("assert").strict
const markdown = require("discord-markdown")
const pb = require("prettier-bytes")
const DiscordTypes = require("discord-api-types/v10")
const {tag} = require("html-template-tag")
const passthrough = require("../../passthrough")
const {sync, db, discord, select, from} = passthrough
@ -12,12 +11,8 @@ const {sync, db, discord, select, from} = passthrough
const file = sync.require("../../matrix/file")
/** @type {import("./emoji-to-key")} */
const emojiToKey = sync.require("./emoji-to-key")
/** @type {import("../actions/lottie")} */
const lottie = sync.require("../actions/lottie")
/** @type {import("../../m2d/converters/utils")} */
const mxUtils = sync.require("../../m2d/converters/utils")
/** @type {import("../../discord/utils")} */
const dUtils = sync.require("../../discord/utils")
/** @type {import("./lottie")} */
const lottie = sync.require("./lottie")
const reg = require("../../matrix/read-registration")
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
@ -39,27 +34,26 @@ function getDiscordParseCallbacks(message, guild, useHTML) {
return `@${username}:`
}
},
/** @param {{id: string, type: "discordChannel", row: {room_id: string, name: string, nick: string?}?, via: string}} node */
/** @param {{id: string, type: "discordChannel"}} node */
channel: node => {
if (!node.row) { // fallback for when this channel is not bridged
const channel = discord.channels.get(node.id)
if (channel) {
return `#${channel.name} [channel not bridged]`
} else {
return `#unknown-channel [channel from an unbridged server]`
}
const row = select("channel_room", ["room_id", "name", "nick"], {channel_id: node.id}).get()
if (!row) {
return `<#${node.id}>` // fallback for when this channel is not bridged
} else if (useHTML) {
return `<a href="https://matrix.to/#/${node.row.room_id}?${node.via}">#${node.row.nick || node.row.name}</a>`
return `<a href="https://matrix.to/#/${row.room_id}">#${row.nick || row.name}</a>`
} else {
return `#${node.row.nick || node.row.name}`
return `#${row.nick || row.name}`
}
},
/** @param {{animated: boolean, name: string, id: string, type: "discordEmoji"}} node */
emoji: node => {
if (useHTML) {
const mxc = select("emoji", "mxc_url", {emoji_id: node.id}).pluck().get()
assert(mxc) // All emojis should have been added ahead of time in the messageToEvent function.
if (mxc) {
return `<img data-mx-emoticon height="32" src="${mxc}" title=":${node.name}:" alt=":${node.name}:">`
} else { // We shouldn't get here since all emojis should have been added ahead of time in the messageToEvent function.
return `<img src="mxc://cadence.moe/${node.id}" data-mx-emoticon alt=":${node.name}:" title=":${node.name}:" height="24">`
}
} else {
return `:${node.name}:`
}
@ -67,9 +61,7 @@ function getDiscordParseCallbacks(message, guild, useHTML) {
role: node => {
const role = guild.roles.find(r => r.id === node.id)
if (!role) {
// This fallback should only trigger if somebody manually writes a silly message, or if the cache breaks (hasn't happened yet).
// If the cache breaks, fix discord-packets.js to store role info properly.
return "@&" + node.id
return "@&" + node.id // fallback for if the cache breaks. if this happens, fix discord-packets.js to store the role info.
} else if (useHTML && role.color) {
return `<font color="#${role.color.toString(16)}">@${role.name}</font>`
} else if (useHTML) {
@ -78,117 +70,10 @@ function getDiscordParseCallbacks(message, guild, useHTML) {
return `@${role.name}:`
}
},
everyone: () => {
if (message.mention_everyone) return "@room"
return "@everyone"
},
here: () => {
if (message.mention_everyone) return "@room"
return "@here"
}
}
}
const embedTitleParser = markdown.markdownEngine.parserFor({
...markdown.rules,
autolink: undefined,
link: undefined
})
/**
* @param {{room?: boolean, user_ids?: string[]}} mentions
* @param {DiscordTypes.APIAttachment} attachment
*/
async function attachmentToEvent(mentions, attachment) {
const emoji =
attachment.content_type?.startsWith("image/jp") ? "📸"
: attachment.content_type?.startsWith("image/") ? "🖼️"
: attachment.content_type?.startsWith("video/") ? "🎞️"
: attachment.content_type?.startsWith("text/") ? "📝"
: attachment.content_type?.startsWith("audio/") ? "🎶"
: "📄"
// no native media spoilers in Element, so we'll post a link instead, forcing it to not preview using a blockquote
if (attachment.filename.startsWith("SPOILER_")) {
return {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.text",
body: `${emoji} Uploaded SPOILER file: ${attachment.url} (${pb(attachment.size)})`,
format: "org.matrix.custom.html",
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
else if (attachment.size > reg.ooye.max_file_size) {
return {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.text",
body: `${emoji} Uploaded file: ${attachment.url} (${pb(attachment.size)})`,
format: "org.matrix.custom.html",
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 {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.image",
url: await file.uploadDiscordFileToMxc(attachment.url),
external_url: attachment.url,
body: attachment.description || attachment.filename,
filename: attachment.filename,
info: {
mimetype: attachment.content_type,
w: attachment.width,
h: attachment.height,
size: attachment.size
}
}
} else if (attachment.content_type?.startsWith("video/") && attachment.width && attachment.height) {
return {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.video",
url: await file.uploadDiscordFileToMxc(attachment.url),
external_url: attachment.url,
body: attachment.description || attachment.filename,
filename: attachment.filename,
info: {
mimetype: attachment.content_type,
w: attachment.width,
h: attachment.height,
size: attachment.size
}
}
} else if (attachment.content_type?.startsWith("audio/")) {
return {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.audio",
url: await file.uploadDiscordFileToMxc(attachment.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 ? attachment.duration_secs * 1000 : undefined
}
}
} else {
return {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.file",
url: await file.uploadDiscordFileToMxc(attachment.url),
external_url: attachment.url,
body: attachment.description || attachment.filename,
filename: attachment.filename,
info: {
mimetype: attachment.content_type,
size: attachment.size
}
}
everyone: node =>
"@room",
here: node =>
"@here"
}
}
@ -203,7 +88,6 @@ async function attachmentToEvent(mentions, attachment) {
async function messageToEvent(message, guild, options = {}, di) {
const events = []
/* c8 ignore next 7 */
if (message.type === DiscordTypes.MessageType.ThreadCreated) {
// This is the kind of message that appears when somebody makes a thread which isn't close enough to the message it's based off.
// It lacks the lines and the pill, so it looks kind of like a member join message, and it says:
@ -261,37 +145,6 @@ async function messageToEvent(message, guild, options = {}, di) {
if (row) {
repliedToEventRow = row
}
} else if (dUtils.isWebhookMessage(message) && message.embeds[0]?.author?.name?.endsWith("↩️")) {
// It could be a PluralKit emulated reply, let's see if it has a message link
const isEmulatedReplyToText = message.embeds[0].description?.startsWith("**[Reply to:]")
const isEmulatedReplyToAttachment = message.embeds[0].description?.startsWith("*[(click to see attachment")
if (isEmulatedReplyToText || isEmulatedReplyToAttachment) {
assert(message.embeds[0].description)
const match = message.embeds[0].description.match(/\/channels\/[0-9]*\/[0-9]*\/([0-9]{2,})/)
if (match) {
const row = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id").select("event_id", "room_id", "source").and("WHERE message_id = ? AND part = 0").get(match[1])
if (row) {
/*
we generate a partial referenced_message based on what PK provided. we don't need everything, since this will only be used for further message-to-event converting.
the following properties are necessary:
- content: used for generating the reply fallback
- author: used for the top of the reply fallback (only used for discord authors. for matrix authors, repliedToEventSenderMxid is set.)
*/
const emulatedMessageContent =
( isEmulatedReplyToAttachment ? "[Media]"
: message.embeds[0].description.replace(/^.*?\)\*\*\s*/, ""))
message.referenced_message = {
content: emulatedMessageContent,
// @ts-ignore
author: {
username: message.embeds[0].author.name.replace(/\s*↩️\s*$/, "")
}
}
message.embeds.shift()
repliedToEventRow = row
}
}
}
}
if (repliedToEventRow && repliedToEventRow.source === 0) { // reply was originally from Matrix
// Need to figure out who sent that event...
@ -301,63 +154,16 @@ async function messageToEvent(message, guild, options = {}, di) {
addMention(repliedToEventSenderMxid)
}
/** @type {Map<string, Promise<string>>} */
const viaMemo = new Map()
/**
* @param {string} roomID
* @returns {Promise<string>} string encoded URLSearchParams
*/
function getViaServersMemo(roomID) {
// @ts-ignore
if (viaMemo.has(roomID)) return viaMemo.get(roomID)
const promise = mxUtils.getViaServersQuery(roomID, di.api).then(p => p.toString())
viaMemo.set(roomID, promise)
return promise
}
/**
* Translate Discord message links to Matrix event links.
* If OOYE has handled this message in the past, this is an instant database lookup.
* Otherwise, if OOYE knows the channel, this is a multi-second request to /timestamp_to_event to approximate.
* @param {string} content Partial or complete Discord message content
*/
async function transformContentMessageLinks(content) {
let offset = 0
for (const match of [...content.matchAll(/https:\/\/(?:ptb\.|canary\.|www\.)?discord(?:app)?\.com\/channels\/[0-9]+\/([0-9]+)\/([0-9]+)/g)]) {
assert(typeof match.index === "number")
const [_, channelID, messageID] = match
let result
const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
if (roomID) {
async function addTextEvent(content, msgtype, {scanMentions}) {
content = content.replace(/https:\/\/(?:ptb\.|canary\.|www\.)?discord(?:app)?\.com\/channels\/([0-9]+)\/([0-9]+)\/([0-9]+)/, (whole, guildID, channelID, messageID) => {
const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get()
const via = await getViaServersMemo(roomID)
const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
if (eventID && roomID) {
result = `https://matrix.to/#/${roomID}/${eventID}?${via}`
return `https://matrix.to/#/${roomID}/${eventID}`
} else {
const ts = dUtils.snowflakeToTimestampExact(messageID)
const {event_id} = await di.api.getEventForTimestamp(roomID, ts)
result = `https://matrix.to/#/${roomID}/${event_id}?${via}`
return `${whole} [event not found]`
}
} else {
result = `${match[0]} [event is from another server]`
}
content = content.slice(0, match.index + offset) + result + content.slice(match.index + match[0].length + offset)
offset += result.length - match[0].length
}
return content
}
/**
* Translate links and emojis and mentions and stuff. Give back the text and HTML so they can be combined into bigger events.
* @param {string} content Partial or complete Discord message content
* @param {any} customOptions
* @param {any} customParser
* @param {any} customHtmlOutput
*/
async function transformContent(content, customOptions = {}, customParser = null, customHtmlOutput = null) {
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.
@ -365,42 +171,39 @@ async function messageToEvent(message, guild, options = {}, di) {
await Promise.all(emojiMatches.map(match => {
const id = match[3]
const name = match[2]
const animated = !!match[1]
const animated = match[1]
return emojiToKey.emojiToKey({id, name, animated}) // Register the custom emoji if needed
}))
async function transformParsedVia(parsed) {
for (const node of parsed) {
if (node.type === "discordChannel") {
node.row = select("channel_room", ["room_id", "name", "nick"], {channel_id: node.id}).get()
if (node.row?.room_id) {
node.via = await getViaServersMemo(node.row.room_id)
}
}
if (Array.isArray(node.content)) {
await transformParsedVia(node.content)
}
}
return parsed
}
let html = markdown.toHTML(content, {
discordCallback: getDiscordParseCallbacks(message, guild, true)
}, null, null)
let html = await markdown.toHtmlWithPostParser(content, transformParsedVia, {
discordCallback: getDiscordParseCallbacks(message, guild, true),
...customOptions
}, customParser, customHtmlOutput)
let body = await markdown.toHtmlWithPostParser(content, transformParsedVia, {
let body = markdown.toHTML(content, {
discordCallback: getDiscordParseCallbacks(message, guild, false),
discordOnly: true,
escapeHTML: false,
...customOptions
}, null, null)
return {body, html}
// Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention.
if (scanMentions) {
const matches = [...content.matchAll(/@ ?([a-z0-9._]+)\b/gi)]
if (matches.length && matches.some(m => m[1].match(/[a-z]/i))) {
const writtenMentionsText = matches.map(m => m[1].toLowerCase())
const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
assert(roomID)
const {joined} = await di.api.getJoinedMembers(roomID)
for (const [mxid, member] of Object.entries(joined)) {
if (!userRegex.some(rx => mxid.match(rx))) {
const localpart = mxid.match(/@([^:]*)/)
assert(localpart)
const displayName = member.display_name || localpart[1]
if (writtenMentionsText.includes(localpart[1].toLowerCase()) || writtenMentionsText.includes(displayName.toLowerCase())) addMention(mxid)
}
}
}
}
// 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
@ -421,7 +224,7 @@ async function messageToEvent(message, guild, options = {}, di) {
if (repliedToEventRow?.source === 0 && repliedToEventSenderMxid) {
const match = repliedToEventSenderMxid.match(/^@([^:]*)/)
assert(match)
repliedToDisplayName = message.referenced_message?.author.username || match[1] || "a Matrix user" // grab the localpart as the display name, whatever
repliedToDisplayName = match[1] || "a Matrix user" // grab the localpart as the display name, whatever
repliedToUserHtml = `<a href="https://matrix.to/#/${repliedToEventSenderMxid}">${repliedToDisplayName}</a>`
} else {
repliedToDisplayName = message.referenced_message?.author.global_name || message.referenced_message?.author.username || "a Discord user"
@ -478,98 +281,149 @@ async function messageToEvent(message, guild, options = {}, di) {
message.content = "changed the channel name to **" + 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 (matches.length && matches.some(m => m[1].match(/[a-z]/i))) {
const writtenMentionsText = matches.map(m => m[1].toLowerCase())
const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
assert(roomID)
const {joined} = await di.api.getJoinedMembers(roomID)
for (const [mxid, member] of Object.entries(joined)) {
if (!userRegex.some(rx => mxid.match(rx))) {
const localpart = mxid.match(/@([^:]*)/)
assert(localpart)
const displayName = member.display_name || localpart[1]
if (writtenMentionsText.includes(localpart[1].toLowerCase()) || writtenMentionsText.includes(displayName.toLowerCase())) addMention(mxid)
}
}
}
// Text content appears first
if (message.content) {
const {body, html} = await transformContent(message.content)
await addTextEvent(body, html, msgtype, {scanMentions: true})
await addTextEvent(message.content, msgtype, {scanMentions: true})
}
// Then attachments
const attachmentEvents = await Promise.all(message.attachments.map(attachmentToEvent.bind(null, mentions)))
const attachmentEvents = await Promise.all(message.attachments.map(async attachment => {
const emoji =
attachment.content_type?.startsWith("image/jp") ? "📸"
: attachment.content_type?.startsWith("image/") ? "🖼️"
: attachment.content_type?.startsWith("video/") ? "🎞️"
: attachment.content_type?.startsWith("text/") ? "📝"
: attachment.content_type?.startsWith("audio/") ? "🎶"
: "📄"
// no native media spoilers in Element, so we'll post a link instead, forcing it to not preview using a blockquote
if (attachment.filename.startsWith("SPOILER_")) {
return {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.text",
body: `${emoji} Uploaded SPOILER file: ${attachment.url} (${pb(attachment.size)})`,
format: "org.matrix.custom.html",
formatted_body: `<blockquote>${emoji} Uploaded SPOILER file: <span data-mx-spoiler><a href="${attachment.url}">View</a></span> (${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
else if (attachment.size > reg.ooye.max_file_size) {
return {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.text",
body: `${emoji} Uploaded file: ${attachment.url} (${pb(attachment.size)})`,
format: "org.matrix.custom.html",
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 {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.image",
url: await file.uploadDiscordFileToMxc(attachment.url),
external_url: attachment.url,
body: attachment.filename,
filename: attachment.filename,
info: {
mimetype: attachment.content_type,
w: attachment.width,
h: attachment.height,
size: attachment.size
}
}
} else if (attachment.content_type?.startsWith("video/") && attachment.width && attachment.height) {
return {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.video",
url: await file.uploadDiscordFileToMxc(attachment.url),
external_url: attachment.url,
body: attachment.description || attachment.filename,
filename: attachment.filename,
info: {
mimetype: attachment.content_type,
w: attachment.width,
h: attachment.height,
size: attachment.size
}
}
} else if (attachment.content_type?.startsWith("audio/")) {
return {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.audio",
url: await file.uploadDiscordFileToMxc(attachment.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 ? attachment.duration_secs * 1000 : undefined
}
}
} else {
return {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.file",
url: await file.uploadDiscordFileToMxc(attachment.url),
external_url: attachment.url,
body: attachment.filename,
filename: attachment.filename,
info: {
mimetype: attachment.content_type,
size: attachment.size
}
}
}
}))
events.push(...attachmentEvents)
// Then embeds
for (const embed of message.embeds || []) {
if (embed.type === "image") {
continue // Matrix's own URL previews are fine for images.
continue // Matrix already does a fine enough job of providing image embeds.
}
// Start building up a replica ("rep") of the embed in Discord-markdown format, which we will convert into both plaintext and formatted body at once
const rep = new mxUtils.MatrixStringBuilder()
let repParagraphs = []
const makeUrlTitle = (text, url) =>
( text && url ? `[**${text}**](${url})`
: text ? `**${text}**`
: url ? `**${url}**`
: "")
// Author and URL into a paragraph
let authorNameText = embed.author?.name || ""
if (authorNameText && embed.author?.icon_url) authorNameText = `⏺️ ${authorNameText}` // using the emoji instead of an image
if (authorNameText || embed.author?.url) {
if (embed.author?.url) {
const authorURL = await transformContentMessageLinks(embed.author.url)
rep.addParagraph(`## ${authorNameText} ${authorURL}`, tag`<strong><a href="${authorURL}">${authorNameText}</a></strong>`)
} else {
rep.addParagraph(`## ${authorNameText}`, tag`<strong>${authorNameText}</strong>`)
}
}
if (authorNameText && embed.author?.icon_url) authorNameText = `⏺️ ${authorNameText}` // not using the real image
let authorTitle = makeUrlTitle(authorNameText, embed.author?.url)
if (authorTitle) repParagraphs.push(authorTitle)
// Title and URL into a paragraph
if (embed.title) {
const {body, html} = await transformContent(embed.title, {}, embedTitleParser, markdown.htmlOutput)
if (embed.url) {
rep.addParagraph(`## ${body} ${embed.url}`, tag`<strong><a href="${embed.url}">$${html}</a></strong>`)
} else {
rep.addParagraph(`## ${body}`, `<strong>${html}</strong>`)
}
} else if (embed.url) {
rep.addParagraph(`## ${embed.url}`, tag`<strong><a href="${embed.url}">${embed.url}</a></strong>`)
}
let title = makeUrlTitle(embed.title, embed.url)
if (title) repParagraphs.push(title)
if (embed.description) {
const {body, html} = await transformContent(embed.description)
rep.addParagraph(body, html)
}
if (embed.image?.url) repParagraphs.push(`📸 ${embed.image.url}`)
if (embed.video?.url) repParagraphs.push(`🎞️ ${embed.video.url}`)
if (embed.description) repParagraphs.push(embed.description)
for (const field of embed.fields || []) {
const name = field.name.match(/^[\s­]*$/) ? {body: "", html: ""} : await transformContent(field.name, {}, embedTitleParser, markdown.htmlOutput)
const value = await transformContent(field.value)
const fieldRep = new mxUtils.MatrixStringBuilder()
.addLine(`### ${name.body}`, `<strong>${name.html}</strong>`, name.body)
.addLine(value.body, value.html, !!value.body)
rep.addParagraph(fieldRep.get().body, fieldRep.get().formatted_body)
repParagraphs.push(`**${field.name}**\n${field.value}`)
}
if (embed.image?.url) rep.addParagraph(`📸 ${embed.image.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()
body = body.split("\n").map(l => "| " + l).join("\n")
html = `<blockquote>${html}</blockquote>`
if (embed.footer?.text) repParagraphs.push(`${embed.footer.text}`)
const repContent = repParagraphs.join("\n\n")
const repContentQuoted = repContent.split("\n").map(l => "> " + l).join("\n")
// 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", {scanMentions: false})
await addTextEvent(repContentQuoted, "m.notice", {scanMentions: false})
}
// Then stickers
if (message.sticker_items) {
const stickerEvents = await Promise.all(message.sticker_items.map(async stickerItem => {
const format = file.stickerFormat.get(stickerItem.format_type)
assert(format?.mime)
if (format?.mime === "lottie") {
try {
const {mxc_url, info} = await lottie.convert(stickerItem)
return {
$type: "m.sticker",
@ -578,7 +432,15 @@ async function messageToEvent(message, guild, options = {}, di) {
info,
url: mxc_url
}
} else {
} catch (e) {
return {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.notice",
body: `Failed to convert Lottie sticker:\n${e.toString()}\n${e.stack}`
}
}
} else if (format?.mime) {
let body = stickerItem.name
const sticker = guild.stickers.find(sticker => sticker.id === stickerItem.id)
if (sticker && sticker.description) body += ` - ${sticker.description}`
@ -592,6 +454,12 @@ async function messageToEvent(message, guild, options = {}, di) {
url: await file.uploadDiscordFileToMxc(file.sticker(stickerItem))
}
}
return {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.notice",
body: `Unsupported sticker format ${format?.mime}. Name: ${stickerItem.name}`
}
}))
events.push(...stickerEvents)
}

View file

@ -1,134 +0,0 @@
const {test} = require("supertape")
const {messageToEvent} = require("./message-to-event")
const data = require("../../test/data")
const Ty = require("../../types")
/**
* @param {string} roomID
* @param {string} eventID
* @returns {(roomID: string, eventID: string) => Promise<Ty.Event.Outer<Ty.Event.M_Room_Message>>}
*/
function mockGetEvent(t, roomID_in, eventID_in, outer) {
return async function(roomID, eventID) {
t.equal(roomID, roomID_in)
t.equal(eventID, eventID_in)
return new Promise(resolve => {
setTimeout(() => {
resolve({
event_id: eventID_in,
room_id: roomID_in,
origin_server_ts: 1680000000000,
unsigned: {
age: 2245,
transaction_id: "$local.whatever"
},
...outer
})
})
})
}
}
test("message2event: pk reply to matrix is converted to native matrix reply", async t => {
const events = await messageToEvent(data.pk_message.pk_reply_to_matrix, {}, {}, {
api: {
getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU", {
type: "m.room.message",
sender: "@cadence:cadence.moe",
content: {
msgtype: "m.text",
body: "now for my next experiment:"
}
})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {
user_ids: [
"@cadence:cadence.moe"
]
},
msgtype: "m.text",
body: "> cadence [they]: now for my next experiment:\n\nthis is a reply",
format: "org.matrix.custom.html",
formatted_body: '<mx-reply><blockquote><a href="https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU">In reply to</a> <a href="https://matrix.to/#/@cadence:cadence.moe">cadence [they]</a><br>'
+ "now for my next experiment:</blockquote></mx-reply>"
+ "this is a reply",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU"
}
}
}])
})
test("message2event: pk reply to discord is converted to native matrix reply", async t => {
const events = await messageToEvent(data.pk_message.pk_reply_to_discord, {}, {}, {
api: {
getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU", {
type: "m.room.message",
sender: "@_ooye_.wing.:cadence.moe",
content: {
msgtype: "m.text",
body: "some text"
}
})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
"m.mentions": {},
body: "> wing: some text\n\nthis is a reply",
format: "org.matrix.custom.html",
formatted_body: '<mx-reply><blockquote><a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA">In reply to</a> wing<br>'
+ "some text</blockquote></mx-reply>"
+ "this is a reply",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA"
}
}
}])
})
test("message2event: pk reply to matrix attachment is converted to native matrix reply", async t => {
const events = await messageToEvent(data.pk_message.pk_reply_to_matrix_attachment, {}, {}, {
api: {
getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$OEEK-Wam2FTh6J-6kVnnJ6KnLA_lLRnLTHatKKL62-Y", {
sender: "@ampflower:matrix.org",
type: "m.room.message",
content: {
body: "catnod.gif",
filename: "catnod.gif",
info: {
h: 128,
mimetype: "image/gif",
size: 20816,
w: 128
},
msgtype: "m.image",
url: "mxc://matrix.org/jtzXIawXCkFIHSsMUNsKkUJX"
}
})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
"m.mentions": {
user_ids: ["@ampflower:matrix.org"]
},
body: "> Ampflower 🌺: [Media]\n\nCat nod",
format: "org.matrix.custom.html",
formatted_body: '<mx-reply><blockquote><a href="https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$OEEK-Wam2FTh6J-6kVnnJ6KnLA_lLRnLTHatKKL62-Y">In reply to</a> <a href="https://matrix.to/#/@ampflower:matrix.org">Ampflower 🌺</a><br>'
+ "[Media]</blockquote></mx-reply>"
+ "Cat nod",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$OEEK-Wam2FTh6J-6kVnnJ6KnLA_lLRnLTHatKKL62-Y"
}
}
}])
})

View file

@ -62,60 +62,14 @@ 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, "!kLRqKKUQXcibIMtOpl: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, "!kLRqKKUQXcibIMtOpl:cadence.moe")
return {
joined: {
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
"@user:matrix.org": {display_name: null, avatar_url: null}
}
}
}
}
})
const events = await messageToEvent(data.message.simple_room_mention, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "#main",
format: "org.matrix.custom.html",
formatted_body: '<a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&via=matrix.org">#main</a>'
}])
t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each")
})
test("message2event: unknown room mention", async t => {
const events = await messageToEvent(data.message.unknown_room_mention, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "#unknown-channel [channel from an unbridged server]"
}])
})
test("message2event: unbridged room mention", async t => {
const events = await messageToEvent(data.message.unbridged_room_mention, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "#bad-boots-prison [channel not bridged]"
formatted_body: '<a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe">#main</a>'
}])
})
@ -131,114 +85,15 @@ test("message2event: simple role mentions", async t => {
}])
})
test("message2event: manually constructed unknown roles should use fallback", async t => {
const events = await messageToEvent(data.message.unknown_role, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "I'm just @&4 testing a few role pings <@&B> don't mind me",
format: "org.matrix.custom.html",
formatted_body: "I'm just @&4 testing a few role pings &lt;@&amp;B&gt; don't mind me"
}])
})
test("message2event: simple message link", async t => {
let called = 0
const events = await messageToEvent(data.message.simple_message_link, data.guild.general, {}, {
api: {
async getStateEvent(roomID, type, key) {
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) {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
return {
joined: {
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
"@user:super.invalid": {display_name: null, avatar_url: null}
}
}
}
}
})
const events = await messageToEvent(data.message.simple_message_link, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg?via=cadence.moe&via=super.invalid",
body: "https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg",
format: "org.matrix.custom.html",
formatted_body: '<a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg?via=cadence.moe&amp;via=super.invalid">https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg?via=cadence.moe&amp;via=super.invalid</a>'
}])
t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each")
})
test("message2event: message link that OOYE doesn't know about", 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")
return {
event_id: "$E8IQDGFqYzOU7BwY5Z74Bg-cwaU9OthXSroaWtgYc7U",
origin_server_ts: 1613287812754
}
},
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:_ "
+ "https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$E8IQDGFqYzOU7BwY5Z74Bg-cwaU9OthXSroaWtgYc7U?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> "
+ '<a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$E8IQDGFqYzOU7BwY5Z74Bg-cwaU9OthXSroaWtgYc7U?via=cadence.moe&amp;via=matrix.org">https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$E8IQDGFqYzOU7BwY5Z74Bg-cwaU9OthXSroaWtgYc7U?via=cadence.moe&amp;via=matrix.org</a>'
}])
t.equal(called, 3, "getEventForTimestamp, getStateEvent, and getJoinedMembers should be called once each")
})
test("message2event: message link from another server", async t => {
const events = await messageToEvent(data.message.message_link_from_another_server, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "Neither of these servers are known to OOYE: https://discord.com/channels/111/222/333 [event is from another server] https://canary.discordapp.com/channels/444/555/666 [event is from another server]",
format: "org.matrix.custom.html",
formatted_body: 'Neither of these servers are known to OOYE: <a href="https://discord.com/channels/111/222/333">https://discord.com/channels/111/222/333</a> [event is from another server]'
+ ' <a href="https://canary.discordapp.com/channels/444/555/666">https://canary.discordapp.com/channels/444/555/666</a> [event is from another server]'
formatted_body: '<a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg">https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg</a>'
}])
})
@ -269,7 +124,7 @@ test("message2event: spoiler attachment", async t => {
msgtype: "m.text",
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://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>"
formatted_body: "<blockquote>📄 Uploaded SPOILER file: <span data-mx-spoiler><a href=\"https://cdn.discordapp.com/attachments/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci\">View</a></span> (74 KB)</blockquote>"
}])
})
@ -307,21 +162,6 @@ test("message2event: stickers", async t => {
}])
})
test("message2event: lottie sticker", async t => {
const events = await messageToEvent(data.message.lottie_sticker, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.sticker",
"m.mentions": {},
body: "8",
info: {
mimetype: "image/png",
w: 160,
h: 160
},
url: "mxc://cadence.moe/ZtvvVbwMIdUZeovWVyGVFCeR"
}])
})
test("message2event: skull webp attachment with content", async t => {
const events = await messageToEvent(data.message.skull_webp_attachment_with_content, data.guild.general, {})
t.deepEqual(events, [{
@ -445,81 +285,6 @@ test("message2event: simple reply to matrix user, reply fallbacks disabled", asy
}])
})
test("message2event: reply with a video", async t => {
const events = await messageToEvent(data.message.reply_with_video, data.guild.general, {
api: {
getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$7tJoMw1h44n2gxgLUE1T_YinGrLbK0x-TDY1z6M7GBw", {
type: "m.room.message",
content: {
msgtype: "m.text",
body: 'deadpicord "extremity you woke up at 4 am"'
},
sender: "@_ooye_extremity:cadence.moe"
})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.video",
body: "Ins_1960637570.mp4",
filename: "Ins_1960637570.mp4",
url: "mxc://cadence.moe/kMqLycqMURhVpwleWkmASpnU",
external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1197621094786531358/Ins_1960637570.mp4?ex=65bbee8f&is=65a9798f&hm=ae14f7824c3d526c5e11c162e012e1ee405fd5776e1e9302ed80ccd86503cfda&",
info: {
h: 854,
mimetype: "video/mp4",
size: 860559,
w: 480,
},
"m.mentions": {},
"m.relates_to": {
"m.in_reply_to": {
event_id: "$7tJoMw1h44n2gxgLUE1T_YinGrLbK0x-TDY1z6M7GBw"
}
}
}])
})
test("message2event: voice message", async t => {
const events = await messageToEvent(data.message.voice_message)
t.deepEqual(events, [{
$type: "m.room.message",
body: "voice-message.ogg",
external_url: "https://cdn.discordapp.com/attachments/1099031887500034088/1112476845502365786/voice-message.ogg?ex=65c92d4c&is=65b6b84c&hm=0654bab5027474cbe23875954fa117cf44d8914c144cd151879590fa1baf8b1c&",
filename: "voice-message.ogg",
info: {
duration: 3960.0000381469727,
mimetype: "audio/ogg",
size: 10584,
},
"m.mentions": {},
msgtype: "m.audio",
url: "mxc://cadence.moe/MRRPDggXQMYkrUjTpxQbmcxB"
}])
})
test("message2event: misc file", async t => {
const events = await messageToEvent(data.message.misc_file)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "final final final revised draft",
"m.mentions": {}
}, {
$type: "m.room.message",
body: "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",
size: 2274
},
"m.mentions": {},
msgtype: "m.file",
url: "mxc://cadence.moe/HnQIYQmmlIKwOQsbFsIGpzPP"
}])
})
test("message2event: simple reply in thread to a matrix user's reply", async t => {
const events = await messageToEvent(data.message.simple_reply_to_reply_in_thread, data.guild.general, {}, {
api: {
@ -553,9 +318,9 @@ test("message2event: simple reply in thread to a matrix user's reply", async t =
user_ids: ["@cadence:cadence.moe"]
},
msgtype: "m.text",
body: "> cadence [they]: What about them?\n\nWell, they don't seem to...",
body: "> cadence: What about them?\n\nWell, they don't seem to...",
format: "org.matrix.custom.html",
formatted_body: "<mx-reply><blockquote><a href=\"https://matrix.to/#/!FuDZhlOAtqswlyxzeR:cadence.moe/$nUM-ABBF8KdnvrhXwLlYAE9dgDl_tskOvvcNIBrtsVo\">In reply to</a> <a href=\"https://matrix.to/#/@cadence:cadence.moe\">cadence [they]</a><br>What about them?</blockquote></mx-reply>Well, they don't seem to...",
formatted_body: "<mx-reply><blockquote><a href=\"https://matrix.to/#/!FuDZhlOAtqswlyxzeR:cadence.moe/$nUM-ABBF8KdnvrhXwLlYAE9dgDl_tskOvvcNIBrtsVo\">In reply to</a> <a href=\"https://matrix.to/#/@cadence:cadence.moe\">cadence</a><br>What about them?</blockquote></mx-reply>Well, they don't seem to...",
}])
})

View file

@ -12,7 +12,6 @@ function pinsToList(pins) {
const eventID = select("event_message", "event_id", {message_id: message.id, part: 0}).pluck().get()
if (eventID) result.push(eventID)
}
result.reverse()
return result
}

View file

@ -5,8 +5,8 @@ const {pinsToList} = require("./pins-to-list")
test("pins2list: converts known IDs, ignores unknown IDs", t => {
const result = pinsToList(data.pins.faked)
t.deepEqual(result, [
"$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4",
"$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg",
"$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA",
"$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg"
"$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4"
])
})

View file

@ -6,8 +6,6 @@ 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 userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
@ -31,9 +29,8 @@ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thr
const msgtype = creatorMxid ? "m.emote" : "m.text"
const template = creatorMxid ? "started a thread:" : "Thread started:"
const via = await mxUtils.getViaServersQuery(threadRoomID, di.api)
let body = `${template} ${thread.name} https://matrix.to/#/${threadRoomID}?${via.toString()}`
let html = `${template} <a href="https://matrix.to/#/${threadRoomID}?${via.toString()}">${thread.name}</a>`
let body = `${template} ${thread.name} https://matrix.to/#/${threadRoomID}`
let html = `${template} <a href="https://matrix.to/#/${threadRoomID}">${thread.name}</a>`
return {
msgtype,

View file

@ -29,34 +29,16 @@ function mockGetEvent(t, roomID_in, eventID_in, outer) {
}
}
const viaApi = {
async getStateEvent(roomID, type, key) {
return {
users: {
"@_ooye_bot:cadence.moe": 100
}
}
},
async getJoinedMembers(roomID) {
return {
joined: {
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
"@user:matrix.org": {display_name: null, avatar_url: null}
}
}
}
}
test("thread2announcement: no known creator, no branched from event", async t => {
const content = await threadToAnnouncement("!parent", "!thread", null, {
name: "test thread",
id: "-1"
}, {api: viaApi})
})
t.deepEqual(content, {
msgtype: "m.text",
body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
body: "Thread started: test thread https://matrix.to/#/!thread",
format: "org.matrix.custom.html",
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org">test thread</a>`,
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread">test thread</a>`,
"m.mentions": {}
})
})
@ -65,12 +47,12 @@ test("thread2announcement: known creator, no branched from event", async t => {
const content = await threadToAnnouncement("!parent", "!thread", "@_ooye_crunch_god:cadence.moe", {
name: "test thread",
id: "-1"
}, {api: viaApi})
})
t.deepEqual(content, {
msgtype: "m.emote",
body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
body: "started a thread: test thread https://matrix.to/#/!thread",
format: "org.matrix.custom.html",
formatted_body: `started a thread: <a href="https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org">test thread</a>`,
formatted_body: `started a thread: <a href="https://matrix.to/#/!thread">test thread</a>`,
"m.mentions": {}
})
})
@ -88,15 +70,14 @@ test("thread2announcement: no known creator, branched from discord event", async
msgtype: 'm.text',
body: 'testing testing testing'
}
}),
...viaApi
})
}
})
t.deepEqual(content, {
msgtype: "m.text",
body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
body: "Thread started: test thread https://matrix.to/#/!thread",
format: "org.matrix.custom.html",
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org">test thread</a>`,
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread">test thread</a>`,
"m.mentions": {},
"m.relates_to": {
"m.in_reply_to": {
@ -119,15 +100,14 @@ test("thread2announcement: known creator, branched from discord event", async t
msgtype: 'm.text',
body: 'testing testing testing'
}
}),
...viaApi
})
}
})
t.deepEqual(content, {
msgtype: "m.emote",
body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
body: "started a thread: test thread https://matrix.to/#/!thread",
format: "org.matrix.custom.html",
formatted_body: `started a thread: <a href="https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org">test thread</a>`,
formatted_body: `started a thread: <a href="https://matrix.to/#/!thread">test thread</a>`,
"m.mentions": {},
"m.relates_to": {
"m.in_reply_to": {
@ -150,15 +130,14 @@ test("thread2announcement: no known creator, branched from matrix event", async
body: "so can you reply to my webhook uwu"
},
sender: "@cadence:cadence.moe"
}),
...viaApi
})
}
})
t.deepEqual(content, {
msgtype: "m.text",
body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
body: "Thread started: test thread https://matrix.to/#/!thread",
format: "org.matrix.custom.html",
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org">test thread</a>`,
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread">test thread</a>`,
"m.mentions": {
user_ids: ["@cadence:cadence.moe"]
},

View file

@ -5,10 +5,6 @@ const assert = require("assert")
const passthrough = require("../../passthrough")
const {select} = passthrough
const SPECIAL_USER_MAPPINGS = new Map([
["1081004946872352958", ["clyde_ai", "clyde"]]
])
/**
* Downcased and stripped username. Can only include a basic set of characters.
* https://spec.matrix.org/v1.6/appendices/#user-identifiers
@ -34,7 +30,7 @@ function downcaseUsername(user) {
/** @param {string[]} preferences */
function* generateLocalpartAlternatives(preferences) {
const best = preferences[0]
assert(best)
assert.ok(best)
// First, suggest the preferences...
for (const localpart of preferences) {
yield localpart
@ -54,18 +50,15 @@ function* generateLocalpartAlternatives(preferences) {
* @returns {string}
*/
function userToSimName(user) {
if (!SPECIAL_USER_MAPPINGS.has(user.id)) { // skip this check for known special users
assert.notEqual(user.discriminator, "0000", `cannot create user for a webhook: ${JSON.stringify(user)}`)
}
assert.notEqual(user.discriminator, "0000", "cannot create user for a webhook")
// 1. Is sim user already registered?
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")
if (existing) return existing
// 2. Register based on username (could be new or old format)
// (Unless it's a special user, in which case copy their provided mappings.)
const downcased = downcaseUsername(user)
const preferences = SPECIAL_USER_MAPPINGS.get(user.id) || [downcased]
const preferences = [downcased]
if (user.discriminator.length === 4) { // Old style tag? If user.username is unavailable, try the full tag next
preferences.push(downcased + user.discriminator)
}

View file

@ -1,7 +1,6 @@
const {test} = require("supertape")
const tryToCatch = require("try-to-catch")
const assert = require("assert")
const data = require("../../test/data")
const {userToSimName} = require("./user-to-mxid")
test("user2name: cannot create user for a webhook", async t => {
@ -40,7 +39,3 @@ test("user2name: uses ID if name becomes too short", t => {
test("user2name: uses ID when name has only disallowed characters", t => {
t.equal(userToSimName({username: "!@#$%^&*", discriminator: "0001", id: "9"}), "9")
})
test("user2name: works on special user", t => {
t.equal(userToSimName(data.user.clyde_ai), "clyde_ai")
})

View file

@ -1,8 +1,12 @@
// @ts-check
const { SnowTransfer } = require("snowtransfer")
const registration = require("../matrix/read-registration")
const { Endpoints, SnowTransfer } = require("snowtransfer")
const { Client: CloudStorm } = require("cloudstorm")
// @ts-ignore
Endpoints.BASE_HOST = registration.ooye.discord_origin; Endpoints.CDN_URL = registration.ooye.discord_cdn_origin
const passthrough = require("../passthrough")
const { sync } = passthrough
@ -47,16 +51,7 @@ class DiscordClient {
if (listen !== "no") {
this.cloud.on("event", message => discordPackets.onPacket(this, message, listen))
}
const addEventLogger = (eventName, logName) => {
this.cloud.on(eventName, (...args) => {
const d = new Date().toISOString().slice(0, 19)
console.error(`[${d} Client ${logName}]`, ...args)
})
}
addEventLogger("error", "Error")
addEventLogger("disconnected", "Disconnected")
addEventLogger("ready", "Ready")
this.cloud.on("error", console.error)
}
}

View file

@ -42,8 +42,6 @@ const utils = {
client.channels.set(thread.id, thread)
}
if (listen === "full") {
eventDispatcher.checkMissedExpressions(message.d)
eventDispatcher.checkMissedPins(client, message.d)
eventDispatcher.checkMissedMessages(client, message.d)
}
@ -95,13 +93,6 @@ const utils = {
client.channels.set(message.d.id, message.d)
} else if (message.t === "CHANNEL_PINS_UPDATE") {
const channel = client.channels.get(message.d.channel_id)
if (channel) {
channel["last_pin_timestamp"] = message.d.last_pin_timestamp
}
} else if (message.t === "GUILD_DELETE") {
client.guilds.delete(message.d.id)
const channels = client.guildChannelMap.get(message.d.id)
@ -161,9 +152,6 @@ const utils = {
} else if (message.t === "MESSAGE_DELETE") {
await eventDispatcher.onMessageDelete(client, message.d)
} else if (message.t === "MESSAGE_DELETE_BULK") {
await eventDispatcher.onMessageDeleteBulk(client, message.d)
} else if (message.t === "TYPING_START") {
await eventDispatcher.onTypingStart(client, message.d)

View file

@ -1,5 +1,3 @@
// @ts-check
const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10")
const util = require("util")
@ -25,18 +23,8 @@ const createSpace = sync.require("./actions/create-space")
const updatePins = sync.require("./actions/update-pins")
/** @type {import("../matrix/api")}) */
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 {any} */ // @ts-ignore bad types from semaphore
const Semaphore = require("@chriscdn/promise-semaphore")
const checkMissedPinsSema = new Semaphore()
let lastReportedEvent = 0
@ -59,30 +47,27 @@ module.exports = {
if (Date.now() - lastReportedEvent < 5000) return
lastReportedEvent = Date.now()
const channelID = gatewayMessage.d["channel_id"]
const channelID = gatewayMessage.d.channel_id
if (!channelID) return
const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
if (!roomID) return
let stackLines = null
if (e.stack) {
stackLines = e.stack.split("\n")
let stackLines = e.stack.split("\n")
let cloudstormLine = stackLines.findIndex(l => l.includes("/node_modules/cloudstorm/"))
if (cloudstormLine !== -1) {
stackLines = stackLines.slice(0, cloudstormLine - 2)
}
}
const builder = new mxUtils.MatrixStringBuilder()
builder.addLine("\u26a0 Bridged event from Discord not delivered", "\u26a0 <strong>Bridged event from Discord not delivered</strong>")
builder.addLine(`Gateway event: ${gatewayMessage.t}`)
builder.addLine(e.toString())
if (stackLines) {
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>`)
api.sendEvent(roomID, "m.room.message", {
...builder.get(),
msgtype: "m.text",
body: "\u26a0 Bridged event from Discord not delivered. See formatted content for full details.",
format: "org.matrix.custom.html",
formatted_body: "\u26a0 <strong>Bridged event from Discord not delivered</strong>"
+ `<br>Gateway event: ${gatewayMessage.t}`
+ `<br>${e.toString()}`
+ `<br><details><summary>Error trace</summary>`
+ `<pre>${stackLines.join("\n")}</pre></details>`
+ `<details><summary>Original payload</summary>`
+ `<pre>${util.inspect(gatewayMessage.d, false, 4, false)}</pre></details>`,
"moe.cadence.ooye.error": {
source: "discord",
payload: gatewayMessage
@ -106,18 +91,10 @@ module.exports = {
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
if (!channel.last_message_id) continue
const latestWasBridged = prepared.get(channel.last_message_id)
if (latestWasBridged) continue
// Permissions check
const member = guild.members.find(m => m.user?.id === client.user.id)
if (!member) return
if (!("permission_overwrites" in channel)) continue
const permissions = dUtils.getPermissions(member.roles, guild.roles, client.user.id, channel.permission_overwrites)
const wants = BigInt(1 << 10) | BigInt(1 << 16) // VIEW_CHANNEL + READ_MESSAGE_HISTORY
if ((permissions & wants) !== wants) continue // We don't have permission to look back in this channel
/** More recent messages come first. */
// console.log(`[check missed messages] in ${channel.id} (${guild.name} / ${channel.name}) because its last message ${channel.last_message_id} is not in the database`)
let messages
@ -139,6 +116,7 @@ module.exports = {
for (let i = Math.min(messages.length, latestBridgedMessageIndex)-1; i >= 0; i--) {
const simulatedGatewayDispatchData = {
guild_id: guild.id,
mentions: [],
backfill: true,
...messages[i]
}
@ -147,43 +125,6 @@ module.exports = {
}
},
/**
* When logging back in, check if the pins on Matrix-side are up to date. If they aren't, update all pins.
* Rather than query every room on Matrix-side, we cache the latest pinned message in the database and compare against that.
* @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayGuildCreateDispatchData} guild
*/
async checkMissedPins(client, guild) {
if (guild.unavailable) return
const member = guild.members.find(m => m.user?.id === client.user.id)
if (!member) return
for (const channel of guild.channels) {
if (!("last_pin_timestamp" in channel) || !channel.last_pin_timestamp) continue // Only care about channels that have pins
if (!("permission_overwrites" in channel)) continue
const lastPin = updatePins.convertTimestamp(channel.last_pin_timestamp)
// Permissions check
const permissions = dUtils.getPermissions(member.roles, guild.roles, client.user.id, channel.permission_overwrites)
const wants = BigInt(1 << 10) | BigInt(1 << 16) // VIEW_CHANNEL + READ_MESSAGE_HISTORY
if ((permissions & wants) !== wants) continue // We don't have permission to look up the pins in this channel
const row = select("channel_room", ["room_id", "last_bridged_pin_timestamp"], {channel_id: channel.id}).get()
if (!row) continue // Only care about already bridged channels
if (row.last_bridged_pin_timestamp == null || lastPin > row.last_bridged_pin_timestamp) {
checkMissedPinsSema.request(() => updatePins.updatePins(channel.id, row.room_id, lastPin))
}
}
},
/**
* When logging back in, check if we missed any changes to emojis or stickers. Apply the changes if so.
* @param {DiscordTypes.GatewayGuildCreateDispatchData} guild
*/
async checkMissedExpressions(guild) {
const data = {guild_id: guild.id, ...guild}
createSpace.syncSpaceExpressions(data, true)
},
/**
* Announces to the parent room that the thread room has been created.
* See notes.md, "Ignore MESSAGE_UPDATE and bridge THREAD_CREATE as the announcement"
@ -191,8 +132,7 @@ module.exports = {
* @param {DiscordTypes.APIThreadChannel} thread
*/
async onThreadCreate(client, thread) {
const channelID = thread.parent_id || undefined
const parentRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
const parentRoomID = select("channel_room", "room_id", {channel_id: thread.parent_id}).pluck().get()
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)
@ -226,8 +166,7 @@ module.exports = {
async onChannelPinsUpdate(client, data) {
const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get()
if (!roomID) return // No target room to update pins in
const convertedTimestamp = updatePins.convertTimestamp(data.last_pin_timestamp)
await updatePins.updatePins(data.channel_id, roomID, convertedTimestamp)
await updatePins.updatePins(data.channel_id, roomID)
},
/**
@ -236,21 +175,19 @@ module.exports = {
*/
async onMessageCreate(client, message) {
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)
if (message.webhook_id) {
const row = select("webhook", "webhook_id", {webhook_id: message.webhook_id}).pluck().get()
if (row) return // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it.
if (row) {
// The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it.
return
}
}
/** @type {DiscordTypes.APIGuildChannel} */
const channel = client.channels.get(message.channel_id)
if (!channel.guild_id) return // Nothing we can do in direct messages.
const guild = client.guilds.get(channel.guild_id)
const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id)
if (affected) return
// @ts-ignore
await sendMessage.sendMessage(message, guild, row),
await sendMessage.sendMessage(message, guild),
await discordCommandHandler.execute(message, channel, guild)
},
@ -261,26 +198,21 @@ module.exports = {
async onMessageUpdate(client, data) {
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.
if (row) {
// The update was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it.
return
}
}
// Edits need to go through the speedbump as well. If the message is delayed but the edit isn't, we don't have anything to edit from.
const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id)
if (affected) return
// Based on looking at data they've sent me over the gateway, this is the best way to check for meaningful changes.
// If the message content is a string then it includes all interesting fields and is meaningful.
if (typeof data.content === "string") {
/** @type {DiscordTypes.GatewayMessageCreateDispatchData} */
// @ts-ignore
const message = data
/** @type {DiscordTypes.APIGuildChannel} */
const channel = client.channels.get(message.channel_id)
if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages.
if (!channel.guild_id) return // Nothing we can do in direct messages.
const guild = client.guilds.get(channel.guild_id)
assert(guild)
// @ts-ignore
await editMessage.editMessage(message, guild, row)
await editMessage.editMessage(message, guild)
}
},
@ -307,20 +239,11 @@ module.exports = {
* @param {DiscordTypes.GatewayMessageDeleteDispatchData} data
*/
async onMessageDelete(client, data) {
speedbump.onMessageDelete(data.id)
await deleteMessage.deleteMessage(data)
},
/**
* @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayMessageDeleteBulkDispatchData} data
*/
async onMessageDeleteBulk(client, data) {
await deleteMessage.deleteMessageBulk(data)
},
/**
* @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayTypingStartDispatchData} data
*/
async onTypingStart(client, data) {
@ -339,6 +262,6 @@ module.exports = {
* @param {DiscordTypes.GatewayGuildEmojisUpdateDispatchData | DiscordTypes.GatewayGuildStickersUpdateDispatchData} data
*/
async onExpressionsUpdate(client, data) {
await createSpace.syncSpaceExpressions(data, false)
await createSpace.syncSpaceExpressions(data)
}
}

View file

View file

@ -1,5 +0,0 @@
BEGIN TRANSACTION;
ALTER TABLE channel_room ADD COLUMN last_bridged_pin_timestamp INTEGER;
COMMIT;

View file

@ -1,7 +0,0 @@
BEGIN TRANSACTION;
ALTER TABLE channel_room ADD COLUMN speedbump_id TEXT;
ALTER TABLE channel_room ADD COLUMN speedbump_webhook_id TEXT;
ALTER TABLE channel_room ADD COLUMN speedbump_checked INTEGER;
COMMIT;

View file

@ -1,6 +0,0 @@
CREATE TABLE IF NOT EXISTS sim_proxy (
user_id TEXT NOT NULL,
proxy_owner_id TEXT NOT NULL,
displayname TEXT NOT NULL,
PRIMARY KEY(user_id)
) WITHOUT ROWID;

12
db/orm-defs.d.ts vendored
View file

@ -6,10 +6,6 @@ export type Models = {
nick: string | null
thread_parent: string | null
custom_avatar: string | null
last_bridged_pin_timestamp: number | null
speedbump_id: string | null
speedbump_webhook_id: string | null
speedbump_checked: number | null
}
event_message: {
@ -50,7 +46,6 @@ export type Models = {
channel_id: string
}
// A sim is an account that is being simulated by the bridge to copy events from the other side. See d2m\actions\register-user.js for relevant behavior and comments.
sim: {
user_id: string
sim_name: string
@ -64,12 +59,6 @@ export type Models = {
hashed_profile_content: number
}
sim_proxy: {
user_id: string
proxy_owner_id: string
displayname: string
}
webhook: {
channel_id: string
webhook_id: string
@ -107,4 +96,3 @@ export type Prepared<Row> = {
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}

View file

@ -38,8 +38,6 @@ class From {
/** @private @type {Table[]} */
this.tables = [table]
/** @private */
this.directions = []
/** @private */
this.sql = ""
/** @private */
this.cols = []
@ -55,14 +53,12 @@ class From {
* @template {keyof U.Models} Table2
* @param {Table2} table
* @param {Col & (keyof U.Models[Table2])} col
* @param {"inner" | "left"} [direction]
*/
join(table, col, direction = "inner") {
join(table, col) {
/** @type {From<Table | Table2, keyof U.Merge<U.Models[Table | Table2]>>} */
// @ts-ignore
const r = this
r.tables.push(table)
r.directions.push(direction.toUpperCase())
r.using.push(col)
return r
}
@ -86,6 +82,7 @@ class From {
/** @type {Pluck<Table, Select>} */
// @ts-ignore
const r = this
r.constructor = Pluck
r.cols = [col]
r.isPluck = true
return r
@ -116,8 +113,7 @@ class From {
for (let i = 1; i < this.tables.length; i++) {
const table = this.tables[i]
const col = this.using[i-1]
const direction = this.directions[i-1]
sql += `${direction} JOIN ${table} USING (${col}) `
sql += `INNER JOIN ${table} USING (${col}) `
}
sql += this.sql
/** @type {U.Prepared<Pick<U.Merge<U.Models[Table]>, Col>>} */
@ -137,7 +133,6 @@ class From {
}
}
/* c8 ignore start - this code is only used for types and does not actually execute */
/**
* @template {keyof U.Models} Table
* @template {keyof U.Merge<U.Models[Table]>} Col
@ -161,7 +156,6 @@ class Pluck extends From {
return prepared.all(..._)
}
}
/* c8 ignore stop */
/**
* @template {keyof U.Models} Table

View file

@ -44,12 +44,3 @@ test("orm: from: where and pluck works", t => {
const subtypes = from("event_message").where({message_id: "1141501302736695316"}).pluck("event_subtype").all()
t.deepEqual(subtypes.sort(), ["m.image", "m.text"])
})
test("orm: from: join direction works", t => {
const hasOwner = from("sim").join("sim_proxy", "user_id", "left").select("user_id", "proxy_owner_id").where({sim_name: "_pk_zoego"}).get()
t.deepEqual(hasOwner, {user_id: "43d378d5-1183-47dc-ab3c-d14e21c3fe58", proxy_owner_id: "196188877885538304"})
const hasNoOwner = from("sim").join("sim_proxy", "user_id", "left").select("user_id", "proxy_owner_id").where({sim_name: "crunch_god"}).get()
t.deepEqual(hasNoOwner, {user_id: "820865262526005258", proxy_owner_id: null})
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, null)
})

View file

@ -2,8 +2,6 @@
const DiscordTypes = require("discord-api-types/v10")
const EPOCH = 1420070400000
/**
* @param {string[]} userRoles
* @param {DiscordTypes.APIGuild["roles"]} guildRoles
@ -34,7 +32,7 @@ function getPermissions(userRoles, guildRoles, userID, channelOverwrites) {
// Role deny
overwrite => userRoles.includes(overwrite.id) && (allowed &= ~BigInt(overwrite.deny)),
// Role allow
overwrite => userRoles.includes(overwrite.id) && (allowed |= BigInt(overwrite.allow)),
overwrite => userRoles.includes(overwrite.id) && (allowed |= ~BigInt(overwrite.allow)),
// User deny
overwrite => overwrite.id === userID && (allowed &= ~BigInt(overwrite.deny)),
// User allow
@ -58,17 +56,5 @@ function isWebhookMessage(message) {
return message.webhook_id && !isInteractionResponse
}
/** @param {string} snowflake */
function snowflakeToTimestampExact(snowflake) {
return Number(BigInt(snowflake) >> 22n) + EPOCH
}
/** @param {number} timestamp */
function timestampToSnowflakeInexact(timestamp) {
return String((timestamp - EPOCH) * 2**22)
}
module.exports.getPermissions = getPermissions
module.exports.isWebhookMessage = isWebhookMessage
module.exports.snowflakeToTimestampExact = snowflakeToTimestampExact
module.exports.timestampToSnowflakeInexact = timestampToSnowflakeInexact

View file

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

View file

@ -32,13 +32,13 @@ What does it look like on Discord-side?
This is an API request to get the pinned messages. To update this, an API request will pin or unpin any specific message, adding or removing it from the list.
## What will the converter do?
## What will the converter look like?
The converter will be very different in both directions.
**For d2m, we will get the list of pinned messages, we will convert each message ID into the ID of an event we already have, and then we will set the entire `m.room.pinned_events` state to that list.**
For d2m, we will get the list of pinned messages, we will convert each message ID into the ID of an event we already have, and then we will set the entire `m.room.pinned_events` state to that list.
**For m2d, we will have to diff the list of pinned messages against the previous version of the list, and for each event that was pinned or unpinned, we will send an API request to Discord to change its st**ate.
For m2d, we will have to diff the list of pinned messages against the previous version of the list, and for each event that was pinned or unpinned, we will send an API request to Discord to change its state.
## Missing messages
@ -53,7 +53,7 @@ In this situation we need to stop and think about the possible paths forward we
The latter method would still make the message appear at the bottom of the timeline for most Matrix clients, since for most the timestamp doesn't determine the actual _order._ It would then be confusing why an odd message suddenly appeared, because a pins change isn't that noticable in the room.
To avoid this problem, I'll just go with the former method and ignore the message, so Matrix will only have some of the pins that Discord has. We will need to watch out if a Matrix user edits this list of partial pins, because if we _only_ pinned things on Discord that were pinned on Matrix, then pins Matrix doesn't know about would be lost from Discord side.
To avoid this problem, I'll just go with the former method and ignore the message, so Matrix will only have some of the pins that Discord has. We will need to watch out if a Matrix user edits this list of partial pins, because if we _only_ pinned things on Discord that were pinned on Matrix, those partial pins Discord would be lost from Discord side.
In this situation I will prefer to keep the pins list inconsistent between both sides and only bridge _changes_ to the list.
@ -61,9 +61,7 @@ If you were implementing this for real, you might have made different decisions
## Test data for the d2m converter
Let's start writing the d2m converter. It's helpful to write automated tests for Out Of Your Element, since this lets you check if it worked without having to start up a local copy of the bridge or mess around with the interface.
To test the Discord-to-Matrix pin converter, we'll need some samples of Discord message objects. Then we can put these sample message objects through the converter and check what comes out the other side.
Let's start writing the d2m converter. It's helpful to write unit tests for Out Of Your Element, since this lets you check if it worked without having to start up a local copy of the bridge or play around with the interface.
Normally for getting test data, I would `curl` the Discord API to grab some real data and put it into `data.js` (and possibly also `ooye-test-data.sql`. But this time, I'll fabricate some test data. Here it is:
@ -76,7 +74,7 @@ Normally for getting test data, I would `curl` the Discord API to grab some real
]
```
"These aren't message objects!" I hear you cry. Correct. I already know that my implementation is not going to care about any properties on these message object other than the IDs, so to save time, I'm just making a list of IDs.
"These aren't message objects!" I hear you cry. Correct. I already know that my implementation is not going to care about any properties on these message object other than the IDs, so I'm just making a list of IDs to save time.
These IDs were carefully chosen. The first three are already in `ooye-test-data.sql` and are associated with event IDs. This is great, because in our test case, the Discord IDs will be converted to those event IDs. The fourth ID doesn't exist on Matrix-side. This is to test that partial pins are handled as expected, like I wrote in the previous section.
@ -106,7 +104,7 @@ index c36f252..4919beb 100644
## Writing the d2m converter
We can write a function that operates on this data to convert it to events. This is a _converter,_ not an _action._ It won't _do_ anything by itself. So it goes in the converters folder. I've already planned (in the "What will the converter do?" section) what to do, so writing the function is pretty simple:
We can write a function that operates on this data to convert it to events. This is a _converter,_ not an _action._ it won't _do_ anything by itself. So it goes in the converters folder. The actual function is pretty simple since I've already planned what to do:
```diff
diff --git a/d2m/converters/pins-to-list.js b/d2m/converters/pins-to-list.js
@ -135,36 +133,9 @@ index 0000000..e4107be
+module.exports.pinsToList = pinsToList
```
### Explaining the code
All converters have a `function` which does the work, and the function is added to `module.exports` so that other files can use it.
Importing `select` from `passthrough` lets us do database access. Calling the `select` function can select from OOYE's own SQLite database. If you want to see what's in the database, look at `ooye-test-data.sql` for test data, or open `ooye.db` for real data from your own bridge.
The comments `// @ts-check`, `/** @type ... */`, and `/** @param ... */` provide type-based autosuggestions when editing in Visual Studio Code.
Here's the code I haven't yet discussed:
```js
function pinsToList(pins) {
const result = []
for (const message of pins) {
const eventID = select("event_message", "event_id", {message_id: message.id}).pluck().get()
if (eventID) result.push(eventID)
}
return result
}
```
It will go through each `message` in `pins`. For each message, it will look up the corresponding Matrix event in the database, and if found, it will add it to `result`.
The `select` line will run this SQL: `SELECT event_id FROM event_message WHERE message_id = {the message ID}` and will return the event ID as a string or null.
For any database experts worried about an SQL query inside a loop, the N+1 problem does not apply to SQLite because the queries are executed in the same process rather than crossing a process (and network) boundary. https://www.sqlite.org/np1queryprob.html
## Test case for the d2m converter
There's not much room for bugs in this function. A single manual test that it works would be good enough for me. But since this is an example of how you can add your own, let's add a test case for this. The testing code will take the data we just prepared and process it through the `pinsToList` function we just wrote. Then, it will check the result is what we expected.
There's not much room for bugs in this function. A single manual test that it works would be good enough for me. But since this is an example of how you can add your own, let's add a test case for this. We'll take the data we just prepared and process it through the function we just wrote:
```diff
diff --git a/d2m/converters/pins-to-list.test.js b/d2m/converters/pins-to-list.test.js
@ -206,18 +177,6 @@ index 5cc851e..280503d 100644
Good to go.
### Explaining the code
`require("supertape")` is a library that helps with testing and printing test results. `data = require("../../test/data")` is the file we edited earlier in the "Test data for the d2m converter" section. `require("./pins-to-list")` is the function we want to test.
Here is how you declare a test: `test("pins2list: converts known IDs, ignores unknown IDs", t => {` The string describes what you are trying to test and it will be displayed if the test fails.
`result = pinsToList(data.pins.faked)` is calling the implementation function we wrote.
`t.deepEqual(actual, expected)` will check whether the `actual` result value is the same as our `expected` result value. If it's not, it'll mark that as a failed test.
### Run the test!
```
><> $ npm t
@ -242,19 +201,15 @@ Here is how you declare a test: `test("pins2list: converts known IDs, ignores un
at module.exports (out-of-your-element/node_modules/try-to-catch/lib/try-to-catch.js:7:29)
```
Oh no! (I promise I didn't make it fail for demonstration purposes, this was actually an accident!) Let's see what this bug is. It's returning the right number of IDs, but 2 out of the 3 are incorrect. The green `-` lines are "expected" and the red `+` lines are "actual". The wrong ID `$51f...` must have been taken from _somewhere_ in the test data, so I'll first search the codebase and find where it came from:
Oh, this was actually an accident, I didn't make it fail for demonstration purposes! Let's see what this bug is. It's returning the right number of IDs, but 2 out of the 3 are incorrect. The green `-` lines are "expected" and the red `+` lines are "actual". I should check where that wrong ID `$51f...` got taken from.
```sql
-- snipped from ooye-test-data.sql
```
- snip - ooye-test-data.sql
('$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA', 'm.room.message', 'm.text', '1141501302736695316', 0, 1),
('$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCI', 'm.room.message', 'm.image', '1141501302736695316', 1, 1),
```
Explanation: This Discord message `1141501302736695316` is actually part of 2 different Matrix events, `$mtR...` and `$51f...`. This often happens when a Discord user uploads an image with a caption. Matrix doesn't support combined image+text events, so the image and the text have to be bridged to separate events.
In the current code, `pinsToList` is picking ALL the associated event IDs, and then `.get` is forcing it to limit that list to 1. It doesn't care which, so it's essentially random which event it wants to pin.
We should make a decision on which event is more important. You can make whatever decision you want - you could even make it pin every event associated with a message - but I've decided that the text should be the primary part and be pinned, and the image should be considered a secondary part and left unpinned.
Context: This Discord message `1141501302736695316` is actually part of 2 different Matrix events, `$mtR...` and `$51f...`. This often happens when a Discord user uploads an image with a caption. Matrix doesn't support combined image+text events, so the image and the text have to be bridged to separate events. We should consider the text to be the primary part, and pin that, and consider the image to be the secondary part, and not pin that.
We already have a column `part` in the `event_message` table for this reason! When `part = 0`, that's the primary part. I'll edit the converter to actually use that column:
@ -274,8 +229,6 @@ index e4107be..f401de2 100644
return result
```
As long as the database is consistent, this new `select` will return at most 1 event, always choosing the primary part.
```
><> $ npm t
@ -316,7 +269,7 @@ index 83c31cd..4de84d9 100644
await eventDispatcher.onThreadCreate(client, message.d)
```
`event-dispatcher.js` will now check if the event seems reasonable and is allowed in this context. For example, we can only update pins if the channel is actually bridged somewhere. After the check, we'll call the action:
`event-dispatcher.js` will now check if the event seems reasonable and is allowed in this context. For example, we can only update pins if the channel is actually bridged somewhere. This should be another quick check which passes to an action to do the API calls:
```diff
diff --git a/d2m/event-dispatcher.js b/d2m/event-dispatcher.js
@ -351,7 +304,7 @@ index 0f9f1e6..6e91e9e 100644
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message
```
And now I can write the `update-pins.js` action:
And now I can create the `update-pins.js` action:
```diff
diff --git a/d2m/actions/update-pins.js b/d2m/actions/update-pins.js
@ -386,76 +339,6 @@ index 0000000..40cc358
I try to keep as much logic as possible out of the actions and in the converters. This should mean I *never have to unit test the actions themselves.* The actions will be tested manually with the real bot.
## See if it works
Since the automated tests pass, let's start up the bridge and run our nice new code:
```
node start.js
```
We can try these things and see if they are bridged to Matrix:
- Pin a recent message on Discord-side
- Pin an old message on Discord-side
- Unpin a message on Discord-side
It works like I'd expect!
## Order of pinned messages
I expected that to be the end of the guide, but after some time, I noticed a new problem: The pins are in reverse order. How could this happen?
[After some investigation,](https://gitdab.com/cadence/out-of-your-element/issues/16) it turns out Discord puts the most recently pinned message at the start of the array and displays the array in forwards order, while Matrix puts the most recently pinned message at the end of the array and displays the array in reverse order.
We can fix this by reversing the order of the list of pins before we store it. The converter can do this:
```diff
diff --git a/d2m/converters/pins-to-list.js b/d2m/converters/pins-to-list.js
index f401de2..047bb9f 100644
--- a/d2m/converters/pins-to-list.js
+++ b/d2m/converters/pins-to-list.js
@@ -12,6 +12,7 @@ function pinsToList(pins) {
const eventID = select("event_message", "event_id", {message_id: message.id, part: 0}).pluck().get()
if (eventID) result.push(eventID)
}
+ result.reverse()
return result
}
```
Since the results have changed, I'll need to update the test so it expects the new result:
```diff
diff --git a/d2m/converters/pins-to-list.test.js b/d2m/converters/pins-to-list.test.js
index c2e3774..92e5678 100644
--- a/d2m/converters/pins-to-list.test.js
+++ b/d2m/converters/pins-to-list.test.js
@@ -5,8 +5,8 @@ const {pinsToList} = require("./pins-to-list")
test("pins2list: converts known IDs, ignores unknown IDs", t => {
const result = pinsToList(data.pins.faked)
t.deepEqual(result, [
- "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg",
- "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA",
- "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4"
+ "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4",
+ "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA",
+ "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg"
])
})
```
```
><> $ npm t
144 tests
232 passed
Pass!
```
Next time a message is pinned or unpinned on Discord, OOYE should update the order of all the pins on Matrix.
## Notes on missed events
Note that this will only sync pins _when the pins change._ Existing pins from Discord will not be backfilled to Matrix rooms. If I wanted, there's a couple of ways I could address this:
@ -463,4 +346,4 @@ Note that this will only sync pins _when the pins change._ Existing pins from Di
* I could create a one-shot script in `scripts/update-pins.js` which will sync pins for _all_ Discord channels right away. I can run this after finishing the feature, or if the bot has been offline for some time.
* I could create a database table that holds the timestamp of the most recently detected pin for each channel - the `last_pin_timestamp` field from the gateway. Every time the bot starts, it would automatically compare the database table against every channel, and if the pins have changed since it last looked, it could automatically update them.
I already have code to backfill missed messages when the bridge starts up. The second option above would add a similar feature for backfilling missed pins. It would be worth considering.
I already have a mechanism for backfilling missed messages when the bridge starts up. Option 2 there would add a similar feature for backfilling missed pins. That could be worth considering, but it's less important and more complex. Perhaps we'll come back to it.

View file

@ -1,98 +0,0 @@
## What is PluralKit
PluralKit is a Discord bot. After a Discord user registers with PK, PK will delete and repost their messages. The reposted messages will be sent by a webhook with a custom display name and avatar. This effectively lets a person assume a custom display name and avatar at will on a per-message basis. People use this for roleplaying and/or dissociative-identity-disorder things. PK is extremely popular.
## PK terminology
- **Proxying:** The act of deleting and reposting messages.
- **Member:** Identity that messages will be posted by.
- **System:** Systems contain members. A system is usually controlled by one Discord account, but it's also possible to have multiple accounts be part of the same system.
## PK API schema
https://pluralkit.me/api/models/
## Experience on OOYE without special PK handling
1. Message is sent by Discord user and copied to Matrix-side.
1. The message is immediately deleted by PK and deleted from Matrix-side.
1. The message is resent by the PK webhook and copied to Matrix-side (by @_ooye_bot) with limited authorship information.
## Experience on Half-Shot's bridge without special PK handling
1. Message is sent by Discord user and copied to Matrix-side.
1. The message is immediately deleted by PK and deleted from Matrix-side.
1. The message is resent by the PK webhook and copied to Matrix-side _by a dedicated sim user for that webhook's username._
If a PK system member changes their display name, the webhook display name will change too. But Half-Shot's bridge can't keep track of webhook message authorship. It uses the webhook's display name to determine whether to reuse the previous sim user account. This makes Half-Shot's bridge create a brand new sim user for the same system member, and causes the Matrix-side member list to eventually fill up with lots of abandoned sim users named @_discord_NUMBERS_NUMBERS_GARBLED_NAME.
## Goals of special PK handling
1. Avoid bridging the send-delete-send dance (solution: the speedbump)
2. Attribute message authorship to the actual PK system member (solution: system member mapping)
3. Avoid creating too many sim users (solution: OOYE sending other webhook messages as @_ooye_bot)
## What is the speedbump (goal 1)
When a Discord user sends a message, we can't know whether or not it's about to be deleted by PK.
If PK doesn't plan to delete the message, we should deliver it straight away to Matrix-side.
But if PK does plan to delete the message, we shouldn't bridge it at all. We should wait until the PK webhook sends the replacement message, then deliver _that_ message to Matrix-side.
Unfortunately, we can't see into the future. We don't know if PK will delete the message or not.
My solution is the speedbump. In speedbump-enabled channels, OOYE will wait a few seconds before delivering the message. The **purpose of the speedbump is to avoid the send-delete-send dance** by not bridging a message until we know it's supposed to stay.
## Configuring the speedbump
Nuh-uh. Offering configuration creates an opportunity for misconfiguration. OOYE wants to act in the best possible way with the default settings. In general, everything in OOYE should work in an intelligent, predictable way without having to think about it.
Since it slows down messages, the speedbump has a negative impact on user experience if it's not needed. So OOYE will automatically activate and deactivate the speedbump if it's necessary. Here's how it works.
When a message is deleted in a channel, the following logic is triggered:
1. Discord API: Get the list of webhooks in this channel.
1. If there is a webhook owned by PK, speedbump mode is now ON. Otherwise, speedbump mode is now OFF.
This check is only done every so often to avoid encountering the Discord API's rate limits.
## PK system member mapping (goal 2)
PK system members need to be mapped to individual Matrix sim users, so we need to map the member data to all the fields of a Matrix profile. (This will replace the existing logic of `userToSimName`.) I'll map them in this way:
- **Matrix ID:** `@_ooye_pk_[FIVE_CHAR_ID].example.org`
- **Display name:** `[NAME] [[PRONOUNS]]`
- **Avatar:** webhook_avatar_url ?? avatar_url
I'll get this data by calling the PK API for each message: https://api.pluralkit.me/v2/messages/[PK_WEBHOOK_MESSAGE_ID]
## Special code paths for PK users
When a message is deleted, re-evaluate speedbump mode if necessary, and store who the PK webhook is for this channel if exists.
When a message is received and the speedbump is enabled, put it into a queue to be sent a few seconds later.
When a message is deleted, remove it from the queue.
When a message is received, if it's from a webhook, and the webhook is in the "speedbump_webhook" table, and the webhook user ID is the public PK instance, then look up member details in the PK API, and use a different MXID mapping algorithm based on those details.
### Edits should Just Work without any special code paths
Proxied messages are edited by sending "pk;edit blah blah" as a reply to the message to edit. PK will delete the edit command and use the webhook edit endpoint to update the message.
OOYE's speedbump will prevent the edit command appearing at all on Matrix-side, and OOYE already understands how to do webhook edits.
## Database schema
* channel_room
+ speedbump_id - the ID of the webhook that may be proxying in this channel
+ speedbump_checked - time in unix seconds when the webhooks were last queried
## Unsolved problems
- Improve the contents of PK's reply embeds to be the actual reply text, not the OOYE context preamble
- Possibly change OOYE's reply context to be an embed (for visual consistency with replies from PK users)
- Possibly extract information from OOYE's reply embed and transform it into an mx-reply structure for Matrix users
- Unused or removed system members should be removed from the member list too.
- When a Discord user leaves a server, all their system members should leave the member list too. (I also have to solve this for regular non-PK users.)

View file

@ -1,36 +0,0 @@
// @ts-check
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")
/**
* Downloads the emoji from the web and converts to uncompressed PNG data.
* @param {string} mxc a single mxc:// URL
* @returns {Promise<Buffer | undefined>} uncompressed PNG data, or undefined if the downloaded emoji is not valid
*/
async function getAndConvertEmoji(mxc) {
const abortController = new AbortController()
const url = utils.getPublicUrlForMxc(mxc)
assert(url)
/** @type {import("node-fetch").Response} */
// If it turns out to be a GIF, we want to abandon the connection without downloading the whole thing.
// If we were using connection pooling, we would be forced to download the entire GIF.
// So we set no agent to ensure we are not connection pooling.
// @ts-ignore the signal is slightly different from the type it wants (still works fine)
const res = await fetch(url, {agent: false, signal: abortController.signal})
return emojiSheetConverter.convertImageStream(res.body, () => {
abortController.abort()
res.body.pause()
res.body.emit("end")
})
}
module.exports.getAndConvertEmoji = getAndConvertEmoji

View file

@ -13,9 +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)
discord.snow.channel.deleteMessage(row.channel_id, row.message_id, event.content.reason)
}
}

View file

@ -15,10 +15,6 @@ const channelWebhook = sync.require("./channel-webhook")
const eventToMessage = sync.require("../converters/event-to-message")
/** @type {import("../../matrix/api")}) */
const api = sync.require("../../matrix/api")
/** @type {import("../../d2m/actions/register-user")} */
const registerUser = sync.require("../../d2m/actions/register-user")
/** @type {import("../actions/emoji-sheet")} */
const emojiSheet = sync.require("../actions/emoji-sheet")
/**
* @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
@ -77,7 +73,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, fetch, mxcDownloader: emojiSheet.getAndConvertEmoji})
let {messagesToEdit, messagesToSend, messagesToDelete} = await eventToMessage.eventToMessage(event, guild, {api})
messagesToEdit = await Promise.all(messagesToEdit.map(async e => {
e.message = await resolvePendingFiles(e.message)
@ -98,8 +94,6 @@ 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)
}
@ -113,10 +107,6 @@ async function sendEvent(event) {
messageResponses.push(messageResponse)
}
for (const user of ensureJoined) {
registerUser.ensureSimJoined(user, event.room_id)
}
return messageResponses
}

View file

@ -4,7 +4,8 @@ const assert = require("assert").strict
const {pipeline} = require("stream").promises
const sharp = require("sharp")
const {GIFrame} = require("giframe")
const {PNG} = require("pngjs")
const utils = require("./utils")
const fetch = require("node-fetch").default
const streamMimeType = require("stream-mime-type")
const SIZE = 48
@ -14,11 +15,64 @@ const IMAGES_ACROSS = Math.floor(RESULT_WIDTH / SIZE)
/**
* Composite a bunch of Matrix emojis into a kind of spritesheet image to upload to Discord.
* @param {string[]} mxcs mxc URLs, in order
* @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.
* @returns {Promise<Buffer>} PNG image
*/
async function compositeMatrixEmojis(mxcs, mxcDownloader) {
const buffers = await Promise.all(mxcs.map(mxcDownloader))
async function compositeMatrixEmojis(mxcs) {
let buffers = await Promise.all(mxcs.map(async mxc => {
const abortController = new AbortController()
try {
const url = utils.getPublicUrlForMxc(mxc)
assert(url)
/** @type {import("node-fetch").Response} res */
// If it turns out to be a GIF, we want to abandon the connection without downloading the whole thing.
// If we were using connection pooling, we would be forced to download the entire GIF.
// So we set no agent to ensure we are not connection pooling.
// @ts-ignore the signal is slightly different from the type it wants (still works fine)
const res = await fetch(url, {agent: false, signal: abortController.signal})
const {stream, mime} = await streamMimeType.getMimeType(res.body)
if (mime === "image/png" || mime === "image/jpeg" || mime === "image/webp") {
/** @type {{info: sharp.OutputInfo, buffer: Buffer}} */
const result = await new Promise((resolve, reject) => {
const transformer = sharp()
.resize(SIZE, SIZE, {fit: "contain", background: {r: 0, g: 0, b: 0, alpha: 0}})
.png({compressionLevel: 0})
.toBuffer((err, buffer, info) => {
/* c8 ignore next */
if (err) return reject(err)
resolve({info, buffer})
})
pipeline(
stream,
transformer
)
})
return result.buffer
} else if (mime === "image/gif") {
const giframe = new GIFrame(0)
stream.on("data", chunk => {
giframe.feed(chunk)
})
const frame = await giframe.getFrame()
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})
return buffer.data
} else {
// unsupported mime type
console.error(`I don't know what a ${mime} emoji is.`)
return null
}
} finally {
abortController.abort()
}
}))
// Calculate the size of the final composited image
const totalWidth = Math.min(buffers.length, IMAGES_ACROSS) * SIZE
@ -47,67 +101,4 @@ async function compositeMatrixEmojis(mxcs, mxcDownloader) {
return output.data
}
/**
* @param {import("node-fetch").Response["body"]} streamIn
* @param {() => any} stopStream
* @returns {Promise<Buffer | undefined>} Uncompressed PNG image
*/
async function convertImageStream(streamIn, stopStream) {
const {stream, mime} = await streamMimeType.getMimeType(streamIn)
assert(["image/png", "image/jpeg", "image/webp", "image/gif", "image/apng"].includes(mime), `Mime type ${mime} is impossible for emojis`)
try {
if (mime === "image/png" || mime === "image/jpeg" || mime === "image/webp") {
/** @type {{info: sharp.OutputInfo, buffer: Buffer}} */
const result = await new Promise((resolve, reject) => {
const transformer = sharp()
.resize(SIZE, SIZE, {fit: "contain", background: {r: 0, g: 0, b: 0, alpha: 0}})
.png({compressionLevel: 0})
.toBuffer((err, buffer, info) => {
/* c8 ignore next */
if (err) return reject(err)
resolve({info, buffer})
})
pipeline(
stream,
transformer
)
})
return result.buffer
} else if (mime === "image/gif") {
const giframe = new GIFrame(0)
stream.on("data", chunk => {
giframe.feed(chunk)
})
const frame = await giframe.getFrame()
stopStream()
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})
return buffer.data
} else if (mime === "image/apng") {
const png = new PNG({maxFrames: 1})
// @ts-ignore
stream.pipe(png)
/** @type {Buffer} */ // @ts-ignore
const frame = await new Promise(resolve => png.on("parsed", resolve))
stopStream()
const buffer = await sharp(frame, {raw: {width: png.width, height: png.height, channels: 4}})
.resize(SIZE, SIZE, {fit: "contain", background: {r: 0, g: 0, b: 0, alpha: 0}})
.png({compressionLevel: 0})
.toBuffer({resolveWithObject: true})
return buffer.data
}
} finally {
stopStream()
}
}
module.exports.compositeMatrixEmojis = compositeMatrixEmojis
module.exports.convertImageStream = convertImageStream

View file

@ -1,58 +0,0 @@
const {test} = require("supertape")
const {convertImageStream} = require("./emoji-sheet")
const fs = require("fs")
const {Transform} = require("stream").Transform
/* c8 ignore next 7 */
function slow() {
if (process.argv.includes("--slow")) {
return test
} else {
return test.skip
}
}
class Meter extends Transform {
bytes = 0
_transform(chunk, encoding, cb) {
this.bytes += chunk.length
this.push(chunk)
cb()
}
}
/**
* @param {import("supertape").Test} t
* @param {string} path
* @param {number} totalSize
* @param {number => boolean} sizeCheck
*/
async function runSingleTest(t, path, totalSize, sizeCheck) {
const file = fs.createReadStream(path)
const meter = new Meter()
const p = file.pipe(meter)
const result = await convertImageStream(p, () => {
file.pause()
file.emit("end")
})
t.equal(result.subarray(1, 4).toString("ascii"), "PNG", `test that this is a PNG file: ${result.toString("base64").slice(0, 100)}`)
/* c8 ignore next 5 */
if (sizeCheck(meter.bytes)) {
t.pass("read the correct amount of the file")
} else {
t.fail(`read too much or too little of the file, read: ${meter.bytes}, total: ${totalSize}`)
}
}
slow()("emoji-sheet: only partial file is read for APNG", async t => {
await runSingleTest(t, "test/res/butterfly.png", 2438998, n => n < 2438998 / 4) // should download less than 25% of the file
})
slow()("emoji-sheet: only partial file is read for GIF", async t => {
await runSingleTest(t, "test/res/butterfly.gif", 781223, n => n < 781223 / 4) // should download less than 25% of the file
})
slow()("emoji-sheet: entire file is read for static PNG", async t => {
await runSingleTest(t, "test/res/RLMgJGfgTPjIQtvvWZsYjhjy.png", 11301, n => n === 11301) // should download entire file
})

View file

@ -40,8 +40,6 @@ function encodeEmoji(input, shortcode) {
"%E2%AD%90", // ⭐
"%F0%9F%90%88", // 🐈
"%E2%9D%93", // ❓
"%F0%9F%8F%86", // 🏆️
"%F0%9F%93%9A", // 📚️
]
discordPreferredEncoding =

View file

@ -5,16 +5,13 @@ const DiscordTypes = require("discord-api-types/v10")
const {Readable} = require("stream")
const chunk = require("chunk-text")
const TurndownService = require("turndown")
const domino = require("domino")
const assert = require("assert").strict
const entities = require("entities")
const passthrough = require("../../passthrough")
const {sync, db, discord, select, from} = passthrough
/** @type {import("../converters/utils")} */
const mxUtils = sync.require("../converters/utils")
/** @type {import("../../discord/utils")} */
const dUtils = sync.require("../../discord/utils")
const utils = sync.require("../converters/utils")
/** @type {import("./emoji-sheet")} */
const emojiSheet = sync.require("./emoji-sheet")
@ -33,19 +30,13 @@ const markdownEscapes = [
[/^>/g, '\\>'],
[/_/g, '\\_'],
[/^(\d+)\. /g, '$1\\. ']
/*
Strikethrough is deliberately not escaped. Usually when Matrix users type ~~ it's not because they wanted to send ~~,
it's because they wanted strikethrough and it didn't work because their client doesn't support it.
As bridge developers, we can choose between "messages should look as similar as possible" vs "it was most likely intended to be strikethrough".
I went with the latter. Even though the appearance doesn't match, I'd rather it displayed as originally intended for 80% of the readers than for 0%.
*/
]
const turndownService = new TurndownService({
hr: "----",
headingStyle: "atx",
preformattedCode: true,
codeBlockStyle: "fenced"
codeBlockStyle: "fenced",
})
/**
@ -93,15 +84,11 @@ turndownService.addRule("blockquote", {
turndownService.addRule("spoiler", {
filter: function (node, options) {
return node.tagName === "SPAN" && node.hasAttribute("data-mx-spoiler")
return node.hasAttribute("data-mx-spoiler")
},
replacement: function (content, node) {
if (node.getAttribute("data-mx-spoiler")) {
// escape parentheses so it can't become a link
return `\\(${node.getAttribute("data-mx-spoiler")}\\) ||${content}||`
}
return `||${content}||`
return "||" + content + "||"
}
})
@ -114,40 +101,12 @@ turndownService.addRule("inlineLink", {
},
replacement: function (content, node) {
if (node.getAttribute("data-user-id")) {
const user_id = node.getAttribute("data-user-id")
const row = select("sim_proxy", ["displayname", "proxy_owner_id"], {user_id}).get()
if (row) {
return `**@${row.displayname}** (<@${row.proxy_owner_id}>)`
} else {
return `<@${user_id}>`
}
}
if (node.getAttribute("data-message-id")) return `https://discord.com/channels/${node.getAttribute("data-guild-id")}/${node.getAttribute("data-channel-id")}/${node.getAttribute("data-message-id")}`
if (node.getAttribute("data-user-id")) return `<@${node.getAttribute("data-user-id")}>`
if (node.getAttribute("data-channel-id")) return `<#${node.getAttribute("data-channel-id")}>`
const href = node.getAttribute("href")
content = content.replace(/ @.*/, "")
if (href === content) return href
if (href.startsWith("https://matrix.to/#/@") && content[0] !== "@") content = "@" + content
return "[" + content + "](" + href + ")"
}
})
turndownService.addRule("listItem", {
filter: "li",
replacement: function (content, node, options) {
content = content
.replace(/^\n+/, "") // remove leading newlines
.replace(/\n+$/, "\n") // replace trailing newlines with just a single one
.replace(/\n/gm, "\n ") // indent
var prefix = options.bulletListMarker + " "
var parent = node.parentNode
if (parent.nodeName === "OL") {
var start = parent.getAttribute("start")
var index = Array.prototype.indexOf.call(parent.children, node)
prefix = (start ? Number(start) + index : index + 1) + ". "
}
return prefix + content + (node.nextSibling && !/\n$/.test(content) ? "\n" : "")
let brackets = ["", ""]
if (href.startsWith("https://matrix.to")) brackets = ["<", ">"]
return "[" + content + "](" + brackets[0] + href + brackets[1] + ")"
}
})
@ -161,8 +120,32 @@ turndownService.addRule("emoji", {
replacement: function (content, node) {
const mxcUrl = node.getAttribute("src")
// Get the known emoji from the database. (We may not be able to actually use this if it was from another server.)
const row = select("emoji", ["emoji_id", "name", "animated"], {mxc_url: mxcUrl}).get()
// Also guess a suitable emoji based on the ID (if available) or name
let guess = null
const guessedName = node.getAttribute("title").replace(/^:|:$/g, "")
return convertEmoji(mxcUrl, guessedName, true, true)
for (const guild of discord.guilds.values()) {
/** @type {{name: string, id: string, animated: number}[]} */
// @ts-ignore
const emojis = guild.emojis
const match = emojis.find(e => e.id === row?.emoji_id) || emojis.find(e => e.name === guessedName) || emojis.find(e => e.name?.toLowerCase() === guessedName.toLowerCase())
if (match) {
guess = match
break
}
}
if (guess) {
// We know an emoji, and we can use it
const animatedChar = guess.animated ? "a" : ""
return `<${animatedChar}:${guess.name}:${guess.id}>`
} else if (endOfMessageEmojis.includes(mxcUrl)) {
// We can't locate or use a suitable emoji. After control returns, it will rewind over this, delete this section, and upload the emojis as a sprite sheet.
return `<::>`
} else {
// We prefer not to upload this as a sprite sheet because the emoji is not at the end of the message, it is in the middle.
return `[${node.getAttribute("title")}](${utils.getPublicUrlForMxc(mxcUrl)})`
}
}
})
@ -179,7 +162,7 @@ turndownService.addRule("fencedCodeBlock", {
const className = node.firstChild.getAttribute("class") || ""
const language = (className.match(/language-(\S+)/) || [null, ""])[1]
const code = node.firstChild
const visibleCode = getCodeContent(code)
const visibleCode = code.childNodes.map(c => c.nodeName === "BR" ? "\n" : c.textContent).join("").replace(/\n*$/g, "")
var fence = "```"
@ -191,62 +174,6 @@ turndownService.addRule("fencedCodeBlock", {
}
})
/** @param {{ childNodes: Node[]; }} preCode the <code> directly inside the <pre> */
function getCodeContent(preCode) {
return preCode.childNodes.map(c => c.nodeName === "BR" ? "\n" : c.textContent).join("").replace(/\n*$/g, "")
}
/**
* @param {string | null} mxcUrl
* @param {string | null} nameForGuess without colons
* @param {boolean} allowSpriteSheetIndicator
* @param {boolean} allowLink
* @returns {string} discord markdown that represents the custom emoji in some form
*/
function convertEmoji(mxcUrl, nameForGuess, allowSpriteSheetIndicator, allowLink) {
// Get the known emoji from the database.
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.id))
if (!found) row = null
}
// Or, if we don't have an emoji right now, we search for the name instead.
if (!row && nameForGuess) {
const nameForGuessLower = nameForGuess.toLowerCase()
for (const guild of discord.guilds.values()) {
/** @type {{name: string, id: string, animated: number}[]} */
// @ts-ignore
const emojis = guild.emojis
const found = emojis.find(e => e.id === row?.id || e.name?.toLowerCase() === nameForGuessLower)
if (found) {
row = {
animated: found.animated,
emoji_id: found.id,
name: found.name
}
break
}
}
}
if (row) {
// We know an emoji, and we can use it
const animatedChar = row.animated ? "a" : ""
return `<${animatedChar}:${row.name}:${row.emoji_id}>`
} else if (allowSpriteSheetIndicator && mxcUrl && endOfMessageEmojis.includes(mxcUrl)) {
// We can't locate or use a suitable emoji. After control returns, it will rewind over this, delete this section, and upload the emojis as a sprite sheet.
return `<::>`
} else if (allowLink && mxcUrl && nameForGuess) {
// We prefer not to upload this as a sprite sheet because the emoji is not at the end of the message, it is in the middle.
return `[:${nameForGuess}:](${mxUtils.getPublicUrlForMxc(mxcUrl)})`
} else if (nameForGuess) {
return `:${nameForGuess}:`
} else {
return ""
}
}
/**
* @param {string} roomID
* @param {string} mxid
@ -264,8 +191,8 @@ async function getMemberFromCacheOrHomeserver(roomID, mxid, api) {
}
/**
* Splits a display name into one chunk containing <=80 characters (80 being how many characters Discord allows for the name of a webhook),
* and another chunk containing the rest of the characters. Splits on whitespace if possible.
* Splits a display name into one chunk containing <=80 characters, and another chunk containing the rest of the characters. Splits on
* whitespace if possible.
* These chunks, respectively, go in the display name, and at the top of the message.
* If the second part isn't empty, it'll also contain boldening markdown and a line break at the end, so that regardless of its value it
* can be prepended to the message content as-is.
@ -288,25 +215,14 @@ function splitDisplayName(displayName) {
}
}
/**
* Convert a Matrix user ID into a Discord user ID for mentioning, where if the user is a PK proxy, it will mention the proxy owner.
* @param {string} mxid
*/
function getUserOrProxyOwnerID(mxid) {
const row = from("sim").join("sim_proxy", "user_id", "left").select("user_id", "proxy_owner_id").where({mxid}).get()
if (!row) return null
return row.proxy_owner_id || row.user_id
}
/**
* At the time of this executing, we know what the end of message emojis are, and we know that at least one of them is unknown.
* 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, 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) {
async function uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles) {
if (!content.includes("<::>")) return content // No unknown emojis, nothing to do
// Remove known and unknown emojis from the end of the message
const r = /<a?:[a-zA-Z0-9_]*:[0-9]*>\s*$/
@ -314,115 +230,18 @@ async function uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles,
content = content.replace(r, "")
}
// Create a sprite sheet of known and unknown emojis from the end of the message
const buffer = await emojiSheet.compositeMatrixEmojis(endOfMessageEmojis, mxcDownloader)
const buffer = await emojiSheet.compositeMatrixEmojis(endOfMessageEmojis)
// Attach it
const name = "emojis.png"
attachments.push({id: String(attachments.length), name})
attachments.push({id: "0", name})
pendingFiles.push({name, buffer})
return content
}
/**
* @param {string} input
* @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API
*/
async function handleRoomOrMessageLinks(input, di) {
let offset = 0
for (const match of [...input.matchAll(/("?https:\/\/matrix.to\/#\/(![^"/, ?)]+)(?:\/(\$[^"/ ?)]+))?(?:\?[^",:!? )]*)?)(">|[, )]|$)/g)]) {
assert(typeof match.index === "number")
const [_, attributeValue, roomID, eventID, endMarker] = match
let result
const resultType = endMarker === '">' ? "html" : "plain"
const MAKE_RESULT = {
ROOM_LINK: {
html: channelID => `${attributeValue}" data-channel-id="${channelID}">`,
plain: channelID => `<#${channelID}>${endMarker}`
},
MESSAGE_LINK: {
html: (guildID, channelID, messageID) => `${attributeValue}" data-channel-id="${channelID}" data-guild-id="${guildID}" data-message-id="${messageID}">`,
plain: (guildID, channelID, messageID) => `https://discord.com/channels/${guildID}/${channelID}/${messageID}${endMarker}`
}
}
// 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
const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get()
if (!channelID) continue
if (!eventID) {
// 1: It's a room link, so <#link> to the channel
result = MAKE_RESULT.ROOM_LINK[resultType](channelID)
} else {
// Linking to a particular event with a discord.com/channels/guildID/channelID/messageID link
// Need to know the guildID and messageID
const guildID = discord.channels.get(channelID)?.["guild_id"]
if (!guildID) continue
const messageID = select("event_message", "message_id", {event_id: eventID}).pluck().get()
if (messageID) {
// 2: Linking to a known event
result = MAKE_RESULT.MESSAGE_LINK[resultType](guildID, channelID, messageID)
} else {
// 3: Linking to an unknown event that OOYE didn't originally bridge - we can guess messageID from the timestamp
let originalEvent
try {
originalEvent = await di.api.getEvent(roomID, eventID)
} catch (e) {
continue // Our homeserver doesn't know about the event, so can't resolve it to a Discord link
}
const guessedMessageID = dUtils.timestampToSnowflakeInexact(originalEvent.origin_server_ts)
result = MAKE_RESULT.MESSAGE_LINK[resultType](guildID, channelID, guessedMessageID)
}
}
input = input.slice(0, match.index + offset) + result + input.slice(match.index + match[0].length + offset)
offset += result.length - match[0].length
}
return input
}
/**
* @param {string} content
* @param {DiscordTypes.APIGuild} guild
* @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, fetch: import("node-fetch")["default"]}} di
*/
async function checkWrittenMentions(content, guild, di) {
let writtenMentionMatch = content.match(/(?:^|[^"[<>/A-Za-z0-9])@([A-Za-z][A-Za-z0-9._\[\]\(\)-]+):?/d) // /d flag for indices requires node.js 16+
if (writtenMentionMatch) {
const results = await di.snow.guild.searchGuildMembers(guild.id, {query: writtenMentionMatch[1]})
if (results[0]) {
assert(results[0].user)
return {
// @ts-ignore - typescript doesn't know about indices yet
content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `<@${results[0].user.id}>` + content.slice(writtenMentionMatch.indices[1][1]),
ensureJoined: results[0].user
}
}
}
}
/**
* @param {Element} node
* @param {string[]} tagNames allcaps tag names
* @returns {any | undefined} the node you were checking for, or undefined
*/
function nodeIsChildOf(node, tagNames) {
// @ts-ignore
for (; node; node = node.parentNode) if (tagNames.includes(node.tagName)) return node
}
const attachmentEmojis = new Map([
["m.image", "🖼️"],
["m.video", "🎞️"],
["m.audio", "🎶"],
["m.file", "📄"]
])
/**
* @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, fetch: import("node-fetch")["default"], mxcDownloader: (mxc: string) => Promise<Buffer | undefined>}} di simple-as-nails dependency injection for the matrix API
* @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API
*/
async function eventToMessage(event, guild, di) {
/** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]})[]} */
@ -439,7 +258,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) || undefined
if (member.avatar_url) avatarURL = utils.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)
@ -452,18 +271,18 @@ async function eventToMessage(event, guild, di) {
const attachments = []
/** @type {({name: string, url: string} | {name: string, url: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} */
const pendingFiles = []
/** @type {DiscordTypes.APIUser[]} */
const ensureJoined = []
// Convert content depending on what the message is
if (event.type === "m.room.message" && (event.content.msgtype === "m.text" || event.content.msgtype === "m.emote")) {
// Handling edits. If the edit was an edit of a reply, edits do not include the reply reference, so we need to fetch up to 2 more events.
// this event ---is an edit of--> original event ---is a reply to--> past event
await (async () => {
// Check if there is an edit
if (!event.content["m.new_content"]) return
const relatesTo = event.content["m.relates_to"]
if (!event.content["m.new_content"] || !relatesTo || relatesTo.rel_type !== "m.replace") return
if (!relatesTo) return
// Check if we have a pointer to what was edited
const relType = relatesTo.rel_type
if (relType !== "m.replace") return
const originalEventId = relatesTo.event_id
if (!originalEventId) return
messageIDsToEdit = select("event_message", "message_id", {event_id: originalEventId}, "ORDER BY part").pluck().all()
@ -495,43 +314,8 @@ async function eventToMessage(event, guild, di) {
await (async () => {
const repliedToEventId = event.content["m.relates_to"]?.["m.in_reply_to"]?.event_id
if (!repliedToEventId) return
let repliedToEvent
try {
repliedToEvent = await di.api.getEvent(event.room_id, repliedToEventId)
} catch (e) {
// Original event isn't on our homeserver, so we'll *partially* trust the client's reply fallback.
// We'll trust the fallback's quoted content and put it in the reply preview, but we won't trust the authorship info on it.
// (But if the fallback's quoted content doesn't exist, we give up. There's nothing for us to quote.)
if (event.content["format"] !== "org.matrix.custom.html" || typeof event.content["formatted_body"] !== "string") {
const lines = event.content.body.split("\n")
let stage = 0
for (let i = 0; i < lines.length; i++) {
if (stage >= 0 && lines[i][0] === ">") stage = 1
if (stage >= 1 && lines[i].trim() === "") stage = 2
if (stage === 2 && lines[i].trim() !== "") {
event.content.body = lines.slice(i).join("\n")
break
}
}
return
}
const mxReply = event.content["formatted_body"]
const quoted = mxReply.match(/^<mx-reply><blockquote>.*?In reply to.*?<br>(.*)<\/blockquote><\/mx-reply>/)?.[1]
if (!quoted) return
const contentPreviewChunks = chunk(
entities.decodeHTML5Strict( // Remove entities like &amp; &quot;
quoted.replace(/^\s*<blockquote>.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards
.replace(/(?:\n|<br>)+/g, " ") // Should all be on one line
.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]
if (contentPreviewChunks.length > 1) replyLine = replyLine.replace(/[,.']$/, "") + "..."
replyLine += "\n"
return
}
let repliedToEvent = await di.api.getEvent(event.room_id, repliedToEventId)
if (!repliedToEvent) return
// @ts-ignore
const autoEmoji = new Map(select("auto_emoji", ["name", "emoji_id"], {}, "WHERE name = 'L1' OR name = 'L2'").raw().all())
replyLine = `<:L1:${autoEmoji.get("L1")}><:L2:${autoEmoji.get("L2")}>`
@ -540,16 +324,12 @@ async function eventToMessage(event, guild, di) {
replyLine += `https://discord.com/channels/${guild.id}/${row.channel_id}/${row.message_id} `
}
const sender = repliedToEvent.sender
const authorID = getUserOrProxyOwnerID(sender)
const authorID = select("sim", "user_id", {mxid: repliedToEvent.sender}).pluck().get()
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]
}
let senderName = select("member_cache", "displayname", {mxid: repliedToEvent.sender}).pluck().get()
if (!senderName) senderName = sender.match(/@([^:]*)/)?.[1] || sender
replyLine += `Ⓜ️**${senderName}**`
}
// If the event has been edited, the homeserver will include the relation in `unsigned`.
@ -558,34 +338,26 @@ async function eventToMessage(event, guild, di) {
repliedToEvent.content = repliedToEvent.content["m.new_content"]
}
let contentPreview
const fileReplyContentAlternative = attachmentEmojis.get(repliedToEvent.content.msgtype)
const fileReplyContentAlternative =
( repliedToEvent.content.msgtype === "m.image" ? "🖼️"
: repliedToEvent.content.msgtype === "m.video" ? "🎞️"
: repliedToEvent.content.msgtype === "m.audio" ? "🎶"
: repliedToEvent.content.msgtype === "m.file" ? "📄"
: null)
if (fileReplyContentAlternative) {
contentPreview = " " + fileReplyContentAlternative
} else if (repliedToEvent.unsigned?.redacted_because) {
contentPreview = " (in reply to a deleted message)"
} 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
repliedToContent = repliedToContent.replace(/<span [^>]*data-mx-spoiler\b[^>]*>.*?<\/span>/g, "[spoiler]") // Good enough method of removing spoiler content. (I don't want to break out the HTML parser unless I have to.)
repliedToContent = repliedToContent.replace(/<img([^>]*)>/g, (_, att) => { // Convert Matrix emoji images into Discord emoji markdown
const mxcUrlMatch = att.match(/\bsrc="(mxc:\/\/[^"]+)"/)
const titleTextMatch = att.match(/\btitle=":?([^:"]+)/)
return convertEmoji(mxcUrlMatch?.[1], titleTextMatch?.[1], false, false)
})
repliedToContent = repliedToContent.replace(/<[^:>][^>]*>/g, "") // Completely strip all HTML tags and formatting.
repliedToContent = entities.decodeHTML5Strict(repliedToContent) // Remove entities like &amp; &quot;
const contentPreviewChunks = chunk(repliedToContent, 50)
if (contentPreviewChunks.length) {
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 repliedToContent = repliedToEvent.content.formatted_body || repliedToEvent.content.body
const contentPreviewChunks = chunk(
entities.decodeHTML5Strict( // Remove entities like &amp; &quot;
repliedToContent.replace(/.*<\/mx-reply>/, "") // Remove everything before replies, so just use the actual message body
.replace(/^\s*<blockquote>.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards
.replace(/(?:\n|<br>)+/g, " ") // Should all be on one line
.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)
contentPreview = ":\n> "
contentPreview += contentPreviewChunks.length > 1 ? contentPreviewChunks[0] + "..." : contentPreviewChunks[0]
}
replyLine = `> ${replyLine}${contentPreview}\n`
})()
@ -597,29 +369,19 @@ async function eventToMessage(event, guild, di) {
}
// Handling mentions of Discord users
input = input.replace(/("https:\/\/matrix.to\/#\/((?:@|%40)[^"]+)")>/g, (whole, attributeValue, mxid) => {
mxid = decodeURIComponent(mxid)
if (mxUtils.eventSenderIsFromDiscord(mxid)) {
// Handle mention of an OOYE sim user by their mxid
const id = select("sim", "user_id", {mxid}).pluck().get()
if (!id) return whole
return `${attributeValue} data-user-id="${id}">`
} else {
// Handle mention of a Matrix user by their mxid
// Check if this Matrix user is actually the sim user from another old bridge in the room?
const match = mxid.match(/[^:]*discord[^:]*_([0-9]{6,}):/) // try to match @_discord_123456, @_discordpuppet_123456, etc.
if (match) return `${attributeValue} data-user-id="${match[1]}">`
// Nope, just a real Matrix user.
return whole
}
input = input.replace(/("https:\/\/matrix.to\/#\/(@[^"]+)")>/g, (whole, attributeValue, mxid) => {
if (!utils.eventSenderIsFromDiscord(mxid)) return whole
const userID = select("sim", "user_id", {mxid: mxid}).pluck().get()
if (!userID) return whole
return `${attributeValue} data-user-id="${userID}">`
})
// Handling mentions of rooms and room-messages
input = await handleRoomOrMessageLinks(input, di)
// Stripping colons after mentions
input = input.replace(/( data-user-id.*?<\/a>):?/g, "$1")
input = input.replace(/("https:\/\/matrix.to.*?<\/a>):?/g, "$1")
// Handling mentions of Discord rooms
input = input.replace(/("https:\/\/matrix.to\/#\/(![^"]+)")>/g, (whole, attributeValue, roomID) => {
const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get()
if (!channelID) return whole
return `${attributeValue} data-channel-id="${channelID}">`
})
// Element adds a bunch of <br> before </blockquote> but doesn't render them. I can't figure out how this even works in the browser, so let's just delete those.
input = input.replace(/(?:\n|<br ?\/?>\s*)*<\/blockquote>/g, "</blockquote>")
@ -635,7 +397,7 @@ async function eventToMessage(event, guild, di) {
beforeTag = beforeTag || ""
afterContext = afterContext || ""
afterTag = afterTag || ""
if (!mxUtils.BLOCK_ELEMENTS.includes(beforeTag.toUpperCase()) && !mxUtils.BLOCK_ELEMENTS.includes(afterTag.toUpperCase())) {
if (!utils.BLOCK_ELEMENTS.includes(beforeTag.toUpperCase()) && !utils.BLOCK_ELEMENTS.includes(afterTag.toUpperCase())) {
return beforeContext + "<br>" + afterContext
} else {
return whole
@ -647,51 +409,6 @@ async function eventToMessage(event, guild, di) {
// input = input.replace(/ /g, "&nbsp;")
// There is also a corresponding test to uncomment, named "event2message: whitespace is retained"
// Handling written @mentions: we need to look for candidate Discord members to join to the room
// This shouldn't apply to code blocks, links, or inside attributes. So editing the HTML tree instead of regular expressions is a sensible choice here.
// We're using the domino parser because Turndown uses the same and can reuse this tree.
const doc = domino.createDocument(
// DOM parsers arrange elements in the <head> and <body>. Wrapping in a custom element ensures elements are reliably arranged in a single element.
'<x-turndown id="turndown-root">' + input + '</x-turndown>'
);
const root = doc.getElementById("turndown-root");
async function forEachNode(node) {
for (; node; node = node.nextSibling) {
// Check written mentions
if (node.nodeType === 3 && node.nodeValue.includes("@") && !nodeIsChildOf(node, ["A", "CODE", "PRE"])) {
const result = await checkWrittenMentions(node.nodeValue, guild, di)
if (result) {
node.nodeValue = result.content
ensureJoined.push(result.ensureJoined)
}
}
// Check for incompatible backticks in code blocks
let preNode
if (node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"]))) {
if (preNode.firstChild?.nodeName === "CODE") {
const ext = (preNode.firstChild.className.match(/language-(\S+)/) || [null, "txt"])[1]
const filename = `inline_code.${ext}`
// Build the replacement <code> node
const replacementCode = doc.createElement("code")
replacementCode.textContent = `[${filename}]`
// Build its containing <span> node
const replacement = doc.createElement("span")
replacement.appendChild(doc.createTextNode(" "))
replacement.appendChild(replacementCode)
replacement.appendChild(doc.createTextNode(" "))
// Replace the code block with the <span>
preNode.replaceWith(replacement)
// Upload the code as an attachment
const content = getCodeContent(preNode.firstChild)
attachments.push({id: String(attachments.length), filename})
pendingFiles.push({name: filename, buffer: Buffer.from(content, "utf8")})
}
}
await forEachNode(node.firstChild)
}
}
await forEachNode(root)
// SPRITE SHEET EMOJIS FEATURE: Emojis at the end of the message that we don't know about will be reuploaded as a sprite sheet.
// First we need to determine which emojis are at the end.
endOfMessageEmojis = []
@ -701,24 +418,18 @@ async function eventToMessage(event, guild, di) {
if (!match[0].includes("data-mx-emoticon")) break
const mxcUrl = match[0].match(/\bsrc="(mxc:\/\/[^"]+)"/)
if (mxcUrl) endOfMessageEmojis.unshift(mxcUrl[1])
assert(typeof match.index === "number", "Your JavaScript implementation does not comply with TC39: https://tc39.es/ecma262/multipage/text-processing.html#sec-regexpbuiltinexec")
if (typeof match.index !== "number") break
last = match.index
}
// @ts-ignore bad type from turndown
content = turndownService.turndown(root)
// Put < > around any surviving matrix.to links to hide the URL previews
content = content.replace(/\bhttps?:\/\/matrix\.to\/[^ )]*/, "<$&>")
content = turndownService.turndown(input)
// It's designed for commonmark, we need to replace the space-space-newline with just newline
content = content.replace(/ \n/g, "\n")
// If there's a blockquote at the start of the message body and this message is a reply, they should be visually separated
if (replyLine && content.startsWith("> ")) content = "\n" + content
// SPRITE SHEET EMOJIS FEATURE:
content = await uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles, di?.mxcDownloader)
content = await uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles)
} else {
// Looks like we're using the plaintext body!
content = event.content.body
@ -727,42 +438,30 @@ async function eventToMessage(event, guild, di) {
content = `* ${displayName} ${content}`
}
content = await handleRoomOrMessageLinks(content, di) // Replace matrix.to links with discord.com equivalents where possible
content = content.replace(/\bhttps?:\/\/matrix\.to\/[^ )]*/, "<$&>") // Put < > around any surviving matrix.to links to hide the URL previews
const result = await checkWrittenMentions(content, guild, di)
if (result) {
content = result.content
ensureJoined.push(result.ensureJoined)
}
// Markdown needs to be escaped, though take care not to escape the middle of links
// @ts-ignore bad type from turndown
content = turndownService.escape(content)
}
} else if (event.type === "m.room.message" && (event.content.msgtype === "m.file" || event.content.msgtype === "m.video" || event.content.msgtype === "m.audio" || event.content.msgtype === "m.image")) {
content = ""
const filename = event.content.filename || event.content.body
// A written `event.content.body` will be bridged to Discord's image `description` which is like alt text.
// Bridging as description rather than message content in order to match Matrix clients (Element, Neochat) which treat this as alt text or title text.
const description = (event.content.body !== event.content.filename && event.content.filename && event.content.body) || undefined
const filename = event.content.body
if ("url" in event.content) {
// Unencrypted
const url = mxUtils.getPublicUrlForMxc(event.content.url)
const url = utils.getPublicUrlForMxc(event.content.url)
assert(url)
attachments.push({id: "0", description, filename})
attachments.push({id: "0", filename})
pendingFiles.push({name: filename, url})
} else {
// Encrypted
const url = mxUtils.getPublicUrlForMxc(event.content.file.url)
const url = utils.getPublicUrlForMxc(event.content.file.url)
assert(url)
assert.equal(event.content.file.key.alg, "A256CTR")
attachments.push({id: "0", description, filename})
attachments.push({id: "0", filename})
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)
const url = utils.getPublicUrlForMxc(event.content.url)
assert(url)
let filename = event.content.body
if (event.type === "m.sticker") {
@ -770,11 +469,8 @@ async function eventToMessage(event, guild, di) {
if (event.content.info?.mimetype?.includes("/")) {
mimetype = event.content.info.mimetype
} else {
const res = await di.fetch(url, {method: "HEAD"})
if (res.status === 200) {
mimetype = res.headers.get("content-type")
}
if (!mimetype) throw new Error(`Server error ${res.status} or missing content-type while detecting sticker mimetype`)
const res = await fetch(url, {method: "HEAD"})
mimetype = res.headers.get("content-type") || "image/webp"
}
filename += "." + mimetype.split("/")[1]
}
@ -825,8 +521,7 @@ async function eventToMessage(event, guild, di) {
return {
messagesToEdit,
messagesToSend,
messagesToDelete: messageIDsToEdit,
ensureJoined
messagesToDelete: messageIDsToEdit
}
}

File diff suppressed because it is too large Load diff

View file

@ -16,7 +16,6 @@ const BLOCK_ELEMENTS = [
"NOSCRIPT", "OL", "OUTPUT", "P", "PRE", "SECTION", "SUMMARY", "TABLE", "TBODY", "TD",
"TFOOT", "TH", "THEAD", "TR", "UL"
]
const NEWLINE_ELEMENTS = BLOCK_ELEMENTS.concat(["BR"])
/**
* Determine whether an event is the bridged representation of a discord message.
@ -64,159 +63,7 @@ function getEventIDHash(eventID) {
return signedHash
}
class MatrixStringBuilder {
constructor() {
this.body = ""
this.formattedBody = ""
}
/**
* @param {string} body
* @param {string} [formattedBody]
* @param {any} [condition]
*/
add(body, formattedBody, condition = true) {
if (condition) {
if (formattedBody == undefined) formattedBody = body
this.body += body
this.formattedBody += formattedBody
}
return this
}
/**
* @param {string} body
* @param {string} [formattedBody]
* @param {any} [condition]
*/
addLine(body, formattedBody, condition = true) {
if (condition) {
if (formattedBody == undefined) formattedBody = body
if (this.body.length && this.body.slice(-1) !== "\n") this.body += "\n"
this.body += body
const match = this.formattedBody.match(/<\/?([a-zA-Z]+[a-zA-Z0-9]*)[^>]*>\s*$/)
if (this.formattedBody.length && (!match || !NEWLINE_ELEMENTS.includes(match[1].toUpperCase()))) this.formattedBody += "<br>"
this.formattedBody += formattedBody
}
return this
}
/**
* @param {string} body
* @param {string} [formattedBody]
* @param {any} [condition]
*/
addParagraph(body, formattedBody, condition = true) {
if (condition) {
if (formattedBody == undefined) formattedBody = body
if (this.body.length && this.body.slice(-1) !== "\n") this.body += "\n\n"
this.body += body
formattedBody = `<p>${formattedBody}</p>`
this.formattedBody += formattedBody
}
return this
}
get() {
return {
msgtype: "m.text",
body: this.body,
format: "org.matrix.custom.html",
formatted_body: this.formattedBody
}
}
}
/**
* Context: Room IDs are not routable on their own. Room permalinks need a list of servers to try. The client is responsible for coming up with a list of servers.
* ASSUMPTION 1: The bridge bot is a member of the target room and can therefore access its power levels and member list for calculation.
* ASSUMPTION 2: Because the bridge bot is a member of the target room, the target room is bridged.
* https://spec.matrix.org/v1.9/appendices/#routing
* https://gitdab.com/cadence/out-of-your-element/issues/11
* @param {string} roomID
* @param {{[K in "getStateEvent" | "getJoinedMembers"]: import("../../matrix/api")[K]}} api
*/
async function getViaServers(roomID, api) {
const candidates = []
const {joined} = await api.getJoinedMembers(roomID)
// Candidate 0: The bot's own server name
candidates.push(reg.ooye.server_name)
// Candidate 1: Highest joined non-sim non-bot power level user in the room
// https://github.com/matrix-org/matrix-react-sdk/blob/552c65db98b59406fb49562e537a2721c8505517/src/utils/permalinks/Permalinks.ts#L172
try {
/** @type {{users?: {[mxid: string]: number}}} */
const powerLevels = await api.getStateEvent(roomID, "m.room.power_levels", "")
if (powerLevels.users) {
const sorted = Object.entries(powerLevels.users).sort((a, b) => b[1] - a[1]) // Highest...
for (const power of sorted) {
const mxid = power[0]
if (!(mxid in joined)) continue // joined...
if (userRegex.some(r => mxid.match(r))) continue // non-sim non-bot...
const match = mxid.match(/:(.*)/)
assert(match)
if (!candidates.includes(match[1])) {
candidates.push(match[1])
break
}
}
}
} catch (e) {
// power levels event not found
}
// Candidates 2-3: Most popular servers in the room
/** @type {Map<string, number>} */
const servers = new Map()
// We can get the most popular servers if we know the members, so let's process those...
Object.keys(joined)
.filter(mxid => !mxid.startsWith("@_")) // Quick check
.filter(mxid => !userRegex.some(r => mxid.match(r))) // Full check
.slice(0, 1000) // Just sample the first thousand real members
.map(mxid => {
const match = mxid.match(/:(.*)/)
assert(match)
return match[1]
})
.filter(server => !server.match(/([a-f0-9:]+:+)+[a-f0-9]+/)) // No IPv6 servers
.filter(server => !server.match(/[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/)) // No IPv4 servers
// I don't care enough to check ACLs
.forEach(server => {
const existing = servers.get(server)
if (!existing) servers.set(server, 1)
else servers.set(server, existing + 1)
})
const serverList = [...servers.entries()].sort((a, b) => b[1] - a[1])
for (const server of serverList) {
if (!candidates.includes(server[0])) {
candidates.push(server[0])
if (candidates.length >= 4) break // Can have at most 4 candidate via servers
}
}
return candidates
}
/**
* Context: Room IDs are not routable on their own. Room permalinks need a list of servers to try. The client is responsible for coming up with a list of servers.
* ASSUMPTION 1: The bridge bot is a member of the target room and can therefore access its power levels and member list for calculation.
* ASSUMPTION 2: Because the bridge bot is a member of the target room, the target room is bridged.
* https://spec.matrix.org/v1.9/appendices/#routing
* https://gitdab.com/cadence/out-of-your-element/issues/11
* @param {string} roomID
* @param {{[K in "getStateEvent" | "getJoinedMembers"]: import("../../matrix/api")[K]}} api
* @returns {Promise<URLSearchParams>}
*/
async function getViaServersQuery(roomID, api) {
const list = await getViaServers(roomID, api)
const qs = new URLSearchParams()
for (const server of list) {
qs.append("via", server)
}
return qs
}
module.exports.BLOCK_ELEMENTS = BLOCK_ELEMENTS
module.exports.eventSenderIsFromDiscord = eventSenderIsFromDiscord
module.exports.getPublicUrlForMxc = getPublicUrlForMxc
module.exports.getEventIDHash = getEventIDHash
module.exports.MatrixStringBuilder = MatrixStringBuilder
module.exports.getViaServers = getViaServers
module.exports.getViaServersQuery = getViaServersQuery

View file

@ -1,23 +1,7 @@
// @ts-check
const e = new Error("Custom error")
const {test} = require("supertape")
const {eventSenderIsFromDiscord, getEventIDHash, MatrixStringBuilder, getViaServers} = require("./utils")
const util = require("util")
/** @param {string[]} mxids */
function joinedList(mxids) {
/** @type {{[mxid: string]: {display_name: null, avatar_url: null}}} */
const joined = {}
for (const mxid of mxids) {
joined[mxid] = {
display_name: null,
avatar_url: null
}
}
return {joined}
}
const {eventSenderIsFromDiscord, getEventIDHash} = require("./utils")
test("sender type: matrix user", t => {
t.notOk(eventSenderIsFromDiscord("@cadence:cadence.moe"))
@ -39,140 +23,3 @@ test("event hash: hash is the same each time", t => {
test("event hash: hash is different for different inputs", t => {
t.notEqual(getEventIDHash("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe1"), getEventIDHash("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe2"))
})
test("MatrixStringBuilder: add, addLine, add same text", t => {
const gatewayMessage = {t: "MY_MESSAGE", d: {display: "Custom message data"}}
let stackLines = e.stack?.split("\n")
const builder = new MatrixStringBuilder()
builder.addLine("\u26a0 Bridged event from Discord not delivered", "\u26a0 <strong>Bridged event from Discord not delivered</strong>")
builder.addLine(`Gateway event: ${gatewayMessage.t}`)
builder.addLine(e.toString())
if (stackLines) {
stackLines = stackLines.slice(0, 2)
stackLines[1] = stackLines[1].replace(/\\/g, "/").replace(/(\s*at ).*(\/m2d\/)/, "$1.$2")
builder.addLine(`Error trace:`, `<details><summary>Error trace</summary>`)
builder.add(`\n${stackLines.join("\n")}`, `<pre>${stackLines.join("\n")}</pre></details>`)
}
builder.addLine("", `<details><summary>Original payload</summary><pre>${util.inspect(gatewayMessage.d, false, 4, false)}</pre></details>`)
t.deepEqual(builder.get(), {
msgtype: "m.text",
body: "\u26a0 Bridged event from Discord not delivered"
+ "\nGateway event: MY_MESSAGE"
+ "\nError: Custom error"
+ "\nError trace:"
+ "\nError: Custom error"
+ "\n at ./m2d/converters/utils.test.js:3:11)\n",
format: "org.matrix.custom.html",
formatted_body: "\u26a0 <strong>Bridged event from Discord not delivered</strong>"
+ "<br>Gateway event: MY_MESSAGE"
+ "<br>Error: Custom error"
+ "<br><details><summary>Error trace</summary><pre>Error: Custom error\n at ./m2d/converters/utils.test.js:3:11)</pre></details>"
+ `<details><summary>Original payload</summary><pre>{ display: 'Custom message data' }</pre></details>`
})
})
test("MatrixStringBuilder: complete code coverage", t => {
const builder = new MatrixStringBuilder()
builder.add("Line 1")
builder.addParagraph("Line 2")
builder.add("Line 3")
builder.addParagraph("Line 4")
t.deepEqual(builder.get(), {
msgtype: "m.text",
body: "Line 1\n\nLine 2Line 3\n\nLine 4",
format: "org.matrix.custom.html",
formatted_body: "Line 1<p>Line 2</p>Line 3<p>Line 4</p>"
})
})
test("getViaServers: returns the server name if the room only has sim users", async t => {
const result = await getViaServers("!baby", {
getStateEvent: async () => ({}),
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe"])
})
t.deepEqual(result, ["cadence.moe"])
})
test("getViaServers: also returns the most popular servers in order", async t => {
const result = await getViaServers("!baby", {
getStateEvent: async () => ({}),
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid"])
})
t.deepEqual(result, ["cadence.moe", "thecollective.invalid", "selfhosted.invalid"])
})
test("getViaServers: does not return IP address servers", async t => {
const result = await getViaServers("!baby", {
getStateEvent: async () => ({}),
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:45.77.232.172:8443", "@cadence:[::1]:8443", "@cadence:123example.456example.invalid"])
})
t.deepEqual(result, ["cadence.moe", "123example.456example.invalid"])
})
test("getViaServers: also returns the highest power level user (100)", async t => {
const result = await getViaServers("!baby", {
getStateEvent: async () => ({
users: {
"@moderator:tractor.invalid": 50,
"@singleuser:selfhosted.invalid": 100,
"@_ooye_bot:cadence.moe": 100
}
}),
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid", "@moderator:tractor.invalid"])
})
t.deepEqual(result, ["cadence.moe", "selfhosted.invalid", "thecollective.invalid", "tractor.invalid"])
})
test("getViaServers: also returns the highest power level user (50)", async t => {
const result = await getViaServers("!baby", {
getStateEvent: async () => ({
users: {
"@moderator:tractor.invalid": 50,
"@_ooye_bot:cadence.moe": 100
}
}),
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid", "@singleuser:selfhosted.invalid"])
})
t.deepEqual(result, ["cadence.moe", "tractor.invalid", "thecollective.invalid", "selfhosted.invalid"])
})
test("getViaServers: returns at most 4 results", async t => {
const result = await getViaServers("!baby", {
getStateEvent: async () => ({
users: {
"@moderator:tractor.invalid": 50,
"@singleuser:selfhosted.invalid": 100,
"@_ooye_bot:cadence.moe": 100
}
}),
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@cadence:123example.456example.invalid"])
})
t.deepEqual(result.length, 4)
})
test("getViaServers: returns results even when power levels can't be fetched", async t => {
const result = await getViaServers("!baby", {
getStateEvent: async () => {
throw new Error("event not found or something")
},
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@cadence:123example.456example.invalid"])
})
t.deepEqual(result.length, 4)
})
test("getViaServers: only considers power levels of currently joined members", async t => {
const result = await getViaServers("!baby", {
getStateEvent: async () => ({
users: {
"@moderator:tractor.invalid": 50,
"@former_moderator:missing.invalid": 100,
"@_ooye_bot:cadence.moe": 100
}
}),
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid", "@singleuser:selfhosted.invalid"])
})
t.deepEqual(result, ["cadence.moe", "tractor.invalid", "thecollective.invalid", "selfhosted.invalid"])
})

View file

@ -19,17 +19,15 @@ const makeTxnId = sync.require("./txnid")
* @returns {string} the new endpoint
*/
function path(p, mxid, otherParams = {}) {
if (!mxid) return p
const u = new URL(p, "http://localhost")
if (mxid) u.searchParams.set("user_id", mxid)
u.searchParams.set("user_id", mxid)
for (const entry of Object.entries(otherParams)) {
if (entry[1] != undefined) {
u.searchParams.set(entry[0], entry[1])
}
}
let result = u.pathname
const str = u.searchParams.toString()
if (str) result += "?" + str
return result
return u.pathname + "?" + u.searchParams.toString()
}
/**
@ -84,16 +82,6 @@ async function getEvent(roomID, eventID) {
return root
}
/**
* @param {string} roomID
* @param {number} ts unix silliseconds
*/
async function getEventForTimestamp(roomID, ts) {
/** @type {{event_id: string, origin_server_ts: number}} */
const root = await mreq.mreq("GET", path(`/client/v1/rooms/${roomID}/timestamp_to_event`, null, {ts}))
return root
}
/**
* @param {string} roomID
* @returns {Promise<Ty.Event.BaseStateEvent[]>}
@ -115,7 +103,7 @@ function getStateEvent(roomID, type, key) {
/**
* "Any of the AS's users must be in the room. This API is primarily for Application Services and should be faster to respond than /members as it can be implemented more efficiently on the server."
* @param {string} roomID
* @returns {Promise<{joined: {[mxid: string]: {avatar_url: string?, display_name: string?}}}>}
* @returns {Promise<{joined: {[mxid: string]: {avatar_url?: string, display_name?: string}}}>}
*/
function getJoinedMembers(roomID) {
return mreq.mreq("GET", `/client/v3/rooms/${roomID}/joined_members`)
@ -235,7 +223,6 @@ module.exports.joinRoom = joinRoom
module.exports.inviteToRoom = inviteToRoom
module.exports.leaveRoom = leaveRoom
module.exports.getEvent = getEvent
module.exports.getEventForTimestamp = getEventForTimestamp
module.exports.getAllState = getAllState
module.exports.getStateEvent = getStateEvent
module.exports.getJoinedMembers = getJoinedMembers

View file

@ -20,7 +20,3 @@ test("api path: existing query parameters with mxid", t => {
test("api path: real world mxid", t => {
t.equal(path("/hello/world", "@cookie_monster:cadence.moe"), "/hello/world?user_id=%40cookie_monster%3Acadence.moe")
})
test("api path: extras number works", t => {
t.equal(path(`/client/v3/rooms/!example/timestamp_to_event`, null, {ts: 1687324651120}), "/client/v3/rooms/!example/timestamp_to_event?ts=1687324651120")
})

View file

@ -2,7 +2,6 @@
const assert = require("assert").strict
const mixin = require("mixin-deep")
const deepEqual = require("deep-equal")
/** Mutates the input. */
function kstateStripConditionals(kstate) {
@ -51,14 +50,18 @@ function diffKState(actual, target) {
// Special handling for power levels, we want to deep merge the actual and target into the final state.
if (!(key in actual)) throw new Error(`want to apply a power levels diff, but original power level data is missing\nstarted with: ${JSON.stringify(actual)}\nwant to apply: ${JSON.stringify(target)}`)
const temp = mixin({}, actual[key], target[key])
if (!deepEqual(actual[key], temp, {strict: true})) {
try {
assert.deepEqual(actual[key], temp)
} catch (e) {
// they differ. use the newly prepared object as the diff.
diff[key] = temp
}
} else if (key in actual) {
// diff
if (!deepEqual(actual[key], target[key], {strict: true})) {
try {
assert.deepEqual(actual[key], target[key])
} catch (e) {
// they differ. use the target as the diff.
diff[key] = target[key]
}

View file

@ -1,4 +1,3 @@
const assert = require("assert")
const {kstateToState, stateToKState, diffKState, kstateStripConditionals} = require("./kstate")
const {test} = require("supertape")
@ -163,29 +162,3 @@ test("diffKState: power levels are mixed together", t => {
})
t.notDeepEqual(original, result)
})
test("diffKState: cannot merge power levels if original power levels are missing", t => {
const original = {}
assert.throws(() =>
diffKState(original, {
"m.room.power_levels/": {
"events": {
"m.room.avatar": 0
}
}
})
, /original power level data is missing/)
t.pass()
})
test("diffKState: kstate keys must contain a slash separator", t => {
assert.throws(() =>
diffKState({
"m.room.name/": {name: "test name"},
}, {
"m.room.name/": {name: "test name"},
"new": {a: 2}
})
, /does not contain a slash separator/)
t.pass()
})

View file

@ -96,6 +96,55 @@ function replyctx(execute) {
}
}
const NEWLINE_ELEMENTS = mxUtils.BLOCK_ELEMENTS.concat(["BR"])
class MatrixStringBuilder {
constructor() {
this.body = ""
this.formattedBody = ""
}
/**
* @param {string} body
* @param {string} formattedBody
* @param {any} [condition]
*/
add(body, formattedBody, condition = true) {
if (condition) {
if (formattedBody == undefined) formattedBody = body
this.body += body
this.formattedBody += formattedBody
}
return this
}
/**
* @param {string} body
* @param {string} [formattedBody]
* @param {any} [condition]
*/
addLine(body, formattedBody, condition = true) {
if (condition) {
if (formattedBody == undefined) formattedBody = body
if (this.body.length && this.body.slice(-1) !== "\n") this.body += "\n"
this.body += body
const match = this.formattedBody.match(/<\/?([a-zA-Z]+[a-zA-Z0-9]*)[^>]*>\s*$/)
if (this.formattedBody.length && (!match || !NEWLINE_ELEMENTS.includes(match[1].toUpperCase()))) this.formattedBody += "<br>"
this.formattedBody += formattedBody
}
return this
}
get() {
return {
msgtype: "m.text",
body: this.body,
format: "org.matrix.custom.html",
formatted_body: this.formattedBody
}
}
}
/** @type {Command[]} */
const commands = [{
aliases: ["emoji"],
@ -170,7 +219,7 @@ const commands = [{
})
}
const b = new mxUtils.MatrixStringBuilder()
const b = new MatrixStringBuilder()
.addLine("## Emoji preview", "<h2>Emoji preview</h2>")
.addLine(`Ⓜ️ This room isn't bridged to Discord. ${matrixOnlyConclusion}`, `Ⓜ️ <em>This room isn't bridged to Discord. ${matrixOnlyConclusion}</em>`, matrixOnlyReason === "NOT_BRIDGED")
.addLine(`Ⓜ️ *Discord ran out of space for emojis. ${matrixOnlyConclusion}`, `Ⓜ️ <em>Discord ran out of space for emojis. ${matrixOnlyConclusion}</em>`, matrixOnlyReason === "CAPACITY")
@ -201,7 +250,7 @@ const commands = [{
}
}
if (!("images" in pack)) pack.images = {}
const b = new mxUtils.MatrixStringBuilder()
const b = new MatrixStringBuilder()
.addLine(`Created ${toUpload.length} emojis`, "")
for (const e of toUpload) {
pack.images[e.name] = {

View file

@ -2,8 +2,6 @@
const fetch = require("node-fetch").default
const mixin = require("mixin-deep")
const stream = require("stream")
const getStream = require("get-stream")
const passthrough = require("../passthrough")
const { sync } = passthrough
@ -29,15 +27,9 @@ class MatrixServerError extends Error {
* @param {any} [extra]
*/
async function mreq(method, url, body, extra = {}) {
if (body == undefined || Object.is(body.constructor, Object)) {
body = JSON.stringify(body)
} else if (body instanceof stream.Readable && reg.ooye.content_length_workaround) {
body = await getStream.buffer(body)
}
const opts = mixin({
method,
body,
body: (body == undefined || Object.is(body.constructor, Object)) ? JSON.stringify(body) : body,
headers: {
Authorization: `Bearer ${reg.as_token}`
}
@ -47,18 +39,7 @@ async function mreq(method, url, body, extra = {}) {
const res = await fetch(baseUrl + url, opts)
const root = await res.json()
if (!res.ok || root.errcode) {
if (root.error?.includes("Content-Length")) {
console.error(`OOYE cannot stream uploads to Synapse. Please choose one of these workarounds:`
+ `\n * Run an nginx reverse proxy to Synapse, and point registration.yaml's`
+ `\n \`server_origin\` to nginx`
+ `\n * Set \`content_length_workaround: true\` in registration.yaml (this will`
+ `\n halve the speed of bridging d->m files)`)
throw new Error("Synapse is not accepting stream uploads, see the message above.")
}
delete opts.headers.Authorization
throw new MatrixServerError(root, {baseUrl, url, ...opts})
}
if (!res.ok || root.errcode) throw new MatrixServerError(root, opts)
return root
}

743
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -18,23 +18,20 @@
"@chriscdn/promise-semaphore": "^2.0.1",
"better-sqlite3": "^9.0.0",
"chunk-text": "^2.0.1",
"cloudstorm": "^0.10.8",
"deep-equal": "^2.2.3",
"discord-markdown": "git+https://git.sr.ht/~cadence/nodejs-discord-markdown#2881b447954fcea10510f212fa4c1dbbdc0a57a3",
"cloudstorm": "^0.9.0",
"discord-markdown": "git+https://git.sr.ht/~cadence/nodejs-discord-markdown#abc56d544072a1dc5624adfea455b0e902adf7b3",
"entities": "^4.5.0",
"get-stream": "^6.0.1",
"giframe": "github:cloudrac3r/giframe#v0.4.1",
"heatsync": "^2.4.1",
"html-template-tag": "github:cloudrac3r/html-template-tag#v5.0",
"js-yaml": "^4.1.0",
"matrix-appservice": "^2.0.0",
"minimist": "^1.2.8",
"mixin-deep": "github:cloudrac3r/mixin-deep#v3.0.0",
"node-fetch": "^2.6.7",
"pngjs": "github:cloudrac3r/pngjs#v7.0.2",
"pngjs": "^7.0.0",
"prettier-bytes": "^1.0.4",
"sharp": "^0.32.6",
"snowtransfer": "^0.10.5",
"snowtransfer": "^0.9.0",
"stream-mime-type": "^1.0.2",
"try-to-catch": "^3.0.1",
"turndown": "^7.1.2",
@ -44,16 +41,15 @@
"@types/node": "^18.16.0",
"@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.3.0",
"supertape": "^8.3.0",
"tap-dot": "github:cloudrac3r/tap-dot#9dd7750ececeae3a96afba91905be812b6b2cc2d"
},
"scripts": {
"addbot": "node addbot.js",
"test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap test/test.js | tap-dot",
"test-slow": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js -- --slow | tap-dot",
"cover": "c8 --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"
"test-slow": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap test/test.js -- --slow | tap-dot",
"cover": "c8 --skip-full -x db/migrations -x matrix/file.js -x matrix/api.js -x matrix/mreq.js -r html -r text supertape --no-check-assertions-count --format fail test/test.js -- --slow"
}
}

View file

@ -2,9 +2,9 @@
<img src="docs/img/icon.png" height="128" width="128">
Modern Matrix-to-Discord appservice bridge, created by [@cadence:cadence.moe](https://matrix.to/#/@cadence:cadence.moe)
Modern Matrix-to-Discord appservice bridge.
[![Releases](https://img.shields.io/gitea/v/release/cadence/out-of-your-element?gitea_url=https%3A%2F%2Fgitdab.com&style=plastic&color=green)](https://gitdab.com/cadence/out-of-your-element/releases) [![Discuss on Matrix](https://img.shields.io/badge/discuss-%23out--of--your--element-white?style=plastic)](https://matrix.to/#/#out-of-your-element:cadence.moe)
Created by [@cadence:cadence.moe](https://matrix.to/#/@cadence:cadence.moe) // Discuss in [#out-of-your-element:cadence.moe](https://matrix.to/#/#out-of-your-element:cadence.moe)
## Docs
@ -15,7 +15,7 @@ This readme has the most important info. The rest is [in the docs folder.](https
* Modern: Supports new Discord features like replies, threads and stickers, and new Matrix features like edits, spaces and space membership.
* Efficient: Special attention has been given to memory usage, database indexes, disk footprint, runtime algorithms, and queries to the homeserver.
* Reliable: Any errors on either side are notified on Matrix and can be retried.
* Tested: A test suite and code coverage make sure all the logic and special cases work.
* Tested: A test suite and code coverage make sure all the core logic works.
* Simple development: No build step (it's JavaScript, not TypeScript), minimal/lightweight dependencies, and abstraction only where necessary so that less background knowledge is required. No need to learn about Intents or library functions.
* No locking algorithm: Other bridges use a locking algorithm which is a source of frequent bugs. This bridge avoids the need for one.
* Latest API: Being on the latest Discord API version lets it access all features, without the risk of deprecated API versions being removed.
@ -42,18 +42,17 @@ Most features you'd expect in both directions, plus a little extra spice:
* Custom emojis in messages
* Custom room names/avatars can be applied on Matrix-side
* Larger files from Discord are linked instead of reuploaded to Matrix
* 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)
## Caveats
* This bridge is not designed for puppetting.
* Direct Messaging is not supported until I figure out a good way of doing it.
* Direct Messaging is not supported yet.
## Efficiency details
Using WeatherStack as a thin layer between the bridge application and the Discord API lets us control exactly what data is cached in memory. Only necessary information is cached. For example, member data, user data, message content, and past edits are never stored in memory. This keeps the memory usage low and also prevents it ballooning in size over the bridge's runtime.
Using WeatherStack as a thin layer between the bridge application and the Discord API lets us control exactly what data is cached. 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 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.
@ -61,11 +60,11 @@ Only necessary data and columns are queried from the database. We only contact t
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.
# Setup
If you get stuck, you're welcome to message [#out-of-your-element:cadence.moe](https://matrix.to/#/#out-of-your-element:cadence.moe) or [@cadence:cadence.moe](https://matrix.to/#/@cadence:cadence.moe) to ask for help setting up OOYE!
If you get stuck, you're welcome to message @cadence:cadence.moe to ask for help setting up OOYE!
You'll need:
@ -76,8 +75,7 @@ Follow these steps:
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. Clone this repo and checkout a specific tag. (Development happens on main. Stabler versions are tagged.)
1. Install dependencies: `npm install --save-dev` (omit --save-dev if you will not run the automated tests)
@ -97,10 +95,6 @@ Follow these steps:
* $ `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, either add yourself to `invite` in `registration.yaml`, or use the `//invite [your mxid here]` command on Discord.
# Development setup
* Be sure to install dependencies with `--save-dev` so you can run the tests.
@ -167,17 +161,14 @@ To get into the rooms on your Matrix account, either add yourself to `invite` in
* (1) chunk-text: It does what I want.
* (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust.
* (8) snowtransfer: Discord API library with bring-your-own-caching that I trust.
* (0) deep-equal: It's already pulled in by supertape.
* (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) discord-markdown: This is my fork! I make sure it does what I want.
* (0) giframe: This is my fork! It should do what I want.
* (1) heatsync: Module hot-reloader that I trust.
* (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.)
* (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.

View file

@ -18,7 +18,8 @@ ooye:
max_file_size: 5000000
server_name: [the part after the colon in your matrix id, like cadence.moe]
server_origin: [the full protocol and domain of your actual matrix server's location, with no trailing slash, like https://matrix.cadence.moe]
content_length_workaround: false
discord_origin: https://discord.com
discord_cdn_origin: https://cdn.discordapp.com
invite:
# uncomment this to auto-invite the named user to newly created spaces and mark them as admin (PL 100) everywhere
# - '@cadence:cadence.moe'
# - @cadence:cadence.moe

View file

@ -67,22 +67,6 @@ async function uploadAutoEmoji(guild, name, filename) {
console.log("✅ Database is ready...")
// ensure appservice bot user is registered...
try {
await api.register(reg.sender_localpart)
} 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
}
}
// upload initial images...
const avatarUrl = await file.uploadDiscordFileToMxc("https://cadence.moe/friends/out_of_your_element.png")
console.log("✅ Matrix appservice login works...")
// upload the L1 L2 emojis to some guild
const emojis = db.prepare("SELECT name FROM auto_emoji WHERE name = 'L1' OR name = 'L2'").pluck().all()
if (emojis.length !== 2) {
@ -120,6 +104,11 @@ async function uploadAutoEmoji(guild, name, filename) {
}
console.log("✅ Emojis are ready...")
// ensure homeserver well-known is valid and returns reg.ooye.server_name...
// upload initial images...
const avatarUrl = await file.uploadDiscordFileToMxc("https://cadence.moe/friends/out_of_your_element.png")
// set profile data on discord...
const avatarImageBuffer = await fetch("https://cadence.moe/friends/out_of_your_element.png").then(res => res.arrayBuffer())
await discord.snow.user.updateSelf({avatar: "data:image/png;base64," + Buffer.from(avatarImageBuffer).toString("base64")})

View file

@ -16,8 +16,6 @@ const api = sync.require("./matrix/api")
const file = sync.require("./matrix/file")
const sendEvent = sync.require("./m2d/actions/send-event")
const eventDispatcher = sync.require("./d2m/event-dispatcher")
const updatePins = sync.require("./d2m/actions/update-pins")
const speedbump = sync.require("./d2m/actions/speedbump")
const ks = sync.require("./matrix/kstate")
const guildID = "112760669178241024"

View file

@ -183,23 +183,6 @@ module.exports = {
safety_alerts_channel_id: null
}
},
user: {
clyde_ai: {
id: "1081004946872352958",
username: "clyde",
avatar: "a_6170487d32fdfe9f988720ad80e6ab8c",
discriminator: "0000",
public_flags: 0,
premium_type: 2,
flags: 0,
bot: true,
banner: null,
accent_color: null,
global_name: "Clyde",
avatar_decoration_data: null,
banner_color: null
}
},
member: {
kumaccino: {
avatar: null,
@ -495,88 +478,6 @@ module.exports = {
attachments: [],
guild_id: "112760669178241024"
},
unknown_room_mention: {
type: 0,
tts: false,
timestamp: "2023-07-10T20:04:25.939000+00:00",
referenced_message: null,
pinned: false,
nonce: "1128054139385806848",
mentions: [],
mention_roles: [],
mention_everyone: false,
member: {
roles: [],
premium_since: null,
pending: false,
nick: null,
mute: false,
joined_at: "2015-11-11T09:55:40.321000+00:00",
flags: 0,
deaf: false,
communication_disabled_until: null,
avatar: null
},
id: "1128054143064494233",
flags: 0,
embeds: [],
edited_timestamp: null,
content: "<#555>",
components: [],
channel_id: "266767590641238027",
author: {
username: "kumaccino",
public_flags: 128,
id: "113340068197859328",
global_name: "kumaccino",
discriminator: "0",
avatar_decoration: null,
avatar: "b48302623a12bc7c59a71328f72ccb39"
},
attachments: [],
guild_id: "112760669178241024"
},
unbridged_room_mention: {
type: 0,
tts: false,
timestamp: "2023-07-10T20:04:25.939000+00:00",
referenced_message: null,
pinned: false,
nonce: "1128054139385806848",
mentions: [],
mention_roles: [],
mention_everyone: false,
member: {
roles: [],
premium_since: null,
pending: false,
nick: null,
mute: false,
joined_at: "2015-11-11T09:55:40.321000+00:00",
flags: 0,
deaf: false,
communication_disabled_until: null,
avatar: null
},
id: "1128054143064494233",
flags: 0,
embeds: [],
edited_timestamp: null,
content: "<#498323546729086986>",
components: [],
channel_id: "266767590641238027",
author: {
username: "kumaccino",
public_flags: 128,
id: "113340068197859328",
global_name: "kumaccino",
discriminator: "0",
avatar_decoration: null,
avatar: "b48302623a12bc7c59a71328f72ccb39"
},
attachments: [],
guild_id: "112760669178241024"
},
simple_role_mentions: {
id: "1162374402785153106",
type: 0,
@ -607,36 +508,6 @@ module.exports = {
flags: 0,
components: []
},
unknown_role: {
id: "1162374402785153106",
type: 0,
content: "I'm just <@&4> testing a few role pings <@&B> don't mind me",
channel_id: "160197704226439168",
author: {
id: "772659086046658620",
username: "cadence.worm",
avatar: "4b5c4b28051144e4c111f0113a0f1cf1",
discriminator: "0",
public_flags: 0,
flags: 0,
banner: null,
accent_color: null,
global_name: "cadence",
avatar_decoration_data: null,
banner_color: null
},
attachments: [],
embeds: [],
mentions: [],
mention_roles: [ "212762309364285440", "503685967463448616" ],
pinned: false,
mention_everyone: false,
tts: false,
timestamp: "2023-10-13T13:00:53.496000+00:00",
edited_timestamp: null,
flags: 0,
components: []
},
simple_message_link: {
id: "1126788210308161626",
type: 0,
@ -668,71 +539,6 @@ module.exports = {
flags: 0,
components: []
},
message_link_to_before_ooye: {
id: "1160824382755708948",
type: 0,
content: "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:_ https://discord.com/channels/112760669178241024/112760669178241024/810412561941921851",
channel_id: "112760669178241024",
author: {
id: "271237147401045000",
username: "jinx",
avatar: "a0ba563c16aff137289f67f38545807f",
discriminator: "0",
public_flags: 0,
premium_type: 0,
flags: 0,
banner: null,
accent_color: null,
global_name: "Jinx",
avatar_decoration_data: null,
banner_color: null
},
attachments: [],
embeds: [],
mentions: [],
mention_roles: [],
pinned: false,
mention_everyone: false,
tts: false,
timestamp: '2023-10-09T06:21:39.923000+00:00',
edited_timestamp: null,
flags: 0,
components: []
},
message_link_from_another_server: {
id: "1160824382755708948",
type: 0,
content: "Neither of these servers are known to OOYE: https://discord.com/channels/111/222/333 https://canary.discordapp.com/channels/444/555/666",
channel_id: "112760669178241024",
author: {
id: "271237147401045000",
username: "jinx",
avatar: "a0ba563c16aff137289f67f38545807f",
discriminator: "0",
public_flags: 0,
premium_type: 0,
flags: 0,
banner: null,
accent_color: null,
global_name: "Jinx",
avatar_decoration_data: null,
banner_color: null
},
attachments: [],
embeds: [],
mentions: [],
mention_roles: [],
pinned: false,
mention_everyone: false,
tts: false,
timestamp: '2023-10-09T06:21:39.923000+00:00',
edited_timestamp: null,
flags: 0,
components: []
},
simple_written_at_mention_for_matrix: {
id: "1159030564049915915",
type: 0,
@ -951,51 +757,6 @@ module.exports = {
],
guild_id: '1100319549670301727'
},
attachment_with_description: {
id: "1187111292243288194",
type: 0,
content: "",
channel_id: "1100319550446252084",
author: {
id: "772659086046658620",
username: "cadence.worm",
avatar: "4b5c4b28051144e4c111f0113a0f1cf1",
discriminator: "0",
public_flags: 0,
premium_type: 0,
flags: 0,
banner: null,
accent_color: null,
global_name: "cadence",
avatar_decoration_data: null,
banner_color: null
},
attachments: [
{
id: "1187111291932905554",
filename: "image.png",
size: 50208,
url: "https://cdn.discordapp.com/attachments/1100319550446252084/1187111291932905554/image.png?ex=6595b28b&is=65833d8b&hm=6a3c07328749b9d0d1a612ea0cbf1711a7fe29aeaa833c12a6eb6d5db1a87ea4&",
proxy_url: "https://media.discordapp.net/attachments/1100319550446252084/1187111291932905554/image.png?ex=6595b28b&is=65833d8b&hm=6a3c07328749b9d0d1a612ea0cbf1711a7fe29aeaa833c12a6eb6d5db1a87ea4&",
width: 412,
height: 228,
description: "here is my description!",
content_type: "image/png",
placeholder: "C/gBBIDPqKiim3h8hpBMv8RvVw==",
placeholder_version: 1
}
],
embeds: [],
mentions: [],
mention_roles: [],
pinned: false,
mention_everyone: false,
tts: false,
timestamp: "2023-12-20T19:16:27.532000+00:00",
edited_timestamp: null,
flags: 0,
components: []
},
skull_webp_attachment_with_content: {
type: 0,
tts: false,
@ -1286,184 +1047,6 @@ module.exports = {
attachments: [],
guild_id: "112760669178241024"
},
reply_with_video: {
id: "1197621094983676007",
type: 19,
content: "",
channel_id: "112760669178241024",
author: {
id: "772659086046658620",
username: "cadence.worm",
avatar: "4b5c4b28051144e4c111f0113a0f1cf1",
discriminator: "0",
public_flags: 0,
premium_type: 2,
flags: 0,
banner: null,
accent_color: null,
global_name: "cadence",
avatar_decoration_data: null,
banner_color: null
},
attachments: [
{
id: "1197621094786531358",
filename: "Ins_1960637570.mp4",
size: 860559,
url: "https://cdn.discordapp.com/attachments/112760669178241024/1197621094786531358/Ins_1960637570.mp4?ex=65bbee8f&is=65a9798f&hm=ae14f7824c3d526c5e11c162e012e1ee405fd5776e1e9302ed80ccd86503cfda&",
proxy_url: "https://media.discordapp.net/attachments/112760669178241024/1197621094786531358/Ins_1960637570.mp4?ex=65bbee8f&is=65a9798f&hm=ae14f7824c3d526c5e11c162e012e1ee405fd5776e1e9302ed80ccd86503cfda&",
width: 480,
height: 854,
content_type: "video/mp4",
placeholder: "wvcFBABod4gIl3enl6iqfM+s+A==",
placeholder_version: 1
}
],
embeds: [],
mentions: [
{
id: "114147806469554185",
username: "extremity",
avatar: "e0394d500407a8fa93774e1835b8b03a",
discriminator: "0",
public_flags: 768,
premium_type: 2,
flags: 768,
banner: null,
accent_color: null,
global_name: "Extremity",
avatar_decoration_data: null,
banner_color: null
}
],
mention_roles: [],
pinned: false,
mention_everyone: false,
tts: false,
timestamp: "2024-01-18T19:18:39.768000+00:00",
edited_timestamp: null,
flags: 0,
components: [],
message_reference: {
channel_id: "112760669178241024",
message_id: "1197612733600895076",
guild_id: "112760669178241024"
},
referenced_message: {
id: "1197612733600895076",
type: 0,
content: 'deadpicord "extremity you wake up at 4am"',
channel_id: "112760669178241024",
author: {
id: "114147806469554185",
username: "extremity",
avatar: "e0394d500407a8fa93774e1835b8b03a",
discriminator: "0",
public_flags: 768,
premium_type: 2,
flags: 768,
banner: null,
accent_color: null,
global_name: "Extremity",
avatar_decoration_data: null,
banner_color: null
},
attachments: [],
embeds: [],
mentions: [],
mention_roles: [],
pinned: false,
mention_everyone: false,
tts: false,
timestamp: "2024-01-18T18:45:26.259000+00:00",
edited_timestamp: null,
flags: 0,
components: []
}
},
voice_message: {
id: "1112476845783388160",
type: 0,
content: "",
channel_id: "1099031887500034088",
author: {
id: "113340068197859328",
username: "kumaccino",
avatar: "b48302623a12bc7c59a71328f72ccb39",
discriminator: "0",
public_flags: 128,
premium_type: 0,
flags: 128,
banner: null,
accent_color: null,
global_name: "kumaccino",
avatar_decoration_data: null,
banner_color: null
},
attachments: [
{
id: "1112476845502365786",
filename: "voice-message.ogg",
size: 10584,
url: "https://cdn.discordapp.com/attachments/1099031887500034088/1112476845502365786/voice-message.ogg?ex=65c92d4c&is=65b6b84c&hm=0654bab5027474cbe23875954fa117cf44d8914c144cd151879590fa1baf8b1c&",
proxy_url: "https://media.discordapp.net/attachments/1099031887500034088/1112476845502365786/voice-message.ogg?ex=65c92d4c&is=65b6b84c&hm=0654bab5027474cbe23875954fa117cf44d8914c144cd151879590fa1baf8b1c&",
duration_secs: 3.9600000381469727,
waveform: "AAgXAAwAPBsCAAAAInEDFwAAAAAbMwATEBAAAAAAAAAAAAAAAA==",
content_type: "audio/ogg"
}
],
embeds: [],
mentions: [],
mention_roles: [],
pinned: false,
mention_everyone: false,
tts: false,
timestamp: "2023-05-28T20:25:48.855000+00:00",
edited_timestamp: null,
flags: 8192,
components: []
},
misc_file: {
id: "1174514575819931718",
type: 0,
content: "final final final revised draft",
channel_id: "122155380120748034",
author: {
id: "142843483923677184",
username: "huck",
avatar: "a_1c7fda09a242d714570b4c828ef07504",
discriminator: "0",
public_flags: 512,
premium_type: 2,
flags: 512,
banner: null,
accent_color: null,
global_name: null,
avatar_decoration_data: null,
banner_color: null
},
attachments: [
{
id: "1174514575220158545",
filename: "the.yml",
size: 2274,
url: "https://cdn.discordapp.com/attachments/122155380120748034/1174514575220158545/the.yml?ex=65cd6270&is=65baed70&hm=8c5f1b571784e3c7f99628492298815884e351ae0dc7c2ae40dd22d97caf27d9&",
proxy_url: "https://media.discordapp.net/attachments/122155380120748034/1174514575220158545/the.yml?ex=65cd6270&is=65baed70&hm=8c5f1b571784e3c7f99628492298815884e351ae0dc7c2ae40dd22d97caf27d9&",
content_type: "text/plain; charset=utf-8",
content_scan_version: 0
}
],
embeds: [],
mentions: [],
mention_roles: [],
pinned: false,
mention_everyone: false,
tts: false,
timestamp: "2023-11-16T01:01:36.301000+00:00",
edited_timestamp: null,
flags: 0,
components: []
},
simple_reply_to_reply_in_thread: {
type: 19,
tts: false,
@ -1591,38 +1174,6 @@ module.exports = {
name: "pomu puff"
}]
},
lottie_sticker: {
id: "1106366167788044450",
type: 0,
content: "",
channel_id: "122155380120748034",
author: {
id: "113340068197859328",
username: "Cookie 🍪",
global_name: null,
display_name: null,
avatar: "b48302623a12bc7c59a71328f72ccb39",
discriminator: "7766",
public_flags: 128,
avatar_decoration: null
},
attachments: [],
embeds: [],
mentions: [],
mention_roles: [],
pinned: false,
mention_everyone: false,
tts: false,
timestamp: "2023-05-11T23:44:09.690000+00:00",
edited_timestamp: null,
flags: 0,
components: [],
sticker_items: [{
id: "860171525772279849",
format_type: 3,
name: "8"
}]
},
message_in_thread: {
type: 0,
tts: false,
@ -1805,126 +1356,6 @@ module.exports = {
components: []
}
},
pk_message: {
pk_reply_to_matrix: {
id: "1202543812644306965",
type: 0,
content: "this is a reply",
channel_id: "1160894080998461480",
author: {
id: "1195662438662680720",
username: "special name",
avatar: "6b44a106659e78a2550474c61889194d",
discriminator: "0000",
public_flags: 0,
flags: 0,
bot: true,
global_name: null
},
attachments: [],
embeds: [
{
type: "rich",
description: "**[Reply to:](https://discord.com/channels/1160893336324931584/1160894080998461480/1202543413652881428)** now for my next experiment:",
author: {
name: "cadence [they] ↩️",
icon_url: "https://cdn.discordapp.com/avatars/1162510387057545227/af0ead3b92cf6e448fdad80b4e7fc9e5.png",
proxy_icon_url: "https://images-ext-1.discordapp.net/external/wWslraV-s-bLDwphL64YxeDm30M7PIhQQy0EQa8jpDc/https/cdn.discordapp.com/avatars/1162510387057545227/af0ead3b92cf6e448fdad80b4e7fc9e5.png"
}
}
],
mentions: [],
mention_roles: [],
pinned: false,
mention_everyone: false,
tts: false,
timestamp: "2024-02-01T09:19:47.118000+00:00",
edited_timestamp: null,
flags: 0,
components: [],
application_id: "466378653216014359",
webhook_id: "1195662438662680720"
},
pk_reply_to_discord: {
id: "1202543812644306965",
type: 0,
content: "this is a reply",
channel_id: "1160894080998461480",
author: {
id: "1195662438662680720",
username: "special name",
avatar: "6b44a106659e78a2550474c61889194d",
discriminator: "0000",
public_flags: 0,
flags: 0,
bot: true,
global_name: null
},
attachments: [],
embeds: [
{
type: "rich",
description: "**[Reply to:](https://discord.com/channels/112760669178241024/112760669178241024/1141501302736695316)** some text",
author: {
name: "wing",
icon_url: "https://cdn.discordapp.com/avatars/112890272819507200/47db1be7ab77e1d812a4573177af0692.png",
proxy_icon_url: "https://images-ext-1.discordapp.net/external/wWslraV-s-bLDwphL64YxeDm30M7PIhQQy0EQa8jpDc/https/cdn.discordapp.com/avatars/112890272819507200/47db1be7ab77e1d812a4573177af0692.png"
}
}
],
mentions: [],
mention_roles: [],
pinned: false,
mention_everyone: false,
tts: false,
timestamp: "2024-02-01T09:19:47.118000+00:00",
edited_timestamp: null,
flags: 0,
components: [],
application_id: "466378653216014359",
webhook_id: "1195662438662680720"
},
pk_reply_to_matrix_attachment: {
id: "1207486739023798332",
type: 0,
content: "Cat nod",
channel_id: "1160894080998461480",
author: {
id: "1195662438662680720",
username: "Azalea &flwr; 🌺",
avatar: "48032c7bb5009701ef1aa9bd3446a67a",
discriminator: "0000",
public_flags: 0,
flags: 0,
bot: true,
global_name: null
},
attachments: [],
embeds: [
{
type: "rich",
description: "*[(click to see attachment)](https://discord.com/channels/1160893336324931584/1160894080998461480/1207486471489986620)*",
color: 14032878,
author: {
name: "Ampflower 🌺 ↩️",
icon_url: "https://cdn.discordapp.com/avatars/1162510387057545227/5d8c4e541a4d8255777fe64b4caef971.png",
proxy_icon_url: "https://images-ext-2.discordapp.net/external/RfjCRz6fSGzbIFtAqT0CGmQjJyuiD7d48mTU-CkqW5w/https/cdn.discordapp.com/avatars/1162510387057545227/5d8c4e541a4d8255777fe64b4caef971.png"
}
}
],
mentions: [],
mention_roles: [],
pinned: false,
mention_everyone: false,
tts: false,
timestamp: "2024-02-15T00:41:12.602000+00:00",
edited_timestamp: null,
flags: 0,
components: [],
application_id: "466378653216014359",
webhook_id: "1195662438662680720"
}
},
message_with_embeds: {
nothing_but_a_field: {
guild_id: "497159726455455754",
@ -2174,114 +1605,6 @@ module.exports = {
edited_timestamp: "2023-10-01T01:42:05.631000+00:00",
flags: 0,
components: []
},
blockquote_in_embed: {
id: "1158894131322552391",
type: 0,
content: "<:emoji:288858540888686602> **4 |** <#176333891320283136>",
channel_id: "331390333810376704",
author: {
id: "700796664276844612",
username: "Starboard",
avatar: "1db8745493a3701235275be62ce05fea",
discriminator: "9387",
public_flags: 65536,
flags: 65536,
bot: true,
banner: null,
accent_color: null,
global_name: null,
avatar_decoration_data: null,
banner_color: null
},
attachments: [],
embeds: [
{
type: "rich",
description: "reply draft\n" +
"> The following is a message composed via consensus of the Stinker Council.\n" +
"> \n" +
"> For those who are not currently aware of our existence, we represent the organization known as Wonderland. Our previous mission centered around the assortment and study of puzzling objects, entities and other assorted phenomena. This mission was the focus of our organization for more than 28 years.\n" +
"> \n" +
"> Due to circumstances outside of our control, this directive has now changed. Our new mission will be the extermination of the stinker race.\n" +
"> \n" +
"> There will be no further communication.",
color: 16769436,
timestamp: "2023-10-03T19:06:01.516000+00:00",
fields: [
{
name: "",
value: "[Go to Message](https://discord.com/channels/112760669178241024/176333891320283136/1158842413025071135)",
inline: false
}
],
author: {
name: "minimus",
url: "https://discord.com/channels/112760669178241024/176333891320283136/1158842413025071135",
icon_url: "https://cdn.discordapp.com/guilds/112760669178241024/users/112760500130975744/avatars/caf8f18d190e92c280f8bc7e13f3dfb7.png",
proxy_icon_url: "https://images-ext-2.discordapp.net/external/ufuM1hu_C6wpfbLS-RVb5iqa_X6Ht3aIj-xntAo8jjw/https/cdn.discordapp.com/guilds/112760669178241024/users/112760500130975744/avatars/caf8f18d190e92c280f8bc7e13f3dfb7.png"
}
}
],
mentions: [],
mention_roles: [],
pinned: false,
mention_everyone: false,
tts: false,
timestamp: "2023-10-03T22:31:32.119000+00:00",
edited_timestamp: null,
flags: 0,
components: []
},
escaping_crazy_html_tags: {
id: "1158894131322552391",
type: 0,
content: "",
channel_id: "331390333810376704",
author: {
id: "700796664276844612",
username: "Starboard",
avatar: "1db8745493a3701235275be62ce05fea",
discriminator: "9387",
public_flags: 65536,
flags: 65536,
bot: true,
banner: null,
accent_color: null,
global_name: null,
avatar_decoration_data: null,
banner_color: null
},
attachments: [],
embeds: [{
type: "rich",
title: "<strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;)",
description: "<strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;)",
url: "https://a.co/&amp;<script>",
footer: {
text: "<strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;)"
},
author: {
name: "<strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;)",
url: "https://a.co/&amp;<script>",
icon_url: "https://a.co/&amp;<script>"
},
fields: [
{
name: "<strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;)",
value: "<strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;)"
}
]
}],
mentions: [],
mention_roles: [],
pinned: false,
mention_everyone: false,
tts: false,
timestamp: "2023-10-03T22:31:32.119000+00:00",
edited_timestamp: null,
flags: 0,
components: []
}
},
message_update: {
@ -2662,88 +1985,6 @@ module.exports = {
name: "pomu puff"
}]
},
edited_content_with_sticker_and_attachments_but_all_parts_equal_0: {
id: "1106366167788044451",
type: 0,
content: "only the content can be edited",
channel_id: "122155380120748034",
author: {
id: "113340068197859328",
username: "Cookie 🍪",
global_name: null,
display_name: null,
avatar: "b48302623a12bc7c59a71328f72ccb39",
discriminator: "7766",
public_flags: 128,
avatar_decoration: null
},
attachments: [{
id: "1106366167486038016",
filename: "image.png",
size: 127373,
url: "https://cdn.discordapp.com/attachments/122155380120748034/1106366167486038016/image.png",
proxy_url: "https://media.discordapp.net/attachments/122155380120748034/1106366167486038016/image.png",
width: 333,
height: 287,
content_type: "image/png"
}],
embeds: [],
mentions: [],
mention_roles: [],
pinned: false,
mention_everyone: false,
tts: false,
timestamp: "2023-05-11T23:44:09.690000+00:00",
edited_timestamp: "2023-05-11T23:44:19.690000+00:00",
flags: 0,
components: [],
sticker_items: [{
id: "1106323941183717586",
format_type: 1,
name: "pomu puff"
}]
},
edited_content_with_sticker_and_attachments_but_all_parts_equal_1: {
id: "1106366167788044452",
type: 0,
content: "only the content can be edited",
channel_id: "122155380120748034",
author: {
id: "113340068197859328",
username: "Cookie 🍪",
global_name: null,
display_name: null,
avatar: "b48302623a12bc7c59a71328f72ccb39",
discriminator: "7766",
public_flags: 128,
avatar_decoration: null
},
attachments: [{
id: "1106366167486038016",
filename: "image.png",
size: 127373,
url: "https://cdn.discordapp.com/attachments/122155380120748034/1106366167486038016/image.png",
proxy_url: "https://media.discordapp.net/attachments/122155380120748034/1106366167486038016/image.png",
width: 333,
height: 287,
content_type: "image/png"
}],
embeds: [],
mentions: [],
mention_roles: [],
pinned: false,
mention_everyone: false,
tts: false,
timestamp: "2023-05-11T23:44:09.690000+00:00",
edited_timestamp: "2023-05-11T23:44:19.690000+00:00",
flags: 0,
components: [],
sticker_items: [{
id: "1106323941183717586",
format_type: 1,
name: "pomu puff"
}]
},
edit_of_reply_to_skull_webp_attachment_with_content: {
type: 19,
tts: false,

View file

@ -10,10 +10,7 @@ INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom
('1100319550446252084', '!BnKuBPCvyfOkhcUjEu:cadence.moe', 'worm-farm', NULL, NULL, NULL),
('1162005314908999790', '!FuDZhlOAtqswlyxzeR:cadence.moe', 'Hey.', NULL, '1100319550446252084', NULL),
('297272183716052993', '!rEOspnYqdOalaIFniV:cadence.moe', 'general', NULL, NULL, NULL),
('122155380120748034', '!cqeGDbPiMFAhLsqqqq:cadence.moe', 'cadences-mind', 'coding', NULL, NULL),
('176333891320283136', '!qzDBLKlildpzrrOnFZ:cadence.moe', '🌈丨davids-horse_she-took-the-kids', 'wonderland', NULL, 'mxc://cadence.moe/EVvrSkKIRONHjtRJsMLmHWLS'),
('489237891895768942', '!tnedrGVYKFNUdnegvf:tchncs.de', 'ex-room-doesnt-exist-any-more', NULL, NULL, NULL),
('1160894080998461480', '!TqlyQmifxGUggEmdBN:cadence.moe', 'ooyexperiment', NULL, NULL, NULL);
('122155380120748034', '!cqeGDbPiMFAhLsqqqq:cadence.moe', 'cadences-mind', 'coding', NULL, NULL);
INSERT INTO sim (user_id, sim_name, localpart, mxid) VALUES
('0', 'bot', '_ooye_bot', '@_ooye_bot:cadence.moe'),
@ -22,19 +19,13 @@ INSERT INTO sim (user_id, sim_name, localpart, mxid) VALUES
('112890272819507200', '.wing.', '_ooye_.wing.', '@_ooye_.wing.:cadence.moe'),
('114147806469554185', 'extremity', '_ooye_extremity', '@_ooye_extremity:cadence.moe'),
('111604486476181504', 'kyuugryphon', '_ooye_kyuugryphon', '@_ooye_kyuugryphon:cadence.moe'),
('1109360903096369153', 'amanda', '_ooye_amanda', '@_ooye_amanda:cadence.moe'),
('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '_pk_zoego', '_ooye__pk_zoego', '@_ooye__pk_zoego:cadence.moe');
INSERT INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES
('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '196188877885538304', 'Azalea &flwr; 🌺');
('1109360903096369153', 'amanda', '_ooye_amanda', '@_ooye_amanda:cadence.moe');
INSERT INTO sim_member (mxid, room_id, hashed_profile_content) VALUES
('@_ooye_bojack_horseman:cadence.moe', '!hYnGGlPHlbujVVfktC:cadence.moe', NULL);
INSERT INTO message_channel (message_id, channel_id) VALUES
('1106366167788044450', '122155380120748034'),
('1106366167788044451', '122155380120748034'),
('1106366167788044452', '122155380120748034'),
('1126786462646550579', '112760669178241024'),
('1128084748338741392', '112760669178241024'),
('1128084851279536279', '112760669178241024'),
@ -49,11 +40,7 @@ INSERT INTO message_channel (message_id, channel_id) VALUES
('1145688633186193480', '1100319550446252084'),
('1145688633186193481', '1100319550446252084'),
('1162005526675193909', '1162005314908999790'),
('1162625810109317170', '497161350934560778'),
('1158842413025071135', '176333891320283136'),
('1197612733600895076', '112760669178241024'),
('1202543413652881428', '1160894080998461480'),
('1207486471489986620', '1160894080998461480');
('1162625810109317170', '497161350934560778');
INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES
('$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg', 'm.room.message', 'm.text', '1126786462646550579', 0, 0, 1),
@ -70,12 +57,6 @@ INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part
('$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4', 'm.room.message', 'm.text', '1106366167788044450', 0, 1, 1),
('$Ijf1MFCD39ktrNHxrA-i2aKoRWNYdAV2ZXYQeiZIgEU', 'm.room.message', 'm.image', '1106366167788044450', 1, 1, 0),
('$f9cjKiacXI9qPF_nUAckzbiKnJEi0LM399kOkhdd8f8', 'm.sticker', NULL, '1106366167788044450', 1, 0, 0),
('$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd999', 'm.room.message', 'm.text', '1106366167788044451', 0, 0, 1),
('$Ijf1MFCD39ktrNHxrA-i2aKoRWNYdAV2ZXYQeiZI999', 'm.room.message', 'm.image', '1106366167788044451', 0, 0, 1),
('$f9cjKiacXI9qPF_nUAckzbiKnJEi0LM399kOkhdd999', 'm.sticker', NULL, '1106366167788044451', 0, 0, 1),
('$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd111', 'm.room.message', 'm.text', '1106366167788044452', 1, 1, 1),
('$Ijf1MFCD39ktrNHxrA-i2aKoRWNYdAV2ZXYQeiZI111', 'm.room.message', 'm.image', '1106366167788044452', 1, 1, 1),
('$f9cjKiacXI9qPF_nUAckzbiKnJEi0LM399kOkhdd111', 'm.sticker', NULL, '1106366167788044452', 1, 1, 1),
('$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04', 'm.room.message', 'm.text', '1144865310588014633', 0, 0, 1),
('$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8', 'm.room.message', 'm.text', '1144874214311067708', 0, 0, 0),
('$7LIdiJCEqjcWUrpzWzS8TELOlFfBEe4ytgS7zn2lbSs', 'm.room.message', 'm.text', '1145688633186193479', 0, 0, 0),
@ -83,11 +64,7 @@ INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part
('$7LIdiJCEqjcWUrpzWzS8TELOlFfBEe4ytgS7zn2lbSt', 'm.room.message', 'm.text', '1145688633186193481', 1, 0, 0),
('$nUM-ABBF8KdnvrhXwLlYAE9dgDl_tskOvvcNIBrtsVo', 'm.room.message', 'm.text', '1162005526675193909', 0, 0, 0),
('$0wEdIP8fhTq-P68xwo_gyUw-Zv0KA2aS2tfhdFSrLZc', 'm.room.message', 'm.text', '1162625810109317170', 1, 1, 1),
('$zJFjTvNn1w_YqpR4o4ISKUFisNRgZcu1KSMI_LADPVQ', 'm.room.message', 'm.notice', '1162625810109317170', 1, 0, 1),
('$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo', 'm.room.message', 'm.text', '1158842413025071135', 0, 0, 1),
('$7tJoMw1h44n2gxgLUE1T_YinGrLbK0x-TDY1z6M7GBw', 'm.room.message', 'm.text', '1197612733600895076', 0, 0, 1),
('$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU', 'm.room.message', 'm.text', '1202543413652881428', 0, 0, 0),
('$OEEK-Wam2FTh6J-6kVnnJ6KnLA_lLRnLTHatKKL62-Y', 'm.room.message', 'm.image', '1207486471489986620', 0, 0, 0);
('$zJFjTvNn1w_YqpR4o4ISKUFisNRgZcu1KSMI_LADPVQ', 'm.room.message', 'm.notice', '1162625810109317170', 1, 0, 1);
INSERT INTO file (discord_url, mxc_url) VALUES
('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'),
@ -103,21 +80,15 @@ INSERT INTO file (discord_url, mxc_url) VALUES
('https://cdn.discordapp.com/emojis/393635038903926784.gif', 'mxc://cadence.moe/WbYqNlACRuicynBfdnPYtmvc'),
('https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg', 'mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR'),
('https://cdn.discordapp.com/emojis/1125827250609201255.png', 'mxc://cadence.moe/pgdGTxAyEltccRgZKxdqzHHP'),
('https://cdn.discordapp.com/avatars/320067006521147393/5fc4ad85c1ea876709e9a7d3374a78a1.png?size=1024', 'mxc://cadence.moe/JPzSmALLirnIprlSMKohSSoX'),
('https://cdn.discordapp.com/emojis/288858540888686602.png', 'mxc://cadence.moe/mwZaCtRGAQQyOItagDeCocEO'),
('https://cdn.discordapp.com/attachments/112760669178241024/1197621094786531358/Ins_1960637570.mp4', 'mxc://cadence.moe/kMqLycqMURhVpwleWkmASpnU'),
('https://cdn.discordapp.com/attachments/1099031887500034088/1112476845502365786/voice-message.ogg', 'mxc://cadence.moe/MRRPDggXQMYkrUjTpxQbmcxB'),
('https://cdn.discordapp.com/attachments/122155380120748034/1174514575220158545/the.yml', 'mxc://cadence.moe/HnQIYQmmlIKwOQsbFsIGpzPP');
('https://cdn.discordapp.com/avatars/320067006521147393/5fc4ad85c1ea876709e9a7d3374a78a1.png?size=1024', 'mxc://cadence.moe/JPzSmALLirnIprlSMKohSSoX');
INSERT INTO emoji (emoji_id, name, animated, mxc_url) VALUES
('230201364309868544', 'hippo', 0, 'mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC'),
('393635038903926784', 'hipposcope', 1, 'mxc://cadence.moe/WbYqNlACRuicynBfdnPYtmvc'),
('457898385297815911', 'emoji_from_unreachable_server', 0, 'mxc://cadence.moe/bZFuuUSEebJYXUMSxuuSuLTa'),
('362741439211503616', 'bn_re', 0, 'mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT'),
('551636841284108289', 'ae_botrac4r', 0, 'mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs'),
('975572106295259148', 'brillillillilliant_move', 0, 'mxc://cadence.moe/scfRIDOGKWFDEBjVXocWYQHik'),
('606664341298872324', 'online', 0, 'mxc://cadence.moe/LCEqjStXCxvRQccEkuslXEyZ'),
('288858540888686602', 'upstinky', 0, 'mxc://cadence.moe/mwZaCtRGAQQyOItagDeCocEO');
('606664341298872324', 'online', 0, 'mxc://cadence.moe/LCEqjStXCxvRQccEkuslXEyZ');
INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES
('!kLRqKKUQXcibIMtOpl:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL),
@ -125,14 +96,7 @@ INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES
('!fGgIymcYWOqjbSRUdV:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU'),
('!BnKuBPCvyfOkhcUjEu:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU'),
('!maggESguZBqGBZtSnr:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU'),
('!CzvdIdUQXgUjDVKxeU:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU'),
('!cBxtVRxDlZvSVhJXVK:cadence.moe', '@Milan:tchncs.de', 'Milan', NULL),
('!TqlyQmifxGUggEmdBN:cadence.moe', '@ampflower:matrix.org', 'Ampflower 🌺', 'mxc://cadence.moe/PRfhXYBTOalvgQYtmCLeUXko'),
('!TqlyQmifxGUggEmdBN:cadence.moe', '@aflower:syndicated.gay', 'Rose', 'mxc://syndicated.gay/ZkBUPXCiXTjdJvONpLJmcbKP'),
('!TqlyQmifxGUggEmdBN:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL);
INSERT INTO lottie (sticker_id, mxc_url) VALUES
('860171525772279849', 'mxc://cadence.moe/ZtvvVbwMIdUZeovWVyGVFCeR');
('!CzvdIdUQXgUjDVKxeU:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU');
INSERT INTO "auto_emoji" ("name","emoji_id","guild_id") VALUES
('L1','1144820033948762203','529176156398682115'),

File diff suppressed because one or more lines are too long

View file

@ -1,16 +0,0 @@
# Source
Flying Bee by Afif Ridwan
https://lottiefiles.com/animations/flying-bee-WkXvUiWkZ1
# License
Lottie Simple License (FL 9.13.21) https://lottiefiles.com/page/license
Copyright © 2021 Design Barn Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of the public animation files available for download at the LottieFiles site (“Files”) to download, reproduce, modify, publish, distribute, publicly display, and publicly digitally perform such Files, including for commercial purposes, provided that any display, publication, performance, or distribution of Files must contain (and be subject to) the same terms and conditions of this license. Modifications to Files are deemed derivative works and must also be expressly distributed under the same terms and conditions of this license. You may not purport to impose any additional or different terms or conditions on, or apply any technical measures that restrict exercise of, the rights granted under this license. This license does not include the right to collect or compile Files from LottieFiles to replicate or develop a similar or competing service.
Use of Files without attributing the creator(s) of the Files is permitted under this license, though attribution is strongly encouraged. If attributions are included, such attributions should be visible to the end user.
FILES ARE PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE LAW, IN NO EVENT WILL THE CREATOR(S) OF FILES OR DESIGN BARN, INC. BE LIABLE ON ANY LEGAL THEORY FOR ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE, OR EXEMPLARY DAMAGES ARISING OUT OF THIS LICENSE OR THE USE OF SUCH FILES.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

View file

@ -2,16 +2,11 @@
const fs = require("fs")
const {join} = require("path")
const stp = require("stream").promises
const sqlite = require("better-sqlite3")
const migrate = require("../db/migrate")
const HeatSync = require("heatsync")
const {test} = require("supertape")
const data = require("./data")
/** @type {import("node-fetch").default} */
// @ts-ignore
const fetch = require("node-fetch")
const {green} = require("colorette")
const config = require("../config")
const passthrough = require("../passthrough")
@ -19,7 +14,6 @@ const db = new sqlite(":memory:")
const reg = require("../matrix/read-registration")
reg.ooye.server_origin = "https://matrix.cadence.moe" // so that tests will pass even when hard-coded
reg.ooye.server_name = "cadence.moe"
reg.ooye.invite = ["@test_auto_invite:example.org"]
const sync = new HeatSync({watchFS: false})
@ -30,16 +24,7 @@ const discord = {
]),
application: {
id: "684280192553844747"
},
channels: new Map([
["497161350934560778", {
guild_id: "497159726455455754"
}],
["498323546729086986", {
guild_id: "497159726455455754",
name: "bad-boots-prison"
}]
])
}
}
Object.assign(passthrough, { discord, config, sync, db })
@ -49,68 +34,17 @@ passthrough.from = orm.from
passthrough.select = orm.select
const file = sync.require("../matrix/file")
/* c8 ignore next */
file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not allowed to upload files during testing.\nURL: ${url}`) }
;(async () => {
/* c8 ignore start - maybe download some more test files in slow mode */
if (process.argv.includes("--slow")) {
test("test files: download", async t => {
/** @param {{url: string, to: string}[]} files */
async function allReporter(files) {
return new Promise(resolve => {
let resolved = 0
const report = files.map(file => file.to.split("/").slice(-1)[0][0])
files.map(download).forEach((p, i) => {
p.then(() => {
report[i] = green(".")
process.stderr.write("\r" + report.join(""))
if (++resolved === files.length) resolve(null)
})
})
})
}
async function download({url, to}) {
if (await fs.existsSync(to)) return
const res = await fetch(url)
await stp.pipeline(res.body, fs.createWriteStream(to, {encoding: "binary"}))
}
await allReporter([
{url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/RLMgJGfgTPjIQtvvWZsYjhjy", to: "test/res/RLMgJGfgTPjIQtvvWZsYjhjy.png"},
{url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/bZFuuUSEebJYXUMSxuuSuLTa", to: "test/res/bZFuuUSEebJYXUMSxuuSuLTa.png"},
{url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/qWmbXeRspZRLPcjseyLmeyXC", to: "test/res/qWmbXeRspZRLPcjseyLmeyXC.png"},
{url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/wcouHVjbKJJYajkhJLsyeJAA", to: "test/res/wcouHVjbKJJYajkhJLsyeJAA.png"},
{url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/WbYqNlACRuicynBfdnPYtmvc", to: "test/res/WbYqNlACRuicynBfdnPYtmvc.gif"},
{url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/HYcztccFIPgevDvoaWNsEtGJ", to: "test/res/HYcztccFIPgevDvoaWNsEtGJ.png"},
{url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/lHfmJpzgoNyNtYHdAmBHxXix", to: "test/res/lHfmJpzgoNyNtYHdAmBHxXix.png"},
{url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/MtRdXixoKjKKOyHJGWLsWLNU", to: "test/res/MtRdXixoKjKKOyHJGWLsWLNU.png"},
{url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/HXfFuougamkURPPMflTJRxGc", to: "test/res/HXfFuougamkURPPMflTJRxGc.png"},
{url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/ikYKbkhGhMERAuPPbsnQzZiX", to: "test/res/ikYKbkhGhMERAuPPbsnQzZiX.png"},
{url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/AYPpqXzVJvZdzMQJGjioIQBZ", to: "test/res/AYPpqXzVJvZdzMQJGjioIQBZ.png"},
{url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/UVuzvpVUhqjiueMxYXJiFEAj", to: "test/res/UVuzvpVUhqjiueMxYXJiFEAj.png"},
{url: "https://ezgif.com/images/format-demo/butterfly.gif", to: "test/res/butterfly.gif"},
{url: "https://ezgif.com/images/format-demo/butterfly.png", to: "test/res/butterfly.png"},
])
}, {timeout: 60000})
}
/* c8 ignore end */
const p = migrate.migrate(db)
test("migrate: migration works", async t => {
await p
t.pass("it did not throw an error")
})
await p
test("migrate: migration works the second time", async t => {
await migrate.migrate(db)
t.pass("it did not throw an error")
})
db.exec(fs.readFileSync(join(__dirname, "ooye-test-data.sql"), "utf8"))
require("../db/orm.test")
require("../discord/utils.test")
require("../matrix/kstate.test")
require("../matrix/api.test")
require("../matrix/file.test")
@ -120,15 +54,12 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not
require("../d2m/actions/register-user.test")
require("../d2m/converters/edit-to-changes.test")
require("../d2m/converters/emoji-to-key.test")
require("../d2m/converters/lottie.test")
require("../d2m/converters/message-to-event.test")
require("../d2m/converters/message-to-event.embeds.test")
require("../d2m/converters/message-to-event.pk.test")
require("../d2m/converters/pins-to-list.test")
require("../d2m/converters/remove-reaction.test")
require("../d2m/converters/thread-to-announcement.test")
require("../d2m/converters/user-to-mxid.test")
require("../m2d/converters/event-to-message.test")
require("../m2d/converters/utils.test")
require("../m2d/converters/emoji-sheet.test")
})()

44
types.d.ts vendored
View file

@ -21,7 +21,8 @@ export type AppServiceRegistrationConfig = {
max_file_size: number
server_name: string
server_origin: string
content_length_workaround: boolean
discord_origin: string
discord_cdn_origin: string
invite: string[]
}
old_bridge?: {
@ -35,45 +36,6 @@ export type WebhookCreds = {
token: string
}
export type PkSystem = {
id: string
uuid: string
name: string | null
description: string | null
tag: string | null
pronouns: string | null
avatar_url: string | null
banner: string | null
color: string | null
created: string | null
}
export type PkMember = {
id: string
uuid: string
name: string
display_name: string | null
color: string | null
birthday: string | null
pronouns: string | null
avatar_url: string | null
webhook_avatar_url: string | null
banner: string | null
description: string | null
created: string | null
keep_proxy: boolean
tts: boolean
autoproxy_enabled: boolean | null
message_count: number | null
last_message_timestamp: string
}
export type PkMessage = {
system: PkSystem
member: PkMember
sender: string
}
export namespace Event {
export type Outer<T> = {
type: string
@ -131,7 +93,6 @@ export namespace Event {
export type M_Room_Message_File = {
msgtype: "m.file" | "m.image" | "m.video" | "m.audio"
body: string
filename?: string
url: string
info?: any
"m.relates_to"?: {
@ -148,7 +109,6 @@ export namespace Event {
export type M_Room_Message_Encrypted_File = {
msgtype: "m.file" | "m.image" | "m.video" | "m.audio"
body: string
filename?: string
file: {
url: string
iv: string