Move everything to src folder... it had to happen

This commit is contained in:
Cadence Ember 2024-09-12 17:05:13 +12:00
commit 4247a3114a
103 changed files with 1 additions and 1 deletions

View file

@ -0,0 +1,44 @@
// @ts-check
const assert = require("assert").strict
const passthrough = require("../../passthrough")
const {discord, sync, db, select} = passthrough
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("./register-user")} */
const registerUser = sync.require("./register-user")
/** @type {import("../actions/create-room")} */
const createRoom = sync.require("../actions/create-room")
/** @type {import("../converters/emoji-to-key")} */
const emojiToKey = sync.require("../converters/emoji-to-key")
/**
* @param {import("discord-api-types/v10").GatewayMessageReactionAddDispatchData} data
*/
async function addReaction(data) {
const user = data.member?.user
assert.ok(user && user.username)
const parentID = select("event_message", "event_id", {message_id: data.message_id, reaction_part: 0}).pluck().get()
if (!parentID) return // Nothing can be done if the parent message was never bridged.
assert.equal(typeof parentID, "string")
const key = await emojiToKey.emojiToKey(data.emoji)
const shortcode = key.startsWith("mxc://") ? `:${data.emoji.name}:` : undefined
const roomID = await createRoom.ensureRoom(data.channel_id)
const senderMxid = await registerUser.ensureSimJoined(user, roomID)
const eventID = await api.sendEvent(roomID, "m.reaction", {
"m.relates_to": {
rel_type: "m.annotation",
event_id: parentID,
key
},
shortcode
}, senderMxid)
return eventID
}
module.exports.addReaction = addReaction

View file

@ -0,0 +1,27 @@
// @ts-check
const assert = require("assert")
const passthrough = require("../../passthrough")
const {discord, sync, db, select} = passthrough
/** @type {import("../converters/thread-to-announcement")} */
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
* @param {string} threadRoomID
* @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 content = await threadToAnnouncement.threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, {api})
await api.sendEvent(parentRoomID, "m.room.message", content, creatorMxid)
}
module.exports.announceThread = announceThread

View file

@ -0,0 +1,457 @@
// @ts-check
const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10")
const Ty = require("../../types")
const {reg} = require("../../matrix/read-registration")
const passthrough = require("../../passthrough")
const {discord, sync, db, select} = passthrough
/** @type {import("../../matrix/file")} */
const file = sync.require("../../matrix/file")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/kstate")} */
const ks = sync.require("../../matrix/kstate")
/** @type {import("../../discord/utils")} */
const utils = sync.require("../../discord/utils")
/** @type {import("./create-space")}) */
const createSpace = sync.require("./create-space") // watch out for the require loop
/**
* There are 3 levels of room privacy:
* 0: Room is invite-only.
* 1: Anybody can use a link to join.
* 2: Room is published in room directory.
*/
const PRIVACY_ENUMS = {
PRESET: ["private_chat", "public_chat", "public_chat"],
VISIBILITY: ["private", "private", "public"],
SPACE_HISTORY_VISIBILITY: ["invited", "world_readable", "world_readable"], // copying from element client
ROOM_HISTORY_VISIBILITY: ["shared", "shared", "world_readable"], // any events sent after <value> are visible, but for world_readable anybody can read without even joining
GUEST_ACCESS: ["can_join", "forbidden", "forbidden"], // whether guests can join space if other conditions are met
SPACE_JOIN_RULES: ["invite", "public", "public"],
ROOM_JOIN_RULES: ["restricted", "public", "public"]
}
const DEFAULT_PRIVACY_LEVEL = 0
/** @type {Map<string, Promise<string>>} channel ID -> Promise<room ID> */
const inflightRoomCreate = new Map()
/**
* Async because it gets all room state from the homeserver.
* @param {string} roomID
*/
async function roomToKState(roomID) {
const root = await api.getAllState(roomID)
return ks.stateToKState(root)
}
/**
* @param {string} roomID
* @param {any} kstate
*/
async function applyKStateDiffToRoom(roomID, kstate) {
const events = await ks.kstateToState(kstate)
return Promise.all(events.map(({type, state_key, content}) =>
api.sendState(roomID, type, state_key, content)
))
}
/**
* @param {{id: string, name: string, topic?: string?, type: number, parent_id?: string?}} channel
* @param {{id: string}} guild
* @param {string | null | undefined} customName
*/
function convertNameAndTopic(channel, guild, customName) {
// @ts-ignore
const parentChannel = discord.channels.get(channel.parent_id)
let channelPrefix =
( parentChannel?.type === DiscordTypes.ChannelType.GuildForum ? ""
: channel.type === DiscordTypes.ChannelType.PublicThread ? "[⛓️] "
: channel.type === DiscordTypes.ChannelType.PrivateThread ? "[🔒⛓️] "
: channel.type === DiscordTypes.ChannelType.GuildVoice ? "[🔊] "
: "")
const chosenName = customName || (channelPrefix + channel.name);
const maybeTopicWithPipe = channel.topic ? ` | ${channel.topic}` : '';
const maybeTopicWithNewlines = channel.topic ? `${channel.topic}\n\n` : '';
const channelIDPart = `Channel ID: ${channel.id}`;
const guildIDPart = `Guild ID: ${guild.id}`;
const convertedTopic = customName
? `#${channel.name}${maybeTopicWithPipe}\n\n${channelIDPart}\n${guildIDPart}`
: `${maybeTopicWithNewlines}${channelIDPart}\n${guildIDPart}`;
return [chosenName, convertedTopic];
}
/**
* Async because it may create the guild and/or upload the guild icon to mxc.
* @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel
* @param {DiscordTypes.APIGuild} guild
* @param {{api: {getStateEvent: typeof api.getStateEvent}}} di simple-as-nails dependency injection for the matrix API
*/
async function channelToKState(channel, guild, di) {
// @ts-ignore
const parentChannel = discord.channels.get(channel.parent_id)
/** Used for membership/permission checks. */
let guildSpaceID
/** Used as the literal parent on Matrix, for categorisation. Will be the same as `guildSpaceID` unless it's a forum channel's thread, in which case a different space is used to group those threads. */
let parentSpaceID
let privacyLevel
if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum) { // it's a forum channel's thread, so use a different space to group those threads
guildSpaceID = await createSpace.ensureSpace(guild)
parentSpaceID = await ensureRoom(channel.parent_id)
privacyLevel = select("guild_space", "privacy_level", {space_id: guildSpaceID}).pluck().get()
} else { // otherwise use the guild's space like usual
parentSpaceID = await createSpace.ensureSpace(guild)
guildSpaceID = parentSpaceID
privacyLevel = select("guild_space", "privacy_level", {space_id: parentSpaceID}).pluck().get()
}
assert(typeof parentSpaceID === "string")
assert(typeof guildSpaceID === "string")
assert(typeof privacyLevel === "number")
const row = select("channel_room", ["nick", "custom_avatar"], {channel_id: channel.id}).get()
const customName = row?.nick
const customAvatar = row?.custom_avatar
const [convertedName, convertedTopic] = convertNameAndTopic(channel, guild, customName)
const avatarEventContent = {}
if (customAvatar) {
avatarEventContent.url = customAvatar
} else if (guild.icon) {
avatarEventContent.url = {$url: file.guildIcon(guild)}
}
let history_visibility = PRIVACY_ENUMS.ROOM_HISTORY_VISIBILITY[privacyLevel]
if (channel["thread_metadata"]) history_visibility = "world_readable"
/** @type {{join_rule: string, allow?: any}} */
let join_rules = {
join_rule: "restricted",
allow: [{
type: "m.room_membership",
room_id: guildSpaceID
}]
}
if (PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel] !== "restricted") {
join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]}
}
const everyonePermissions = utils.getPermissions([], guild.roles, undefined, channel.permission_overwrites)
const everyoneCanMentionEveryone = utils.hasAllPermissions(everyonePermissions, ["MentionEveryone"])
const globalAdmins = select("member_power", ["mxid", "power_level"], {room_id: "*"}).all()
const globalAdminPower = globalAdmins.reduce((a, c) => (a[c.mxid] = c.power_level, a), {})
/** @type {Ty.Event.M_Power_Levels} */
const spacePowerEvent = await di.api.getStateEvent(guildSpaceID, "m.room.power_levels", "")
const spacePower = spacePowerEvent.users
const channelKState = {
"m.room.name/": {name: convertedName},
"m.room.topic/": {topic: convertedTopic},
"m.room.avatar/": avatarEventContent,
"m.room.guest_access/": {guest_access: PRIVACY_ENUMS.GUEST_ACCESS[privacyLevel]},
"m.room.history_visibility/": {history_visibility},
[`m.space.parent/${parentSpaceID}`]: {
via: [reg.ooye.server_name],
canonical: true
},
/** @type {{join_rule: string, [x: string]: any}} */
"m.room.join_rules/": join_rules,
"m.room.power_levels/": {
notifications: {
room: everyoneCanMentionEveryone ? 0 : 20
},
users: {...spacePower, ...globalAdminPower}
},
"chat.schildi.hide_ui/read_receipts": {
hidden: true
},
[`uk.half-shot.bridge/moe.cadence.ooye://discord/${guild.id}/${channel.id}`]: {
bridgebot: `@${reg.sender_localpart}:${reg.ooye.server_name}`,
protocol: {
id: "discord",
displayname: "Discord"
},
network: {
id: guild.id,
displayname: guild.name,
avatar_url: await file.uploadDiscordFileToMxc(file.guildIcon(guild))
},
channel: {
id: channel.id,
displayname: channel.name,
external_url: `https://discord.com/channels/${guild.id}/${channel.id}`
}
}
}
return {spaceID: parentSpaceID, privacyLevel, channelKState}
}
/**
* Create a bridge room, store the relationship in the database, and add it to the guild's space.
* @param {DiscordTypes.APIGuildTextChannel} channel
* @param guild
* @param {string} spaceID
* @param {any} kstate
* @param {number} privacyLevel
* @returns {Promise<string>} room ID
*/
async function createRoom(channel, guild, spaceID, kstate, privacyLevel) {
let threadParent = null
if (channel.type === DiscordTypes.ChannelType.PublicThread) threadParent = channel.parent_id
let spaceCreationContent = {}
if (channel.type === DiscordTypes.ChannelType.GuildForum) spaceCreationContent = {creation_content: {type: "m.space"}}
// Name and topic can be done earlier in room creation rather than in initial_state
// https://spec.matrix.org/latest/client-server-api/#creation
const name = kstate["m.room.name/"].name
delete kstate["m.room.name/"]
assert(name)
const topic = kstate["m.room.topic/"].topic
delete kstate["m.room.topic/"]
assert(topic)
const roomID = await postApplyPowerLevels(kstate, async kstate => {
const roomID = await api.createRoom({
name,
topic,
preset: PRIVACY_ENUMS.PRESET[privacyLevel], // This is closest to what we want, but properties from kstate override it anyway
visibility: PRIVACY_ENUMS.VISIBILITY[privacyLevel],
invite: [],
initial_state: await ks.kstateToState(kstate),
...spaceCreationContent
})
db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent) VALUES (?, ?, ?, NULL, ?)").run(channel.id, roomID, channel.name, threadParent)
return roomID
})
// Put the newly created child into the space, no need to await this
_syncSpaceMember(channel, spaceID, roomID)
return roomID
}
/**
* Handling power levels separately. The spec doesn't specify what happens, Dendrite differs,
* and Synapse does an absolutely insane *shallow merge* of what I provide on top of what it creates.
* We don't want the `events` key to be overridden completely.
* https://github.com/matrix-org/synapse/blob/develop/synapse/handlers/room.py#L1170-L1210
* https://github.com/matrix-org/matrix-spec/issues/492
* @param {any} kstate
* @param {(_: any) => Promise<string>} callback must return room ID
* @returns {Promise<string>} room ID
*/
async function postApplyPowerLevels(kstate, callback) {
const powerLevelContent = kstate["m.room.power_levels/"]
const kstateWithoutPowerLevels = {...kstate}
delete kstateWithoutPowerLevels["m.room.power_levels/"]
/** @type {string} */
const roomID = await callback(kstateWithoutPowerLevels)
// Now *really* apply the power level overrides on top of what Synapse *really* set
if (powerLevelContent) {
const newRoomKState = await roomToKState(roomID)
const newRoomPowerLevelsDiff = ks.diffKState(newRoomKState, {"m.room.power_levels/": powerLevelContent})
await applyKStateDiffToRoom(roomID, newRoomPowerLevelsDiff)
}
return roomID
}
/**
* @param {DiscordTypes.APIGuildChannel} channel
*/
function channelToGuild(channel) {
const guildID = channel.guild_id
assert(guildID)
const guild = discord.guilds.get(guildID)
assert(guild)
return guild
}
/*
Ensure flow:
1. Get IDs
2. Does room exist? If so great!
(it doesn't, so it needs to be created)
3. Get kstate for channel
4. Create room, return new ID
Ensure + sync flow:
1. Get IDs
2. Does room exist?
2.5: If room does exist AND wasn't asked to sync: return here
3. Get kstate for channel
4. Create room with kstate if room doesn't exist
5. Get and update room state with kstate if room does exist
*/
/**
* @param {string} channelID
* @param {boolean} shouldActuallySync false if just need to ensure room exists (which is a quick database check), true if also want to sync room data when it does exist (slow)
* @returns {Promise<string>} room ID
*/
async function _syncRoom(channelID, shouldActuallySync) {
/** @ts-ignore @type {DiscordTypes.APIGuildChannel} */
const channel = discord.channels.get(channelID)
assert.ok(channel)
const guild = channelToGuild(channel)
if (inflightRoomCreate.has(channelID)) {
await inflightRoomCreate.get(channelID) // just waiting, and then doing a new db query afterwards, is the simplest way of doing it
}
const existing = select("channel_room", ["room_id", "thread_parent"], {channel_id: channelID}).get()
if (!existing) {
const creation = (async () => {
const {spaceID, privacyLevel, channelKState} = await channelToKState(channel, guild, {api})
const roomID = await createRoom(channel, guild, spaceID, channelKState, privacyLevel)
inflightRoomCreate.delete(channelID) // OK to release inflight waiters now. they will read the correct `existing` row
return roomID
})()
inflightRoomCreate.set(channelID, creation)
return creation // Naturally, the newly created room is already up to date, so we can always skip syncing here.
}
const roomID = existing.room_id
if (!shouldActuallySync) {
return existing.room_id // only need to ensure room exists, and it does. return the room ID
}
console.log(`[room sync] to matrix: ${channel.name}`)
const {spaceID, channelKState} = await channelToKState(channel, guild, {api}) // calling this in both branches because we don't want to calculate this if not syncing
// sync channel state to room
const roomKState = await roomToKState(roomID)
if (+roomKState["m.room.create/"].room_version <= 8) {
// join_rule `restricted` is not available in room version < 8 and not working properly in version == 8
// read more: https://spec.matrix.org/v1.8/rooms/v9/
// we have to use `public` instead, otherwise the room will be unjoinable.
channelKState["m.room.join_rules/"] = {join_rule: "public"}
}
const roomDiff = ks.diffKState(roomKState, channelKState)
const roomApply = applyKStateDiffToRoom(roomID, roomDiff)
db.prepare("UPDATE channel_room SET name = ? WHERE room_id = ?").run(channel.name, roomID)
// sync room as space member
const spaceApply = _syncSpaceMember(channel, spaceID, roomID)
await Promise.all([roomApply, spaceApply])
return roomID
}
/** Ensures the room exists. If it doesn't, creates the room with an accurate initial state. */
function ensureRoom(channelID) {
return _syncRoom(channelID, false)
}
/** Actually syncs. Gets all room state from the homeserver in order to diff, and uploads the icon to mxc if it has changed. */
function syncRoom(channelID) {
return _syncRoom(channelID, true)
}
async function _unbridgeRoom(channelID) {
/** @ts-ignore @type {DiscordTypes.APIGuildChannel} */
const channel = discord.channels.get(channelID)
assert.ok(channel)
assert.ok(channel.guild_id)
return unbridgeDeletedChannel(channel, channel.guild_id)
}
/**
* @param {{id: string, topic?: string?}} channel
* @param {string} guildID
*/
async function unbridgeDeletedChannel(channel, guildID) {
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
assert.ok(roomID)
const spaceID = select("guild_space", "space_id", {guild_id: guildID}).pluck().get()
assert.ok(spaceID)
// remove room from being a space member
await api.sendState(roomID, "m.space.parent", spaceID, {})
await api.sendState(spaceID, "m.space.child", roomID, {})
// remove declaration that the room is bridged
await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channel.id}`, {})
if ("topic" in channel) {
// previously the Matrix topic would say the channel ID. we should remove that
await api.sendState(roomID, "m.room.topic", "", {topic: channel.topic || ""})
}
// send a notification in the room
await api.sendEvent(roomID, "m.room.message", {
msgtype: "m.notice",
body: "⚠️ This room was removed from the bridge."
})
// leave room
await api.leaveRoom(roomID)
// delete room from database
db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channel.id)
}
/**
* Async because it gets all space state from the homeserver, then if necessary sends one state event back.
* @param {DiscordTypes.APIGuildTextChannel} channel
* @param {string} spaceID
* @param {string} roomID
* @returns {Promise<string[]>}
*/
async function _syncSpaceMember(channel, spaceID, roomID) {
const spaceKState = await roomToKState(spaceID)
let spaceEventContent = {}
if (
channel.type !== DiscordTypes.ChannelType.PrivateThread // private threads do not belong in the space (don't offer people something they can't join)
&& !channel["thread_metadata"]?.archived // archived threads do not belong in the space (don't offer people conversations that are no longer relevant)
) {
spaceEventContent = {
via: [reg.ooye.server_name]
}
}
const spaceDiff = ks.diffKState(spaceKState, {
[`m.space.child/${roomID}`]: spaceEventContent
})
return applyKStateDiffToRoom(spaceID, spaceDiff)
}
async function createAllForGuild(guildID) {
const channelIDs = discord.guildChannelMap.get(guildID)
assert.ok(channelIDs)
for (const channelID of channelIDs) {
const allowedTypes = [DiscordTypes.ChannelType.GuildText, DiscordTypes.ChannelType.PublicThread]
// @ts-ignore
if (allowedTypes.includes(discord.channels.get(channelID)?.type)) {
const roomID = await syncRoom(channelID)
console.log(`synced ${channelID} <-> ${roomID}`)
}
}
}
module.exports.DEFAULT_PRIVACY_LEVEL = DEFAULT_PRIVACY_LEVEL
module.exports.PRIVACY_ENUMS = PRIVACY_ENUMS
module.exports.createRoom = createRoom
module.exports.ensureRoom = ensureRoom
module.exports.syncRoom = syncRoom
module.exports.createAllForGuild = createAllForGuild
module.exports.channelToKState = channelToKState
module.exports.roomToKState = roomToKState
module.exports.applyKStateDiffToRoom = applyKStateDiffToRoom
module.exports.postApplyPowerLevels = postApplyPowerLevels
module.exports._convertNameAndTopic = convertNameAndTopic
module.exports._unbridgeRoom = _unbridgeRoom
module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel

View file

@ -0,0 +1,144 @@
// @ts-check
const mixin = require("@cloudrac3r/mixin-deep")
const {channelToKState, _convertNameAndTopic} = require("./create-room")
const {kstateStripConditionals} = require("../../matrix/kstate")
const {test} = require("supertape")
const testData = require("../../test/data")
const passthrough = require("../../passthrough")
const {db} = passthrough
test("channel2room: discoverable privacy room", async t => {
let called = 0
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
called++
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {users: {"@example:matrix.org": 50}}
}
db.prepare("UPDATE guild_space SET privacy_level = 2").run()
t.deepEqual(
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)),
Object.assign({}, testData.room.general, {
"m.room.guest_access/": {guest_access: "forbidden"},
"m.room.join_rules/": {join_rule: "public"},
"m.room.history_visibility/": {history_visibility: "world_readable"},
"m.room.power_levels/": mixin({users: {"@example:matrix.org": 50}}, testData.room.general["m.room.power_levels/"])
})
)
t.equal(called, 1)
})
test("channel2room: linkable privacy room", async t => {
let called = 0
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
called++
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {users: {"@example:matrix.org": 50}}
}
db.prepare("UPDATE guild_space SET privacy_level = 1").run()
t.deepEqual(
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)),
Object.assign({}, testData.room.general, {
"m.room.guest_access/": {guest_access: "forbidden"},
"m.room.join_rules/": {join_rule: "public"},
"m.room.power_levels/": mixin({users: {"@example:matrix.org": 50}}, testData.room.general["m.room.power_levels/"])
})
)
t.equal(called, 1)
})
test("channel2room: invite-only privacy room", async t => {
let called = 0
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
called++
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {users: {"@example:matrix.org": 50}}
}
db.prepare("UPDATE guild_space SET privacy_level = 0").run()
t.deepEqual(
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)),
Object.assign({}, testData.room.general, {
"m.room.power_levels/": mixin({users: {"@example:matrix.org": 50}}, testData.room.general["m.room.power_levels/"])
})
)
t.equal(called, 1)
})
test("channel2room: room where limited people can mention everyone", async t => {
let called = 0
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
called++
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {users: {"@example:matrix.org": 50}}
}
const limitedGuild = mixin({}, testData.guild.general)
limitedGuild.roles[0].permissions = (BigInt(limitedGuild.roles[0].permissions) - 131072n).toString()
const limitedRoom = mixin({}, testData.room.general, {"m.room.power_levels/": {
notifications: {room: 20},
users: {"@example:matrix.org": 50}
}})
t.deepEqual(
kstateStripConditionals(await channelToKState(testData.channel.general, limitedGuild, {api: {getStateEvent}}).then(x => x.channelKState)),
limitedRoom
)
t.equal(called, 1)
})
test("convertNameAndTopic: custom name and topic", t => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, "hauntings"),
["hauntings", "#the-twilight-zone | Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"]
)
})
test("convertNameAndTopic: custom name, no topic", t => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", type: 0}, {id: "456"}, "hauntings"),
["hauntings", "#the-twilight-zone\n\nChannel ID: 123\nGuild ID: 456"]
)
})
test("convertNameAndTopic: original name and topic", t => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, null),
["the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"]
)
})
test("convertNameAndTopic: original name, no topic", t => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", type: 0}, {id: "456"}, null),
["the-twilight-zone", "Channel ID: 123\nGuild ID: 456"]
)
})
test("convertNameAndTopic: public thread icon", t => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 11}, {id: "456"}, null),
["[⛓️] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"]
)
})
test("convertNameAndTopic: private thread icon", t => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 12}, {id: "456"}, null),
["[🔒⛓️] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"]
)
})
test("convertNameAndTopic: voice channel icon", t => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 2}, {id: "456"}, null),
["[🔊] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"]
)
})

View file

@ -0,0 +1,244 @@
// @ts-check
const assert = require("assert").strict
const {isDeepStrictEqual} = require("util")
const DiscordTypes = require("discord-api-types/v10")
const Ty = require("../../types")
const {reg} = require("../../matrix/read-registration")
const 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("./create-room")} */
const createRoom = sync.require("./create-room")
/** @type {import("./expression")} */
const expression = sync.require("./expression")
/** @type {import("../../matrix/kstate")} */
const ks = sync.require("../../matrix/kstate")
/** @type {Map<string, Promise<string>>} guild ID -> Promise<space ID> */
const inflightSpaceCreate = new Map()
/**
* @param {DiscordTypes.RESTGetAPIGuildResult} guild
* @param {any} kstate
*/
async function createSpace(guild, kstate) {
const name = kstate["m.room.name/"].name
const topic = kstate["m.room.topic/"]?.topic || undefined
assert(name)
const globalAdmins = select("member_power", "mxid", {room_id: "*"}).pluck().all()
const roomID = await createRoom.postApplyPowerLevels(kstate, async kstate => {
return api.createRoom({
name,
preset: createRoom.PRIVACY_ENUMS.PRESET[createRoom.DEFAULT_PRIVACY_LEVEL], // New spaces will have to use the default privacy level; we obviously can't look up the existing entry
visibility: createRoom.PRIVACY_ENUMS.VISIBILITY[createRoom.DEFAULT_PRIVACY_LEVEL],
power_level_content_override: {
events_default: 100, // space can only be managed by bridge
invite: 0 // any existing member can invite others
},
invite: globalAdmins,
topic,
creation_content: {
type: "m.space"
},
initial_state: await ks.kstateToState(kstate)
})
})
db.prepare("INSERT INTO guild_space (guild_id, space_id) VALUES (?, ?)").run(guild.id, roomID)
return roomID
}
/**
* @param {DiscordTypes.APIGuild} guild
* @param {number} privacyLevel
*/
async function guildToKState(guild, privacyLevel) {
assert.equal(typeof privacyLevel, "number")
const globalAdmins = select("member_power", ["mxid", "power_level"], {room_id: "*"}).all()
const guildKState = {
"m.room.name/": {name: guild.name},
"m.room.avatar/": {
$if: guild.icon,
url: {$url: file.guildIcon(guild)}
},
"m.room.guest_access/": {guest_access: createRoom.PRIVACY_ENUMS.GUEST_ACCESS[privacyLevel]},
"m.room.history_visibility/": {history_visibility: createRoom.PRIVACY_ENUMS.SPACE_HISTORY_VISIBILITY[privacyLevel]},
"m.room.join_rules/": {join_rule: createRoom.PRIVACY_ENUMS.SPACE_JOIN_RULES[privacyLevel]},
"m.room.power_levels/": {users: globalAdmins.reduce((a, c) => (a[c.mxid] = c.power_level, a), {})} // used in guild initial creation postApplyPowerLevels
}
return guildKState
}
/**
* @param {DiscordTypes.APIGuild} guild
* @param {boolean} shouldActuallySync false if just need to ensure nspace exists (which is a quick database check),
* true if also want to efficiently sync space name, space avatar, and child room avatars
* @returns {Promise<string>} room ID
*/
async function _syncSpace(guild, shouldActuallySync) {
assert.ok(guild)
if (inflightSpaceCreate.has(guild.id)) {
await inflightSpaceCreate.get(guild.id) // just waiting, and then doing a new db query afterwards, is the simplest way of doing it
}
const row = select("guild_space", ["space_id", "privacy_level"], {guild_id: guild.id}).get()
if (!row) {
const creation = (async () => {
const guildKState = await guildToKState(guild, createRoom.DEFAULT_PRIVACY_LEVEL) // New spaces will have to use the default privacy level; we obviously can't look up the existing entry
const spaceID = await createSpace(guild, guildKState)
inflightSpaceCreate.delete(guild.id)
return spaceID
})()
inflightSpaceCreate.set(guild.id, creation)
return creation // Naturally, the newly created space is already up to date, so we can always skip syncing here.
}
const {space_id: spaceID, privacy_level} = row
if (!shouldActuallySync) {
return spaceID // only need to ensure space exists, and it does. return the space ID
}
console.log(`[space sync] to matrix: ${guild.name}`)
const guildKState = await guildToKState(guild, privacy_level) // calling this in both branches because we don't want to calculate this if not syncing
// sync guild state to space
const spaceKState = await createRoom.roomToKState(spaceID)
const spaceDiff = ks.diffKState(spaceKState, guildKState)
await createRoom.applyKStateDiffToRoom(spaceID, spaceDiff)
// guild icon was changed, so room avatars need to be updated as well as the space ones
// doing it this way rather than calling syncRoom for great efficiency gains
const newAvatarState = spaceDiff["m.room.avatar/"]
if (guild.icon && newAvatarState?.url) {
// don't try to update rooms with custom avatars though
const roomsWithCustomAvatars = select("channel_room", "room_id", {}, "WHERE custom_avatar IS NOT NULL").pluck().all()
const state = await ks.kstateToState(spaceKState)
const childRooms = state.filter(({type, state_key, content}) => {
return type === "m.space.child" && "via" in content && !roomsWithCustomAvatars.includes(state_key)
}).map(({state_key}) => state_key)
for (const roomID of childRooms) {
const avatarEventContent = await api.getStateEvent(roomID, "m.room.avatar", "")
if (avatarEventContent.url !== newAvatarState.url) {
await api.sendState(roomID, "m.room.avatar", "", newAvatarState)
}
}
}
return spaceID
}
/**
* Ensures the space exists. If it doesn't, creates the space with an accurate initial state.
* @param {DiscordTypes.APIGuild} guild
*/
function ensureSpace(guild) {
return _syncSpace(guild, false)
}
/**
* Actually syncs. Efficiently updates the space name, space avatar, and child room avatars.
* @param {DiscordTypes.APIGuild} guild
*/
function syncSpace(guild) {
return _syncSpace(guild, true)
}
/**
* Inefficiently force the space and its existing child rooms to be fully updated.
* Prefer not to call this as part of the bridge's normal operation.
*/
async function syncSpaceFully(guildID) {
/** @ts-ignore @type {DiscordTypes.APIGuild} */
const guild = discord.guilds.get(guildID)
assert.ok(guild)
const row = select("guild_space", ["space_id", "privacy_level"], {guild_id: guildID}).get()
if (!row) {
const guildKState = await guildToKState(guild, createRoom.DEFAULT_PRIVACY_LEVEL)
const spaceID = await createSpace(guild, guildKState)
return spaceID // Naturally, the newly created space is already up to date, so we can always skip syncing here.
}
const {space_id: spaceID, privacy_level} = row
console.log(`[space sync] to matrix: ${guild.name}`)
const guildKState = await guildToKState(guild, privacy_level)
// sync guild state to space
const spaceKState = await createRoom.roomToKState(spaceID)
const spaceDiff = ks.diffKState(spaceKState, guildKState)
await createRoom.applyKStateDiffToRoom(spaceID, spaceDiff)
const childRooms = await api.getFullHierarchy(spaceID)
for (const {room_id} of childRooms) {
const channelID = select("channel_room", "channel_id", {room_id}).pluck().get()
if (!channelID) continue
if (discord.channels.has(channelID)) {
await createRoom.syncRoom(channelID)
} else {
await createRoom.unbridgeDeletedChannel({id: channelID}, guildID)
}
}
return spaceID
}
/**
* @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
*/
async function syncSpaceExpressions(data, checkBeforeSync) {
// 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 (isDeepStrictEqual(existing, content)) return
}
api.sendState(spaceID, "im.ponies.room_emotes", eventKey, content)
}
update(spaceID, "emojis", "moe.cadence.ooye.pack.emojis", expression.emojisToState)
update(spaceID, "stickers", "moe.cadence.ooye.pack.stickers", expression.stickersToState)
}
module.exports.createSpace = createSpace
module.exports.ensureSpace = ensureSpace
module.exports.syncSpace = syncSpace
module.exports.syncSpaceFully = syncSpaceFully
module.exports.guildToKState = guildToKState
module.exports.syncSpaceExpressions = syncSpaceExpressions

View file

@ -0,0 +1,38 @@
// @ts-check
const mixin = require("@cloudrac3r/mixin-deep")
const {guildToKState, ensureSpace} = require("./create-space")
const {kstateStripConditionals, kstateUploadMxc} = require("../../matrix/kstate")
const {test} = require("supertape")
const testData = require("../../test/data")
const passthrough = require("../../passthrough")
const {db} = passthrough
test("guild2space: can generate kstate for a guild, passing privacy level 0", async t => {
t.deepEqual(
await kstateUploadMxc(kstateStripConditionals(await guildToKState(testData.guild.general, 0))),
{
"m.room.avatar/": {
url: "mxc://cadence.moe/zKXGZhmImMHuGQZWJEFKJbsF"
},
"m.room.guest_access/": {
guest_access: "can_join"
},
"m.room.history_visibility/": {
history_visibility: "invited"
},
"m.room.join_rules/": {
join_rule: "invite"
},
"m.room.name/": {
name: "Psychonauts 3"
},
"m.room.power_levels/": {
users: {
"@test_auto_invite:example.org": 100
},
},
}
)
})

View file

@ -0,0 +1,46 @@
// @ts-check
const passthrough = require("../../passthrough")
const {sync, db, select, from} = 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)
for (const eventID of eventsToRedact) {
// Awaiting will make it go slower, but since this could be a long-running operation either way, we want to leave rate limit capacity for other operations
await api.redactEvent(roomID, eventID)
}
}
module.exports.deleteMessage = deleteMessage
module.exports.deleteMessageBulk = deleteMessageBulk

View file

@ -0,0 +1,80 @@
// @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)
}
}
// 1. Replace all the things.
for (const {oldID, newContent} of eventsToReplace) {
const eventType = newContent.$type
/** @type {Pick<typeof newContent, Exclude<keyof newContent, "$type">> & { $type?: string }} */
const newContentWithoutType = {...newContent}
delete newContentWithoutType.$type
await api.sendEvent(roomID, eventType, newContentWithoutType, senderMxid)
// Ensure the database is up to date.
// The columns are event_id, event_type, event_subtype, message_id, channel_id, part, source. Only event_subtype could potentially be changed by a replacement event.
const subtype = newContentWithoutType.msgtype || null
db.prepare("UPDATE event_message SET event_subtype = ? WHERE event_id = ?").run(subtype, oldID)
}
// 2. Redact all the things.
// Not redacting as the last action because the last action is likely to be shown in the room preview in clients, and we don't want it to look like somebody actually deleted a message.
for (const eventID of eventsToRedact) {
await api.redactEvent(roomID, eventID, senderMxid)
db.prepare("DELETE FROM event_message WHERE event_id = ?").run(eventID)
}
// 3. Consistency: Ensure there is exactly one part = 0
const sendNewEventParts = new Set()
for (const promotion of promotions) {
if ("eventID" in promotion) {
db.prepare(`UPDATE event_message SET ${promotion.column} = ? WHERE event_id = ?`).run(promotion.value ?? 0, promotion.eventID)
} else if ("nextEvent" in promotion) {
sendNewEventParts.add(promotion.column)
}
}
// 4. Send all the things.
if (eventsToSend.length) {
db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id)
}
for (const content of eventsToSend) {
const eventType = content.$type
/** @type {Pick<typeof content, Exclude<keyof content, "$type">> & { $type?: string }} */
const contentWithoutType = {...content}
delete contentWithoutType.$type
delete contentWithoutType.$sender
const part = sendNewEventParts.has("part") && eventsToSend[0] === content ? 0 : 1
const reactionPart = sendNewEventParts.has("reaction_part") && eventsToSend[eventsToSend.length - 1] === content ? 0 : 1
const eventID = await api.sendEvent(roomID, eventType, contentWithoutType, senderMxid)
db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 1)").run(eventID, eventType, content.msgtype || null, message.id, part, reactionPart) // source 1 = discord
}
}
module.exports.editMessage = editMessage

View file

@ -0,0 +1,82 @@
// @ts-check
const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough")
const {sync, db} = passthrough
/** @type {import("../../matrix/file")} */
const file = sync.require("../../matrix/file")
/**
* @param {DiscordTypes.APIEmoji[]} emojis
*/
async function emojisToState(emojis) {
const result = {
pack: {
display_name: "Discord Emojis",
usage: ["emoticon"] // we'll see...
},
images: {
}
}
await Promise.all(emojis.map(emoji =>
// the homeserver can probably cope with doing this in parallel
file.uploadDiscordFileToMxc(file.emoji(emoji.id, emoji.animated)).then(url => {
result.images[emoji.name] = {
info: {
mimetype: emoji.animated ? "image/gif" : "image/png"
},
url
}
db.prepare("INSERT OR IGNORE INTO emoji (emoji_id, name, animated, mxc_url) VALUES (?, ?, ?, ?)").run(emoji.id, emoji.name, +!!emoji.animated, url)
}).catch(e => {
if (e.data.errcode === "M_TOO_LARGE") { // Very unlikely to happen. Only possible for 3x-series emojis uploaded shortly after animated emojis were introduced, when there was no 256 KB size limit.
return
}
console.error(`Trying to handle emoji ${emoji.name} (${emoji.id}), but...`)
throw e
})
))
return result
}
/**
* @param {DiscordTypes.APISticker[]} stickers
*/
async function stickersToState(stickers) {
const result = {
pack: {
display_name: "Discord Stickers",
usage: ["sticker"] // we'll see...
},
images: {
}
}
const shortcodes = []
await Promise.all(stickers.map(sticker =>
// the homeserver can probably cope with doing this in parallel
file.uploadDiscordFileToMxc(file.sticker(sticker)).then(url => {
/** @type {string | undefined} */
let body = sticker.name
if (sticker && sticker.description) body += ` - ${sticker.description}`
if (!body) body = undefined
let shortcode = sticker.name.toLowerCase().replace(/[^a-zA-Z0-9_-]/g, "-").replace(/^-|-$/g, "").replace(/--+/g, "-")
while (shortcodes.includes(shortcode)) shortcode = shortcode + "~"
shortcodes.push(shortcode)
result.images[shortcodes] = {
info: {
mimetype: file.stickerFormat.get(sticker.format_type)?.mime || "image/png"
},
body,
url
}
})
))
return result
}
module.exports.emojisToState = emojisToState
module.exports.stickersToState = stickersToState

53
src/d2m/actions/lottie.js Normal file
View file

@ -0,0 +1,53 @@
// @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

@ -0,0 +1,164 @@
// @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, 0)
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>} */
async function fetchMessage(messageID) {
// Their backend is weird. Sometimes it says "message not found" (code 20006) on the first try, so we make multiple attempts.
let attempts = 0
do {
var res = await fetch(`https://api.pluralkit.me/v2/messages/${messageID}`)
if (res.ok) return res.json()
// I think the backend needs some time to update.
await new Promise(resolve => setTimeout(resolve, 2000))
} while (++attempts < 3)
const errorMessage = await res.json()
throw new Error(`PK API returned an error after ${attempts} tries: ${JSON.stringify(errorMessage)}`)
}
module.exports._memberToStateContent = memberToStateContent
module.exports.ensureSim = ensureSim
module.exports.ensureSimJoined = ensureSimJoined
module.exports.syncUser = syncUser
module.exports.fetchMessage = fetchMessage

View file

@ -0,0 +1,247 @@
// @ts-check
const assert = require("assert").strict
const {reg} = require("../../matrix/read-registration")
const DiscordTypes = require("discord-api-types/v10")
const mixin = require("@cloudrac3r/mixin-deep")
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("../../discord/utils")} */
const utils = sync.require("../../discord/utils")
/** @type {import("../converters/user-to-mxid")} */
const userToMxid = sync.require("../converters/user-to-mxid")
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
let hasher = null
// @ts-ignore
require("xxhash-wasm")().then(h => hasher = h)
/**
* A sim is an account that is being simulated by the bridge to copy events from the other side.
* @param {DiscordTypes.APIUser} user
* @returns mxid
*/
async function createSim(user) {
// Choose sim name
const simName = userToMxid.userToSimName(user)
const localpart = reg.ooye.namespace_prefix + simName
const mxid = `@${localpart}:${reg.ooye.server_name}`
// Save chosen name in the database forever
// Making this database change right away so that in a concurrent registration, the 2nd registration will already have generated a different localpart because it can see this row when it generates
db.prepare("INSERT INTO sim (user_id, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(user.id, 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(user.id)
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 {DiscordTypes.APIUser} user
* @returns {Promise<string>} mxid
*/
async function ensureSim(user) {
let mxid = null
const existing = select("sim", "mxid", {user_id: user.id}).pluck().get()
if (existing) {
mxid = existing
} else {
mxid = await createSim(user)
}
return mxid
}
/**
* Ensure a sim is registered for the user and is joined to the room.
* @param {DiscordTypes.APIUser} user
* @param {string} roomID
* @returns {Promise<string>} mxid
*/
async function ensureSimJoined(user, roomID) {
// Ensure room ID is really an ID, not an alias
assert.ok(roomID[0] === "!")
// Ensure user
const mxid = await ensureSim(user)
// 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 {DiscordTypes.APIUser} user
* @param {Omit<DiscordTypes.APIGuildMember, "user">} member
*/
async function memberToStateContent(user, member, guildID) {
let displayname = user.username
if (user.global_name) displayname = user.global_name
if (member.nick) displayname = member.nick
const content = {
displayname,
membership: "join",
"moe.cadence.ooye.member": {
},
"uk.half-shot.discord.member": {
bot: !!user.bot,
displayColor: user.accent_color,
id: user.id,
username: user.discriminator.length === 4 ? `${user.username}#${user.discriminator}` : `@${user.username}`
}
}
if (member.avatar || user.avatar) {
// const avatarPath = file.userAvatar(user) // the user avatar only
const avatarPath = file.memberAvatar(guildID, user, member) // the member avatar or the user avatar
content["moe.cadence.ooye.member"].avatar = avatarPath
content.avatar_url = await file.uploadDiscordFileToMxc(avatarPath)
}
return content
}
/**
* https://gitdab.com/cadence/out-of-your-element/issues/9
* @param {DiscordTypes.APIUser} user
* @param {Omit<DiscordTypes.APIGuildMember, "user">} member
* @param {DiscordTypes.APIGuild} guild
* @param {DiscordTypes.APIGuildChannel} channel
* @returns {number} 0 to 100
*/
function memberToPowerLevel(user, member, guild, channel) {
const permissions = utils.getPermissions(member.roles, guild.roles, user.id, channel.permission_overwrites)
/*
* PL 100 = Administrator = People who can brick the room. RATIONALE:
* - Administrator.
* - Manage Webhooks: People who remove the webhook can break the room.
* - Manage Guild: People who can manage guild can add bots.
* - Manage Channels: People who can manage the channel can delete it.
* (Setting sim users to PL 100 is safe because even though we can't demote the sims we can use code to make the sims demote themselves.)
*/
if (guild.owner_id === user.id || utils.hasSomePermissions(permissions, ["Administrator", "ManageWebhooks", "ManageGuild", "ManageChannels"])) return 100
/*
* PL 50 = Moderator = People who can manage people and messages in many ways. RATIONALE:
* - Manage Messages: Can moderate by pinning or deleting the conversation.
* - Manage Nicknames: Can moderate by removing inappropriate nicknames.
* - Manage Threads: Can moderate by deleting conversations.
* - Kick Members & Ban Members: Can moderate by removing disruptive people.
* - Mute Members & Deafen Members: Can moderate by silencing disruptive people in ways they can't undo.
* - Moderate Members.
*/
if (utils.hasSomePermissions(permissions, ["ManageMessages", "ManageNicknames", "ManageThreads", "KickMembers", "BanMembers", "MuteMembers", "DeafenMembers", "ModerateMembers"])) return 50
/* PL 20 = Mention Everyone for technical reasons. */
if (utils.hasSomePermissions(permissions, ["MentionEveryone"])) return 20
return 0
}
/**
* @param {any} content
* @param {number} powerLevel
*/
function _hashProfileContent(content, powerLevel) {
const unsignedHash = hasher.h64(`${content.displayname}\u0000${content.avatar_url}\u0000${powerLevel}`)
const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
return signedHash
}
/**
* 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. Calculate the power level the user should get based on their Discord permissions
* 4. Compare against the previously known state content, which is helpfully stored in the database
* 5. If the state content or power level have changed, send them to Matrix and update them in the database for next time
* @param {DiscordTypes.APIUser} user
* @param {Omit<DiscordTypes.APIGuildMember, "user">} member
* @param {DiscordTypes.APIGuildChannel} channel
* @param {DiscordTypes.APIGuild} guild
* @param {string} roomID
* @returns {Promise<string>} mxid of the updated sim
*/
async function syncUser(user, member, channel, guild, roomID) {
const mxid = await ensureSimJoined(user, roomID)
const content = await memberToStateContent(user, member, guild.id)
const powerLevel = memberToPowerLevel(user, member, guild, channel)
const currentHash = _hashProfileContent(content, powerLevel)
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) {
// Update room member state
await api.sendState(roomID, "m.room.member", mxid, content, mxid)
// Update power levels
const powerLevelsStateContent = await api.getStateEvent(roomID, "m.room.power_levels", "")
const oldPowerLevel = powerLevelsStateContent.users?.[mxid] || 0
mixin(powerLevelsStateContent, {users: {[mxid]: powerLevel}})
if (powerLevel === 0) delete powerLevelsStateContent.users[mxid] // keep the event compact
const sendPowerLevelAs = powerLevel < oldPowerLevel ? mxid : undefined // bridge bot won't not have permission to demote equal power users, so do this action as themselves
await api.sendState(roomID, "m.room.power_levels", "", powerLevelsStateContent, sendPowerLevelAs)
// Update cached hash
db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid)
}
return mxid
}
/**
* @param {string} roomID
*/
async function syncAllUsersInRoom(roomID) {
const mxids = select("sim_member", "mxid", {room_id: roomID}).pluck().all()
const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get()
assert.ok(typeof channelID === "string")
/** @ts-ignore @type {DiscordTypes.APIGuildChannel} */
const channel = discord.channels.get(channelID)
const guildID = channel.guild_id
assert.ok(typeof guildID === "string")
/** @ts-ignore @type {DiscordTypes.APIGuild} */
const guild = discord.guilds.get(guildID)
for (const mxid of mxids) {
const userID = select("sim", "user_id", {mxid}).pluck().get()
assert.ok(typeof userID === "string")
/** @ts-ignore @type {Required<DiscordTypes.APIGuildMember>} */
const member = await discord.snow.guild.getGuildMember(guildID, userID)
/** @ts-ignore @type {Required<DiscordTypes.APIUser>} user */
const user = member.user
assert.ok(user)
console.log(`[user sync] to matrix: ${user.username} in ${channel.name}`)
await syncUser(user, member, channel, guild, roomID)
}
}
module.exports._memberToStateContent = memberToStateContent
module.exports._hashProfileContent = _hashProfileContent
module.exports.ensureSim = ensureSim
module.exports.ensureSimJoined = ensureSimJoined
module.exports.syncUser = syncUser
module.exports.syncAllUsersInRoom = syncAllUsersInRoom

View file

@ -0,0 +1,63 @@
const {_memberToStateContent} = require("./register-user")
const {test} = require("supertape")
const testData = require("../../test/data")
test("member2state: without member nick or avatar", async t => {
t.deepEqual(
await _memberToStateContent(testData.member.kumaccino.user, testData.member.kumaccino, testData.guild.general.id),
{
avatar_url: "mxc://cadence.moe/UpAeIqeclhKfeiZNdIWNcXXL",
displayname: "kumaccino",
membership: "join",
"moe.cadence.ooye.member": {
avatar: "/avatars/113340068197859328/b48302623a12bc7c59a71328f72ccb39.png?size=1024"
},
"uk.half-shot.discord.member": {
bot: false,
displayColor: 10206929,
id: "113340068197859328",
username: "@kumaccino"
}
}
)
})
test("member2state: with global name, without member nick or avatar", async t => {
t.deepEqual(
await _memberToStateContent(testData.member.papiophidian.user, testData.member.papiophidian, testData.guild.general.id),
{
avatar_url: "mxc://cadence.moe/JPzSmALLirnIprlSMKohSSoX",
displayname: "PapiOphidian",
membership: "join",
"moe.cadence.ooye.member": {
avatar: "/avatars/320067006521147393/5fc4ad85c1ea876709e9a7d3374a78a1.png?size=1024"
},
"uk.half-shot.discord.member": {
bot: false,
displayColor: 1579292,
id: "320067006521147393",
username: "@papiophidian"
}
}
)
})
test("member2state: with member nick and avatar", async t => {
t.deepEqual(
await _memberToStateContent(testData.member.sheep.user, testData.member.sheep, testData.guild.general.id),
{
avatar_url: "mxc://cadence.moe/rfemHmAtcprjLEiPiEuzPhpl",
displayname: "The Expert's Submarine",
membership: "join",
"moe.cadence.ooye.member": {
avatar: "/guilds/112760669178241024/users/134826546694193153/avatars/38dd359aa12bcd52dd3164126c587f8c.png?size=1024"
},
"uk.half-shot.discord.member": {
bot: false,
displayColor: null,
id: "134826546694193153",
username: "@aprilsong"
}
}
)
})

View file

@ -0,0 +1,72 @@
// @ts-check
const Ty = require("../../types")
const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough")
const {discord, sync, db, select} = passthrough
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("../converters/emoji-to-key")} */
const emojiToKey = sync.require("../converters/emoji-to-key")
/** @type {import("../../m2d/converters/emoji")} */
const emoji = sync.require("../../m2d/converters/emoji")
/** @type {import("../converters/remove-reaction")} */
const converter = sync.require("../converters/remove-reaction")
/**
* @param {DiscordTypes.GatewayMessageReactionRemoveDispatchData | DiscordTypes.GatewayMessageReactionRemoveEmojiDispatchData | DiscordTypes.GatewayMessageReactionRemoveAllDispatchData} data
*/
async function removeSomeReactions(data) {
const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get()
if (!roomID) return
const eventIDForMessage = select("event_message", "event_id", {message_id: data.message_id, reaction_part: 0}).pluck().get()
if (!eventIDForMessage) return
const reactions = await api.getFullRelations(roomID, eventIDForMessage, "m.annotation")
// Run the proper strategy and any strategy-specific database changes
const removals = await
( "user_id" in data ? removeReaction(data, reactions)
: "emoji" in data ? removeEmojiReaction(data, reactions)
: removeAllReactions(data, reactions))
// Redact the events and delete individual stored events in the database
for (const removal of removals) {
await api.redactEvent(roomID, removal.eventID, removal.mxid)
if (removal.hash) db.prepare("DELETE FROM reaction WHERE hashed_event_id = ?").run(removal.hash)
}
}
/**
* @param {DiscordTypes.GatewayMessageReactionRemoveDispatchData} data
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>[]} reactions
*/
async function removeReaction(data, reactions) {
const key = await emojiToKey.emojiToKey(data.emoji)
return converter.removeReaction(data, reactions, key)
}
/**
* @param {DiscordTypes.GatewayMessageReactionRemoveEmojiDispatchData} data
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>[]} reactions
*/
async function removeEmojiReaction(data, reactions) {
const key = await emojiToKey.emojiToKey(data.emoji)
const discordPreferredEncoding = emoji.encodeEmoji(key, undefined)
db.prepare("DELETE FROM reaction WHERE message_id = ? AND encoded_emoji = ?").run(data.message_id, discordPreferredEncoding)
return converter.removeEmojiReaction(data, reactions, key)
}
/**
* @param {DiscordTypes.GatewayMessageReactionRemoveAllDispatchData} data
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>[]} reactions
*/
async function removeAllReactions(data, reactions) {
db.prepare("DELETE FROM reaction WHERE message_id = ?").run(data.message_id)
return converter.removeAllReactions(data, reactions)
}
module.exports.removeSomeReactions = removeSomeReactions

View file

@ -0,0 +1,61 @@
// @ts-check
const {EventEmitter} = require("events")
const passthrough = require("../../passthrough")
const {select} = passthrough
const DEBUG_RETRIGGER = false
function debugRetrigger(message) {
if (DEBUG_RETRIGGER) {
console.log(message)
}
}
const emitter = new EventEmitter()
/**
* Due to Eventual Consistency(TM) an update/delete may arrive before the original message arrives
* (or before the it has finished being bridged to an event).
* In this case, wait until the original message has finished bridging, then retrigger the passed function.
* @template {(...args: any[]) => Promise<any>} T
* @param {string} messageID
* @param {T} fn
* @param {Parameters<T>} rest
* @returns {boolean} false if the event was found and the function will be ignored, true if the event was not found and the function will be retriggered
*/
function eventNotFoundThenRetrigger(messageID, fn, ...rest) {
const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get()
if (eventID) {
debugRetrigger(`[retrigger] OK mid <-> eid = ${messageID} <-> ${eventID}`)
return false // event was found so don't retrigger
}
debugRetrigger(`[retrigger] WAIT mid <-> eid = ${messageID} <-> ${eventID}`)
emitter.once(messageID, () => {
debugRetrigger(`[retrigger] TRIGGER mid = ${messageID}`)
fn(...rest)
})
// if the event never arrives, don't trigger the callback, just clean up
setTimeout(() => {
if (emitter.listeners(messageID).length) {
debugRetrigger(`[retrigger] EXPIRE mid = ${messageID}`)
}
emitter.removeAllListeners(messageID)
}, 60 * 1000) // 1 minute
return true // event was not found, then retrigger
}
/**
* Triggers any pending operations that were waiting on the corresponding event ID.
* @param {string} messageID
*/
function messageFinishedBridging(messageID) {
if (emitter.listeners(messageID).length) {
debugRetrigger(`[retrigger] EMIT mid = ${messageID}`)
}
emitter.emit(messageID)
}
module.exports.eventNotFoundThenRetrigger = eventNotFoundThenRetrigger
module.exports.messageFinishedBridging = messageFinishedBridging

View file

@ -0,0 +1,80 @@
// @ts-check
const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough")
const { discord, sync, db } = passthrough
/** @type {import("../converters/message-to-event")} */
const messageToEvent = sync.require("../converters/message-to-event")
/** @type {import("../../matrix/api")} */
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")} */
const dUtils = sync.require("../../discord/utils")
/**
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message
* @param {DiscordTypes.APIGuildChannel} channel
* @param {DiscordTypes.APIGuild} guild
* @param {{speedbump_id: string, speedbump_webhook_id: string} | null} row data about the webhook which is proxying messages in this channel
*/
async function sendMessage(message, channel, guild, row) {
const roomID = await createRoom.ensureRoom(message.channel_id)
let senderMxid = null
if (!dUtils.isWebhookMessage(message)) {
if (message.author.id === discord.application.id) {
// no need to sync the bot's own user
} else if (message.member) { // available on a gateway message create event
senderMxid = await registerUser.syncUser(message.author, message.member, channel, guild, roomID)
} else { // well, good enough...
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 pkMessage = await registerPkUser.fetchMessage(message.id)
senderMxid = await registerPkUser.syncUser(message.author, pkMessage, roomID)
}
}
const events = await messageToEvent.messageToEvent(message, guild, {}, {api})
const eventIDs = []
if (events.length) {
db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id)
if (senderMxid) api.sendTyping(roomID, false, senderMxid)
}
for (const event of events) {
const part = event === events[0] ? 0 : 1
const reactionPart = event === events[events.length - 1] ? 0 : 1
const eventType = event.$type
if ("$sender" in event) senderMxid = event.$sender
/** @type {Pick<typeof event, Exclude<keyof event, "$type" | "$sender">> & { $type?: string, $sender?: string }} */
const eventWithoutType = {...event}
delete eventWithoutType.$type
delete eventWithoutType.$sender
const useTimestamp = message["backfill"] ? new Date(message.timestamp).getTime() : undefined
const eventID = await api.sendEvent(roomID, eventType, eventWithoutType, senderMxid, useTimestamp)
db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 1)").run(eventID, eventType, event.msgtype || null, message.id, part, reactionPart) // source 1 = discord
// The primary event is part = 0 and has the most important and distinct information. It is used to provide reply previews, be pinned, and possibly future uses.
// The first event is chosen to be the primary part because it is usually the message text content and is more likely to be distinct.
// For example, "Reply to 'this meme made me think of you'" is more useful than "Replied to image".
// The last event gets reaction_part = 0. Reactions are managed there because reactions are supposed to appear at the bottom.
eventIDs.push(eventID)
}
return eventIDs
}
module.exports.sendMessage = sendMessage

View file

@ -0,0 +1,68 @@
// @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

@ -0,0 +1,37 @@
// @ts-check
const passthrough = require("../../passthrough")
const {discord, sync, db} = 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) {
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

@ -0,0 +1,190 @@
// @ts-check
const assert = require("assert").strict
const passthrough = require("../../passthrough")
const {sync, select, from} = passthrough
/** @type {import("./message-to-event")} */
const messageToEvent = sync.require("../converters/message-to-event")
/** @type {import("../../m2d/converters/utils")} */
const utils = sync.require("../../m2d/converters/utils")
function eventCanBeEdited(ev) {
// Discord does not allow files, images, attachments, or videos to be edited.
if (ev.old.event_type === "m.room.message" && ev.old.event_subtype !== "m.text" && ev.old.event_subtype !== "m.emote" && ev.old.event_subtype !== "m.notice") {
return false
}
// Discord does not allow stickers to be edited.
if (ev.old.event_type === "m.sticker") {
return false
}
// Anything else is fair game.
return true
}
/**
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
* @param {import("discord-api-types/v10").APIGuild} guild
* @param {import("../../matrix/api")} api simple-as-nails dependency injection for the matrix API
*/
async function editToChanges(message, guild, api) {
// If it is a user edit, allow deleting old messages (e.g. they might have removed text from an image).
// If it is the system adding a generated embed to a message, don't delete old messages since the system only sends partial data.
// Since an update in August 2024, the system always provides the full data of message updates. I'll leave in the old code since it won't cause problems.
const isGeneratedEmbed = !("content" in message)
// Figure out what events we will be replacing
const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
assert(roomID)
const oldEventRows = select("event_message", ["event_id", "event_type", "event_subtype", "part", "reaction_part"], {message_id: message.id}).all()
/** @type {string?} Null if we don't have a sender in the room, which will happen if it's a webhook's message. The bridge bot will do the edit instead. */
let senderMxid = null
if (message.author) {
senderMxid = from("sim").join("sim_member", "mxid").where({user_id: message.author.id, room_id: roomID}).pluck("mxid").get() || null
} else {
// Should be a system generated embed. We want the embed to be sent by the same user who sent the message, so that the messages get grouped in most clients.
const eventID = oldEventRows[0].event_id // a calling function should have already checked that there is at least one message to edit
const event = await api.getEvent(roomID, eventID)
if (utils.eventSenderIsFromDiscord(event.sender)) {
senderMxid = event.sender
}
}
// Figure out what we will be replacing them with
const newFallbackContent = await messageToEvent.messageToEvent(message, guild, {includeEditFallbackStar: true}, {api})
const newInnerContent = await messageToEvent.messageToEvent(message, guild, {includeReplyFallback: false}, {api})
assert.ok(newFallbackContent.length === newInnerContent.length)
// Match the new events to the old events
/*
Rules:
+ The events must have the same type.
+ The events must have the same subtype.
Events will therefore be divided into four categories:
*/
/** 1. Events that are matched, and should be edited by sending another m.replace event */
let eventsToReplace = []
/** 2. Events that are present in the old version only, and should be blanked or redacted */
let eventsToRedact = []
/** 3. Events that are present in the new version only, and should be sent as new, with references back to the context */
let eventsToSend = []
/** 4. Events that are matched and have definitely not changed, so they don't need to be edited or replaced at all. */
let unchangedEvents = []
function shift() {
newFallbackContent.shift()
newInnerContent.shift()
}
// For each old event...
outer: while (newFallbackContent.length) {
const newe = newFallbackContent[0]
// Find a new event to pair it with...
for (let i = 0; i < oldEventRows.length; i++) {
const olde = oldEventRows[i]
if (olde.event_type === newe.$type && olde.event_subtype === (newe.msgtype || null)) { // The spec does allow subtypes to change, so I can change this condition later if I want to
// Found one!
// Set up the pairing
eventsToReplace.push({
old: olde,
newFallbackContent: newFallbackContent[0],
newInnerContent: newInnerContent[0]
})
// These events have been handled now, so remove them from the source arrays
shift()
oldEventRows.splice(i, 1)
// Go all the way back to the start of the next iteration of the outer loop
continue outer
}
}
// If we got this far, we could not pair it to an existing event, so it'll have to be a new one
eventsToSend.push(newInnerContent[0])
shift()
}
// Anything remaining in oldEventRows is present in the old version only and should be redacted.
eventsToRedact = oldEventRows.map(e => ({old: e}))
// If this is a generated embed update, only allow the embeds to be updated, since the system only sends data about events. Ignore changes to other things.
if (isGeneratedEmbed) {
unchangedEvents.push(...eventsToRedact.filter(e => e.old.event_subtype !== "m.notice")) // Move them from eventsToRedact to unchangedEvents.
eventsToRedact = eventsToRedact.filter(e => e.old.event_subtype === "m.notice")
}
// Now, everything in eventsToSend and eventsToRedact is a real change, but everything in eventsToReplace might not have actually changed!
// (Example: a MESSAGE_UPDATE for a text+image message - Discord does not allow the image to be changed, but the text might have been.)
// So we'll remove entries from eventsToReplace that *definitely* cannot have changed. (This is category 4 mentioned above.) Everything remaining *may* have changed.
unchangedEvents.push(...eventsToReplace.filter(ev => !eventCanBeEdited(ev))) // Move them from eventsToRedact to unchangedEvents.
eventsToReplace = eventsToReplace.filter(eventCanBeEdited)
// We want to maintain exactly one part = 0 and one reaction_part = 0 database row at all times.
/** @type {({column: string, eventID: string, value?: number} | {column: string, nextEvent: true})[]} */
const promotions = []
for (const column of ["part", "reaction_part"]) {
const candidatesForParts = unchangedEvents.concat(eventsToReplace)
// If no events with part = 0 exist (or will exist), we need to do some management.
if (!candidatesForParts.some(e => e.old[column] === 0)) {
if (candidatesForParts.length) {
// We can choose an existing event to promote. Bigger order is better.
const order = e => 2*+(e.event_type === "m.room.message") + 1*+(e.old.event_subtype === "m.text")
candidatesForParts.sort((a, b) => order(b) - order(a))
if (column === "part") {
promotions.push({column, eventID: candidatesForParts[0].old.event_id}) // part should be the first one
} else {
promotions.push({column, eventID: candidatesForParts[candidatesForParts.length - 1].old.event_id}) // reaction_part should be the last one
}
} else {
// No existing events to promote, but new events are being sent. Whatever gets sent will be the next part = 0.
promotions.push({column, nextEvent: true})
}
}
// If adding events, try to keep reactions attached to the bottom of the group (unless reactions have already been added)
if (eventsToSend.length && !promotions.length) {
const existingReaction = select("reaction", "message_id", {message_id: message.id}).pluck().get()
if (!existingReaction) {
const existingPartZero = candidatesForParts.find(p => p.old.reaction_part === 0)
assert(existingPartZero) // will exist because a reaction_part=0 always exists and no events are being removed
promotions.push({column: "reaction_part", eventID: existingPartZero.old.event_id, value: 1}) // update the current reaction_part to 1
promotions.push({column: "reaction_part", nextEvent: true}) // the newly created event will have reaction_part = 0
}
}
}
// Removing unnecessary properties before returning
eventsToRedact = eventsToRedact.map(e => e.old.event_id)
eventsToReplace = eventsToReplace.map(e => ({oldID: e.old.event_id, newContent: makeReplacementEventContent(e.old.event_id, e.newFallbackContent, e.newInnerContent)}))
return {roomID, eventsToReplace, eventsToRedact, eventsToSend, senderMxid, promotions}
}
/**
* @template T
* @param {string} oldID
* @param {T} newFallbackContent
* @param {T} newInnerContent
* @returns {import("../../types").Event.ReplacementContent<T>} content
*/
function makeReplacementEventContent(oldID, newFallbackContent, newInnerContent) {
const content = {
...newFallbackContent,
"m.mentions": {},
"m.new_content": {
...newInnerContent
},
"m.relates_to": {
rel_type: "m.replace",
event_id: oldID
}
}
delete content["m.new_content"]["$type"]
// Client-Server API spec 11.37.3: Any m.relates_to property within m.new_content is ignored.
delete content["m.new_content"]["m.relates_to"]
return content
}
module.exports.editToChanges = editToChanges
module.exports.makeReplacementEventContent = makeReplacementEventContent

View file

@ -0,0 +1,327 @@
const {test} = require("supertape")
const {editToChanges} = require("./edit-to-changes")
const data = require("../../test/data")
const Ty = require("../../types")
test("edit2changes: edit by webhook", async t => {
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edit_by_webhook, data.guild.general, {})
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, [])
t.deepEqual(eventsToReplace, [{
oldID: "$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10",
newContent: {
$type: "m.room.message",
msgtype: "m.text",
body: "* test 2",
"m.mentions": {},
"m.new_content": {
// *** Replaced With: ***
msgtype: "m.text",
body: "test 2",
"m.mentions": {}
},
"m.relates_to": {
rel_type: "m.replace",
event_id: "$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10"
}
}
}])
t.equal(senderMxid, null)
t.deepEqual(promotions, [])
})
test("edit2changes: bot response", async t => {
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.bot_response, data.guild.general, {
async getJoinedMembers(roomID) {
t.equal(roomID, "!hYnGGlPHlbujVVfktC:cadence.moe")
return new Promise(resolve => {
setTimeout(() => {
resolve({
joined: {
"@cadence:cadence.moe": {
displayname: "cadence [they]",
avatar_url: "whatever"
},
"@_ooye_botrac4r:cadence.moe": {
displayname: "botrac4r",
avatar_url: "whatever"
}
}
})
})
})
}
})
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, [])
t.deepEqual(eventsToReplace, [{
oldID: "$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY",
newContent: {
$type: "m.room.message",
msgtype: "m.text",
body: "* :ae_botrac4r: @cadence asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.",
format: "org.matrix.custom.html",
formatted_body: '* <img data-mx-emoticon height="32" src="mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs" title=":ae_botrac4r:" alt=":ae_botrac4r:"> @cadence asked <code>­</code>, I respond: Stop drinking paint. (No)<br><br>Hit <img data-mx-emoticon height="32" src="mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT" title=":bn_re:" alt=":bn_re:"> to reroll.',
"m.mentions": {
// Client-Server API spec 11.37.7: Copy Discord's behaviour by not re-notifying anyone that an *edit occurred*
},
// *** Replaced With: ***
"m.new_content": {
msgtype: "m.text",
body: ":ae_botrac4r: @cadence asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.",
format: "org.matrix.custom.html",
formatted_body: '<img data-mx-emoticon height="32" src="mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs" title=":ae_botrac4r:" alt=":ae_botrac4r:"> @cadence asked <code>­</code>, I respond: Stop drinking paint. (No)<br><br>Hit <img data-mx-emoticon height="32" src="mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT" title=":bn_re:" alt=":bn_re:"> to reroll.',
"m.mentions": {
// Client-Server API spec 11.37.7: This should contain the mentions for the final version of the event
"user_ids": ["@cadence:cadence.moe"]
}
},
"m.relates_to": {
rel_type: "m.replace",
event_id: "$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY"
}
}
}])
t.equal(senderMxid, "@_ooye_bojack_horseman:cadence.moe")
t.deepEqual(promotions, [])
})
test("edit2changes: remove caption from image", async t => {
const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.removed_caption_from_image, data.guild.general, {})
t.deepEqual(eventsToRedact, ["$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA"])
t.deepEqual(eventsToSend, [])
t.deepEqual(eventsToReplace, [])
t.deepEqual(promotions, [{column: "part", eventID: "$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCI"}])
})
test("edit2changes: change file type", async t => {
const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.changed_file_type, data.guild.general, {})
t.deepEqual(eventsToRedact, ["$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCJ"])
t.deepEqual(eventsToSend, [{
$type: "m.room.message",
body: "📝 Uploaded file: https://cdn.discordapp.com/attachments/112760669178241024/1141501302497615912/gaze_into_my_dark_mind.txt (20 MB)",
format: "org.matrix.custom.html",
formatted_body: "📝 Uploaded file: <a href=\"https://cdn.discordapp.com/attachments/112760669178241024/1141501302497615912/gaze_into_my_dark_mind.txt\">gaze_into_my_dark_mind.txt</a> (20 MB)",
"m.mentions": {},
msgtype: "m.text"
}])
t.deepEqual(eventsToReplace, [])
t.deepEqual(promotions, [{column: "part", nextEvent: true}, {column: "reaction_part", nextEvent: true}])
})
test("edit2changes: add caption back to that image (due to it having a reaction, the reaction_part will not be moved)", async t => {
const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.added_caption_to_image, data.guild.general, {})
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, [{
$type: "m.room.message",
msgtype: "m.text",
body: "some text",
"m.mentions": {}
}])
t.deepEqual(eventsToReplace, [])
t.deepEqual(promotions, [])
})
test("edit2changes: stickers and attachments are not changed, only the content can be edited", async t => {
const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments, data.guild.general, {})
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, [])
t.deepEqual(eventsToReplace, [{
oldID: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4",
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-JgYKHEHIh5qdFv4"
}
}
}])
})
test("edit2changes: edit of reply to skull webp attachment with content", async t => {
const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edit_of_reply_to_skull_webp_attachment_with_content, data.guild.general, {})
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, [])
t.deepEqual(eventsToReplace, [{
oldID: "$vgTKOR5ZTYNMKaS7XvgEIDaOWZtVCEyzLLi5Pc5Gz4M",
newContent: {
$type: "m.room.message",
msgtype: "m.text",
body: "> Extremity: Image\n\n* Edit",
format: "org.matrix.custom.html",
formatted_body:
'<mx-reply><blockquote><a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$oLyUTyZ_7e_SUzGNWZKz880ll9amLZvXGbArJCKai2Q">In reply to</a> Extremity'
+ '<br>Image</blockquote></mx-reply>'
+ '* Edit',
"m.mentions": {},
"m.new_content": {
msgtype: "m.text",
body: "Edit",
"m.mentions": {}
},
"m.relates_to": {
rel_type: "m.replace",
event_id: "$vgTKOR5ZTYNMKaS7XvgEIDaOWZtVCEyzLLi5Pc5Gz4M"
}
}
}])
})
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"
}
])
})
test("edit2changes: generated embed", async t => {
let called = 0
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_social_media_image, data.guild.general, {
async getEvent(roomID, eventID) {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
t.equal(eventID, "$mPSzglkCu-6cZHbYro0RW2u5mHvbH9aXDjO5FCzosc0")
return {sender: "@_ooye_cadence:cadence.moe"}
}
})
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToReplace, [])
t.deepEqual(eventsToSend, [{
$type: "m.room.message",
msgtype: "m.notice",
body: "| via hthrflwrs on cohost"
+ "\n| \n| ## This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO https://cohost.org/jkap/post/4794219-empty"
+ "\n| \n| 1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:"
+ "\n| \n| * Both players draw eight cards"
+ "\n| * Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand"
+ "\n| * Both players present their best five-or-less-card pok...",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><sub>hthrflwrs on cohost</sub>`
+ `</p><p><strong><a href="https://cohost.org/jkap/post/4794219-empty">This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO</a></strong>`
+ `</p><p>1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:`
+ `<br><br><ul><li>Both players draw eight cards`
+ `</li><li>Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand`
+ `</li><li>Both players present their best five-or-less-card pok...</li></ul></p></blockquote>`,
"m.mentions": {}
}])
t.deepEqual(promotions, [{
"column": "reaction_part",
"eventID": "$mPSzglkCu-6cZHbYro0RW2u5mHvbH9aXDjO5FCzosc0",
"value": 1,
}, {
"column": "reaction_part",
"nextEvent": true,
}])
t.equal(senderMxid, "@_ooye_cadence:cadence.moe")
t.equal(called, 1)
})
test("edit2changes: generated embed on a reply", async t => {
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_on_reply, data.guild.general, {})
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToReplace, [{
oldID: "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF",
newContent: {
$type: "m.room.message",
// Unfortunately the edited message doesn't include the message_reference field. Fine. Whatever. It looks normal if you're using a good client.
body: "> a Discord user: [Replied-to message content wasn't provided by Discord]"
+ "\n\n* https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM",
format: "org.matrix.custom.html",
formatted_body: "<mx-reply><blockquote><a href=\"https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM\">In reply to</a> a Discord user<br>[Replied-to message content wasn't provided by Discord]</blockquote></mx-reply>* <a href=\"https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM\">https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM</a>",
"m.mentions": {},
"m.new_content": {
body: "https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM",
format: "org.matrix.custom.html",
formatted_body: "<a href=\"https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM\">https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM</a>",
"m.mentions": {},
msgtype: "m.text",
},
"m.relates_to": {
event_id: "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF",
rel_type: "m.replace",
},
msgtype: "m.text",
},
}])
t.deepEqual(eventsToSend, [{
$type: "m.room.message",
msgtype: "m.notice",
body: "| ## Matrix - Decentralised and secure communication https://matrix.to/"
+ "\n| \n| You're invited to talk on Matrix. If you don't already have a client this link will help you pick one, and join the conversation. If you already have one, this link will help you join the conversation",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><strong><a href="https://matrix.to/">Matrix - Decentralised and secure communication</a></strong>`
+ `</p><p>You're invited to talk on Matrix. If you don't already have a client this link will help you pick one, and join the conversation. If you already have one, this link will help you join the conversation</p></blockquote>`,
"m.mentions": {}
}])
t.deepEqual(promotions, [{
"column": "reaction_part",
"eventID": "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF",
"value": 1,
}, {
"column": "reaction_part",
"nextEvent": true,
}])
t.equal(senderMxid, "@_ooye_cadence:cadence.moe")
})

View file

@ -0,0 +1,38 @@
// @ts-check
const assert = require("assert").strict
const passthrough = require("../../passthrough")
const {discord, sync, db, select} = passthrough
/** @type {import("../../matrix/file")} */
const file = sync.require("../../matrix/file")
/**
* @param {import("discord-api-types/v10").APIEmoji} emoji
* @returns {Promise<string>}
*/
async function emojiToKey(emoji) {
let key
if (emoji.id) {
// Custom emoji
const mxc = select("emoji", "mxc_url", {emoji_id: emoji.id}).pluck().get()
if (mxc) {
// The custom emoji is registered and we should send it
key = mxc
} else {
// The custom emoji is not registered. We will register it and then add it.
assert(emoji.name) // The docs say: "name may be null when custom emoji data is not available, for example, if it was deleted from the guild"
const mxc = await file.uploadDiscordFileToMxc(file.emoji(emoji.id, emoji.animated))
db.prepare("INSERT OR IGNORE INTO emoji (emoji_id, name, animated, mxc_url) VALUES (?, ?, ?, ?)").run(emoji.id, emoji.name, +!!emoji.animated, mxc)
key = mxc
// TODO: what happens if the matrix user also tries adding this reaction? the bridge bot isn't able to use that emoji...
}
} else {
// Default emoji
const name = emoji.name
assert(name)
key = name
}
return key
}
module.exports.emojiToKey = emojiToKey

View file

@ -0,0 +1,21 @@
// @ts-check
const {test} = require("supertape")
const {emojiToKey} = require("./emoji-to-key")
const data = require("../../test/data")
const Ty = require("../../types")
test("emoji2key: unicode emoji works", async t => {
const result = await emojiToKey({id: null, name: "🐈"})
t.equal(result, "🐈")
})
test("emoji2key: custom emoji works", async t => {
const result = await emojiToKey({id: "230201364309868544", name: "hippo", animated: false})
t.equal(result, "mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC")
})
test("emoji2key: custom animated emoji works", async t => {
const result = await emojiToKey({id: "393635038903926784", name: "hipposcope", animated: true})
t.equal(result, "mxc://cadence.moe/WbYqNlACRuicynBfdnPYtmvc")
})

View file

@ -0,0 +1,48 @@
// @ts-check
const assert = require("assert")
const stream = require("stream")
const {PNG} = require("@cloudrac3r/pngjs")
const SIZE = 160 // Discord's display size on 1x displays is 160
/**
* @typedef RlottieWasm
* @prop {(string) => boolean} load load lottie data from string of json
* @prop {() => number} frames get number of frames
* @prop {(frameCount: number, width: number, height: number) => Uint8Array} render render lottie data to bitmap
*/
const Rlottie = (async () => {
const Rlottie = require("./rlottie-wasm.js")
await new Promise(resolve => Rlottie.onRuntimeInitialized = resolve)
return Rlottie
})()
/**
* @param {string} text
* @returns {Promise<import("stream").Readable>}
*/
async function convert(text) {
const r = await Rlottie
/** @type RlottieWasm */
const rh = new r.RlottieWasm()
const status = rh.load(text)
assert(status, `Rlottie unable to load ${text.length} byte data file.`)
const rendered = rh.render(0, SIZE, SIZE)
let png = new PNG({
width: SIZE,
height: SIZE,
bitDepth: 8, // 8 red + 8 green + 8 blue + 8 alpha
colorType: 6, // RGBA
inputColorType: 6, // RGBA
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())
}
module.exports.convert = convert
module.exports.SIZE = SIZE

View file

@ -0,0 +1,34 @@
// @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

@ -0,0 +1,353 @@
const {test} = require("supertape")
const {messageToEvent} = require("./message-to-event")
const data = require("../../test/data")
const Ty = require("../../types")
test("message2event embeds: nothing but a field", async t => {
const events = await messageToEvent(data.message_with_embeds.nothing_but_a_field, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
body: "> ↪️ @papiophidian: used `/stats`",
format: "org.matrix.custom.html",
formatted_body: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
"m.mentions": {},
msgtype: "m.text",
}, {
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.notice",
body: "| ### Amanda 🎵#2192 :online:"
+ "\n| willow tree, branch 0"
+ "\n| ** Uptime:**\n| 3m 55s\n| ** Memory:**\n| 64.45MB",
format: "org.matrix.custom.html",
formatted_body: '<blockquote><p><strong>Amanda 🎵#2192 <img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/LCEqjStXCxvRQccEkuslXEyZ\" title=\":online:\" alt=\":online:\">'
+ '<br>willow tree, branch 0</strong>'
+ '<br><strong> Uptime:</strong><br>3m 55s'
+ '<br><strong> Memory:</strong><br>64.45MB</p></blockquote>'
}])
})
test("message2event embeds: reply with just an embed", async t => {
const events = await messageToEvent(data.message_with_embeds.reply_with_only_embed, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.notice",
"m.mentions": {},
body: "| ## ⏺️ dynastic (@dynastic) https://twitter.com/i/user/719631291747078145"
+ "\n| \n| does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?"
+ "\n| \n| ### Retweets"
+ "\n| 119"
+ "\n| \n| ### Likes"
+ "\n| 5581"
+ "\n| — Twitter",
format: "org.matrix.custom.html",
formatted_body: '<blockquote><p><strong><a href="https://twitter.com/i/user/719631291747078145">⏺️ dynastic (@dynastic)</a></strong>'
+ '</p><p>does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?'
+ '</p><p><strong>Retweets</strong><br>119</p><p><strong>Likes</strong><br>5581</p>— Twitter</blockquote>'
}])
})
test("message2event embeds: image embed and attachment", async t => {
const events = await messageToEvent(data.message_with_embeds.image_embed_and_attachment, data.guild.general, {}, {
api: {
async getJoinedMembers(roomID) {
return {joined: []}
}
}
})
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "https://tootsuite.net/Warp-Gate2.gif\ntanget: @ monster spawner",
format: "org.matrix.custom.html",
formatted_body: '<a href="https://tootsuite.net/Warp-Gate2.gif">https://tootsuite.net/Warp-Gate2.gif</a><br>tanget: @ monster spawner',
"m.mentions": {}
}, {
$type: "m.room.message",
msgtype: "m.image",
url: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR",
body: "Screenshot_20231001_034036.jpg",
external_url: "https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg?ex=651a1faa&is=6518ce2a&hm=eb5ca80a3fa7add8765bf404aea2028a28a2341e4a62435986bcdcf058da82f3&",
filename: "Screenshot_20231001_034036.jpg",
info: {
h: 1170,
w: 1080,
size: 51981,
mimetype: "image/jpeg"
},
"m.mentions": {}
}])
})
test("message2event embeds: blockquote in embed", async t => {
let called = 0
const events = await messageToEvent(data.message_with_embeds.blockquote_in_embed, data.guild.general, {}, {
api: {
async getStateEvent(roomID, type, key) {
called++
t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {
users: {
"@_ooye_bot:cadence.moe": 100
}
}
},
async getJoinedMembers(roomID) {
called++
t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe")
return {
joined: {
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
"@user:example.invalid": {display_name: null, avatar_url: null}
}
}
}
}
})
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: ":emoji: **4 |** #wonderland",
format: "org.matrix.custom.html",
formatted_body: `<img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/mwZaCtRGAQQyOItagDeCocEO\" title=\":emoji:\" alt=\":emoji:\"> <strong>4 |</strong> <a href=\"https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe?via=cadence.moe&via=example.invalid\">#wonderland</a>`,
"m.mentions": {}
}, {
$type: "m.room.message",
msgtype: "m.notice",
body: "| ## ⏺️ minimus https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo?via=cadence.moe&via=example.invalid\n| \n| reply draft\n| > The following is a message composed via consensus of the Stinker Council.\n| > \n| > For those who are not currently aware of our existence, we represent the organization known as Wonderland. Our previous mission centered around the assortment and study of puzzling objects, entities and other assorted phenomena. This mission was the focus of our organization for more than 28 years.\n| > \n| > Due to circumstances outside of our control, this directive has now changed. Our new mission will be the extermination of the stinker race.\n| > \n| > There will be no further communication.\n| \n| [Go to Message](https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo?via=cadence.moe&via=example.invalid)",
format: "org.matrix.custom.html",
formatted_body: "<blockquote><p><strong><a href=\"https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo?via=cadence.moe&amp;via=example.invalid\">⏺️ minimus</a></strong></p><p>reply draft<br><blockquote>The following is a message composed via consensus of the Stinker Council.<br><br>For those who are not currently aware of our existence, we represent the organization known as Wonderland. Our previous mission centered around the assortment and study of puzzling objects, entities and other assorted phenomena. This mission was the focus of our organization for more than 28 years.<br><br>Due to circumstances outside of our control, this directive has now changed. Our new mission will be the extermination of the stinker race.<br><br>There will be no further communication.</blockquote></p><p><a href=\"https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo?via=cadence.moe&amp;via=example.invalid\">Go to Message</a></p></blockquote>",
"m.mentions": {}
}])
t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each")
})
test("message2event embeds: crazy html is all escaped", async t => {
const events = await messageToEvent(data.message_with_embeds.escaping_crazy_html_tags, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.notice",
body: "| ## ⏺️ <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;) https://a.co/&amp;<script>"
+ "\n| \n| ## <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;) https://a.co/&amp;<script>"
+ "\n| \n| <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;)"
+ "\n| \n| ### <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;)"
+ "\n| <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;)"
+ "\n| — <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;)",
format: "org.matrix.custom.html",
formatted_body: `<blockquote>`
+ `<p><strong><a href="https://a.co/&amp;amp;&lt;script&gt;">⏺️ &lt;strong&gt;[&lt;span data-mx-color=&#39;#123456&#39;&gt;Hey&lt;script&gt;](https://a.co/&amp;amp;)</a></strong></p>`
+ `<p><strong><a href=\"https://a.co/&amp;amp;&lt;script&gt;">&lt;strong&gt;[&lt;span data-mx-color='#123456'&gt;Hey&lt;script&gt;](<a href="https://a.co/&amp;amp">https://a.co/&amp;amp</a>;)</a></strong></p>`
+ `<p>&lt;strong&gt;<a href="https://a.co/&amp;amp;">&lt;span data-mx-color='#123456'&gt;Hey&lt;script&gt;</a></p>`
+ `<p><strong>&lt;strong&gt;[&lt;span data-mx-color='#123456'&gt;Hey&lt;script&gt;](<a href=\"https://a.co/&amp;amp\">https://a.co/&amp;amp</a>;)</strong>`
+ `<br>&lt;strong&gt;<a href="https://a.co/&amp;amp;">&lt;span data-mx-color='#123456'&gt;Hey&lt;script&gt;</a></p>`
+ `— &lt;strong&gt;[&lt;span data-mx-color=&#39;#123456&#39;&gt;Hey&lt;script&gt;](https://a.co/&amp;amp;)</blockquote>`,
"m.mentions": {}
}])
})
test("message2event embeds: title without url", async t => {
const events = await messageToEvent(data.message_with_embeds.title_without_url, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
body: "> ↪️ @papiophidian: used `/stats`",
format: "org.matrix.custom.html",
formatted_body: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
"m.mentions": {},
msgtype: "m.text",
}, {
$type: "m.room.message",
msgtype: "m.notice",
body: "| ## Hi, I'm Amanda!\n| \n| I condone pirating music!",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><strong>Hi, I'm Amanda!</strong></p><p>I condone pirating music!</p></blockquote>`,
"m.mentions": {}
}])
})
test("message2event embeds: url without title", async t => {
const events = await messageToEvent(data.message_with_embeds.url_without_title, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
body: "> ↪️ @papiophidian: used `/stats`",
format: "org.matrix.custom.html",
formatted_body: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
"m.mentions": {},
msgtype: "m.text",
}, {
$type: "m.room.message",
msgtype: "m.notice",
body: "| I condone pirating music!",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p>I condone pirating music!</p></blockquote>`,
"m.mentions": {}
}])
})
test("message2event embeds: author without url", async t => {
const events = await messageToEvent(data.message_with_embeds.author_without_url, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
body: "> ↪️ @papiophidian: used `/stats`",
format: "org.matrix.custom.html",
formatted_body: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
"m.mentions": {},
msgtype: "m.text",
}, {
$type: "m.room.message",
msgtype: "m.notice",
body: "| ## Amanda\n| \n| I condone pirating music!",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><strong>Amanda</strong></p><p>I condone pirating music!</p></blockquote>`,
"m.mentions": {}
}])
})
test("message2event embeds: author url without name", async t => {
const events = await messageToEvent(data.message_with_embeds.author_url_without_name, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
body: "> ↪️ @papiophidian: used `/stats`",
format: "org.matrix.custom.html",
formatted_body: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
"m.mentions": {},
msgtype: "m.text",
}, {
$type: "m.room.message",
msgtype: "m.notice",
body: "| I condone pirating music!",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p>I condone pirating music!</p></blockquote>`,
"m.mentions": {}
}])
})
test("message2event embeds: vx image", async t => {
const events = await messageToEvent(data.message_with_embeds.vx_image, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "https://vxtwitter.com/TomorrowCorp/status/1760330671074287875 we got a release date!!!",
format: "org.matrix.custom.html",
formatted_body: '<a href="https://vxtwitter.com/TomorrowCorp/status/1760330671074287875">https://vxtwitter.com/TomorrowCorp/status/1760330671074287875</a> we got a release date!!!',
"m.mentions": {}
}, {
$type: "m.room.message",
msgtype: "m.notice",
body: "| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix"
+ "\n| "
+ "\n| ## Twitter https://twitter.com/tomorrowcorp/status/1760330671074287875"
+ "\n| "
+ "\n| ## Tomorrow Corporation (@TomorrowCorp) https://vxtwitter.com/TomorrowCorp/status/1760330671074287875"
+ "\n| "
+ "\n| Mark your calendar with a wet black stain! World of Goo 2 releases on May 23, 2024 on Nintendo Switch, Epic Games Store (Win/Mac), and http://WorldOfGoo2.com (Win/Mac/Linux)."
+ "\n| "
+ "\n| https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms"
+ "\n| "
+ "\n| 💖 123 🔁 36"
+ "\n| "
+ "\n| 📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><sub><a href="https://github.com/dylanpdx/BetterTwitFix">vxTwitter / fixvx</a></sub>`
+ `</p><p><strong><a href="https://twitter.com/tomorrowcorp/status/1760330671074287875">Twitter</a></strong>`
+ `</p><p><strong><a href="https://vxtwitter.com/TomorrowCorp/status/1760330671074287875">Tomorrow Corporation (@TomorrowCorp)</a></strong>`
+ `</p><p>Mark your calendar with a wet black stain! World of Goo 2 releases on May 23, 2024 on Nintendo Switch, Epic Games Store (Win/Mac), and <a href="http://WorldOfGoo2.com">http://WorldOfGoo2.com</a> (Win/Mac/Linux).`
+ `<br><br><a href="https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms">https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms</a>`
+ `<br><br>💖 123 🔁 36`
+ `</p><p>📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg</p></blockquote>`,
"m.mentions": {}
}])
})
test("message2event embeds: vx video", async t => {
const events = await messageToEvent(data.message_with_embeds.vx_video, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "https://vxtwitter.com/McDonalds/status/1759971752254341417",
format: "org.matrix.custom.html",
formatted_body: '<a href="https://vxtwitter.com/McDonalds/status/1759971752254341417">https://vxtwitter.com/McDonalds/status/1759971752254341417</a>',
"m.mentions": {}
}, {
$type: "m.room.message",
msgtype: "m.notice",
body: "| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix"
+ "\n| \n| ## McDonalds🤝@studiopierrot"
+ "\n| \n| 💖 89 🔁 21 https://twitter.com/McDonalds/status/1759971752254341417"
+ "\n| \n| ## McDonald's (@McDonalds) https://vxtwitter.com/McDonalds/status/1759971752254341417"
+ "\n| \n| 🎞️ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><sub><a href="https://github.com/dylanpdx/BetterTwitFix">vxTwitter / fixvx</a></sub>`
+ `</p><p><strong><a href="https://twitter.com/McDonalds/status/1759971752254341417">McDonalds🤝@studiopierrot\n\n💖 89 🔁 21</a></strong>`
+ `</p><p><strong><a href="https://vxtwitter.com/McDonalds/status/1759971752254341417">McDonald's (@McDonalds)</a></strong>`
+ `</p><p>🎞️ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12</p></blockquote>`,
"m.mentions": {}
}])
})
test("message2event embeds: youtube video", async t => {
const events = await messageToEvent(data.message_with_embeds.youtube_video, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E\n\n\nJutomi I'm gonna make these sounds in your walls tonight",
format: "org.matrix.custom.html",
formatted_body: `<a href="https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E">https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E</a><br><br><br>Jutomi I'm gonna make these sounds in your walls tonight`,
"m.mentions": {}
}, {
$type: "m.room.message",
msgtype: "m.notice",
body: "| via YouTube https://www.youtube.com"
+ "\n| \n| ## Happy O Funny https://www.youtube.com/channel/UCEpQ9aEb1NafpvWp5Aoizrg"
+ "\n| \n| ## Shoebill stork clattering sounds like machine guun~!! (Japan Matsue... https://www.youtube.com/watch?v=kDMHHw8JqLE"
+ "\n| \n| twitter"
+ "\n| https://twitter.com/matsuevogelpark"
+ "\n| \n| The shoebill (Balaeniceps rex) also known as whalehead, whale-headed stork, or shoe-billed stork, is a very large stork-like bird. It derives its name from its enormous shoe-shaped bill"
+ "\n| some people also called them the living dinosaur~~"
+ "\n| \n| #shoebill #livingdinosaur #happyofunny #weirdcreature #weirdsoun..."
+ "\n| \n| 🎞️ https://www.youtube.com/embed/kDMHHw8JqLE",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><sub><a href="https://www.youtube.com">YouTube</a></sub></p>`
+ `<p><strong><a href="https://www.youtube.com/channel/UCEpQ9aEb1NafpvWp5Aoizrg">Happy O Funny</a></strong>`
+ `</p><p><strong><a href="https://www.youtube.com/watch?v=kDMHHw8JqLE">Shoebill stork clattering sounds like machine guun~!! (Japan Matsue...</a></strong>`
+ `</p><p>twitter<br><a href="https://twitter.com/matsuevogelpark">https://twitter.com/matsuevogelpark</a><br><br>The shoebill (Balaeniceps rex) also known as whalehead, whale-headed stork, or shoe-billed stork, is a very large stork-like bird. It derives its name from its enormous shoe-shaped bill<br>some people also called them the living dinosaur~~<br><br>#shoebill #livingdinosaur #happyofunny #weirdcreature #weirdsoun...`
+ `</p><p>🎞️ https://www.youtube.com/embed/kDMHHw8JqLE`
+ `</p></blockquote>`,
"m.mentions": {}
}])
})
test("message2event embeds: if discord creates an embed preview for a discord channel link, don't copy that embed", async t => {
const events = await messageToEvent(data.message_with_embeds.discord_server_included_punctuation_bad_discord, data.guild.general, {}, {
api: {
async getStateEvent(roomID, type, key) {
t.equal(roomID, "!TqlyQmifxGUggEmdBN:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {
users: {
"@_ooye_bot:cadence.moe": 100
}
}
},
async getJoinedMembers(roomID) {
t.equal(roomID, "!TqlyQmifxGUggEmdBN: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",
msgtype: "m.text",
body: "(test https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU?via=cadence.moe&via=matrix.org)",
format: "org.matrix.custom.html",
formatted_body: `(test <a href="https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU?via=cadence.moe&amp;via=matrix.org">https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU?via=cadence.moe&amp;via=matrix.org</a>)`,
"m.mentions": {}
}])
})

View file

@ -0,0 +1,647 @@
// @ts-check
const assert = require("assert").strict
const markdown = require("@cloudrac3r/discord-markdown")
const pb = require("prettier-bytes")
const DiscordTypes = require("discord-api-types/v10")
const {tag} = require("@cloudrac3r/html-template-tag")
const passthrough = require("../../passthrough")
const {sync, db, discord, select, from} = passthrough
/** @type {import("../../matrix/file")} */
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")
const {reg} = require("../../matrix/read-registration")
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
/**
* @param {DiscordTypes.APIMessage} message
* @param {DiscordTypes.APIGuild} guild
* @param {boolean} useHTML
*/
function getDiscordParseCallbacks(message, guild, useHTML) {
return {
/** @param {{id: string, type: "discordUser"}} node */
user: node => {
const mxid = select("sim", "mxid", {user_id: node.id}).pluck().get()
const interaction = message.interaction_metadata || message.interaction
const username = message.mentions.find(ment => ment.id === node.id)?.username
|| (interaction?.user.id === node.id ? interaction.user.username : null)
|| node.id
if (mxid && useHTML) {
return `<a href="https://matrix.to/#/${mxid}">@${username}</a>`
} else {
return `@${username}:`
}
},
/** @param {{id: string, type: "discordChannel", row: {room_id: string, name: string, nick: string?}?, via: string}} 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]`
}
} else if (useHTML) {
return `<a href="https://matrix.to/#/${node.row.room_id}?${node.via}">#${node.row.nick || node.row.name}</a>`
} else {
return `#${node.row.nick || node.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, `Emoji consistency assertion failed for ${node.name}:${node.id}`) // All emojis should have been added ahead of time in the messageToEvent function.
return `<img data-mx-emoticon height="32" src="${mxc}" title=":${node.name}:" alt=":${node.name}:">`
} else {
return `:${node.name}:`
}
},
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
} else if (useHTML && role.color) {
return `<font color="#${role.color.toString(16)}">@${role.name}</font>`
} else if (useHTML) {
return `<span data-mx-color="#ffffff" data-mx-bg-color="#414eef">@${role.name}</span>`
} else {
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
}
}
}
}
/**
* @param {import("discord-api-types/v10").APIMessage} message
* @param {import("discord-api-types/v10").APIGuild} guild
* @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean}} options default values:
* - includeReplyFallback: true
* - includeEditFallbackStar: false
* @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API
*/
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:
// [#] NICKNAME started a thread: __THREAD NAME__. __See all threads__
// We're already bridging the THREAD_CREATED gateway event to make a comparable message, so drop this one.
return []
}
if (message.type === DiscordTypes.MessageType.ThreadStarterMessage) {
// This is the message that appears at the top of a thread when the thread was based off an existing message.
// It's just a message reference, no content.
const ref = message.message_reference
assert(ref)
assert(ref.message_id)
const eventID = select("event_message", "event_id", {message_id: ref.message_id}).pluck().get()
const roomID = select("channel_room", "room_id", {channel_id: ref.channel_id}).pluck().get()
if (!eventID || !roomID) return []
const event = await di.api.getEvent(roomID, eventID)
return [{
...event.content,
$type: event.type,
$sender: null
}]
}
const interaction = message.interaction_metadata || message.interaction
if (message.type === DiscordTypes.MessageType.ChatInputCommand && interaction && "name" in interaction) {
// Commands are sent by the responding bot. Need to attach the metadata of the person using the command at the top.
if (message.content) message.content = `\n${message.content}`
message.content = `> ↪️ <@${interaction.user.id}> used \`/${interaction.name}\`${message.content}`
}
/**
@type {{room?: boolean, user_ids?: string[]}}
We should consider the following scenarios for mentions:
1. A discord user rich-replies to a matrix user with a text post
+ The matrix user needs to be m.mentioned in the text event
+ The matrix user needs to have their name/mxid/link in the text event (notification fallback)
- So prepend their `@name:` to the start of the plaintext body
2. A discord user rich-replies to a matrix user with an image event only
+ The matrix user needs to be m.mentioned in the image event
+ TODO The matrix user needs to have their name/mxid in the image event's body field, alongside the filename (notification fallback)
- So append their name to the filename body, I guess!!!
3. A discord user `@`s a matrix user in the text body of their text box
+ The matrix user needs to be m.mentioned in the text event
+ No change needed to the text event content: it already has their name
- So make sure we don't do anything in this case.
*/
const mentions = {}
let repliedToEventRow = null
let repliedToEventSenderMxid = null
if (message.mention_everyone) mentions.room = true
function addMention(mxid) {
if (!mentions.user_ids) mentions.user_ids = []
if (!mentions.user_ids.includes(mxid)) mentions.user_ids.push(mxid)
}
// Mentions scenarios 1 and 2, part A. i.e. translate relevant message.mentions to m.mentions
// (Still need to do scenarios 1 and 2 part B, and scenario 3.)
if (message.type === DiscordTypes.MessageType.Reply && message.message_reference?.message_id) {
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(message.message_reference.message_id)
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...
const event = await di.api.getEvent(repliedToEventRow.room_id, repliedToEventRow.event_id)
repliedToEventSenderMxid = event.sender
// Need to add the sender to m.mentions
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) {
const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get()
const via = await getViaServersMemo(roomID)
if (eventID && roomID) {
result = `https://matrix.to/#/${roomID}/${eventID}?${via}`
} else {
const ts = dUtils.snowflakeToTimestampExact(messageID)
const {event_id} = await di.api.getEventForTimestamp(roomID, ts)
result = `https://matrix.to/#/${roomID}/${event_id}?${via}`
}
} else {
result = `${match[0]} [event is from another server]`
}
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.
const emojiMatches = [...content.matchAll(/<(a?):([^:>]{1,64}):([0-9]+)>/g)]
await Promise.all(emojiMatches.map(match => {
const id = match[3]
const name = match[2]
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 = await markdown.toHtmlWithPostParser(content, transformParsedVia, {
discordCallback: getDiscordParseCallbacks(message, guild, true),
...customOptions
}, customParser, customHtmlOutput)
let body = await markdown.toHtmlWithPostParser(content, transformParsedVia, {
discordCallback: getDiscordParseCallbacks(message, guild, false),
discordOnly: true,
escapeHTML: false,
...customOptions
})
return {body, html}
}
// 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
html = "* " + html
}
const flags = message.flags || 0
if (flags & 2) {
body = `[🔀 ${message.author.username}]\n` + body
html = `🔀 <strong>${message.author.username}</strong><br>` + html
}
// Fallback body/formatted_body for replies
// This branch is optional - do NOT change anything apart from the reply fallback, since it may not be run
if (repliedToEventRow && options.includeReplyFallback !== false) {
let repliedToDisplayName
let repliedToUserHtml
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
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"
repliedToUserHtml = repliedToDisplayName
}
let repliedToContent = message.referenced_message?.content
if (repliedToContent?.match(/^(-# )?> (-# )?<:L1:/)) {
// If the Discord user is replying to a Matrix user's reply, the fallback is going to contain the emojis and stuff from the bridged rep of the Matrix user's reply quote.
// Need to remove that previous reply rep from this fallback body. The fallbody body should only contain the Matrix user's actual message.
// ┌──────A─────┐ A reply rep starting with >quote or -#smalltext >quote. Match until the end of the line.
// ┆ ┆┌─B─┐ There may be up to 2 reply rep lines in a row if it was created in the old format. Match all lines.
repliedToContent = repliedToContent.replace(/^((-# )?> .*\n){1,2}/, "")
}
if (repliedToContent == "") repliedToContent = "[Media]"
else if (!repliedToContent) repliedToContent = "[Replied-to message content wasn't provided by Discord]"
const repliedToHtml = markdown.toHTML(repliedToContent, {
discordCallback: getDiscordParseCallbacks(message, guild, true)
})
const repliedToBody = markdown.toHTML(repliedToContent, {
discordCallback: getDiscordParseCallbacks(message, guild, false),
discordOnly: true,
escapeHTML: false,
})
html = `<mx-reply><blockquote><a href="https://matrix.to/#/${repliedToEventRow.room_id}/${repliedToEventRow.event_id}">In reply to</a> ${repliedToUserHtml}`
+ `<br>${repliedToHtml}</blockquote></mx-reply>`
+ html
body = (`${repliedToDisplayName}: ` // scenario 1 part B for mentions
+ repliedToBody).split("\n").map(line => "> " + line).join("\n")
+ "\n\n" + body
}
const newTextMessageEvent = {
$type: "m.room.message",
"m.mentions": mentions,
msgtype,
body: body
}
const isPlaintext = body === html
if (!isPlaintext) {
Object.assign(newTextMessageEvent, {
format: "org.matrix.custom.html",
formatted_body: html
})
}
events.push(newTextMessageEvent)
}
let msgtype = "m.text"
// Handle message type 4, channel name changed
if (message.type === DiscordTypes.MessageType.ChannelNameChange) {
msgtype = "m.emote"
message.content = "changed the channel name to **" + message.content + "**"
}
if (message.content) {
// Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention.
const matches = [...message.content.matchAll(/@ ?([a-z0-9._]+)\b/gi)]
if (matches.length && matches.some(m => m[1].match(/[a-z]/i) && m[1] !== "everyone" && m[1] !== "here")) {
const writtenMentionsText = matches.map(m => m[1].toLowerCase())
const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
assert(roomID)
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
const {body, html} = await transformContent(message.content)
await addTextEvent(body, html, msgtype, {scanMentions: true})
}
// Then attachments
if (message.attachments) {
const attachmentEvents = await Promise.all(message.attachments.map(attachmentToEvent.bind(null, mentions)))
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.
}
if (embed.url?.startsWith("https://discord.com/")) {
continue // If discord creates an embed preview for a discord channel link, don't copy that embed
}
// Start building up a replica ("rep") of the embed in Discord-markdown format, which we will convert into both plaintext and formatted body at once
const rep = new mxUtils.MatrixStringBuilder()
// Provider
if (embed.provider?.name) {
if (embed.provider.url) {
rep.addParagraph(`via ${embed.provider.name} ${embed.provider.url}`, tag`<sub><a href="${embed.provider.url}">${embed.provider.name}</a></sub>`)
} else {
rep.addParagraph(`via ${embed.provider.name}`, tag`<sub>${embed.provider.name}</sub>`)
}
}
// Author and URL into a paragraph
let authorNameText = embed.author?.name || ""
if (authorNameText && embed.author?.icon_url) authorNameText = `⏺️ ${authorNameText}` // using the emoji instead of an image
if (authorNameText) {
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>`)
}
}
// 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>`)
}
}
let embedTypeShouldShowDescription = embed.type !== "video" // Discord doesn't display descriptions for videos
if (embed.provider?.name === "YouTube") embedTypeShouldShowDescription = true // But I personally like showing the descriptions for YouTube videos specifically
if (embed.description && embedTypeShouldShowDescription) {
const {body, html} = await transformContent(embed.description)
rep.addParagraph(body, html)
}
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)
}
let chosenImage = embed.image?.url
// the thumbnail seems to be used for "article" type but displayed big at the bottom by discord
if (embed.type === "article" && embed.thumbnail?.url && !chosenImage) chosenImage = embed.thumbnail.url
if (chosenImage) rep.addParagraph(`📸 ${chosenImage}`)
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>`
// 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})
}
// 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") {
const {mxc_url, info} = await lottie.convert(stickerItem)
return {
$type: "m.sticker",
"m.mentions": mentions,
body: stickerItem.name,
info,
url: mxc_url
}
} else {
let body = stickerItem.name
const sticker = guild.stickers.find(sticker => sticker.id === stickerItem.id)
if (sticker && sticker.description) body += ` - ${sticker.description}`
return {
$type: "m.sticker",
"m.mentions": mentions,
body,
info: {
mimetype: format.mime
},
url: await file.uploadDiscordFileToMxc(file.sticker(stickerItem))
}
}
}))
events.push(...stickerEvents)
}
// Rich replies
if (repliedToEventRow) {
Object.assign(events[0], {
"m.relates_to": {
"m.in_reply_to": {
event_id: repliedToEventRow.event_id
}
}
})
}
return events
}
module.exports.messageToEvent = messageToEvent

View file

@ -0,0 +1,134 @@
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

@ -0,0 +1,967 @@
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: simple plaintext", async t => {
const events = await messageToEvent(data.message.simple_plaintext, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "ayy lmao"
}])
})
test("message2event: simple plaintext with quotes", async t => {
const events = await messageToEvent(data.message.simple_plaintext_with_quotes, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: `then he said, "you and her aren't allowed in here!"`
}])
})
test("message2event: simple user mention", async t => {
const events = await messageToEvent(data.message.simple_user_mention, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "@crunch god: Tell me about Phil, renowned martial arts master and creator of the Chin Trick",
format: "org.matrix.custom.html",
formatted_body: '<a href="https://matrix.to/#/@_ooye_crunch_god:cadence.moe">@crunch god</a> Tell me about Phil, renowned martial arts master and creator of the Chin Trick'
}])
})
test("message2event: simple room mention", async t => {
let called = 0
const events = await messageToEvent(data.message.simple_room_mention, data.guild.general, {}, {
api: {
async getStateEvent(roomID, type, key) {
called++
t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {
users: {
"@_ooye_bot:cadence.moe": 100
}
}
},
async getJoinedMembers(roomID) {
called++
t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe")
return {
joined: {
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
"@user:matrix.org": {display_name: null, avatar_url: null}
}
}
}
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "#worm-farm",
format: "org.matrix.custom.html",
formatted_body: '<a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe?via=cadence.moe&via=matrix.org">#worm-farm</a>'
}])
t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each")
})
test("message2event: nicked room mention", async t => {
let called = 0
const events = await messageToEvent(data.message.nicked_room_mention, data.guild.general, {}, {
api: {
async getStateEvent(roomID, type, key) {
called++
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}
}
}
}
}
})
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]"
}])
})
test("message2event: simple role mentions", async t => {
const events = await messageToEvent(data.message.simple_role_mentions, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "I'm just @!!DLCS!!: testing a few role pings @Master Wonder Mage: don't mind me",
format: "org.matrix.custom.html",
formatted_body: `I'm just <font color="#a901ff">@!!DLCS!!</font> testing a few role pings <span data-mx-color="#ffffff" data-mx-bg-color="#414eef">@Master Wonder Mage</span> don't mind me`
}])
})
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}
}
}
}
}
})
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",
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]'
}])
})
test("message2event: attachment with no content", async t => {
const events = await messageToEvent(data.message.attachment_no_content, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.image",
url: "mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM",
body: "image.png",
external_url: "https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png",
filename: "image.png",
info: {
mimetype: "image/png",
w: 466,
h: 85,
size: 12919,
},
}])
})
test("message2event: spoiler attachment", async t => {
const events = await messageToEvent(data.message.spoiler_attachment, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
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>"
}])
})
test("message2event: stickers", async t => {
const events = await messageToEvent(data.message.sticker, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "can have attachments too"
}, {
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.image",
url: "mxc://cadence.moe/ZDCNYnkPszxGKgObUIFmvjus",
body: "image.png",
external_url: "https://cdn.discordapp.com/attachments/122155380120748034/1106366167486038016/image.png",
filename: "image.png",
info: {
mimetype: "image/png",
w: 333,
h: 287,
size: 127373,
},
}, {
$type: "m.sticker",
"m.mentions": {},
body: "pomu puff - damn that tiny lil bitch really chuffing. puffing that fat ass dart",
info: {
mimetype: "image/png"
// thumbnail_url
// thumbnail_info
},
url: "mxc://cadence.moe/UuUaLwXhkxFRwwWCXipDlBHn"
}])
})
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, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "Image"
}, {
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.image",
body: "skull.webp",
info: {
w: 1200,
h: 628,
mimetype: "image/webp",
size: 74290
},
external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1128084747910918195/skull.webp",
filename: "skull.webp",
url: "mxc://cadence.moe/sDxWmDErBhYBxtDcJQgBETes"
}])
})
test("message2event: reply to skull webp attachment with content", async t => {
const events = await messageToEvent(data.message.reply_to_skull_webp_attachment_with_content, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$oLyUTyZ_7e_SUzGNWZKz880ll9amLZvXGbArJCKai2Q"
}
},
"m.mentions": {},
msgtype: "m.text",
body: "> Extremity: Image\n\nReply",
format: "org.matrix.custom.html",
formatted_body:
'<mx-reply><blockquote><a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$oLyUTyZ_7e_SUzGNWZKz880ll9amLZvXGbArJCKai2Q">In reply to</a> Extremity'
+ '<br>Image</blockquote></mx-reply>'
+ 'Reply'
}, {
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.image",
body: "RDT_20230704_0936184915846675925224905.jpg",
info: {
w: 2048,
h: 1536,
mimetype: "image/jpeg",
size: 85906
},
external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1128084851023675515/RDT_20230704_0936184915846675925224905.jpg",
filename: "RDT_20230704_0936184915846675925224905.jpg",
url: "mxc://cadence.moe/WlAbFSiNRIHPDEwKdyPeGywa"
}])
})
test("message2event: simple reply to matrix user", async t => {
const events = await messageToEvent(data.message.simple_reply_to_matrix_user, data.guild.general, {}, {
api: {
getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", {
type: "m.room.message",
content: {
msgtype: "m.text",
body: "so can you reply to my webhook uwu"
},
sender: "@cadence:cadence.moe"
})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4"
}
},
"m.mentions": {
user_ids: [
"@cadence:cadence.moe"
]
},
msgtype: "m.text",
body: "> cadence: so can you reply to my webhook uwu\n\nReply",
format: "org.matrix.custom.html",
formatted_body:
'<mx-reply><blockquote><a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4">In reply to</a> <a href="https://matrix.to/#/@cadence:cadence.moe">cadence</a>'
+ '<br>so can you reply to my webhook uwu</blockquote></mx-reply>'
+ 'Reply'
}])
})
test("message2event: simple reply to matrix user, reply fallbacks disabled", async t => {
const events = await messageToEvent(data.message.simple_reply_to_matrix_user, data.guild.general, {includeReplyFallback: false}, {
api: {
getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", {
type: "m.room.message",
content: {
msgtype: "m.text",
body: "so can you reply to my webhook uwu"
},
sender: "@cadence:cadence.moe"
})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4"
}
},
"m.mentions": {
user_ids: [
"@cadence:cadence.moe"
]
},
msgtype: "m.text",
body: "Reply"
}])
})
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: {
getEvent: mockGetEvent(t, "!FuDZhlOAtqswlyxzeR:cadence.moe", "$nUM-ABBF8KdnvrhXwLlYAE9dgDl_tskOvvcNIBrtsVo", {
type: "m.room.message",
sender: "@cadence:cadence.moe",
content: {
msgtype: "m.text",
body: "> <@_ooye_cadence:cadence.moe> So what I'm wondering is about replies.\n\nWhat about them?",
format: "org.matrix.custom.html",
formatted_body: "<mx-reply><blockquote><a href=\"https://matrix.to/#/!FuDZhlOAtqswlyxzeR:cadence.moe/$fWQT8uOrzLzAXNVXz88VkGx7Oo724iS5uD8Qn5KUy9w?via=cadence.moe\">In reply to</a> <a href=\"https://matrix.to/#/@_ooye_cadence:cadence.moe\">@_ooye_cadence:cadence.moe</a><br>So what I&#39;m wondering is about replies.</blockquote></mx-reply>What about them?",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$fWQT8uOrzLzAXNVXz88VkGx7Oo724iS5uD8Qn5KUy9w"
}
}
},
event_id: "$nUM-ABBF8KdnvrhXwLlYAE9dgDl_tskOvvcNIBrtsVo",
room_id: "!FuDZhlOAtqswlyxzeR:cadence.moe"
})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$nUM-ABBF8KdnvrhXwLlYAE9dgDl_tskOvvcNIBrtsVo"
}
},
"m.mentions": {
user_ids: ["@cadence:cadence.moe"]
},
msgtype: "m.text",
body: "> cadence [they]: 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...",
}])
})
test("message2event: infinidoge's reply to ami's matrix smalltext reply to infinidoge", async t => {
const events = await messageToEvent(data.message.infinidoge_reply_to_ami_matrix_smalltext_reply_to_infinidoge, data.guild.general, {}, {
api: {
getEvent: mockGetEvent(t, "!BnKuBPCvyfOkhcUjEu:cadence.moe", "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4", {
type: "m.room.message",
sender: "@ami:the-apothecary.club",
content: {
msgtype: "m.text",
body: `> <@_ooye_infinidoge:cadence.moe> Neat that they thought of that\n\nlet me guess they got a lot of bug reports like "empty chest with no loot?"`,
format: "org.matrix.custom.html",
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$baby?via=cadence.moe">In reply to</a> <a href="https://matrix.to/#/@_ooye_infinidoge:cadence.moe">@_ooye_infinidoge:cadence.moe</a><br>Neat that they thought of that</blockquote></mx-reply>let me guess they got a lot of bug reports like "empty chest with no loot?"`,
"m.relates_to": {
"m.in_reply_to": {
event_id: "$baby"
}
}
},
event_id: "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4",
room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe"
})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4"
}
},
"m.mentions": {
user_ids: ["@ami:the-apothecary.club"]
},
msgtype: "m.text",
body: `> Ami (she/her): let me guess they got a lot of bug reports like "empty chest with no loot?"\n\nMost likely`,
format: "org.matrix.custom.html",
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4">In reply to</a> <a href="https://matrix.to/#/@ami:the-apothecary.club">Ami (she/her)</a><br>let me guess they got a lot of bug reports like "empty chest with no loot?"</blockquote></mx-reply>Most likely`,
}])
})
test("message2event: infinidoge's reply to ami's matrix smalltext singleline reply to infinidoge", async t => {
const events = await messageToEvent(data.message.infinidoge_reply_to_ami_matrix_smalltext_singleline_reply_to_infinidoge, data.guild.general, {}, {
api: {
getEvent: mockGetEvent(t, "!BnKuBPCvyfOkhcUjEu:cadence.moe", "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4", {
type: "m.room.message",
sender: "@ami:the-apothecary.club",
content: {
msgtype: "m.text",
body: `> <@_ooye_infinidoge:cadence.moe> Neat that they thought of that\n\nlet me guess they got a lot of bug reports like "empty chest with no loot?"`,
format: "org.matrix.custom.html",
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$baby?via=cadence.moe">In reply to</a> <a href="https://matrix.to/#/@_ooye_infinidoge:cadence.moe">@_ooye_infinidoge:cadence.moe</a><br>Neat that they thought of that</blockquote></mx-reply>let me guess they got a lot of bug reports like "empty chest with no loot?"`,
"m.relates_to": {
"m.in_reply_to": {
event_id: "$baby"
}
}
},
event_id: "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4",
room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe"
})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4"
}
},
"m.mentions": {
user_ids: ["@ami:the-apothecary.club"]
},
msgtype: "m.text",
body: `> Ami (she/her): let me guess they got a lot of bug reports like "empty chest with no loot?"\n\nMost likely`,
format: "org.matrix.custom.html",
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4">In reply to</a> <a href="https://matrix.to/#/@ami:the-apothecary.club">Ami (she/her)</a><br>let me guess they got a lot of bug reports like "empty chest with no loot?"</blockquote></mx-reply>Most likely`,
}])
})
test("message2event: simple written @mention for matrix user", async t => {
const events = await messageToEvent(data.message.simple_written_at_mention_for_matrix, data.guild.general, {}, {
api: {
async getJoinedMembers(roomID) {
t.equal(roomID, "!rEOspnYqdOalaIFniV:cadence.moe")
return new Promise(resolve => {
setTimeout(() => {
resolve({
joined: {
"@she_who_brings_destruction:matrix.org": {
avatar_url: "mxc://matrix.org/FKcfnfFZlEhspeMsERfYtCuO",
display_name: "ash (Old)"
},
"@tomskeleton:cadence.moe": {
avatar_url: "mxc://cadence.moe/OvYYicuOwfAACKaXKJCUPbVz",
display_name: "tomskeleton"
},
"@she_who_brings_destruction:cadence.moe": {
avatar_url: "mxc://cadence.moe/XDXLMbkieETPrjFupoeiwyyq",
display_name: "ash"
},
"@_ooye_bot:cadence.moe": {
avatar_url: "mxc://cadence.moe/jlrgFjYQHzfBvORedOmYqXVz",
display_name: "Out Of Your Element"
},
"@cadence:cadence.moe": {
avatar_url: "mxc://cadence.moe/GJDPWiryxIhyRBNJzRNYzAlh",
display_name: "cadence [they]"
},
"@_ooye_tomskeleton:cadence.moe": {
avatar_url: "mxc://cadence.moe/SdSrjjsrNVdyPTAKEGQUhKUK",
display_name: "tomskeleton"
},
"@_ooye_queergasm:cadence.moe": {
avatar_url: "mxc://cadence.moe/KqXYGbUqhPPJKifLmfpoLnmB",
display_name: "queergasm"
},
"@_ooye_.subtext:cadence.moe": {
avatar_url: "mxc://cadence.moe/heoCvaUmfCdpxdzaChwwkpEp",
display_name: ".subtext"
}
}
})
})
})
}
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {
user_ids: [
"@she_who_brings_destruction:cadence.moe"
]
},
msgtype: "m.text",
body: "@ash do you need anything from the store btw as I'm heading there after gym"
}])
})
test("message2event: advanced written @mentions for matrix users", async t => {
let called = 0
const events = await messageToEvent(data.message.advanced_written_at_mention_for_matrix, data.guild.general, {}, {
api: {
async getJoinedMembers(roomID) {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
return new Promise(resolve => {
setTimeout(() => {
resolve({
joined: {
"@cadence:cadence.moe": {
display_name: "cadence [they]",
avatar_url: "whatever"
},
"@huckleton:cadence.moe": {
display_name: "huck",
avatar_url: "whatever"
},
"@_ooye_botrac4r:cadence.moe": {
display_name: "botrac4r",
avatar_url: "whatever"
},
"@_ooye_bot:cadence.moe": {
display_name: "Out Of Your Element",
avatar_url: "whatever"
}
}
})
})
})
}
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {
user_ids: [
"@cadence:cadence.moe",
"@huckleton:cadence.moe"
]
},
msgtype: "m.text",
body: "@Cadence, tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and @huck"
}])
t.equal(called, 1, "should only look up the member list once")
})
test("message2event: very large attachment is linked instead of being uploaded", async t => {
const events = await messageToEvent({
content: "hey",
attachments: [{
filename: "hey.jpg",
url: "https://discord.com/404/hey.jpg",
content_type: "application/i-made-it-up",
size: 100e6
}]
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "hey"
}, {
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "📄 Uploaded file: https://discord.com/404/hey.jpg (100 MB)",
format: "org.matrix.custom.html",
formatted_body: '📄 Uploaded file: <a href="https://discord.com/404/hey.jpg">hey.jpg</a> (100 MB)'
}])
})
test("message2event: type 4 channel name change", async t => {
const events = await messageToEvent(data.special_message.thread_name_change, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.emote",
body: "changed the channel name to **worming**",
format: "org.matrix.custom.html",
formatted_body: "changed the channel name to <strong>worming</strong>"
}])
})
test("message2event: thread start message reference", async t => {
const events = await messageToEvent(data.special_message.thread_start_context, data.guild.general, {}, {
api: {
getEvent: mockGetEvent(t, "!BnKuBPCvyfOkhcUjEu:cadence.moe", "$FchUVylsOfmmbj-VwEs5Z9kY49_dt2zd0vWfylzy5Yo", {
"type": "m.room.message",
"sender": "@_ooye_kyuugryphon:cadence.moe",
"content": {
"m.mentions": {},
"msgtype": "m.text",
"body": "layer 4"
}
})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
$sender: null,
msgtype: "m.text",
body: "layer 4",
"m.mentions": {}
}])
})
test("message2event: single large bridged emoji", async t => {
const events = await messageToEvent(data.message.single_emoji, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: ":hippo:",
format: "org.matrix.custom.html",
formatted_body: '<img data-mx-emoticon height="32" src="mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC" title=":hippo:" alt=":hippo:">'
}])
})
test("message2event: mid-message small bridged emoji", async t => {
const events = await messageToEvent(data.message.surrounded_emoji, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "h is for :hippo:!",
format: "org.matrix.custom.html",
formatted_body: 'h is for <img data-mx-emoticon height="32" src="mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC" title=":hippo:" alt=":hippo:">!'
}])
})
test("message2event: emoji that hasn't been registered yet", async t => {
const events = await messageToEvent(data.message.not_been_registered_emoji, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: ":Yeah:",
format: "org.matrix.custom.html",
formatted_body: '<img data-mx-emoticon height="32" src="mxc://cadence.moe/pgdGTxAyEltccRgZKxdqzHHP" title=":Yeah:" alt=":Yeah:">'
}])
})
test("message2event: emoji triple long name", async t => {
const events = await messageToEvent(data.message.emoji_triple_long_name, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: ":brillillillilliant_move::brillillillilliant_move::brillillillilliant_move:",
format: "org.matrix.custom.html",
formatted_body:
'<img data-mx-emoticon height="32" src="mxc://cadence.moe/scfRIDOGKWFDEBjVXocWYQHik" title=":brillillillilliant_move:" alt=":brillillillilliant_move:">'
+ '<img data-mx-emoticon height="32" src="mxc://cadence.moe/scfRIDOGKWFDEBjVXocWYQHik" title=":brillillillilliant_move:" alt=":brillillillilliant_move:">'
+ '<img data-mx-emoticon height="32" src="mxc://cadence.moe/scfRIDOGKWFDEBjVXocWYQHik" title=":brillillillilliant_move:" alt=":brillillillilliant_move:">'
}])
})
test("message2event: crossposted announcements say where they are crossposted from", async t => {
const events = await messageToEvent(data.special_message.crosspost_announcement, data.guild.general, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "[🔀 Chewey Bot Official Server #announcements]\nAll text based commands are now inactive on Chewey Bot\nTo continue using commands you'll need to use them as slash commands",
format: "org.matrix.custom.html",
formatted_body: "🔀 <strong>Chewey Bot Official Server #announcements</strong><br>All text based commands are now inactive on Chewey Bot<br>To continue using commands you'll need to use them as slash commands"
}])
})
test("message2event: @everyone", async t => {
const events = await messageToEvent(data.message_mention_everyone.at_everyone)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "@room",
"m.mentions": {
room: true
}
}])
})
test("message2event: @here", async t => {
const events = await messageToEvent(data.message_mention_everyone.at_here)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "@room",
"m.mentions": {
room: true
}
}])
})
test("message2event: @everyone without permission", async t => {
const events = await messageToEvent(data.message_mention_everyone.at_everyone_without_permission)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "@everyone <-- this is testing that it DOESN'T mention. if this mentions everyone then my apologies.",
format: "org.matrix.custom.html",
formatted_body: "@everyone &lt;-- this is testing that it DOESN'T mention. if this mentions everyone then my apologies.",
"m.mentions": {}
}])
})
test("message2event: @here without permission", async t => {
const events = await messageToEvent(data.message_mention_everyone.at_here_without_permission)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "@here <-- this is testing that it DOESN'T mention. if this mentions people then my apologies.",
format: "org.matrix.custom.html",
formatted_body: "@here &lt;-- this is testing that it DOESN'T mention. if this mentions people then my apologies.",
"m.mentions": {}
}])
})
test("message2event: @everyone within a link", async t => {
const events = await messageToEvent(data.message_mention_everyone.at_everyone_within_link)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "https://github.com/@everyone",
format: "org.matrix.custom.html",
formatted_body: `<a href="https://github.com/@everyone">https://github.com/@everyone</a>`,
"m.mentions": {}
}])
})

View file

@ -0,0 +1,19 @@
// @ts-check
const {select} = require("../../passthrough")
/**
* @param {import("discord-api-types/v10").RESTGetAPIChannelPinsResult} pins
*/
function pinsToList(pins) {
/** @type {string[]} */
const result = []
for (const message of 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
}
module.exports.pinsToList = pinsToList

View file

@ -0,0 +1,12 @@
const {test} = require("supertape")
const data = require("../../test/data")
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",
"$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA",
"$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg"
])
})

View file

@ -0,0 +1,88 @@
// @ts-check
const Ty = require("../../types")
const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough")
const {discord, sync, select} = passthrough
/** @type {import("../../m2d/converters/utils")} */
const utils = sync.require("../../m2d/converters/utils")
/**
* @typedef ReactionRemoveRequest
* @prop {string} eventID
* @prop {string | null} mxid
* @prop {bigint} [hash]
*/
/**
* @param {DiscordTypes.GatewayMessageReactionRemoveDispatchData} data
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>[]} reactions
* @param {string} key
*/
function removeReaction(data, reactions, key) {
/** @type {ReactionRemoveRequest[]} */
const removals = []
const wantToRemoveMatrixReaction = data.user_id === discord.application.id
for (const event of reactions) {
const eventID = event.event_id
if (event.content["m.relates_to"].key === key) {
const lookingAtMatrixReaction = !utils.eventSenderIsFromDiscord(event.sender)
if (lookingAtMatrixReaction && wantToRemoveMatrixReaction) {
// We are removing a Matrix user's reaction, so we need to redact from the correct user ID (not @_ooye_matrix_bridge).
// Even though the bridge bot only reacted once on Discord-side, multiple Matrix users may have
// reacted on Matrix-side. Semantically, we want to remove the reaction from EVERY Matrix user.
// Also need to clean up the database.
const hash = utils.getEventIDHash(event.event_id)
removals.push({eventID, mxid: null, hash})
}
if (!lookingAtMatrixReaction && !wantToRemoveMatrixReaction) {
// We are removing a Discord user's reaction, so we just make the sim user remove it.
const mxid = select("sim", "mxid", {user_id: data.user_id}).pluck().get()
if (mxid === event.sender) {
removals.push({eventID, mxid})
}
}
}
}
return removals
}
/**
* @param {DiscordTypes.GatewayMessageReactionRemoveEmojiDispatchData} data
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>[]} relations
* @param {string} key
*/
function removeEmojiReaction(data, relations, key) {
/** @type {ReactionRemoveRequest[]} */
const removals = []
for (const event of relations) {
const eventID = event.event_id
if (event.content["m.relates_to"].key === key) {
const mxid = utils.eventSenderIsFromDiscord(event.sender) ? event.sender : null
removals.push({eventID, mxid})
}
}
return removals
}
/**
* @param {DiscordTypes.GatewayMessageReactionRemoveAllDispatchData} data
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>[]} relations
* @returns {ReactionRemoveRequest[]}
*/
function removeAllReactions(data, relations) {
return relations.map(event => {
const eventID = event.event_id
const mxid = utils.eventSenderIsFromDiscord(event.sender) ? event.sender : null
return {eventID, mxid}
})
}
module.exports.removeReaction = removeReaction
module.exports.removeEmojiReaction = removeEmojiReaction
module.exports.removeAllReactions = removeAllReactions

View file

@ -0,0 +1,170 @@
// @ts-check
const {test} = require("supertape")
const removeReaction = require("./remove-reaction")
const BRIDGE_ID = "684280192553844747"
function fakeSpecificReactionRemoval(userID, emoji, emojiID) {
return {
channel_id: "THE_CHANNEL",
message_id: "THE_MESSAGE",
user_id: userID,
emoji: {id: emojiID, name: emoji}
}
}
function fakeEmojiReactionRemoval(emoji, emojiID) {
return {
channel_id: "THE_CHANNEL",
message_id: "THE_MESSAGE",
emoji: {id: emojiID, name: emoji}
}
}
function fakeAllReactionRemoval() {
return {
channel_id: "THE_CHANNEL",
message_id: "THE_MESSAGE"
}
}
function fakeReactions(reactions) {
return reactions.map(({sender, key}, i) => ({
content: {
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$message",
key
}
},
event_id: `$reaction_${i}`,
sender,
type: "m.reaction",
origin_server_ts: 0,
room_id: "!THE_ROOM",
unsigned: null
}))
}
test("remove reaction: a specific discord user's reaction is removed", t => {
const removals = removeReaction.removeReaction(
fakeSpecificReactionRemoval("820865262526005258", "🐈", null),
fakeReactions([{key: "🐈", sender: "@_ooye_crunch_god:cadence.moe"}]),
"🐈"
)
t.deepEqual(removals, [{
eventID: "$reaction_0",
mxid: "@_ooye_crunch_god:cadence.moe"
}])
})
test("remove reaction: a specific matrix user's reaction is removed", t => {
const removals = removeReaction.removeReaction(
fakeSpecificReactionRemoval(BRIDGE_ID, "🐈", null),
fakeReactions([{key: "🐈", sender: "@cadence:cadence.moe"}]),
"🐈"
)
t.deepEqual(removals, [{
eventID: "$reaction_0",
mxid: null,
hash: 2842343637291700751n
}])
})
test("remove reaction: a specific discord user's reaction is removed when there are multiple reactions", t => {
const removals = removeReaction.removeReaction(
fakeSpecificReactionRemoval("820865262526005258", "🐈", null),
fakeReactions([
{key: "🐈‍⬛", sender: "@_ooye_crunch_god:cadence.moe"},
{key: "🐈", sender: "@_ooye_crunch_god:cadence.moe"},
{key: "🐈", sender: "@_ooye_extremity:cadence.moe"},
{key: "🐈", sender: "@cadence:cadence.moe"},
{key: "🐈", sender: "@zoe:cadence.moe"}
]),
"🐈"
)
t.deepEqual(removals, [{
eventID: "$reaction_1",
mxid: "@_ooye_crunch_god:cadence.moe"
}])
})
test("remove reaction: a specific reaction leads to all matrix users' reaction of the emoji being removed", t => {
const removals = removeReaction.removeReaction(
fakeSpecificReactionRemoval(BRIDGE_ID, "🐈", null),
fakeReactions([
{key: "🐈", sender: "@_ooye_crunch_god:cadence.moe"},
{key: "🐈", sender: "@cadence:cadence.moe"},
{key: "🐈‍⬛", sender: "@zoe:cadence.moe"},
{key: "🐈", sender: "@zoe:cadence.moe"},
{key: "🐈", sender: "@_ooye_extremity:cadence.moe"}
]),
"🐈"
)
t.deepEqual(removals, [{
eventID: "$reaction_1",
mxid: null,
hash: -8635141960139030904n
}, {
eventID: "$reaction_3",
mxid: null,
hash: 326222869084879263n
}])
})
test("remove reaction: an emoji removes all instances of the emoij from both sides", t => {
const removals = removeReaction.removeEmojiReaction(
fakeEmojiReactionRemoval("🐈", null),
fakeReactions([
{key: "🐈", sender: "@_ooye_crunch_god:cadence.moe"},
{key: "🐈", sender: "@cadence:cadence.moe"},
{key: "🐈‍⬛", sender: "@zoe:cadence.moe"},
{key: "🐈", sender: "@zoe:cadence.moe"},
{key: "🐈", sender: "@_ooye_extremity:cadence.moe"}
]),
"🐈"
)
t.deepEqual(removals, [{
eventID: "$reaction_0",
mxid: "@_ooye_crunch_god:cadence.moe"
}, {
eventID: "$reaction_1",
mxid: null
}, {
eventID: "$reaction_3",
mxid: null
}, {
eventID: "$reaction_4",
mxid: "@_ooye_extremity:cadence.moe"
}])
})
test("remove reaction: remove all removes all from both sides", t => {
const removals = removeReaction.removeAllReactions(
fakeAllReactionRemoval(),
fakeReactions([
{key: "🐈", sender: "@_ooye_crunch_god:cadence.moe"},
{key: "🐈", sender: "@cadence:cadence.moe"},
{key: "🐈‍⬛", sender: "@zoe:cadence.moe"},
{key: "🐈", sender: "@zoe:cadence.moe"},
{key: "🐈", sender: "@_ooye_extremity:cadence.moe"}
])
)
t.deepEqual(removals, [{
eventID: "$reaction_0",
mxid: "@_ooye_crunch_god:cadence.moe"
}, {
eventID: "$reaction_1",
mxid: null
}, {
eventID: "$reaction_2",
mxid: null
}, {
eventID: "$reaction_3",
mxid: null
}, {
eventID: "$reaction_4",
mxid: "@_ooye_extremity:cadence.moe"
}])
})

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,19 @@
rlottie library by Samsung:
https://github.com/Samsung/rlottie
rlottie-wasm.js and rlottie-wasm.wasm are outputs from rlottie's WASM build process.
rlottie uses the MIT license:
https://github.com/Samsung/rlottie/blob/master/COPYING
The text of the rlottie license follows.
Copyright 2020 (see AUTHORS)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS 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. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

Binary file not shown.

View file

@ -0,0 +1,47 @@
// @ts-check
const assert = require("assert").strict
const passthrough = require("../../passthrough")
const {discord, sync, db, select} = passthrough
/** @type {import("../../m2d/converters/utils")} */
const mxUtils = sync.require("../../m2d/converters/utils")
const {reg} = require("../../matrix/read-registration.js")
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
/**
* @param {string} parentRoomID
* @param {string} threadRoomID
* @param {string?} creatorMxid
* @param {import("discord-api-types/v10").APIThreadChannel} thread
* @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API
*/
async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, di) {
const branchedFromEventID = select("event_message", "event_id", {message_id: thread.id}).pluck().get()
/** @type {{"m.mentions"?: any, "m.in_reply_to"?: any}} */
const context = {}
if (branchedFromEventID) {
// Need to figure out who sent that event...
const event = await di.api.getEvent(parentRoomID, branchedFromEventID)
context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}}
if (event.sender && !userRegex.some(rx => event.sender.match(rx))) context["m.mentions"] = {user_ids: [event.sender]}
}
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>`
return {
msgtype,
body,
format: "org.matrix.custom.html",
formatted_body: html,
"m.mentions": {},
...context
}
}
module.exports.threadToAnnouncement = threadToAnnouncement

View file

@ -0,0 +1,171 @@
const {test} = require("supertape")
const {threadToAnnouncement} = require("./thread-to-announcement")
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
})
})
})
}
}
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",
format: "org.matrix.custom.html",
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org">test thread</a>`,
"m.mentions": {}
})
})
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",
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>`,
"m.mentions": {}
})
})
test("thread2announcement: no known creator, branched from discord event", async t => {
const content = await threadToAnnouncement("!kLRqKKUQXcibIMtOpl:cadence.moe", "!thread", null, {
name: "test thread",
id: "1126786462646550579"
}, {
api: {
getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", {
type: 'm.room.message',
sender: '@_ooye_bot:cadence.moe',
content: {
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",
format: "org.matrix.custom.html",
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org">test thread</a>`,
"m.mentions": {},
"m.relates_to": {
"m.in_reply_to": {
event_id: "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg"
}
}
})
})
test("thread2announcement: known creator, branched from discord event", async t => {
const content = await threadToAnnouncement("!kLRqKKUQXcibIMtOpl:cadence.moe", "!thread", "@_ooye_crunch_god:cadence.moe", {
name: "test thread",
id: "1126786462646550579"
}, {
api: {
getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", {
type: 'm.room.message',
sender: '@_ooye_bot:cadence.moe',
content: {
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",
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>`,
"m.mentions": {},
"m.relates_to": {
"m.in_reply_to": {
event_id: "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg"
}
}
})
})
test("thread2announcement: no known creator, branched from matrix event", async t => {
const content = await threadToAnnouncement("!kLRqKKUQXcibIMtOpl:cadence.moe", "!thread", null, {
name: "test thread",
id: "1128118177155526666"
}, {
api: {
getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", {
type: "m.room.message",
content: {
msgtype: "m.text",
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",
format: "org.matrix.custom.html",
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org">test thread</a>`,
"m.mentions": {
user_ids: ["@cadence:cadence.moe"]
},
"m.relates_to": {
"m.in_reply_to": {
event_id: "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4"
}
}
})
})

View file

@ -0,0 +1,88 @@
// @ts-check
const assert = require("assert")
const {reg} = require("../../matrix/read-registration")
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
* @param {import("discord-api-types/v10").APIUser} user
* @returns {string} localpart
*/
function downcaseUsername(user) {
// First, try to convert the username to the set of allowed characters
let downcased = user.username.toLowerCase()
// spaces to underscores...
.replace(/ /g, "_")
// remove disallowed characters...
.replace(/[^a-z0-9._=/-]*/g, "")
// remove leading and trailing dashes and underscores...
.replace(/(?:^[_-]*|[_-]*$)/g, "")
// If requested, also make the Discord user ID part of the username
if (reg.ooye.include_user_id_in_mxid) {
downcased = user.id + "_" + downcased
}
// The new length must be at least 2 characters (in other words, it should have some content)
if (downcased.length < 2) {
downcased = user.id
}
return downcased
}
/** @param {string[]} preferences */
function* generateLocalpartAlternatives(preferences) {
const best = preferences[0]
assert(best)
// First, suggest the preferences...
for (const localpart of preferences) {
yield localpart
}
// ...then fall back to generating number suffixes...
let i = 2
while (true) {
yield best + (i++)
/* c8 ignore next */
}
}
/**
* Whole process for checking the database and generating the right sim name.
* It is very important this is not an async function: once the name has been chosen, the calling function should be able to immediately claim that name into the database in the same event loop tick.
* @param {import("discord-api-types/v10").APIUser} user
* @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)}`)
}
// 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")
// 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]
if (user.discriminator.length === 4) { // Old style tag? If user.username is unavailable, try the full tag next
preferences.push(downcased + user.discriminator)
}
// Check for conflicts with already registered sims
const matches = select("sim", "sim_name", {}, "WHERE sim_name LIKE ? ESCAPE '@'").pluck().all(downcased + "%")
// Keep generating until we get a suggestion that doesn't conflict
for (const suggestion of generateLocalpartAlternatives(preferences)) {
if (!matches.includes(suggestion)) return suggestion
}
/* c8 ignore next */
throw new Error(`Ran out of suggestions when generating sim name. downcased: "${downcased}"`)
}
module.exports.userToSimName = userToSimName

View file

@ -0,0 +1,54 @@
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 => {
const [error] = await tryToCatch(() => userToSimName({discriminator: "0000"}))
t.ok(error instanceof assert.AssertionError, error.message)
})
test("user2name: works on normal name", t => {
t.equal(userToSimName({username: "Harry Styles!", discriminator: "0001"}), "harry_styles")
})
test("user2name: works on emojis", t => {
t.equal(userToSimName({username: "🍪 Cookie Monster 🍪", discriminator: "0001"}), "cookie_monster")
})
test("user2name: works on single emoji at the end", t => {
t.equal(userToSimName({username: "Melody 🎵", discriminator: "2192"}), "melody")
})
test("user2name: works on crazy name", t => {
t.equal(userToSimName({username: "*** D3 &W (89) _7//-", discriminator: "0001"}), "d3_w_89__7//")
})
test("user2name: adds discriminator if name is unavailable (old tag format)", t => {
t.equal(userToSimName({username: "BOT$", discriminator: "1234"}), "bot1234")
})
test("user2name: adds number suffix if name is unavailable (new username format)", t => {
t.equal(userToSimName({username: "bot", discriminator: "0"}), "bot2")
})
test("user2name: uses ID if name becomes too short", t => {
t.equal(userToSimName({username: "f***", discriminator: "0001", id: "9"}), "9")
})
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")
})
test("user2name: includes ID if requested in config", t => {
const {reg} = require("../../matrix/read-registration")
reg.ooye.include_user_id_in_mxid = true
t.equal(userToSimName({username: "Harry Styles!", discriminator: "0001", id: "123456"}), "123456_harry_styles")
t.equal(userToSimName({username: "f***", discriminator: "0001", id: "123456"}), "123456_f")
reg.ooye.include_user_id_in_mxid = false
})

66
src/d2m/discord-client.js Normal file
View file

@ -0,0 +1,66 @@
// @ts-check
const { SnowTransfer } = require("snowtransfer")
const { Client: CloudStorm } = require("cloudstorm")
const passthrough = require("../passthrough")
const { sync } = passthrough
/** @type {typeof import("./discord-packets")} */
const discordPackets = sync.require("./discord-packets")
class DiscordClient {
/**
* @param {string} discordToken
* @param {string} listen "full", "half", "no" - whether to set up the event listeners for OOYE to operate
*/
constructor(discordToken, listen = "full") {
this.discordToken = discordToken
this.snow = new SnowTransfer(discordToken)
this.cloud = new CloudStorm(discordToken, {
shards: [0],
reconnect: true,
snowtransferInstance: this.snow,
intents: [
"DIRECT_MESSAGES", "DIRECT_MESSAGE_REACTIONS", "DIRECT_MESSAGE_TYPING",
"GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS",
"MESSAGE_CONTENT"
],
ws: {
compress: false,
encoding: "json"
}
})
this.ready = false
/** @type {import("discord-api-types/v10").APIUser} */
// @ts-ignore avoid setting as or null because we know we need to wait for ready anyways
this.user = null
/** @type {Pick<import("discord-api-types/v10").APIApplication, "id" | "flags">} */
// @ts-ignore
this.application = null
/** @type {Map<string, import("discord-api-types/v10").APIChannel>} */
this.channels = new Map()
/** @type {Map<string, import("discord-api-types/v10").APIGuild>} */
this.guilds = new Map()
/** @type {Map<string, Array<string>>} */
this.guildChannelMap = new Map()
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.snow.requestHandler.on("requestError", (requestID, error) => {
console.error("request error:", error)
})
}
}
module.exports = DiscordClient

193
src/d2m/discord-packets.js Normal file
View file

@ -0,0 +1,193 @@
// @ts-check
// Discord library internals type beat
const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../passthrough")
const { sync } = passthrough
const utils = {
/**
* @param {import("./discord-client")} client
* @param {import("cloudstorm").IGatewayMessage} message
* @param {string} listen "full", "half", "no" - whether to set up the event listeners for OOYE to operate
*/
async onPacket(client, message, listen) {
// requiring this later so that the client is already constructed by the time event-dispatcher is loaded
/** @type {typeof import("./event-dispatcher")} */
const eventDispatcher = sync.require("./event-dispatcher")
/** @type {import("../discord/register-interactions")} */
const interactions = sync.require("../discord/register-interactions")
// Client internals, keep track of the state we need
if (message.t === "READY") {
if (client.ready) return
client.ready = true
client.user = message.d.user
client.application = message.d.application
console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`)
} else if (message.t === "GUILD_CREATE") {
client.guilds.set(message.d.id, message.d)
const arr = []
client.guildChannelMap.set(message.d.id, arr)
for (const channel of message.d.channels || []) {
// @ts-ignore
channel.guild_id = message.d.id
arr.push(channel.id)
client.channels.set(channel.id, channel)
}
for (const thread of message.d.threads || []) {
// @ts-ignore
thread.guild_id = message.d.id
arr.push(thread.id)
client.channels.set(thread.id, thread)
}
if (listen === "full") {
eventDispatcher.checkMissedExpressions(message.d)
eventDispatcher.checkMissedPins(client, message.d)
eventDispatcher.checkMissedMessages(client, message.d)
}
} else if (message.t === "GUILD_UPDATE") {
const guild = client.guilds.get(message.d.id)
if (guild) {
for (const prop of Object.keys(message.d)) {
if (!["channels", "threads"].includes(prop)) {
guild[prop] = message.d[prop]
}
}
}
} else if (message.t === "GUILD_EMOJIS_UPDATE") {
const guild = client.guilds.get(message.d.guild_id)
if (guild) {
guild.emojis = message.d.emojis
}
} else if (message.t === "GUILD_STICKERS_UPDATE") {
const guild = client.guilds.get(message.d.guild_id)
if (guild) {
guild.stickers = message.d.stickers
}
} else if (message.t === "GUILD_ROLE_CREATE" || message.t === "GUILD_ROLE_UPDATE" || message.t === "GUILD_ROLE_DELETE") {
const guild = client.guilds.get(message.d.guild_id)
/** Delete this in case of UPDATE or DELETE */
const targetID = "role_id" in message.d ? message.d.role_id : message.d.role.id
/** Add this in case of CREATE or UPDATE */
const newRoles = []
if ("role" in message.d) newRoles.push(message.d.role)
if (guild) {
const targetIndex = guild.roles.findIndex(r => r.id === targetID)
if (targetIndex !== -1) {
// Role already exists. Delete it and maybe replace it.
guild.roles.splice(targetIndex, 1, ...newRoles)
} else {
// Role doesn't already exist.
guild.roles.push(...newRoles)
}
}
} else if (message.t === "THREAD_CREATE") {
client.channels.set(message.d.id, message.d)
} else if (message.t === "CHANNEL_UPDATE" || message.t === "THREAD_UPDATE") {
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)
if (channels) {
for (const id of channels) client.channels.delete(id)
}
client.guildChannelMap.delete(message.d.id)
} else if (message.t === "CHANNEL_CREATE" || message.t === "CHANNEL_DELETE") {
if (message.t === "CHANNEL_CREATE") {
client.channels.set(message.d.id, message.d)
if (message.d["guild_id"]) { // obj[prop] notation can be used to access a property without typescript complaining that it doesn't exist on all values something can have
const channels = client.guildChannelMap.get(message.d["guild_id"])
if (channels && !channels.includes(message.d.id)) channels.push(message.d.id)
}
} else {
client.channels.delete(message.d.id)
if (message.d["guild_id"]) {
const channels = client.guildChannelMap.get(message.d["guild_id"])
if (channels) {
const previous = channels.indexOf(message.d.id)
if (previous !== -1) channels.splice(previous, 1)
}
}
}
}
// Event dispatcher for OOYE bridge operations
if (listen === "full") {
try {
if (message.t === "GUILD_UPDATE") {
await eventDispatcher.onGuildUpdate(client, message.d)
} else if (message.t === "GUILD_EMOJIS_UPDATE" || message.t === "GUILD_STICKERS_UPDATE") {
await eventDispatcher.onExpressionsUpdate(client, message.d)
} else if (message.t === "CHANNEL_UPDATE") {
await eventDispatcher.onChannelOrThreadUpdate(client, message.d, false)
} else if (message.t === "CHANNEL_PINS_UPDATE") {
await eventDispatcher.onChannelPinsUpdate(client, message.d)
} else if (message.t === "CHANNEL_DELETE") {
await eventDispatcher.onChannelDelete(client, message.d)
} else if (message.t === "THREAD_CREATE") {
// @ts-ignore
await eventDispatcher.onThreadCreate(client, message.d)
} else if (message.t === "THREAD_UPDATE") {
await eventDispatcher.onChannelOrThreadUpdate(client, message.d, true)
} else if (message.t === "MESSAGE_CREATE") {
await eventDispatcher.onMessageCreate(client, message.d)
} else if (message.t === "MESSAGE_UPDATE") {
await eventDispatcher.onMessageUpdate(client, message.d)
} 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)
} else if (message.t === "MESSAGE_REACTION_ADD") {
await eventDispatcher.onReactionAdd(client, message.d)
} else if (message.t === "MESSAGE_REACTION_REMOVE" || message.t === "MESSAGE_REACTION_REMOVE_EMOJI" || message.t === "MESSAGE_REACTION_REMOVE_ALL") {
await eventDispatcher.onSomeReactionsRemoved(client, message.d)
} else if (message.t === "INTERACTION_CREATE") {
await interactions.dispatchInteraction(message.d)
}
} catch (e) {
// Let OOYE try to handle errors too
await eventDispatcher.onError(client, e, message)
}
}
}
}
module.exports = utils

368
src/d2m/event-dispatcher.js Normal file
View file

@ -0,0 +1,368 @@
// @ts-check
const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10")
const util = require("util")
const {sync, db, select, from} = require("../passthrough")
/** @type {import("./actions/send-message")}) */
const sendMessage = sync.require("./actions/send-message")
/** @type {import("./actions/edit-message")}) */
const editMessage = sync.require("./actions/edit-message")
/** @type {import("./actions/delete-message")}) */
const deleteMessage = sync.require("./actions/delete-message")
/** @type {import("./actions/add-reaction")}) */
const addReaction = sync.require("./actions/add-reaction")
/** @type {import("./actions/remove-reaction")}) */
const removeReaction = sync.require("./actions/remove-reaction")
/** @type {import("./actions/announce-thread")}) */
const announceThread = sync.require("./actions/announce-thread")
/** @type {import("./actions/create-room")}) */
const createRoom = sync.require("./actions/create-room")
/** @type {import("./actions/create-space")}) */
const createSpace = sync.require("./actions/create-space")
/** @type {import("./actions/update-pins")}) */
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 {import("./actions/retrigger")} */
const retrigger = sync.require("./actions/retrigger")
/** @type {any} */ // @ts-ignore bad types from semaphore
const Semaphore = require("@chriscdn/promise-semaphore")
const checkMissedPinsSema = new Semaphore()
let lastReportedEvent = 0
// Grab Discord events we care about for the bridge, check them, and pass them on
module.exports = {
/**
* @param {import("./discord-client")} client
* @param {Error} e
* @param {import("cloudstorm").IGatewayMessage} gatewayMessage
*/
async onError(client, e, gatewayMessage) {
console.error("hit event-dispatcher's error handler with this exception:")
console.error(e) // TODO: also log errors into a file or into the database, maybe use a library for this? or just wing it? definitely need to be able to store the formatted event body to load back in later
console.error(`while handling this ${gatewayMessage.t} gateway event:`)
console.dir(gatewayMessage.d, {depth: null})
if (gatewayMessage.t === "TYPING_START") return
if (Date.now() - lastReportedEvent < 5000) return
lastReportedEvent = Date.now()
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 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>`)
await api.sendEvent(roomID, "m.room.message", {
...builder.get(),
"moe.cadence.ooye.error": {
source: "discord",
payload: gatewayMessage
},
"m.mentions": {
user_ids: ["@cadence:cadence.moe"]
}
})
},
/**
* When logging back in, check if we missed any conversations in any channels. Bridge up to 49 missed messages per channel.
* If more messages were missed, only the latest missed message will be posted. TODO: Consider bridging more, or post a warning when skipping history?
* This can ONLY detect new messages, not any other kind of event. Any missed edits, deletes, reactions, etc will not be bridged.
* @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayGuildCreateDispatchData} guild
*/
async checkMissedMessages(client, guild) {
if (guild.unavailable) return
const bridgedChannels = select("channel_room", "channel_id").pluck().all()
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
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)
if (!dUtils.hasAllPermissions(permissions, ["ViewChannel", "ReadMessageHistory"])) 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
try {
messages = await client.snow.channel.getChannelMessages(channel.id, {limit: 50})
} catch (e) {
if (e.message === `{"message": "Missing Access", "code": 50001}`) { // pathetic error handling from SnowTransfer
console.log(`[check missed messages] no permissions to look back in channel ${channel.name} (${channel.id})`)
continue // Sucks.
} else {
throw e // Sucks more.
}
}
let latestBridgedMessageIndex = messages.findIndex(m => {
return prepared.get(m.id)
})
// console.log(`[check missed messages] got ${messages.length} messages; last message that IS bridged is at position ${latestBridgedMessageIndex} in the channel`)
if (latestBridgedMessageIndex === -1) latestBridgedMessageIndex = 1 // rather than crawling the ENTIRE channel history, let's just bridge the most recent 1 message to make it up to date.
for (let i = Math.min(messages.length, latestBridgedMessageIndex)-1; i >= 0; i--) {
const simulatedGatewayDispatchData = {
guild_id: guild.id,
backfill: true,
...messages[i]
}
await module.exports.onMessageCreate(client, simulatedGatewayDispatchData)
}
}
},
/**
* 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)
if (!dUtils.hasAllPermissions(permissions, ["ViewChannel", "ReadMessageHistory"])) 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"
* @param {import("./discord-client")} client
* @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()
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)
},
/**
* @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayGuildUpdateDispatchData} guild
*/
async onGuildUpdate(client, guild) {
const spaceID = select("guild_space", "space_id", {guild_id: guild.id}).pluck().get()
if (!spaceID) return
await createSpace.syncSpace(guild)
},
/**
* @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayChannelUpdateDispatchData} channelOrThread
* @param {boolean} isThread
*/
async onChannelOrThreadUpdate(client, channelOrThread, isThread) {
const roomID = select("channel_room", "room_id", {channel_id: channelOrThread.id}).pluck().get()
if (!roomID) return // No target room to update the data on
await createRoom.syncRoom(channelOrThread.id)
},
/**
* @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayChannelPinsUpdateDispatchData} data
*/
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)
},
/**
* @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayChannelDeleteDispatchData} channel
*/
async onChannelDelete(client, channel) {
const guildID = channel["guild_id"]
if (!guildID) return // channel must have been a DM channel or something
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
if (!roomID) return // channel wasn't being bridged in the first place
// @ts-ignore
await createRoom.unbridgeDeletedChannel(channel, guildID)
},
/**
* @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message
*/
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 (dUtils.isEphemeralMessage(message)) return // Ephemeral messages are for the eyes of the receiver only!
const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id)
if (affected) return
// @ts-ignore
await sendMessage.sendMessage(message, channel, guild, row),
await discordCommandHandler.execute(message, channel, guild)
retrigger.messageFinishedBridging(message.id)
},
/**
* @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayMessageUpdateDispatchData} data
*/
async onMessageUpdate(client, data) {
// Based on looking at data they've sent me over the gateway, this is the best way to check for meaningful changes.
// If the message content is a string then it includes all interesting fields and is meaningful.
// Otherwise, if there are embeds, then the system generated URL preview embeds.
if (!(typeof data.content === "string" || "embeds" in data)) return
// Deal with Eventual Consistency(TM)
if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.onMessageUpdate, client, data)) return
if (data.webhook_id) {
const row = select("webhook", "webhook_id", {webhook_id: data.webhook_id}).pluck().get()
if (row) return // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it.
}
if (dUtils.isEphemeralMessage(data)) return // Ephemeral messages are for the eyes of the receiver only!
// Edits need to go through the speedbump as well. If the message is delayed but the edit isn't, we don't have anything to edit from.
const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id)
if (affected) return
/** @type {DiscordTypes.GatewayMessageCreateDispatchData} */
// @ts-ignore
const message = data
const channel = client.channels.get(message.channel_id)
if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages.
const guild = client.guilds.get(channel.guild_id)
assert(guild)
// @ts-ignore
await editMessage.editMessage(message, guild, row)
},
/**
* @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayMessageReactionAddDispatchData} data
*/
async onReactionAdd(client, data) {
if (data.user_id === client.user.id) return // m2d reactions are added by the discord bot user - do not reflect them back to matrix.
discordCommandHandler.onReactionAdd(data)
await addReaction.addReaction(data)
},
/**
* @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayMessageReactionRemoveDispatchData | DiscordTypes.GatewayMessageReactionRemoveEmojiDispatchData | DiscordTypes.GatewayMessageReactionRemoveAllDispatchData} data
*/
async onSomeReactionsRemoved(client, data) {
await removeReaction.removeSomeReactions(data)
},
/**
* @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayMessageDeleteDispatchData} data
*/
async onMessageDelete(client, data) {
speedbump.onMessageDelete(data.id)
if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.onMessageDelete, client, data)) return
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) {
const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get()
if (!roomID) return
const mxid = from("sim").join("sim_member", "mxid").where({user_id: data.user_id, room_id: roomID}).pluck("mxid").get()
if (!mxid) return
// Each Discord user triggers the notification every 8 seconds as long as they remain typing.
// Discord does not send typing stopped events, so typing only stops if the timeout is reached or if the user sends their message.
// (We have to manually stop typing on Matrix-side when the message is sent. This is part of the send action.)
await api.sendTyping(roomID, true, mxid, 10000)
},
/**
* @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayGuildEmojisUpdateDispatchData | DiscordTypes.GatewayGuildStickersUpdateDispatchData} data
*/
async onExpressionsUpdate(client, data) {
await createSpace.syncSpaceExpressions(data, false)
}
}

42
src/db/migrate.js Normal file
View file

@ -0,0 +1,42 @@
// @ts-check
const fs = require("fs")
const {join} = require("path")
async function migrate(db) {
let files = fs.readdirSync(join(__dirname, "migrations"))
files = files.sort()
db.prepare("CREATE TABLE IF NOT EXISTS migration (filename TEXT NOT NULL)").run()
let progress = db.prepare("SELECT * FROM migration").pluck().get()
if (!progress) {
progress = ""
db.prepare("INSERT INTO migration VALUES ('')").run()
}
let migrationRan = false
for (const filename of files) {
if (progress >= filename) continue
console.log(`Applying database migration ${filename}`)
if (filename.endsWith(".sql")) {
const sql = fs.readFileSync(join(__dirname, "migrations", filename), "utf8")
db.exec(sql)
} else if (filename.endsWith(".js")) {
await require("./" + join("migrations", filename))(db)
} else {
continue
}
migrationRan = true
db.transaction(() => {
db.prepare("DELETE FROM migration").run()
db.prepare("INSERT INTO migration VALUES (?)").run(filename)
})()
}
if (migrationRan) {
console.log("Database migrations all done.")
}
}
module.exports.migrate = migrate

0
src/db/migrations/.baby Normal file
View file

View file

@ -0,0 +1,92 @@
BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "sim" (
"discord_id" TEXT NOT NULL,
"sim_name" TEXT NOT NULL UNIQUE,
"localpart" TEXT NOT NULL,
"mxid" TEXT NOT NULL,
PRIMARY KEY("discord_id")
);
CREATE TABLE IF NOT EXISTS "webhook" (
"channel_id" TEXT NOT NULL,
"webhook_id" TEXT NOT NULL,
"webhook_token" TEXT NOT NULL,
PRIMARY KEY("channel_id")
);
CREATE TABLE IF NOT EXISTS "sim_member" (
"mxid" TEXT NOT NULL,
"room_id" TEXT NOT NULL,
"profile_event_content_hash" BLOB,
PRIMARY KEY("room_id","mxid")
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS "member_cache" (
"room_id" TEXT NOT NULL,
"mxid" TEXT NOT NULL,
"displayname" TEXT,
"avatar_url" TEXT,
PRIMARY KEY("room_id","mxid")
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS "file" (
"discord_url" TEXT NOT NULL,
"mxc_url" TEXT NOT NULL,
PRIMARY KEY("discord_url")
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS "guild_space" (
"guild_id" TEXT NOT NULL,
"space_id" TEXT NOT NULL,
PRIMARY KEY("guild_id")
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS "channel_room" (
"channel_id" TEXT NOT NULL,
"room_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"nick" TEXT,
"thread_parent" TEXT,
"custom_avatar" TEXT,
PRIMARY KEY("channel_id","room_id")
);
CREATE TABLE IF NOT EXISTS "message_channel" (
"message_id" TEXT NOT NULL,
"channel_id" TEXT NOT NULL,
PRIMARY KEY("message_id")
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS "event_message" (
"event_id" TEXT NOT NULL,
"message_id" TEXT NOT NULL,
"event_type" TEXT,
"event_subtype" TEXT,
"part" INTEGER NOT NULL,
"source" INTEGER NOT NULL,
PRIMARY KEY("message_id","event_id")
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS "lottie" (
"id" TEXT NOT NULL,
"mxc" TEXT NOT NULL,
PRIMARY KEY("id")
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS "emoji" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"animated" INTEGER NOT NULL,
"mxc_url" TEXT NOT NULL,
PRIMARY KEY("id")
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS "reaction" (
"hashed_event_id" INTEGER NOT NULL,
"message_id" TEXT NOT NULL,
"encoded_emoji" TEXT NOT NULL,
PRIMARY KEY ("hashed_event_id")
) WITHOUT ROWID;
COMMIT;

View file

@ -0,0 +1,20 @@
BEGIN TRANSACTION;
-- Change hashed_profile_content column affinity to INTEGER
CREATE TABLE "new_sim_member" (
"mxid" TEXT NOT NULL,
"room_id" TEXT NOT NULL,
"hashed_profile_content" INTEGER,
PRIMARY KEY("room_id","mxid")
) WITHOUT ROWID;
INSERT INTO new_sim_member SELECT * FROM sim_member;
DROP TABLE sim_member;
ALTER TABLE new_sim_member RENAME TO sim_member;
COMMIT;
VACUUM;

View file

@ -0,0 +1,13 @@
module.exports = async function(db) {
const hasher = await require("xxhash-wasm")()
const contents = db.prepare("SELECT distinct hashed_profile_content FROM sim_member WHERE hashed_profile_content IS NOT NULL").pluck().all()
const stmt = db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE hashed_profile_content = ?")
db.transaction(() => {
for (let s of contents) {
let b = Buffer.isBuffer(s) ? Uint8Array.from(s) : Uint8Array.from(Buffer.from(s))
const unsignedHash = hasher.h64Raw(b)
const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
stmt.run(signedHash, s)
}
})()
}

View file

@ -0,0 +1,19 @@
BEGIN TRANSACTION;
-- Rename mxc to mxc_url for consistency
ALTER TABLE lottie RENAME COLUMN mxc TO mxc_url;
-- Rename id to sticker_id so joins make sense in the future
ALTER TABLE lottie RENAME COLUMN id TO sticker_id;
-- Rename discord_id to user_id so joins make sense in the future
ALTER TABLE sim RENAME COLUMN discord_id TO user_id;
-- Rename id to emoji_id so joins make sense in the future
ALTER TABLE emoji RENAME COLUMN id TO emoji_id;
COMMIT;

View file

@ -0,0 +1,10 @@
BEGIN TRANSACTION;
CREATE TABLE auto_emoji (
name TEXT NOT NULL,
emoji_id TEXT NOT NULL,
guild_id TEXT NOT NULL,
PRIMARY KEY (name)
) WITHOUT ROWID;
COMMIT;

View file

@ -0,0 +1,5 @@
BEGIN TRANSACTION;
DELETE FROM member_cache;
COMMIT;

View file

@ -0,0 +1,5 @@
BEGIN TRANSACTION;
ALTER TABLE guild_space ADD COLUMN privacy_level INTEGER NOT NULL DEFAULT 0;
COMMIT;

View file

@ -0,0 +1,24 @@
BEGIN TRANSACTION;
-- Add column reaction_part to event_message, copying the existing value from part
CREATE TABLE "new_event_message" (
"event_id" TEXT NOT NULL,
"event_type" TEXT,
"event_subtype" TEXT,
"message_id" TEXT NOT NULL,
"part" INTEGER NOT NULL,
"reaction_part" INTEGER NOT NULL,
"source" INTEGER NOT NULL,
PRIMARY KEY("message_id","event_id")
) WITHOUT ROWID;
INSERT INTO new_event_message SELECT event_id, event_type, event_subtype, message_id, part, part, source FROM event_message;
DROP TABLE event_message;
ALTER TABLE new_event_message RENAME TO event_message;
COMMIT;
VACUUM;

View file

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

View file

@ -0,0 +1,7 @@
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

@ -0,0 +1,6 @@
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;

View file

@ -0,0 +1,16 @@
/*
a. If the bridge bot sim already has the correct ID:
- No rows updated.
b. If the bridge bot sim has the wrong ID but there's no duplicate:
- One row updated.
c. If the bridge bot sim has the wrong ID and there's a duplicate:
- One row updated (replaces an existing row).
*/
module.exports = async function(db) {
const config = require("../../config")
const id = Buffer.from(config.discordToken.split(".")[0], "base64").toString()
db.prepare("UPDATE OR REPLACE sim SET user_id = ? WHERE user_id = '0'").run(id)
}

View file

@ -0,0 +1,14 @@
BEGIN TRANSACTION;
-- the power we want them to have
CREATE TABLE IF NOT EXISTS member_power (
mxid TEXT NOT NULL,
room_id TEXT NOT NULL,
power_level INTEGER NOT NULL,
PRIMARY KEY(mxid, room_id)
) WITHOUT ROWID;
-- the power they have
ALTER TABLE member_cache ADD COLUMN power_level INTEGER NOT NULL DEFAULT 0;
COMMIT;

117
src/db/orm-defs.d.ts vendored Normal file
View file

@ -0,0 +1,117 @@
export type Models = {
channel_room: {
channel_id: string
room_id: string
name: string
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: {
event_id: string
message_id: string
event_type: string | null
event_subtype: string | null
part: number
reaction_part: number
source: number
}
file: {
discord_url: string
mxc_url: string
}
guild_space: {
guild_id: string
space_id: string
privacy_level: number
}
lottie: {
sticker_id: string
mxc_url: string
}
member_cache: {
room_id: string
mxid: string
displayname: string | null
avatar_url: string | null,
power_level: number
}
member_power: {
mxid: string
room_id: string
power_level: number
}
message_channel: {
message_id: string
channel_id: string
}
sim: {
user_id: string
sim_name: string
localpart: string
mxid: string
}
sim_member: {
mxid: string
room_id: string
hashed_profile_content: number
}
sim_proxy: {
user_id: string
proxy_owner_id: string
displayname: string
}
webhook: {
channel_id: string
webhook_id: string
webhook_token: string
}
emoji: {
emoji_id: string
name: string
animated: number
mxc_url: string
}
reaction: {
hashed_event_id: number
message_id: string
encoded_emoji: string
}
auto_emoji: {
name: string
emoji_id: string
guild_id: string
}
}
export type Prepared<Row> = {
pluck: () => Prepared<Row[keyof Row]>
safeIntegers: () => Prepared<{[K in keyof Row]: Row[K] extends number ? BigInt : Row[K]}>
raw: () => Prepared<Row[keyof Row][]>
all: (..._: any[]) => Row[]
get: (..._: any[]) => Row | null | undefined
}
export type AllKeys<U> = U extends any ? keyof U : never
export type PickTypeOf<T, K extends AllKeys<T>> = T extends { [k in K]?: any } ? T[K] : never
export type Merge<U> = {[x in AllKeys<U>]: PickTypeOf<U, x>}
export type Nullable<T> = {[k in keyof T]: T[k] | null}
export type Numberish<T> = {[k in keyof T]: T[k] extends number ? (number | bigint) : T[k]}

1
src/db/orm-defs.js Normal file
View file

@ -0,0 +1 @@
module.exports = {}

184
src/db/orm.js Normal file
View file

@ -0,0 +1,184 @@
// @ts-check
const {db} = require("../passthrough")
const U = require("./orm-defs")
/**
* @template {keyof U.Models} Table
* @template {keyof U.Models[Table]} Col
* @param {Table} table
* @param {Col[] | Col} cols
* @param {Partial<U.Numberish<U.Models[Table]>>} where
* @param {string} [e]
*/
function select(table, cols, where = {}, e = "") {
if (!Array.isArray(cols)) cols = [cols]
const parameters = []
const wheres = Object.entries(where).map(([col, value]) => {
parameters.push(value)
return `"${col}" = ?`
})
const whereString = wheres.length ? " WHERE " + wheres.join(" AND ") : ""
/** @type {U.Prepared<Pick<U.Models[Table], Col>>} */
const prepared = db.prepare(`SELECT ${cols.map(k => `"${String(k)}"`).join(", ")} FROM ${table} ${whereString} ${e}`)
prepared.get = prepared.get.bind(prepared, ...parameters)
prepared.all = prepared.all.bind(prepared, ...parameters)
return prepared
}
/**
* @template {keyof U.Models} Table
* @template {keyof U.Merge<U.Models[Table]>} Col
*/
class From {
/**
* @param {Table} table
*/
constructor(table) {
/** @private @type {Table[]} */
this.tables = [table]
/** @private */
this.directions = []
/** @private */
this.sql = ""
/** @private */
this.cols = []
/** @private */
this.makeColsSafe = true
/** @private */
this.using = []
/** @private */
this.isPluck = false
/** @private */
this.parameters = []
}
/**
* @template {keyof U.Models} Table2
* @param {Table2} table
* @param {Col & (keyof U.Models[Table2])} col
* @param {"inner" | "left"} [direction]
*/
join(table, col, direction = "inner") {
/** @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
}
/**
* @template {Col} Select
* @param {Col[] | Select[]} cols
*/
select(...cols) {
/** @type {From<Table, Select>} */
const r = this
r.cols = cols
return r
}
selectUnsafe(...cols) {
this.cols = cols
this.makeColsSafe = false
return this
}
/**
* @template {Col} Select
* @param {Select} col
*/
pluck(col) {
/** @type {Pluck<Table, Select>} */
// @ts-ignore
const r = this
r.cols = [col]
r.isPluck = true
return r
}
/**
* @param {string} sql
*/
and(sql) {
this.sql += " " + sql
return this
}
/**
* @param {Partial<U.Numberish<U.Models[Table]>>} conditions
*/
where(conditions) {
const wheres = Object.entries(conditions).map(([col, value]) => {
this.parameters.push(value)
return `"${col}" = ?`
})
this.sql += " WHERE " + wheres.join(" AND ")
return this
}
prepare() {
if (this.makeColsSafe) this.cols = this.cols.map(k => `"${k}"`)
let sql = `SELECT ${this.cols.join(", ")} FROM ${this.tables[0]} `
for (let i = 1; i < this.tables.length; i++) {
const table = this.tables[i]
const col = this.using[i-1]
const direction = this.directions[i-1]
sql += `${direction} JOIN ${table} USING (${col}) `
}
sql += this.sql
/** @type {U.Prepared<Pick<U.Merge<U.Models[Table]>, Col>>} */
let prepared = db.prepare(sql)
if (this.isPluck) prepared = prepared.pluck()
return prepared
}
get(..._) {
const prepared = this.prepare()
return prepared.get(...this.parameters, ..._)
}
all(..._) {
const prepared = this.prepare()
return prepared.all(...this.parameters, ..._)
}
}
/* 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
*/
class Pluck extends From {
// @ts-ignore
prepare() {
/** @type {U.Prepared<U.Merge<U.Models[Table]>[Col]>} */
// @ts-ignore
const prepared = super.prepare()
return prepared
}
get(..._) {
const prepared = this.prepare()
return prepared.get(..._)
}
all(..._) {
const prepared = this.prepare()
return prepared.all(..._)
}
}
/* c8 ignore stop */
/**
* @template {keyof U.Models} Table
* @param {Table} table
*/
function from(table) {
return new From(table)
}
module.exports.from = from
module.exports.select = select

55
src/db/orm.test.js Normal file
View file

@ -0,0 +1,55 @@
// @ts-check
const {test} = require("supertape")
const data = require("../test/data")
const {db, select, from} = require("../passthrough")
test("orm: select: get works", t => {
const row = select("guild_space", "guild_id", {}, "WHERE space_id = ?").get("!jjWAGMeQdNrVZSSfvz:cadence.moe")
t.equal(row?.guild_id, data.guild.general.id)
})
test("orm: from: get works", t => {
const row = from("guild_space").select("guild_id").and("WHERE space_id = ?").get("!jjWAGMeQdNrVZSSfvz:cadence.moe")
t.equal(row?.guild_id, data.guild.general.id)
})
test("orm: select: get pluck works", t => {
const guildID = select("guild_space", "guild_id", {}, "WHERE space_id = ?").pluck().get("!jjWAGMeQdNrVZSSfvz:cadence.moe")
t.equal(guildID, data.guild.general.id)
})
test("orm: select: get, where and pluck works", t => {
const channelID = select("message_channel", "channel_id", {message_id: "1128118177155526666"}).pluck().get()
t.equal(channelID, "112760669178241024")
})
test("orm: select: all, where and pluck works on multiple columns", t => {
const names = select("member_cache", "displayname", {room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", mxid: "@cadence:cadence.moe"}).pluck().all()
t.deepEqual(names, ["cadence [they]"])
})
test("orm: from: get pluck works", t => {
const guildID = from("guild_space").pluck("guild_id").and("WHERE space_id = ?").get("!jjWAGMeQdNrVZSSfvz:cadence.moe")
t.equal(guildID, data.guild.general.id)
})
test("orm: from: join and pluck works", t => {
const mxid = from("sim").join("sim_member", "mxid").and("WHERE user_id = ? AND room_id = ?").pluck("mxid").get("771520384671416320", "!hYnGGlPHlbujVVfktC:cadence.moe")
t.equal(mxid, "@_ooye_bojack_horseman:cadence.moe")
})
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, undefined)
})

View file

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

View file

@ -0,0 +1,115 @@
// @ts-check
const DiscordTypes = require("discord-api-types/v10")
const Ty = require("../../types")
const {discord, sync, db, select, from, as} = require("../../passthrough")
const assert = require("assert/strict")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {Map<string, Promise<{name: string, value: string}[]>>} spaceID -> list of rooms */
const cache = new Map()
/** @type {Map<string, string>} roomID -> spaceID */
const reverseCache = new Map()
// Manage clearing the cache
sync.addTemporaryListener(as, "type:m.room.name", /** @param {Ty.Event.StateOuter<Ty.Event.M_Room_Name>} event */ async event => {
if (event.state_key !== "") return
const roomID = event.room_id
const spaceID = reverseCache.get(roomID)
if (!spaceID) return
const childRooms = await cache.get(spaceID)
if (!childRooms) return
if (event.content.name) {
const found = childRooms.find(r => r.value === roomID)
if (!found) return
found.name = event.content.name
} else {
cache.set(spaceID, Promise.resolve(childRooms.filter(r => r.value !== roomID)))
reverseCache.delete(roomID)
}
})
// Manage adding to the cache
async function getCachedHierarchy(spaceID) {
return cache.get(spaceID) || (() => {
const entry = (async () => {
const result = await api.getFullHierarchy(spaceID)
/** @type {{name: string, value: string}[]} */
const childRooms = []
for (const room of result) {
if (room.name && !room.name.match(/^\[[⛓️🔊]\]/) && room.room_type !== "m.space") {
childRooms.push({name: room.name, value: room.room_id})
reverseCache.set(room.room_id, spaceID)
}
}
return childRooms
})()
cache.set(spaceID, entry)
return entry
})()
}
/** @param {DiscordTypes.APIApplicationCommandAutocompleteGuildInteraction} interaction */
async function interactAutocomplete({id, token, data, guild_id}) {
const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get()
if (!spaceID) {
return discord.snow.interaction.createInteractionResponse(id, token, {
type: DiscordTypes.InteractionResponseType.ApplicationCommandAutocompleteResult,
data: {
choices: [
{
name: `Error: This server needs to be bridged somewhere first...`,
value: "baby"
}
]
}
})
}
let rooms = await getCachedHierarchy(spaceID)
// @ts-ignore
rooms = rooms.filter(r => r.name.includes(data.options[0].value))
await discord.snow.interaction.createInteractionResponse(id, token, {
type: DiscordTypes.InteractionResponseType.ApplicationCommandAutocompleteResult,
data: {
choices: rooms
}
})
}
/** @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction */
async function interactSubmit({id, token, data, guild_id}) {
const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get()
if (!spaceID) {
return discord.snow.interaction.createInteractionResponse(id, token, {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: "Error: This server needs to be bridged somewhere first...",
flags: DiscordTypes.MessageFlags.Ephemeral
}
})
}
return discord.snow.interaction.createInteractionResponse(id, token, {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: "Valid input. This would do something but it isn't implemented yet.",
flags: DiscordTypes.MessageFlags.Ephemeral
}
})
}
/** @param {DiscordTypes.APIGuildInteraction} interaction */
async function interact(interaction) {
if (interaction.type === DiscordTypes.InteractionType.ApplicationCommandAutocomplete) {
return interactAutocomplete(interaction)
} else if (interaction.type === DiscordTypes.InteractionType.ApplicationCommand) {
// @ts-ignore
return interactSubmit(interaction)
}
}
module.exports.interact = interact

View file

@ -0,0 +1,131 @@
// @ts-check
const DiscordTypes = require("discord-api-types/v10")
const assert = require("assert/strict")
const {discord, sync, db, select, from} = require("../../passthrough")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/**
* @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction
* @returns {Promise<DiscordTypes.APIInteractionResponse>}
*/
async function _interact({data, channel, guild_id}) {
// Check guild is bridged
const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get()
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
if (!spaceID || !roomID) return {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: "This server isn't bridged to Matrix, so you can't invite Matrix users.",
flags: DiscordTypes.MessageFlags.Ephemeral
}
}
// Get named MXID
/** @type {DiscordTypes.APIApplicationCommandInteractionDataStringOption[] | undefined} */ // @ts-ignore
const options = data.options
const input = options?.[0].value || ""
const mxid = input.match(/@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)/)?.[0]
if (!mxid) return {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`",
flags: DiscordTypes.MessageFlags.Ephemeral
}
}
// Check for existing invite to the space
let spaceMember
try {
spaceMember = await api.getStateEvent(spaceID, "m.room.member", mxid)
} catch (e) {}
if (spaceMember && spaceMember.membership === "invite") {
return {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: `\`${mxid}\` already has an invite, which they haven't accepted yet.`,
flags: DiscordTypes.MessageFlags.Ephemeral
}
}
}
// Invite Matrix user if not in space
if (!spaceMember || spaceMember.membership !== "join") {
await api.inviteToRoom(spaceID, mxid)
return {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: `You invited \`${mxid}\` to the server.`
}
}
}
// The Matrix user *is* in the space, maybe we want to invite them to this channel?
let roomMember
try {
roomMember = await api.getStateEvent(roomID, "m.room.member", mxid)
} catch (e) {}
if (!roomMember || (roomMember.membership !== "join" && roomMember.membership !== "invite")) {
return {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: `\`${mxid}\` is already in this server. Would you like to additionally invite them to this specific channel?`,
flags: DiscordTypes.MessageFlags.Ephemeral,
components: [{
type: DiscordTypes.ComponentType.ActionRow,
components: [{
type: DiscordTypes.ComponentType.Button,
custom_id: "invite_channel",
style: DiscordTypes.ButtonStyle.Primary,
label: "Sure",
}]
}]
}
}
}
// The Matrix user *is* in the space and in the channel.
return {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: `\`${mxid}\` is already in this server and this channel.`,
flags: DiscordTypes.MessageFlags.Ephemeral
}
}
}
/**
* @param {DiscordTypes.APIMessageComponentGuildInteraction} interaction
* @returns {Promise<DiscordTypes.APIInteractionResponse>}
*/
async function _interactButton({channel, message}) {
const mxid = message.content.match(/`(@(?:[^:]+):(?:[a-z0-9:-]+\.[a-z0-9.:-]+))`/)?.[1]
assert(mxid)
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
await api.inviteToRoom(roomID, mxid)
return {
type: DiscordTypes.InteractionResponseType.UpdateMessage,
data: {
content: `You invited \`${mxid}\` to the channel.`,
flags: DiscordTypes.MessageFlags.Ephemeral,
components: []
}
}
}
/** @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction */
async function interact(interaction) {
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interact(interaction))
}
/** @param {DiscordTypes.APIMessageComponentGuildInteraction} interaction */
async function interactButton(interaction) {
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interactButton(interaction))
}
module.exports.interact = interact
module.exports.interactButton = interactButton
module.exports._interact = _interact
module.exports._interactButton = _interactButton

View file

@ -0,0 +1,51 @@
// @ts-check
const DiscordTypes = require("discord-api-types/v10")
const {discord, sync, db, select, from} = require("../../passthrough")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @param {DiscordTypes.APIContextMenuGuildInteraction} interaction */
/** @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction */
async function interact({id, token, guild_id, channel, data}) {
const message = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id")
.select("name", "nick", "source", "room_id", "event_id").where({message_id: data.target_id}).get()
if (!message) {
return discord.snow.interaction.createInteractionResponse(id, token, {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: "This message hasn't been bridged to Matrix.",
flags: DiscordTypes.MessageFlags.Ephemeral
}
})
}
const idInfo = `\n-# Room ID: \`${message.room_id}\`\n-# Event ID: \`${message.event_id}\``
if (message.source === 1) { // from Discord
const userID = data.resolved.messages[data.target_id].author.id
return discord.snow.interaction.createInteractionResponse(id, token, {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: `Bridged <@${userID}> https://discord.com/channels/${guild_id}/${channel.id}/${data.target_id} on Discord to [${message.nick || message.name}](<https://matrix.to/#/${message.room_id}/${message.event_id}>) on Matrix.`
+ idInfo,
flags: DiscordTypes.MessageFlags.Ephemeral
}
})
}
// from Matrix
const event = await api.getEvent(message.room_id, message.event_id)
return discord.snow.interaction.createInteractionResponse(id, token, {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: `Bridged [${event.sender}](<https://matrix.to/#/${event.sender}>)'s message in [${message.nick || message.name}](<https://matrix.to/#/${message.room_id}/${message.event_id}>) on Matrix to https://discord.com/channels/${guild_id}/${channel.id}/${data.target_id} on Discord.`
+ idInfo,
flags: DiscordTypes.MessageFlags.Ephemeral
}
})
}
module.exports.interact = interact

View file

@ -0,0 +1,129 @@
// @ts-check
const DiscordTypes = require("discord-api-types/v10")
const Ty = require("../../types")
const {discord, sync, db, select, from} = require("../../passthrough")
const assert = require("assert/strict")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/**
* @param {DiscordTypes.APIContextMenuGuildInteraction} interaction
* @returns {Promise<DiscordTypes.APIInteractionResponse>}
*/
async function _interact({data, channel, guild_id}) {
const row = select("event_message", ["event_id", "source"], {message_id: data.target_id}).get()
assert(row)
// Can't operate on Discord users
if (row.source === 1) { // discord
return {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: `This command is only meaningful for Matrix users.`,
flags: DiscordTypes.MessageFlags.Ephemeral
}
}
}
// Get the message sender, the person that will be inspected/edited
const eventID = row.event_id
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
assert(roomID)
const event = await api.getEvent(roomID, eventID)
const sender = event.sender
// Get the space, where the power levels will be inspected/edited
const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get()
assert(spaceID)
// Get the power level
/** @type {Ty.Event.M_Power_Levels} */
const powerLevelsContent = await api.getStateEvent(spaceID, "m.room.power_levels", "")
const userPower = powerLevelsContent.users?.[event.sender] || 0
// Administrators equal to the bot cannot be demoted
if (userPower >= 100) {
return {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: `\`${sender}\` has administrator permissions. This cannot be edited.`,
flags: DiscordTypes.MessageFlags.Ephemeral
}
}
}
return {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: `Showing permissions for \`${sender}\`. Click to edit.`,
flags: DiscordTypes.MessageFlags.Ephemeral,
components: [
{
type: DiscordTypes.ComponentType.ActionRow,
components: [
{
type: DiscordTypes.ComponentType.StringSelect,
custom_id: "permissions_edit",
options: [
{
label: "Default",
value: "default",
default: userPower < 50
}, {
label: "Moderator",
value: "moderator",
default: userPower >= 50 && userPower < 100
}
]
}
]
}
]
}
}
}
/**
* @param {DiscordTypes.APIMessageComponentSelectMenuInteraction} interaction
*/
async function interactEdit({data, id, token, guild_id, message}) {
// Get the person that will be inspected/edited
const mxid = message.content.match(/`(@(?:[^:]+):(?:[a-z0-9:-]+\.[a-z0-9.:-]+))`/)?.[1]
assert(mxid)
const permission = data.values[0]
const power = permission === "moderator" ? 50 : 0
await discord.snow.interaction.createInteractionResponse(id, token, {
type: DiscordTypes.InteractionResponseType.UpdateMessage,
data: {
content: `Updating \`${mxid}\` to **${permission}**, please wait...`,
components: []
}
})
// Get the space, where the power levels will be inspected/edited
const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get()
assert(spaceID)
// Do it
await api.setUserPowerCascade(spaceID, mxid, power)
// ACK
await discord.snow.interaction.editOriginalInteractionResponse(discord.application.id, token, {
content: `Updated \`${mxid}\` to **${permission}**.`,
components: []
})
}
/** @param {DiscordTypes.APIContextMenuGuildInteraction} interaction */
async function interact(interaction) {
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interact(interaction))
}
module.exports.interact = interact
module.exports.interactEdit = interactEdit
module.exports._interact = _interact

View file

@ -0,0 +1,58 @@
// @ts-check
const DiscordTypes = require("discord-api-types/v10")
const {discord, sync, db, select, from} = require("../../passthrough")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("../../m2d/converters/utils")} */
const utils = sync.require("../../m2d/converters/utils")
/** @param {DiscordTypes.APIContextMenuGuildInteraction} interaction */
/** @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction */
async function interact({id, token, data}) {
const row = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id")
.select("event_id", "room_id").where({message_id: data.target_id}).get()
if (!row) {
return discord.snow.interaction.createInteractionResponse(id, token, {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: "This message hasn't been bridged to Matrix.",
flags: DiscordTypes.MessageFlags.Ephemeral
}
})
}
const reactions = await api.getFullRelations(row.room_id, row.event_id, "m.annotation")
/** @type {Map<string, string[]>} */
const inverted = new Map()
for (const reaction of reactions) {
if (utils.eventSenderIsFromDiscord(reaction.sender)) continue
const key = reaction.content["m.relates_to"].key
const displayname = select("member_cache", "displayname", {mxid: reaction.sender, room_id: row.room_id}).pluck().get() || reaction.sender
if (!inverted.has(key)) inverted.set(key, [])
// @ts-ignore
inverted.get(key).push(displayname)
}
if (inverted.size === 0) {
return discord.snow.interaction.createInteractionResponse(id, token, {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: "Nobody from Matrix reacted to this message.",
flags: DiscordTypes.MessageFlags.Ephemeral
}
})
}
return discord.snow.interaction.createInteractionResponse(id, token, {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: [...inverted.entries()].map(([key, value]) => `${key}${value.join(" ⬩ ")}`).join("\n"),
flags: DiscordTypes.MessageFlags.Ephemeral
}
})
}
module.exports.interact = interact

View file

@ -0,0 +1,94 @@
// @ts-check
const DiscordTypes = require("discord-api-types/v10")
const {discord, sync, db, select} = require("../passthrough")
const {id} = require("../addbot")
const matrixInfo = sync.require("./interactions/matrix-info.js")
const invite = sync.require("./interactions/invite.js")
const permissions = sync.require("./interactions/permissions.js")
const bridge = sync.require("./interactions/bridge.js")
const reactions = sync.require("./interactions/reactions.js")
discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{
name: "Matrix info",
contexts: [DiscordTypes.InteractionContextType.Guild],
type: DiscordTypes.ApplicationCommandType.Message,
}, {
name: "Permissions",
contexts: [DiscordTypes.InteractionContextType.Guild],
type: DiscordTypes.ApplicationCommandType.Message,
default_member_permissions: String(DiscordTypes.PermissionFlagsBits.KickMembers | DiscordTypes.PermissionFlagsBits.ManageRoles)
}, {
name: "Reactions",
contexts: [DiscordTypes.InteractionContextType.Guild],
type: DiscordTypes.ApplicationCommandType.Message
}, {
name: "invite",
contexts: [DiscordTypes.InteractionContextType.Guild],
type: DiscordTypes.ApplicationCommandType.ChatInput,
description: "Invite a Matrix user to this Discord server",
default_member_permissions: String(DiscordTypes.PermissionFlagsBits.CreateInstantInvite),
options: [
{
type: DiscordTypes.ApplicationCommandOptionType.String,
description: "The Matrix user to invite, e.g. @username:example.org",
name: "user"
}
]
}, {
name: "bridge",
contexts: [DiscordTypes.InteractionContextType.Guild],
type: DiscordTypes.ApplicationCommandType.ChatInput,
description: "Start bridging this channel to a Matrix room.",
default_member_permissions: String(DiscordTypes.PermissionFlagsBits.ManageChannels),
options: [
{
type: DiscordTypes.ApplicationCommandOptionType.String,
description: "Destination room to bridge to.",
name: "room",
autocomplete: true
}
]
}])
async function dispatchInteraction(interaction) {
const interactionId = interaction.data.custom_id || interaction.data.name
try {
console.log(interaction)
if (interactionId === "Matrix info") {
await matrixInfo.interact(interaction)
} else if (interactionId === "invite") {
await invite.interact(interaction)
} else if (interactionId === "invite_channel") {
await invite.interactButton(interaction)
} else if (interactionId === "Permissions") {
await permissions.interact(interaction)
} else if (interactionId === "permissions_edit") {
await permissions.interactEdit(interaction)
} else if (interactionId === "bridge") {
await bridge.interact(interaction)
} else if (interactionId === "Reactions") {
await reactions.interact(interaction)
} else {
throw new Error(`Unknown interaction ${interactionId}`)
}
} catch (e) {
let stackLines = null
if (e.stack) {
stackLines = e.stack.split("\n")
let cloudstormLine = stackLines.findIndex(l => l.includes("/node_modules/cloudstorm/"))
if (cloudstormLine !== -1) {
stackLines = stackLines.slice(0, cloudstormLine - 2)
}
}
await discord.snow.interaction.createFollowupMessage(id, interaction.token, {
content: `Interaction failed: **${interactionId}**`
+ `\nError trace:\n\`\`\`\n${stackLines.join("\n")}\`\`\``
+ `Interaction data:\n\`\`\`\n${JSON.stringify(interaction.data, null, 2)}\`\`\``,
flags: DiscordTypes.MessageFlags.Ephemeral
})
}
}
module.exports.dispatchInteraction = dispatchInteraction

127
src/discord/utils.js Normal file
View file

@ -0,0 +1,127 @@
// @ts-check
const DiscordTypes = require("discord-api-types/v10")
const assert = require("assert").strict
const EPOCH = 1420070400000
/**
* @param {string[]} userRoles
* @param {DiscordTypes.APIGuild["roles"]} guildRoles
* @param {string} [userID]
* @param {DiscordTypes.APIGuildChannel["permission_overwrites"]} [channelOverwrites]
*/
function getPermissions(userRoles, guildRoles, userID, channelOverwrites) {
let allowed = BigInt(0)
let everyoneID
// Guild allows
for (const role of guildRoles) {
if (role.name === "@everyone") {
allowed |= BigInt(role.permissions)
everyoneID = role.id
}
if (userRoles.includes(role.id)) {
allowed |= BigInt(role.permissions)
}
}
if (channelOverwrites) {
/** @type {((overwrite: Required<DiscordTypes.APIOverwrite>) => any)[]} */
const actions = [
// Channel @everyone deny
overwrite => overwrite.id === everyoneID && (allowed &= ~BigInt(overwrite.deny)),
// Channel @everyone allow
overwrite => overwrite.id === everyoneID && (allowed |= BigInt(overwrite.allow)),
// Role deny
overwrite => userRoles.includes(overwrite.id) && (allowed &= ~BigInt(overwrite.deny)),
// Role allow
overwrite => userRoles.includes(overwrite.id) && (allowed |= BigInt(overwrite.allow)),
// User deny
overwrite => overwrite.id === userID && (allowed &= ~BigInt(overwrite.deny)),
// User allow
overwrite => overwrite.id === userID && (allowed |= BigInt(overwrite.allow))
]
for (let i = 0; i < actions.length; i++) {
for (const overwrite of channelOverwrites) {
actions[i](overwrite)
}
}
}
return allowed
}
/**
* Note: You can only provide one permission bit to permissionToCheckFor. To check multiple permissions, call `hasAllPermissions` or `hasSomePermissions`.
* It is designed like this to avoid developer error with bit manipulations.
*
* @param {bigint} resolvedPermissions
* @param {bigint} permissionToCheckFor
* @returns {boolean} whether the user has the requested permission
* @example
* const permissions = getPermissions(userRoles, guildRoles, userID, channelOverwrites)
* hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel)
*/
function hasPermission(resolvedPermissions, permissionToCheckFor) {
// Make sure permissionToCheckFor has exactly one permission in it
assert.equal(permissionToCheckFor.toString(2).match(/1/g)?.length, 1)
// Do the actual calculation
return (resolvedPermissions & permissionToCheckFor) === permissionToCheckFor
}
/**
* @param {bigint} resolvedPermissions
* @param {(keyof DiscordTypes.PermissionFlagsBits)[]} permissionsToCheckFor
* @returns {boolean} whether the user has any of the requested permissions
* @example
* const permissions = getPermissions(userRoles, guildRoles, userID, channelOverwrites)
* hasSomePermissions(permissions, ["ViewChannel", "ReadMessageHistory"])
*/
function hasSomePermissions(resolvedPermissions, permissionsToCheckFor) {
return permissionsToCheckFor.some(x => hasPermission(resolvedPermissions, DiscordTypes.PermissionFlagsBits[x]))
}
/**
* @param {bigint} resolvedPermissions
* @param {(keyof DiscordTypes.PermissionFlagsBits)[]} permissionsToCheckFor
* @returns {boolean} whether the user has all of the requested permissions
* @example
* const permissions = getPermissions(userRoles, guildRoles, userID, channelOverwrites)
* hasAllPermissions(permissions, ["ViewChannel", "ReadMessageHistory"])
*/
function hasAllPermissions(resolvedPermissions, permissionsToCheckFor) {
return permissionsToCheckFor.every(x => hasPermission(resolvedPermissions, DiscordTypes.PermissionFlagsBits[x]))
}
/**
* Command interaction responses have a webhook_id for some reason, but still have real author info of a real bot user in the server.
* @param {DiscordTypes.APIMessage} message
*/
function isWebhookMessage(message) {
return message.webhook_id && message.type !== DiscordTypes.MessageType.ChatInputCommand
}
/**
* @param {Pick<DiscordTypes.APIMessage, "flags">} message
*/
function isEphemeralMessage(message) {
return message.flags && (message.flags & DiscordTypes.MessageFlags.Ephemeral)
}
/** @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.hasPermission = hasPermission
module.exports.hasSomePermissions = hasSomePermissions
module.exports.hasAllPermissions = hasAllPermissions
module.exports.isWebhookMessage = isWebhookMessage
module.exports.isEphemeralMessage = isEphemeralMessage
module.exports.snowflakeToTimestampExact = snowflakeToTimestampExact
module.exports.timestampToSnowflakeInexact = timestampToSnowflakeInexact

109
src/discord/utils.test.js Normal file
View file

@ -0,0 +1,109 @@
const DiscordTypes = require("discord-api-types/v10")
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)
})
test("hasSomePermissions: detects the permission", t => {
const userPermissions = DiscordTypes.PermissionFlagsBits.MentionEveryone | DiscordTypes.PermissionFlagsBits.BanMembers
const canRemoveMembers = utils.hasSomePermissions(userPermissions, ["KickMembers", "BanMembers"])
t.equal(canRemoveMembers, true)
})
test("hasSomePermissions: doesn't detect not the permission", t => {
const userPermissions = DiscordTypes.PermissionFlagsBits.MentionEveryone | DiscordTypes.PermissionFlagsBits.SendMessages
const canRemoveMembers = utils.hasSomePermissions(userPermissions, ["KickMembers", "BanMembers"])
t.equal(canRemoveMembers, false)
})
test("hasAllPermissions: detects the permissions", t => {
const userPermissions = DiscordTypes.PermissionFlagsBits.KickMembers | DiscordTypes.PermissionFlagsBits.BanMembers | DiscordTypes.PermissionFlagsBits.MentionEveryone
const canRemoveMembers = utils.hasAllPermissions(userPermissions, ["KickMembers", "BanMembers"])
t.equal(canRemoveMembers, true)
})
test("hasAllPermissions: doesn't detect not the permissions", t => {
const userPermissions = DiscordTypes.PermissionFlagsBits.MentionEveryone | DiscordTypes.PermissionFlagsBits.SendMessages | DiscordTypes.PermissionFlagsBits.KickMembers
const canRemoveMembers = utils.hasAllPermissions(userPermissions, ["KickMembers", "BanMembers"])
t.equal(canRemoveMembers, false)
})

View file

@ -0,0 +1,31 @@
// @ts-check
const assert = require("assert").strict
const Ty = require("../../types")
const passthrough = require("../../passthrough")
const {discord, sync, db, select} = passthrough
/** @type {import("../converters/utils")} */
const utils = sync.require("../converters/utils")
/** @type {import("../converters/emoji")} */
const emoji = sync.require("../converters/emoji")
/**
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>} event
*/
async function addReaction(event) {
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
if (!channelID) return // We just assume the bridge has already been created
const messageID = select("event_message", "message_id", {event_id: event.content["m.relates_to"].event_id}, "ORDER BY reaction_part").pluck().get()
if (!messageID) return // Nothing can be done if the parent message was never bridged.
const key = event.content["m.relates_to"].key
const discordPreferredEncoding = emoji.encodeEmoji(key, event.content.shortcode)
if (!discordPreferredEncoding) return
await discord.snow.channel.createReaction(channelID, messageID, discordPreferredEncoding) // acting as the discord bot itself
db.prepare("REPLACE INTO reaction (hashed_event_id, message_id, encoded_emoji) VALUES (?, ?, ?)").run(utils.getEventIDHash(event.event_id), messageID, discordPreferredEncoding)
}
module.exports.addReaction = addReaction

View file

@ -0,0 +1,99 @@
// @ts-check
const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10")
const {Readable} = require("stream")
const passthrough = require("../../passthrough")
const {discord, db, select} = passthrough
/**
* Look in the database to find webhook credentials for a channel.
* (Note that the credentials may be invalid and need to be re-created if the webhook was interfered with from outside.)
* @param {string} channelID
* @param {boolean} forceCreate create a new webhook no matter what the database says about the state
* @returns id and token for a webhook for that channel
*/
async function ensureWebhook(channelID, forceCreate = false) {
if (!forceCreate) {
const row = select("webhook", ["webhook_id", "webhook_token"], {channel_id: channelID}).get()
if (row) {
return {
id: row.webhook_id,
token: row.webhook_token,
created: false
}
}
}
// If we got here, we need to create a new webhook.
const webhook = await discord.snow.webhook.createWebhook(channelID, {name: "Out Of Your Element: Matrix Bridge"})
assert(webhook.token)
db.prepare("REPLACE INTO webhook (channel_id, webhook_id, webhook_token) VALUES (?, ?, ?)").run(channelID, webhook.id, webhook.token)
return {
id: webhook.id,
token: webhook.token,
created: true
}
}
/**
* @param {string} channelID
* @param {(webhook: import("../../types").WebhookCreds) => Promise<T>} callback
* @returns Promise<T>
* @template T
*/
async function withWebhook(channelID, callback) {
const webhook = await ensureWebhook(channelID, false)
return callback(webhook).catch(async e => {
if (e.message === `{"message": "Unknown Webhook", "code": 10015}`) { // pathetic error handling from SnowTransfer
// Our webhook is gone. Maybe somebody deleted it, or removed and re-added OOYE from the guild.
const newWebhook = await ensureWebhook(channelID, true)
return callback(newWebhook) // not caught; if the error happens again just throw it instead of looping
}
throw e
})
}
/**
* @param {string} channelID
* @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]}} data
* @param {string} [threadID]
*/
async function sendMessageWithWebhook(channelID, data, threadID) {
const result = await withWebhook(channelID, async webhook => {
return discord.snow.webhook.executeWebhook(webhook.id, webhook.token, data, {wait: true, thread_id: threadID})
})
return result
}
/**
* @param {string} channelID
* @param {string} messageID
* @param {DiscordTypes.RESTPatchAPIWebhookWithTokenMessageJSONBody & {files?: {name: string, file: Buffer | Readable}[]}} data
* @param {string} [threadID]
*/
async function editMessageWithWebhook(channelID, messageID, data, threadID) {
const result = await withWebhook(channelID, async webhook => {
return discord.snow.webhook.editWebhookMessage(webhook.id, webhook.token, messageID, {...data, thread_id: threadID})
})
return result
}
/**
* @param {string} channelID
* @param {string} messageID
* @param {string} [threadID]
*/
async function deleteMessageWithWebhook(channelID, messageID, threadID) {
const result = await withWebhook(channelID, async webhook => {
return discord.snow.webhook.deleteWebhookMessage(webhook.id, webhook.token, messageID, threadID)
})
return result
}
module.exports.ensureWebhook = ensureWebhook
module.exports.withWebhook = withWebhook
module.exports.sendMessageWithWebhook = sendMessageWithWebhook
module.exports.editMessageWithWebhook = editMessageWithWebhook
module.exports.deleteMessageWithWebhook = deleteMessageWithWebhook

View file

@ -0,0 +1,36 @@
// @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

43
src/m2d/actions/redact.js Normal file
View file

@ -0,0 +1,43 @@
// @ts-check
const assert = require("assert").strict
const Ty = require("../../types")
const passthrough = require("../../passthrough")
const {discord, sync, db, select, from} = passthrough
/** @type {import("../converters/utils")} */
const utils = sync.require("../converters/utils")
/**
* @param {Ty.Event.Outer_M_Room_Redaction} event
*/
async function deleteMessage(event) {
const rows = from("event_message").join("message_channel", "message_id").select("channel_id", "message_id").where({event_id: event.redacts}).all()
db.prepare("DELETE FROM event_message WHERE event_id = ?").run(event.event_id)
for (const row of rows) {
db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(row.message_id)
await discord.snow.channel.deleteMessage(row.channel_id, row.message_id, event.content.reason)
}
}
/**
* @param {Ty.Event.Outer_M_Room_Redaction} event
*/
async function removeReaction(event) {
const hash = utils.getEventIDHash(event.redacts)
const row = from("reaction").join("message_channel", "message_id").select("channel_id", "message_id", "encoded_emoji").where({hashed_event_id: hash}).get()
if (!row) return
await discord.snow.channel.deleteReactionSelf(row.channel_id, row.message_id, row.encoded_emoji)
db.prepare("DELETE FROM reaction WHERE hashed_event_id = ?").run(hash)
}
/**
* Try everything that could possibly be redacted.
* @param {Ty.Event.Outer_M_Room_Redaction} event
*/
async function handle(event) {
await deleteMessage(event)
await removeReaction(event)
}
module.exports.handle = handle

View file

@ -0,0 +1,147 @@
// @ts-check
const Ty = require("../../types")
const DiscordTypes = require("discord-api-types/v10")
const {Readable} = require("stream")
const assert = require("assert").strict
const crypto = require("crypto")
const fetch = require("node-fetch").default
const passthrough = require("../../passthrough")
const {sync, discord, db, select} = passthrough
/** @type {import("./channel-webhook")} */
const channelWebhook = sync.require("./channel-webhook")
/** @type {import("../converters/event-to-message")} */
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("../../d2m/actions/edit-message")} */
const editMessage = sync.require("../../d2m/actions/edit-message")
/** @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
* @returns {Promise<DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]}>}
*/
async function resolvePendingFiles(message) {
if (!message.pendingFiles) return message
const files = await Promise.all(message.pendingFiles.map(async p => {
if ("buffer" in p) {
return {
name: p.name,
file: p.buffer
}
}
if ("key" in p) {
// Encrypted file
const d = crypto.createDecipheriv("aes-256-ctr", Buffer.from(p.key, "base64url"), Buffer.from(p.iv, "base64url"))
// @ts-ignore
fetch(p.url).then(res => res.body.pipe(d))
return {
name: p.name,
file: d
}
} else {
// Unencrypted file
/** @type {Readable} */ // @ts-ignore
const body = await fetch(p.url).then(res => res.body)
return {
name: p.name,
file: body
}
}
}))
const newMessage = {
...message,
files: files.concat(message.files || [])
}
delete newMessage.pendingFiles
return newMessage
}
/** @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker} event */
async function sendEvent(event) {
const row = select("channel_room", ["channel_id", "thread_parent"], {room_id: event.room_id}).get()
if (!row) return // allow the bot to exist in unbridged rooms, just don't do anything with it
let channelID = row.channel_id
let threadID = undefined
if (row.thread_parent) {
threadID = channelID
channelID = row.thread_parent // it's the thread's parent... get with the times...
}
// @ts-ignore
const guildID = discord.channels.get(channelID).guild_id
const guild = discord.guilds.get(guildID)
assert(guild)
// 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})
messagesToEdit = await Promise.all(messagesToEdit.map(async e => {
e.message = await resolvePendingFiles(e.message)
return e
}))
messagesToSend = await Promise.all(messagesToSend.map(message => {
return resolvePendingFiles(message)
}))
let eventPart = 0 // 0 is primary, 1 is supporting
const pendingEdits = []
/** @type {DiscordTypes.APIMessage[]} */
const messageResponses = []
for (const data of messagesToEdit) {
const messageResponse = await channelWebhook.editMessageWithWebhook(channelID, data.id, data.message, threadID)
eventPart = 1
messageResponses.push(messageResponse)
}
for (const id of messagesToDelete) {
db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(id)
db.prepare("DELETE FROM event_message WHERE message_id = ?").run(id)
await channelWebhook.deleteMessageWithWebhook(channelID, id, threadID)
}
for (const message of messagesToSend) {
const reactionPart = messagesToEdit.length === 0 && message === messagesToSend[messagesToSend.length - 1] ? 0 : 1
const messageResponse = await channelWebhook.sendMessageWithWebhook(channelID, message, threadID)
db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(messageResponse.id, threadID || channelID)
db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 0)").run(event.event_id, event.type, event.content["msgtype"] || null, messageResponse.id, eventPart, reactionPart) // source 0 = matrix
eventPart = 1
messageResponses.push(messageResponse)
/*
If the Discord system has a cached link preview embed for one of the links just sent,
it will be instantly added as part of `embeds` and there won't be a MESSAGE_UPDATE.
To reflect the generated embed back to Matrix, we pretend the message was updated right away.
*/
const sentEmbedsCount = message.embeds?.length || 0
if (messageResponse.embeds.length > sentEmbedsCount) {
// not awaiting here because requests to Matrix shouldn't block requests to Discord
pendingEdits.push(() =>
// @ts-ignore this is a valid message edit payload
editMessage.editMessage({
id: messageResponse.id,
channel_id: messageResponse.channel_id,
guild_id: guild.id,
embeds: messageResponse.embeds
}, guild, null)
)
}
}
for (const user of ensureJoined) {
registerUser.ensureSimJoined(user, event.room_id)
}
await Promise.all(pendingEdits.map(f => f())) // `await` will propagate any errors during editing
return messageResponses
}
module.exports.sendEvent = sendEvent

View file

@ -0,0 +1,114 @@
// @ts-check
const assert = require("assert").strict
const {pipeline} = require("stream").promises
const sharp = require("sharp")
const {GIFrame} = require("@cloudrac3r/giframe")
const {PNG} = require("@cloudrac3r/pngjs")
const streamMimeType = require("stream-mime-type")
const SIZE = 48
const RESULT_WIDTH = 400
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))
// Calculate the size of the final composited image
const totalWidth = Math.min(buffers.length, IMAGES_ACROSS) * SIZE
const imagesDown = Math.ceil(buffers.length / IMAGES_ACROSS)
const totalHeight = imagesDown * SIZE
const comp = []
let left = 0, top = 0
for (const buffer of buffers) {
if (Buffer.isBuffer(buffer)) {
// Composite the current buffer into the sprite sheet
comp.push({left, top, input: buffer})
// The next buffer should be placed one slot to the right
left += SIZE
// If we're out of space to fit the entire next buffer there, wrap to the next line
if (left + SIZE > RESULT_WIDTH) {
left = 0
top += SIZE
}
}
}
const output = await sharp({create: {width: totalWidth, height: totalHeight, channels: 4, background: {r: 0, g: 0, b: 0, alpha: 0}}})
.composite(comp)
.png()
.toBuffer({resolveWithObject: true})
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()
const pixels = Uint8Array.from(frame.pixels)
stopStream()
const buffer = await sharp(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

@ -0,0 +1,58 @@
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

@ -0,0 +1,57 @@
// @ts-check
const assert = require("assert").strict
const Ty = require("../../types")
const passthrough = require("../../passthrough")
const {sync, select} = passthrough
/**
* @param {string} input
* @param {string | null | undefined} shortcode
* @returns {string?}
*/
function encodeEmoji(input, shortcode) {
let discordPreferredEncoding
if (input.startsWith("mxc://")) {
// Custom emoji
let row = select("emoji", ["emoji_id", "name"], {mxc_url: input}).get()
if (!row && shortcode) {
// Use the name to try to find a known emoji with the same name.
const name = shortcode.replace(/^:|:$/g, "")
row = select("emoji", ["emoji_id", "name"], {name: name}).get()
}
if (!row) {
// We don't have this emoji and there's no realistic way to just-in-time upload a new emoji somewhere.
// Sucks!
return null
}
// Cool, we got an exact or a candidate emoji.
discordPreferredEncoding = encodeURIComponent(`${row.name}:${row.emoji_id}`)
} else {
// Default emoji
// https://github.com/discord/discord-api-docs/issues/2723#issuecomment-807022205 ????????????
const encoded = encodeURIComponent(input)
const encodedTrimmed = encoded.replace(/%EF%B8%8F/g, "")
const forceTrimmedList = [
"%F0%9F%91%8D", // 👍
"%F0%9F%91%8E", // 👎️
"%E2%AD%90", // ⭐
"%F0%9F%90%88", // 🐈
"%E2%9D%93", // ❓
"%F0%9F%8F%86", // 🏆️
"%F0%9F%93%9A", // 📚️
]
discordPreferredEncoding =
( forceTrimmedList.includes(encodedTrimmed) ? encodedTrimmed
: encodedTrimmed !== encoded && [...input].length === 2 ? encoded
: encodedTrimmed)
console.log("add reaction from matrix:", input, encoded, encodedTrimmed, "chosen:", discordPreferredEncoding)
}
return discordPreferredEncoding
}
module.exports.encodeEmoji = encodeEmoji

View file

@ -0,0 +1,856 @@
// @ts-check
const Ty = require("../../types")
const DiscordTypes = require("discord-api-types/v10")
const {Readable} = require("stream")
const chunk = require("chunk-text")
const TurndownService = require("@cloudrac3r/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")
/** @type {import("../../matrix/file")} */
const file = sync.require("../../matrix/file")
/** @type {import("./emoji-sheet")} */
const emojiSheet = sync.require("./emoji-sheet")
/** @type {[RegExp, string][]} */
const markdownEscapes = [
[/\\/g, '\\\\'],
[/\*/g, '\\*'],
[/^-/g, '\\-'],
[/^\+ /g, '\\+ '],
[/^(=+)/g, '\\$1'],
[/^(#{1,6}) /g, '\\$1 '],
[/`/g, '\\`'],
[/^~~~/g, '\\~~~'],
[/\[/g, '\\['],
[/\]/g, '\\]'],
[/^>/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"
})
/**
* Markdown characters in the HTML content need to be escaped, though take care not to escape the middle of bare links
* @param {string} string
*/
// @ts-ignore bad type from turndown
turndownService.escape = function (string) {
return string.replace(/\s+|\S+/g, part => { // match chunks of spaces or non-spaces
if (part.match(/\s/)) return part // don't process spaces
if (part.match(/^https?:\/\//)) {
return part
} else {
return markdownEscapes.reduce(function (accumulator, escape) {
return accumulator.replace(escape[0], escape[1])
}, part)
}
})
}
turndownService.remove("mx-reply")
turndownService.addRule("strikethrough", {
filter: ["del", "s"],
replacement: function (content) {
return "~~" + content + "~~"
}
})
turndownService.addRule("underline", {
filter: ["u"],
replacement: function (content) {
return "__" + content + "__"
}
})
turndownService.addRule("blockquote", {
filter: "blockquote",
replacement: function (content) {
content = content.replace(/^\n+|\n+$/g, "")
content = content.replace(/^/gm, "> ")
return content
}
})
turndownService.addRule("spoiler", {
filter: function (node, options) {
return node.tagName === "SPAN" && 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}||`
}
})
turndownService.addRule("inlineLink", {
filter: function (node, options) {
return (
node.nodeName === "A" &&
node.getAttribute("href")
)
},
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-channel-id")) return `<#${node.getAttribute("data-channel-id")}>`
const href = node.getAttribute("href")
content = content.replace(/ @.*/, "")
if (href === content) return href
if (decodeURIComponent(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" : "")
}
})
/** @type {string[]} SPRITE SHEET EMOJIS FEATURE: mxc urls for the currently processing message */
let endOfMessageEmojis = []
turndownService.addRule("emoji", {
filter: function (node, options) {
if (node.nodeName !== "IMG" || !node.hasAttribute("data-mx-emoticon") || !node.getAttribute("src") || !node.getAttribute("title")) return false
return true
},
replacement: function (content, node) {
const mxcUrl = node.getAttribute("src")
const guessedName = node.getAttribute("title").replace(/^:|:$/g, "")
return convertEmoji(mxcUrl, guessedName, true, true)
}
})
turndownService.addRule("fencedCodeBlock", {
filter: function (node, options) {
return (
options.codeBlockStyle === "fenced" &&
node.nodeName === "PRE" &&
node.firstChild &&
node.firstChild.nodeName === "CODE"
)
},
replacement: function (content, node, options) {
const className = node.firstChild.getAttribute("class") || ""
const language = (className.match(/language-(\S+)/) || [null, ""])[1]
const code = node.firstChild
const visibleCode = getCodeContent(code)
var fence = "```"
return (
fence + language + "\n" +
visibleCode +
"\n" + fence
)
}
})
/** @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.
if (mxcUrl) var row = select("emoji", ["emoji_id", "name", "animated"], {mxc_url: mxcUrl}).get()
// Now we have to search all servers to see if we're able to send this emoji.
if (row) {
const found = [...discord.guilds.values()].find(g => g.emojis.find(e => e.id === row?.emoji_id))
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.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
* @returns {Promise<{displayname?: string?, avatar_url?: string?}>}
*/
async function getMemberFromCacheOrHomeserver(roomID, mxid, api) {
const row = select("member_cache", ["displayname", "avatar_url"], {room_id: roomID, mxid}).get()
if (row) return row
return api.getStateEvent(roomID, "m.room.member", mxid).then(event => {
db.prepare("REPLACE INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?)").run(roomID, mxid, event?.displayname || null, event?.avatar_url || null)
return event
}).catch(() => {
return {displayname: null, avatar_url: null}
})
}
/**
* 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.
* 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.
* @summary Splits too-long Matrix names into a display name chunk and a message content chunk.
* @param {string} displayName - The Matrix side display name to chop up.
* @returns {[string, string]} [shortened display name, display name runoff]
*/
function splitDisplayName(displayName) {
/** @type {string[]} */
let displayNameChunks = chunk(displayName, 80)
if (displayNameChunks.length === 1) {
return [displayName, ""]
} else {
const displayNamePreRunoff = displayNameChunks[0]
// displayNameRunoff is a slice of the original rather than a concatenation of the rest of the chunks in order to preserve whatever whitespace it was broken on.
const displayNameRunoff = `**${displayName.slice(displayNamePreRunoff.length + 1)}**\n`
return [displayNamePreRunoff, displayNameRunoff]
}
}
/**
* 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) {
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*$/
while (content.match(r)) {
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)
// Attach it
const name = "emojis.png"
attachments.push({id: String(attachments.length), 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\/#\/(![^"/, ?)]+)(?:\/(\$[^"/ ?)]+))?(?:\?[^",:!? )]*?)?)(">|[,<\n )]|$)/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 {string} senderMxid
* @param {string} roomID
* @param {DiscordTypes.APIGuild} guild
* @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, fetch: import("node-fetch")["default"]}} di
*/
async function checkWrittenMentions(content, senderMxid, roomID, guild, di) {
let writtenMentionMatch = content.match(/(?:^|[^"[<>/A-Za-z0-9])@([A-Za-z][A-Za-z0-9._\[\]\(\)-]+):?/d) // /d flag for indices requires node.js 16+
if (writtenMentionMatch) {
if (writtenMentionMatch[1] === "room") { // convert @room to @everyone
const powerLevels = await di.api.getStateEvent(roomID, "m.room.power_levels", "")
const userPower = powerLevels.users?.[senderMxid] || 0
if (userPower >= powerLevels.notifications?.room) {
return {
// @ts-ignore - typescript doesn't know about indices yet
content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `@everyone` + content.slice(writtenMentionMatch.indices[1][1]),
ensureJoined: [],
allowedMentionsParse: ["everyone"]
}
}
} else {
const results = await di.snow.guild.searchGuildMembers(guild.id, {query: writtenMentionMatch[1]})
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],
allowedMentionsParse: []
}
}
}
}
}
/**
* @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
*/
async function eventToMessage(event, guild, di) {
let displayName = event.sender
let avatarURL = undefined
const allowedMentionsParse = ["users", "roles"]
/** @type {string[]} */
let messageIDsToEdit = []
let replyLine = ""
// Extract a basic display name from the sender
const match = event.sender.match(/^@(.*?):/)
if (match) displayName = match[1]
// 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 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)
// If the message type is m.emote, the full name is already included at the start of the message, so remove any runoff
if (event.type === "m.room.message" && event.content.msgtype === "m.emote") {
displayNameRunoff = ""
}
let content = event.content.body // ultimate fallback
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
const relatesTo = event.content["m.relates_to"]
if (!event.content["m.new_content"] || !relatesTo || relatesTo.rel_type !== "m.replace") return
// Check if we have a pointer to what was edited
const originalEventId = relatesTo.event_id
if (!originalEventId) return
messageIDsToEdit = select("event_message", "message_id", {event_id: originalEventId}, "ORDER BY part").pluck().all()
if (!messageIDsToEdit.length) return
// Ok, it's an edit.
event.content = event.content["m.new_content"]
// Is it editing a reply? We need special handling if it is.
// Get the original event, then check if it was a reply
const originalEvent = await di.api.getEvent(event.room_id, originalEventId)
if (!originalEvent) return
const repliedToEventId = originalEvent.content["m.relates_to"]?.["m.in_reply_to"]?.event_id
if (!repliedToEventId) return
// After all that, it's an edit of a reply.
// We'll be sneaky and prepare the message data so that the next steps can handle it just like original messages.
Object.assign(event.content, {
"m.relates_to": {
"m.in_reply_to": {
event_id: repliedToEventId
}
}
})
})()
// Handling replies. We'll look up the data of the replied-to event from the Matrix homeserver.
// Note that an <mx-reply> element is not guaranteed because this might be m.new_content.
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
}
// @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")}>`
const row = from("event_message").join("message_channel", "message_id").select("channel_id", "message_id").where({event_id: repliedToEventId}).and("ORDER BY part").get()
if (row) {
replyLine += `https://discord.com/channels/${guild.id}/${row.channel_id}/${row.message_id} `
}
const sender = repliedToEvent.sender
const authorID = getUserOrProxyOwnerID(sender)
if (authorID) {
replyLine += `<@${authorID}>`
} else {
let senderName = select("member_cache", "displayname", {mxid: sender}).pluck().get()
if (!senderName) {
const match = sender.match(/@([^:]*)/)
assert(match)
senderName = match[1]
}
replyLine += `**Ⓜ${senderName}**`
}
// If the event has been edited, the homeserver will include the relation in `unsigned`.
if (repliedToEvent.unsigned?.["m.relations"]?.["m.replace"]?.content?.["m.new_content"]) {
repliedToEvent = repliedToEvent.unsigned["m.relations"]["m.replace"] // Note: this changes which event_id is in repliedToEvent.
repliedToEvent.content = repliedToEvent.content["m.new_content"]
}
let contentPreview
const fileReplyContentAlternative = attachmentEmojis.get(repliedToEvent.content.msgtype)
if (fileReplyContentAlternative) {
contentPreview = " " + fileReplyContentAlternative
} else if (repliedToEvent.unsigned?.redacted_because) {
contentPreview = " (in reply to a deleted message)"
} else {
// 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 = repliedToContent.replace(/\bhttps?:\/\/[^ )]*/g, "<$&>")
repliedToContent = entities.decodeHTML5Strict(repliedToContent) // Remove entities like &amp; &quot;
const contentPreviewChunks = chunk(repliedToContent, 50)
if (contentPreviewChunks.length) {
contentPreview = ": " + 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 = ""
}
}
replyLine = `-# > ${replyLine}${contentPreview}\n`
})()
if (event.content.format === "org.matrix.custom.html" && event.content.formatted_body) {
let input = event.content.formatted_body
if (event.content.msgtype === "m.emote") {
input = `* ${displayName} ${input}`
}
// 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
}
})
// 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")
// 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>")
// The matrix spec hasn't decided whether \n counts as a newline or not, but I'm going to count it, because if it's in the data it's there for a reason.
// But I should not count it if it's between block elements.
input = input.replace(/(<\/?([^ >]+)[^>]*>)?\n(<\/?([^ >]+)[^>]*>)?/g, (whole, beforeContext, beforeTag, afterContext, afterTag) => {
// console.error(beforeContext, beforeTag, afterContext, afterTag)
if (typeof beforeTag !== "string" && typeof afterTag !== "string") {
return "<br>"
}
beforeContext = beforeContext || ""
beforeTag = beforeTag || ""
afterContext = afterContext || ""
afterTag = afterTag || ""
if (!mxUtils.BLOCK_ELEMENTS.includes(beforeTag.toUpperCase()) && !mxUtils.BLOCK_ELEMENTS.includes(afterTag.toUpperCase())) {
return beforeContext + "<br>" + afterContext
} else {
return whole
}
})
// Note: Element's renderers on Web and Android currently collapse whitespace, like the browser does. Turndown also collapses whitespace which is good for me.
// If later I'm using a client that doesn't collapse whitespace and I want turndown to follow suit, uncomment the following line of code, and it Just Works:
// 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, event.sender, event.room_id, guild, di)
if (result) {
node.nodeValue = result.content
ensureJoined.push(...result.ensureJoined)
allowedMentionsParse.push(...result.allowedMentionsParse)
}
}
// 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+)/)?.[1] || "txt"
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 = []
let match
let last = input.length
while ((match = input.slice(0, last).match(/<img [^>]*>\s*$/))) {
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")
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\/[^<>\n )]*/g, "<$&>")
// 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)
} else {
// Looks like we're using the plaintext body!
content = event.content.body
if (event.content.msgtype === "m.emote") {
content = `* ${displayName} ${content}`
}
content = await handleRoomOrMessageLinks(content, di) // Replace matrix.to links with discord.com equivalents where possible
content = content.replace(/\bhttps?:\/\/matrix\.to\/[^<>\n )]*/, "<$&>") // Put < > around any surviving matrix.to links to hide the URL previews
const result = await checkWrittenMentions(content, event.sender, event.room_id, guild, di)
if (result) {
content = result.content
ensureJoined.push(...result.ensureJoined)
allowedMentionsParse.push(...result.allowedMentionsParse)
}
// 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
if ("url" in event.content) {
// Unencrypted
const url = mxUtils.getPublicUrlForMxc(event.content.url)
assert(url)
attachments.push({id: "0", description, filename})
pendingFiles.push({name: filename, url})
} else {
// Encrypted
const url = mxUtils.getPublicUrlForMxc(event.content.file.url)
assert(url)
assert.equal(event.content.file.key.alg, "A256CTR")
attachments.push({id: "0", description, filename})
pendingFiles.push({name: filename, url, key: event.content.file.key.k, iv: event.content.file.iv})
}
} else if (event.type === "m.sticker") {
content = ""
const url = mxUtils.getPublicUrlForMxc(event.content.url)
assert(url)
let filename = event.content.body
if (event.type === "m.sticker") {
let mimetype
if (event.content.info?.mimetype?.includes("/")) {
mimetype = event.content.info.mimetype
} else {
const res = await di.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`)
}
filename += "." + mimetype.split("/")[1]
}
attachments.push({id: "0", filename})
pendingFiles.push({name: filename, url})
}
content = displayNameRunoff + replyLine + content
// Split into 2000 character chunks
const chunks = chunk(content, 2000)
/** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]})[]} */
const messages = chunks.map(content => ({
content,
allowed_mentions: {
parse: allowedMentionsParse
},
username: displayNameShortened,
avatar_url: avatarURL
}))
if (attachments.length) {
// If content is empty (should be the case when uploading a file) then chunk-text will create 0 messages.
// There needs to be a message to add attachments to.
if (!messages.length) messages.push({
content,
username: displayNameShortened,
avatar_url: avatarURL
})
messages[0].attachments = attachments
// @ts-ignore these will be converted to real files when the message is about to be sent
messages[0].pendingFiles = pendingFiles
}
const messagesToEdit = []
const messagesToSend = []
for (let i = 0; i < messages.length; i++) {
const next = messageIDsToEdit[0]
if (next) {
messagesToEdit.push({id: next, message: messages[i]})
messageIDsToEdit.shift()
} else {
messagesToSend.push(messages[i])
}
}
// Ensure there is code coverage for adding, editing, and deleting
if (messagesToSend.length) void 0
if (messagesToEdit.length) void 0
if (messageIDsToEdit.length) void 0
return {
messagesToEdit,
messagesToSend,
messagesToDelete: messageIDsToEdit,
ensureJoined
}
}
module.exports.eventToMessage = eventToMessage

File diff suppressed because it is too large Load diff

243
src/m2d/converters/utils.js Normal file
View file

@ -0,0 +1,243 @@
// @ts-check
const assert = require("assert").strict
const passthrough = require("../../passthrough")
const {db} = passthrough
const {reg} = require("../../matrix/read-registration")
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
let hasher = null
// @ts-ignore
require("xxhash-wasm")().then(h => hasher = h)
const BLOCK_ELEMENTS = [
"ADDRESS", "ARTICLE", "ASIDE", "AUDIO", "BLOCKQUOTE", "BODY", "CANVAS",
"CENTER", "DD", "DETAILS", "DIR", "DIV", "DL", "DT", "FIELDSET", "FIGCAPTION", "FIGURE",
"FOOTER", "FORM", "FRAMESET", "H1", "H2", "H3", "H4", "H5", "H6", "HEADER",
"HGROUP", "HR", "HTML", "ISINDEX", "LI", "MAIN", "MENU", "NAV", "NOFRAMES",
"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.
* Such messages shouldn't be bridged again.
* @param {string} sender
*/
function eventSenderIsFromDiscord(sender) {
// If it's from a user in the bridge's namespace, then it originated from discord
// This includes messages sent by the appservice's bot user, because that is what's used for webhooks
// TODO: It would be nice if bridge system messages wouldn't trigger this check and could be bridged from matrix to discord, while webhook reflections would remain ignored...
// TODO that only applies to the above todo: But you'd have to watch out for the /icon command, where the bridge bot would set the room avatar, and that shouldn't be reflected into the room a second time.
if (userRegex.some(x => sender.match(x))) {
return true
}
return false
}
/**
* Event IDs are really big and have more entropy than we need.
* If we want to store the event ID in the database, we can store a more compact version by hashing it with this.
* I choose a 64-bit non-cryptographic hash as only a 32-bit hash will see birthday collisions unreasonably frequently: https://en.wikipedia.org/wiki/Birthday_attack#Mathematics
* xxhash outputs an unsigned 64-bit integer.
* Converting to a signed 64-bit integer with no bit loss so that it can be stored in an SQLite integer field as-is: https://www.sqlite.org/fileformat2.html#record_format
* This should give very efficient storage with sufficient entropy.
* @param {string} eventID
*/
function getEventIDHash(eventID) {
assert(hasher, "xxhash is not ready yet")
if (eventID[0] === "$" && eventID.length >= 13) {
eventID = eventID.slice(1) // increase entropy per character to potentially help xxhash
}
const unsignedHash = hasher.h64(eventID)
const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
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
}
/**
* Since the introduction of authenticated media, this can no longer just be the /_matrix/media/r0/download URL
* because Discord and Discord users cannot use those URLs. Media now has to be proxied through the bridge.
* To avoid the bridge acting as a proxy for *any* media, there is a list of permitted media stored in the database.
* (The other approach would be signing the URLs with a MAC (or similar) and adding the signature, but I'm not a
* cryptographer, so I don't want to.) To reduce database disk space usage, instead of storing each permitted URL,
* we just store its xxhash as a signed (as in +/-, not signature) 64-bit integer, which fits in an SQLite integer field.
* @see https://matrix.org/blog/2024/06/26/sunsetting-unauthenticated-media/ background
* @see https://matrix.org/blog/2024/06/20/matrix-v1.11-release/ implementation details
* @see https://www.sqlite.org/fileformat2.html#record_format SQLite integer field size
* @param {string} mxc
* @returns {string?}
*/
function getPublicUrlForMxc(mxc) {
assert(hasher, "xxhash is not ready yet")
const avatarURLParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
if (!avatarURLParts) return null
const serverAndMediaID = `${avatarURLParts[1]}/${avatarURLParts[2]}`
const unsignedHash = hasher.h64(serverAndMediaID)
const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
db.prepare("INSERT OR IGNORE INTO media_proxy (permitted_hash) VALUES (?)").run(signedHash)
return `${reg.ooye.bridge_origin}/download/matrix/${serverAndMediaID}`
}
module.exports.BLOCK_ELEMENTS = BLOCK_ELEMENTS
module.exports.eventSenderIsFromDiscord = eventSenderIsFromDiscord
module.exports.getPublicUrlForMxc = getPublicUrlForMxc
module.exports.getEventIDHash = getEventIDHash
module.exports.MatrixStringBuilder = MatrixStringBuilder
module.exports.getViaServers = getViaServers
module.exports.getViaServersQuery = getViaServersQuery

View file

@ -0,0 +1,178 @@
// @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}
}
test("sender type: matrix user", t => {
t.notOk(eventSenderIsFromDiscord("@cadence:cadence.moe"))
})
test("sender type: ooye bot", t => {
t.ok(eventSenderIsFromDiscord("@_ooye_bot:cadence.moe"))
})
test("sender type: ooye puppet", t => {
t.ok(eventSenderIsFromDiscord("@_ooye_sheep:cadence.moe"))
})
test("event hash: hash is the same each time", t => {
const eventID = "$example"
t.equal(getEventIDHash(eventID), getEventIDHash(eventID))
})
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"])
})

194
src/m2d/event-dispatcher.js Normal file
View file

@ -0,0 +1,194 @@
// @ts-check
/*
* Grab Matrix events we care about, check them, and bridge them.
*/
const util = require("util")
const Ty = require("../types")
const {discord, db, sync, as, select} = require("../passthrough")
/** @type {import("./actions/send-event")} */
const sendEvent = sync.require("./actions/send-event")
/** @type {import("./actions/add-reaction")} */
const addReaction = sync.require("./actions/add-reaction")
/** @type {import("./actions/redact")} */
const redact = sync.require("./actions/redact")
/** @type {import("../matrix/matrix-command-handler")} */
const matrixCommandHandler = sync.require("../matrix/matrix-command-handler")
/** @type {import("./converters/utils")} */
const utils = sync.require("./converters/utils")
/** @type {import("../matrix/api")}) */
const api = sync.require("../matrix/api")
const {reg} = require("../matrix/read-registration")
let lastReportedEvent = 0
function guard(type, fn) {
return async function(event, ...args) {
try {
return await fn(event, ...args)
} catch (e) {
console.error("hit event-dispatcher's error handler with this exception:")
console.error(e) // TODO: also log errors into a file or into the database, maybe use a library for this? or just wing it?
console.error(`while handling this ${type} gateway event:`)
console.dir(event, {depth: null})
if (Date.now() - lastReportedEvent < 5000) return
lastReportedEvent = Date.now()
let stackLines = e.stack.split("\n")
api.sendEvent(event.room_id, "m.room.message", {
msgtype: "m.text",
body: "\u26a0 Matrix event not delivered to Discord. See formatted content for full details.",
format: "org.matrix.custom.html",
formatted_body: "\u26a0 <strong>Matrix event not delivered to Discord</strong>"
+ `<br>Event type: ${type}`
+ `<br>${e.toString()}`
+ `<br><details><summary>Error trace</summary>`
+ `<pre>${stackLines.join("\n")}</pre></details>`
+ `<details><summary>Original payload</summary>`
+ `<pre>${util.inspect(event, false, 4, false)}</pre></details>`,
"moe.cadence.ooye.error": {
source: "matrix",
payload: event
},
"m.mentions": {
user_ids: ["@cadence:cadence.moe"]
}
})
}
}
}
/**
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>} reactionEvent
*/
async function onRetryReactionAdd(reactionEvent) {
const roomID = reactionEvent.room_id
const event = await api.getEvent(roomID, reactionEvent.content["m.relates_to"]?.event_id)
// Check that it's a real error from OOYE
const error = event.content["moe.cadence.ooye.error"]
if (event.sender !== `@${reg.sender_localpart}:${reg.ooye.server_name}` || !error) return
// To stop people injecting misleading messages, the reaction needs to come from either the original sender or a room moderator
if (reactionEvent.sender !== event.sender) {
// Check if it's a room moderator
const powerLevelsStateContent = await api.getStateEvent(roomID, "m.room.power_levels", "")
const powerLevel = powerLevelsStateContent.users?.[reactionEvent.sender] || 0
if (powerLevel < 50) return
}
// Retry
if (error.source === "matrix") {
as.emit(`type:${error.payload.type}`, error.payload)
} else if (error.source === "discord") {
discord.cloud.emit("event", error.payload)
}
// Redact the error to stop people from executing multiple retries
api.redactEvent(roomID, event.event_id)
}
sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message",
/**
* @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} event it is a m.room.message because that's what this listener is filtering for
*/
async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return
const messageResponses = await sendEvent.sendEvent(event)
if (event.type === "m.room.message" && event.content.msgtype === "m.text") {
// @ts-ignore
await matrixCommandHandler.execute(event)
}
}))
sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker",
/**
* @param {Ty.Event.Outer_M_Sticker} event it is a m.sticker because that's what this listener is filtering for
*/
async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return
const messageResponses = await sendEvent.sendEvent(event)
}))
sync.addTemporaryListener(as, "type:m.reaction", guard("m.reaction",
/**
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>} event it is a m.reaction because that's what this listener is filtering for
*/
async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return
if (event.content["m.relates_to"].key === "🔁") {
// Try to bridge a failed event again?
await onRetryReactionAdd(event)
} else {
matrixCommandHandler.onReactionAdd(event)
await addReaction.addReaction(event)
}
}))
sync.addTemporaryListener(as, "type:m.room.redaction", guard("m.room.redaction",
/**
* @param {Ty.Event.Outer_M_Room_Redaction} event it is a m.room.redaction because that's what this listener is filtering for
*/
async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return
await redact.handle(event)
}))
sync.addTemporaryListener(as, "type:m.room.avatar", guard("m.room.avatar",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Avatar>} event
*/
async event => {
if (event.state_key !== "") return
if (utils.eventSenderIsFromDiscord(event.sender)) return
const url = event.content.url || null
db.prepare("UPDATE channel_room SET custom_avatar = ? WHERE room_id = ?").run(url, event.room_id)
}))
sync.addTemporaryListener(as, "type:m.room.name", guard("m.room.name",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Name>} event
*/
async event => {
if (event.state_key !== "") return
if (utils.eventSenderIsFromDiscord(event.sender)) return
const name = event.content.name || null
db.prepare("UPDATE channel_room SET nick = ? WHERE room_id = ?").run(name, event.room_id)
}))
sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Member>} event
*/
async event => {
if (event.state_key[0] !== "@") return
if (utils.eventSenderIsFromDiscord(event.state_key)) return
if (event.content.membership === "leave" || event.content.membership === "ban") {
// Member is gone
db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key)
} else {
// Member is here
db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?")
.run(
event.room_id, event.state_key,
event.content.displayname || null, event.content.avatar_url || null,
event.content.displayname || null, event.content.avatar_url || null
)
}
}))
sync.addTemporaryListener(as, "type:m.room.power_levels", guard("m.room.power_levels",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Power_Levels>} event
*/
async event => {
if (event.state_key !== "") return
const existingPower = select("member_cache", "mxid", {room_id: event.room_id}).pluck().all()
const newPower = event.content.users || {}
for (const mxid of existingPower) {
db.prepare("UPDATE member_cache SET power_level = ? WHERE room_id = ? AND mxid = ?").run(newPower[mxid] || 0, event.room_id, mxid)
}
}))

338
src/matrix/api.js Normal file
View file

@ -0,0 +1,338 @@
// @ts-check
const Ty = require("../types")
const assert = require("assert").strict
const passthrough = require("../passthrough")
const { discord, sync, db } = passthrough
/** @type {import("./mreq")} */
const mreq = sync.require("./mreq")
/** @type {import("./file")} */
const file = sync.require("./file")
/** @type {import("./txnid")} */
const makeTxnId = sync.require("./txnid")
const {reg} = require("./read-registration.js")
/**
* @param {string} p endpoint to access
* @param {string?} [mxid] optional: user to act as, for the ?user_id parameter
* @param {{[x: string]: any}} [otherParams] optional: any other query parameters to add
* @returns {string} the new endpoint
*/
function path(p, mxid, otherParams = {}) {
const u = new URL(p, "http://localhost")
if (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
}
/**
* @param {string} username
* @returns {Promise<Ty.R.Registered>}
*/
function register(username) {
console.log(`[api] register: ${username}`)
return mreq.mreq("POST", "/client/v3/register", {
type: "m.login.application_service",
username
})
}
/**
* @returns {Promise<string>} room ID
*/
async function createRoom(content) {
console.log(`[api] create room:`, content)
/** @type {Ty.R.RoomCreated} */
const root = await mreq.mreq("POST", "/client/v3/createRoom", content)
return root.room_id
}
/**
* @returns {Promise<string>} room ID
*/
async function joinRoom(roomIDOrAlias, mxid) {
/** @type {Ty.R.RoomJoined} */
const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid))
return root.room_id
}
async function inviteToRoom(roomID, mxidToInvite, mxid) {
await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/invite`, mxid), {
user_id: mxidToInvite
})
}
async function leaveRoom(roomID, mxid) {
console.log(`[api] leave: ${roomID}: ${mxid}`)
await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, mxid), {})
}
/**
* @param {string} roomID
* @param {string} eventID
* @template T
*/
async function getEvent(roomID, eventID) {
/** @type {Ty.Event.Outer<T>} */
const root = await mreq.mreq("GET", `/client/v3/rooms/${roomID}/event/${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[]>}
*/
function getAllState(roomID) {
return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state`)
}
/**
* @param {string} roomID
* @param {string} type
* @param {string} key
* @returns the *content* of the state event
*/
function getStateEvent(roomID, type, key) {
return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state/${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?}}}>}
*/
function getJoinedMembers(roomID) {
return mreq.mreq("GET", `/client/v3/rooms/${roomID}/joined_members`)
}
/**
* @param {string} roomID
* @param {{from?: string, limit?: any}} pagination
* @returns {Promise<Ty.HierarchyPagination<Ty.R.Hierarchy>>}
*/
function getHierarchy(roomID, pagination) {
let path = `/client/v1/rooms/${roomID}/hierarchy`
if (!pagination.from) delete pagination.from
if (!pagination.limit) pagination.limit = 50
path += `?${new URLSearchParams(pagination)}`
return mreq.mreq("GET", path)
}
/**
* Like `getHierarchy` but collects all pages for you.
* @param {string} roomID
*/
async function getFullHierarchy(roomID) {
/** @type {Ty.R.Hierarchy[]} */
let rooms = []
/** @type {string | undefined} */
let nextBatch = undefined
do {
/** @type {Ty.HierarchyPagination<Ty.R.Hierarchy>} */
const res = await getHierarchy(roomID, {from: nextBatch})
rooms.push(...res.rooms)
nextBatch = res.next_batch
} while (nextBatch)
return rooms
}
/**
* @param {string} roomID
* @param {string} eventID
* @param {{from?: string, limit?: any}} pagination
* @param {string?} [relType]
* @returns {Promise<Ty.Pagination<Ty.Event.Outer<any>>>}
*/
function getRelations(roomID, eventID, pagination, relType) {
let path = `/client/v1/rooms/${roomID}/relations/${eventID}`
if (relType) path += `/${relType}`
if (!pagination.from) delete pagination.from
if (!pagination.limit) pagination.limit = 50 // get a little more consistency between homeservers
path += `?${new URLSearchParams(pagination)}`
return mreq.mreq("GET", path)
}
/**
* Like `getRelations` but collects and filters all pages for you.
* @param {string} roomID
* @param {string} eventID
* @param {string?} [relType] type of relations to filter, e.g. "m.annotation" for reactions
*/
async function getFullRelations(roomID, eventID, relType) {
/** @type {Ty.Event.Outer<Ty.Event.M_Reaction>[]} */
let reactions = []
/** @type {string | undefined} */
let nextBatch = undefined
do {
/** @type {Ty.Pagination<Ty.Event.Outer<Ty.Event.M_Reaction>>} */
const res = await getRelations(roomID, eventID, {from: nextBatch}, relType)
reactions = reactions.concat(res.chunk)
nextBatch = res.next_batch
} while (nextBatch)
return reactions
}
/**
* @param {string} roomID
* @param {string} type
* @param {string} stateKey
* @param {string} [mxid]
* @returns {Promise<string>} event ID
*/
async function sendState(roomID, type, stateKey, content, mxid) {
console.log(`[api] state: ${roomID}: ${type}/${stateKey}`)
assert.ok(type)
assert.ok(typeof stateKey === "string")
/** @type {Ty.R.EventSent} */
// encodeURIComponent is necessary because state key can contain some special characters like / but you must encode them so they fit in a single component of the URI
const root = await mreq.mreq("PUT", path(`/client/v3/rooms/${roomID}/state/${type}/${encodeURIComponent(stateKey)}`, mxid), content)
return root.event_id
}
/**
* @param {string} roomID
* @param {string} type
* @param {any} content
* @param {string?} [mxid]
* @param {number} [timestamp] timestamp of the newly created event, in unix milliseconds
*/
async function sendEvent(roomID, type, content, mxid, timestamp) {
if (!["m.room.message", "m.reaction", "m.sticker"].includes(type)) {
console.log(`[api] event ${type} to ${roomID} as ${mxid || "default sim"}`)
}
/** @type {Ty.R.EventSent} */
const root = await mreq.mreq("PUT", path(`/client/v3/rooms/${roomID}/send/${type}/${makeTxnId.makeTxnId()}`, mxid, {ts: timestamp}), content)
return root.event_id
}
/**
* @param {string} roomID
* @param {string} eventID
* @param {string?} [mxid]
* @returns {Promise<string>} event ID
*/
async function redactEvent(roomID, eventID, mxid) {
/** @type {Ty.R.EventRedacted} */
const root = await mreq.mreq("PUT", path(`/client/v3/rooms/${roomID}/redact/${eventID}/${makeTxnId.makeTxnId()}`, mxid), {})
return root.event_id
}
/**
* @param {string} roomID
* @param {boolean} isTyping
* @param {string} mxid
* @param {number} [duration] milliseconds
*/
async function sendTyping(roomID, isTyping, mxid, duration) {
await mreq.mreq("PUT", path(`/client/v3/rooms/${roomID}/typing/${mxid}`, mxid), {
typing: isTyping,
timeout: duration
})
}
async function profileSetDisplayname(mxid, displayname) {
await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/displayname`, mxid), {
displayname
})
}
async function profileSetAvatarUrl(mxid, avatar_url) {
await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/avatar_url`, mxid), {
avatar_url
})
}
/**
* Set a user's power level within a room.
* @param {string} roomID
* @param {string} mxid
* @param {number} power
*/
async function setUserPower(roomID, mxid, power) {
assert(roomID[0] === "!")
assert(mxid[0] === "@")
// Yes there's no shortcut https://github.com/matrix-org/matrix-appservice-bridge/blob/2334b0bae28a285a767fe7244dad59f5a5963037/src/components/intent.ts#L352
const powerLevels = await getStateEvent(roomID, "m.room.power_levels", "")
powerLevels.users = powerLevels.users || {}
if (power != null) {
powerLevels.users[mxid] = power
} else {
delete powerLevels.users[mxid]
}
await sendState(roomID, "m.room.power_levels", "", powerLevels)
return powerLevels
}
/**
* Set a user's power level for a whole room hierarchy.
* @param {string} roomID
* @param {string} mxid
* @param {number} power
*/
async function setUserPowerCascade(roomID, mxid, power) {
assert(roomID[0] === "!")
assert(mxid[0] === "@")
const rooms = await getFullHierarchy(roomID)
for (const room of rooms) {
await setUserPower(room.room_id, mxid, power)
}
}
async function ping() {
const res = await fetch(`${mreq.baseUrl}/client/v1/appservice/${reg.id}/ping`, {
method: "POST",
headers: {
Authorization: `Bearer ${reg.as_token}`
},
body: "{}"
})
const root = await res.json()
return {
ok: res.ok,
status: res.status,
root
}
}
module.exports.path = path
module.exports.register = register
module.exports.createRoom = createRoom
module.exports.joinRoom = joinRoom
module.exports.inviteToRoom = inviteToRoom
module.exports.leaveRoom = leaveRoom
module.exports.getEvent = getEvent
module.exports.getEventForTimestamp = getEventForTimestamp
module.exports.getAllState = getAllState
module.exports.getStateEvent = getStateEvent
module.exports.getJoinedMembers = getJoinedMembers
module.exports.getHierarchy = getHierarchy
module.exports.getFullHierarchy = getFullHierarchy
module.exports.getRelations = getRelations
module.exports.getFullRelations = getFullRelations
module.exports.sendState = sendState
module.exports.sendEvent = sendEvent
module.exports.redactEvent = redactEvent
module.exports.sendTyping = sendTyping
module.exports.profileSetDisplayname = profileSetDisplayname
module.exports.profileSetAvatarUrl = profileSetAvatarUrl
module.exports.setUserPower = setUserPower
module.exports.setUserPowerCascade = setUserPowerCascade
module.exports.ping = ping

26
src/matrix/api.test.js Normal file
View file

@ -0,0 +1,26 @@
const {test} = require("supertape")
const {path} = require("./api")
test("api path: no change for plain path", t => {
t.equal(path("/hello/world"), "/hello/world")
})
test("api path: add mxid to the URL", t => {
t.equal(path("/hello/world", "12345"), "/hello/world?user_id=12345")
})
test("api path: empty path with mxid", t => {
t.equal(path("", "12345"), "/?user_id=12345")
})
test("api path: existing query parameters with mxid", t => {
t.equal(path("/hello/world?foo=bar&baz=qux", "12345"), "/hello/world?foo=bar&baz=qux&user_id=12345")
})
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")
})

8
src/matrix/appservice.js Normal file
View file

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

119
src/matrix/file.js Normal file
View file

@ -0,0 +1,119 @@
// @ts-check
const fetch = require("node-fetch").default
const passthrough = require("../passthrough")
const {sync, db, select} = passthrough
/** @type {import("./mreq")} */
const mreq = sync.require("./mreq")
const DISCORD_IMAGES_BASE = "https://cdn.discordapp.com"
const IMAGE_SIZE = 1024
/** @type {Map<string, Promise<string>>} */
const inflight = new Map()
/**
* @param {string} url
*/
function _removeExpiryParams(url) {
return url.replace(/\?(?:(?:ex|is|sg|hm)=[a-f0-9]+&?)*$/, "")
}
/**
* @param {string} path or full URL if it's not a Discord CDN file
*/
async function uploadDiscordFileToMxc(path) {
let url
if (path.startsWith("http")) {
url = path
} else {
url = DISCORD_IMAGES_BASE + path
}
// Discord attachment content is always the same no matter what their ?ex parameter is.
const urlNoExpiry = _removeExpiryParams(url)
// Are we uploading this file RIGHT NOW? Return the same inflight promise with the same resolution
const existingInflight = inflight.get(urlNoExpiry)
if (existingInflight) {
return existingInflight
}
// Has this file already been uploaded in the past? Grab the existing copy from the database.
const existingFromDb = select("file", "mxc_url", {discord_url: urlNoExpiry}).pluck().get()
if (typeof existingFromDb === "string") {
return existingFromDb
}
// Download from Discord
const promise = fetch(url, {}).then(/** @param {import("node-fetch").Response} res */ async res => {
// Upload to Matrix
const root = await module.exports._actuallyUploadDiscordFileToMxc(urlNoExpiry, res)
// Store relationship in database
db.prepare("INSERT INTO file (discord_url, mxc_url) VALUES (?, ?)").run(urlNoExpiry, root.content_uri)
inflight.delete(urlNoExpiry)
return root.content_uri
})
inflight.set(urlNoExpiry, promise)
return promise
}
async function _actuallyUploadDiscordFileToMxc(url, res) {
const body = res.body
/** @type {import("../types").R.FileUploaded} */
const root = await mreq.mreq("POST", "/media/v3/upload", body, {
headers: {
"Content-Type": res.headers.get("content-type")
}
})
return root
}
function guildIcon(guild) {
return `/icons/${guild.id}/${guild.icon}.png?size=${IMAGE_SIZE}`
}
function userAvatar(user) {
return `/avatars/${user.id}/${user.avatar}.png?size=${IMAGE_SIZE}`
}
function memberAvatar(guildID, user, member) {
if (!member.avatar) return userAvatar(user)
return `/guilds/${guildID}/users/${user.id}/avatars/${member.avatar}.png?size=${IMAGE_SIZE}`
}
function emoji(emojiID, animated) {
const base = `/emojis/${emojiID}`
if (animated) return base + ".gif"
else return base + ".png"
}
const stickerFormat = new Map([
[1, {label: "PNG", ext: "png", mime: "image/png"}],
[2, {label: "APNG", ext: "png", mime: "image/apng"}],
[3, {label: "LOTTIE", ext: "json", mime: "lottie"}],
[4, {label: "GIF", ext: "gif", mime: "image/gif"}]
])
/** @param {{id: string, format_type: number}} sticker */
function sticker(sticker) {
const format = stickerFormat.get(sticker.format_type)
if (!format) throw new Error(`No such format ${sticker.format_type} for sticker ${JSON.stringify(sticker)}`)
const ext = format.ext
return `/stickers/${sticker.id}.${ext}`
}
module.exports.DISCORD_IMAGES_BASE = DISCORD_IMAGES_BASE
module.exports.guildIcon = guildIcon
module.exports.userAvatar = userAvatar
module.exports.memberAvatar = memberAvatar
module.exports.emoji = emoji
module.exports.stickerFormat = stickerFormat
module.exports.sticker = sticker
module.exports.uploadDiscordFileToMxc = uploadDiscordFileToMxc
module.exports._actuallyUploadDiscordFileToMxc = _actuallyUploadDiscordFileToMxc
module.exports._removeExpiryParams = _removeExpiryParams

22
src/matrix/file.test.js Normal file
View file

@ -0,0 +1,22 @@
// @ts-check
const {test} = require("supertape")
const file = require("./file")
test("removeExpiryParams: url without params is unchanged", t => {
const url = "https://cdn.discordapp.com/attachments/1154455830591176734/1157034603496882267/59ce542f-bf66-4d9a-83b7-ad6d05a69bac.jpg"
const result = file._removeExpiryParams(url)
t.equal(result, url)
})
test("removeExpiryParams: params are removed", t => {
const url = "https://cdn.discordapp.com/attachments/112760669178241024/1157363960518029322/image.png?ex=651856ae&is=6517052e&hm=88353defb15cbd833e6977817e8f72f4ff28f4edfd26b8ad5f267a4f2b946e69&"
const result = file._removeExpiryParams(url)
t.equal(result, "https://cdn.discordapp.com/attachments/112760669178241024/1157363960518029322/image.png")
})
test("removeExpiryParams: rearranged params are removed", t => {
const url = "https://cdn.discordapp.com/attachments/112760669178241024/1157363960518029322/image.png?hm=88353defb15cbd833e6977817e8f72f4ff28f4edfd26b8ad5f267a4f2b946e69&ex=651856ae&is=6517052e"
const result = file._removeExpiryParams(url)
t.equal(result, "https://cdn.discordapp.com/attachments/112760669178241024/1157363960518029322/image.png")
})

109
src/matrix/kstate.js Normal file
View file

@ -0,0 +1,109 @@
// @ts-check
const assert = require("assert").strict
const mixin = require("@cloudrac3r/mixin-deep")
const {isDeepStrictEqual} = require("util")
const passthrough = require("../passthrough")
const {sync} = passthrough
/** @type {import("./file")} */
const file = sync.require("./file")
/** Mutates the input. Not recursive - can only include or exclude entire state events. */
function kstateStripConditionals(kstate) {
for (const [k, content] of Object.entries(kstate)) {
// conditional for whether a key is even part of the kstate (doing this declaratively on json is hard, so represent it as a property instead.)
if ("$if" in content) {
if (content.$if) delete content.$if
else delete kstate[k]
}
}
return kstate
}
/** Mutates the input. Works recursively through object tree. */
async function kstateUploadMxc(obj) {
const promises = []
function inner(obj) {
for (const [k, v] of Object.entries(obj)) {
if (v == null || typeof v !== "object") continue
if (v.$url) {
promises.push(
file.uploadDiscordFileToMxc(v.$url)
.then(mxc => obj[k] = mxc)
)
}
inner(v)
}
}
inner(obj)
await Promise.all(promises)
return obj
}
/** Automatically strips conditionals and uploads URLs to mxc. */
async function kstateToState(kstate) {
const events = []
kstateStripConditionals(kstate)
await kstateUploadMxc(kstate)
for (const [k, content] of Object.entries(kstate)) {
const slashIndex = k.indexOf("/")
assert(slashIndex > 0)
const type = k.slice(0, slashIndex)
const state_key = k.slice(slashIndex + 1)
events.push({type, state_key, content})
}
return events
}
/**
* @param {import("../types").Event.BaseStateEvent[]} events
* @returns {any}
*/
function stateToKState(events) {
const kstate = {}
for (const event of events) {
kstate[event.type + "/" + event.state_key] = event.content
}
return kstate
}
function diffKState(actual, target) {
const diff = {}
// go through each key that it should have
for (const key of Object.keys(target)) {
if (!key.includes("/")) throw new Error(`target kstate's key "${key}" does not contain a slash separator; if a blank state_key was intended, add a trailing slash to the kstate key.\ncontext: ${JSON.stringify(target)}`)
if (key === "m.room.power_levels/") {
// 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 (!isDeepStrictEqual(actual[key], temp)) {
// they differ. use the newly prepared object as the diff.
diff[key] = temp
}
} else if (key in actual) {
// diff
if (!isDeepStrictEqual(actual[key], target[key])) {
// they differ. use the target as the diff.
diff[key] = target[key]
}
} else {
// not present, needs to be added
diff[key] = target[key]
}
// keys that are missing in "actual" will not be deleted on "target" (no action)
}
return diff
}
module.exports.kstateStripConditionals = kstateStripConditionals
module.exports.kstateUploadMxc = kstateUploadMxc
module.exports.kstateToState = kstateToState
module.exports.stateToKState = stateToKState
module.exports.diffKState = diffKState

236
src/matrix/kstate.test.js Normal file
View file

@ -0,0 +1,236 @@
const assert = require("assert")
const {kstateToState, stateToKState, diffKState, kstateStripConditionals, kstateUploadMxc} = require("./kstate")
const {test} = require("supertape")
test("kstate strip: strips false conditions", t => {
t.deepEqual(kstateStripConditionals({
a: {$if: false, value: 2},
b: {value: 4}
}), {
b: {value: 4}
})
})
test("kstate strip: keeps true conditions while removing $if", t => {
t.deepEqual(kstateStripConditionals({
a: {$if: true, value: 2},
b: {value: 4}
}), {
a: {value: 2},
b: {value: 4}
})
})
test("kstateUploadMxc: sets the mxc", async t => {
const input = {
"m.room.avatar/": {
url: {$url: "https://cdn.discordapp.com/guilds/112760669178241024/users/134826546694193153/avatars/38dd359aa12bcd52dd3164126c587f8c.png?size=1024"},
test1: {
test2: {
test3: {$url: "https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg"}
}
}
}
}
await kstateUploadMxc(input)
t.deepEqual(input, {
"m.room.avatar/": {
url: "mxc://cadence.moe/rfemHmAtcprjLEiPiEuzPhpl",
test1: {
test2: {
test3: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR"
}
}
}
})
})
test("kstateUploadMxc and strip: work together", async t => {
const input = {
"m.room.avatar/yes": {
$if: true,
url: {$url: "https://cdn.discordapp.com/guilds/112760669178241024/users/134826546694193153/avatars/38dd359aa12bcd52dd3164126c587f8c.png?size=1024"}
},
"m.room.avatar/no": {
$if: false,
url: {$url: "https://cdn.discordapp.com/avatars/320067006521147393/5fc4ad85c1ea876709e9a7d3374a78a1.png?size=1024"}
},
}
kstateStripConditionals(input)
await kstateUploadMxc(input)
t.deepEqual(input, {
"m.room.avatar/yes": {
url: "mxc://cadence.moe/rfemHmAtcprjLEiPiEuzPhpl"
}
})
})
test("kstate2state: general", async t => {
t.deepEqual(await kstateToState({
"m.room.name/": {name: "test name"},
"m.room.member/@cadence:cadence.moe": {membership: "join"},
"uk.half-shot.bridge/org.matrix.appservice-irc://irc/epicord.net/#general": {creator: "@cadence:cadence.moe"}
}), [
{
type: "m.room.name",
state_key: "",
content: {
name: "test name"
}
},
{
type: "m.room.member",
state_key: "@cadence:cadence.moe",
content: {
membership: "join"
}
},
{
type: "uk.half-shot.bridge",
state_key: "org.matrix.appservice-irc://irc/epicord.net/#general",
content: {
creator: "@cadence:cadence.moe"
}
}
])
})
test("state2kstate: general", t => {
t.deepEqual(stateToKState([
{
type: "m.room.name",
state_key: "",
content: {
name: "test name"
}
},
{
type: "m.room.member",
state_key: "@cadence:cadence.moe",
content: {
membership: "join"
}
},
{
type: "uk.half-shot.bridge",
state_key: "org.matrix.appservice-irc://irc/epicord.net/#general",
content: {
creator: "@cadence:cadence.moe"
}
}
]), {
"m.room.name/": {name: "test name"},
"m.room.member/@cadence:cadence.moe": {membership: "join"},
"uk.half-shot.bridge/org.matrix.appservice-irc://irc/epicord.net/#general": {creator: "@cadence:cadence.moe"}
})
})
test("diffKState: detects edits", t => {
t.deepEqual(
diffKState({
"m.room.name/": {name: "test name"},
"same/": {a: 2}
}, {
"m.room.name/": {name: "edited name"},
"same/": {a: 2}
}),
{
"m.room.name/": {name: "edited name"}
}
)
})
test("diffKState: detects new properties", t => {
t.deepEqual(
diffKState({
"m.room.name/": {name: "test name"},
}, {
"m.room.name/": {name: "test name"},
"new/": {a: 2}
}),
{
"new/": {a: 2}
}
)
})
test("diffKState: power levels are mixed together", t => {
const original = {
"m.room.power_levels/": {
"ban": 50,
"events": {
"m.room.name": 100,
"m.room.power_levels": 100
},
"events_default": 0,
"invite": 50,
"kick": 50,
"notifications": {
"room": 20
},
"redact": 50,
"state_default": 50,
"users": {
"@example:localhost": 100
},
"users_default": 0
}
}
const result = diffKState(original, {
"m.room.power_levels/": {
"events": {
"m.room.avatar": 0
}
}
})
t.deepEqual(result, {
"m.room.power_levels/": {
"ban": 50,
"events": {
"m.room.name": 100,
"m.room.power_levels": 100,
"m.room.avatar": 0
},
"events_default": 0,
"invite": 50,
"kick": 50,
"notifications": {
"room": 20
},
"redact": 50,
"state_default": 50,
"users": {
"@example:localhost": 100
},
"users_default": 0
}
})
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

@ -0,0 +1,297 @@
// @ts-check
const assert = require("assert").strict
const Ty = require("../types")
const {pipeline} = require("stream").promises
const sharp = require("sharp")
const {discord, sync, db, select} = require("../passthrough")
/** @type {import("./api")}) */
const api = sync.require("./api")
/** @type {import("../m2d/converters/utils")} */
const mxUtils = sync.require("../m2d/converters/utils")
/** @type {import("../discord/utils")} */
const dUtils = sync.require("../discord/utils")
/** @type {import("./kstate")} */
const ks = sync.require("./kstate")
const {reg} = require("./read-registration")
const PREFIXES = ["//", "/"]
const EMOJI_SIZE = 128
/** This many normal emojis + this many animated emojis. The total number is doubled. */
const TIER_EMOJI_SLOTS = new Map([
[1, 100],
[2, 150],
[3, 250]
])
/** @param {number} tier */
function getSlotCount(tier) {
return TIER_EMOJI_SLOTS.get(tier) || 50
}
let buttons = []
/**
* @param {string} roomID where to add the button
* @param {string} eventID where to add the button
* @param {string} key emoji to add as a button
* @param {string} mxid only listen for responses from this user
* @returns {Promise<import("discord-api-types/v10").GatewayMessageReactionAddDispatchData>}
*/
async function addButton(roomID, eventID, key, mxid) {
await api.sendEvent(roomID, "m.reaction", {
"m.relates_to": {
rel_type: "m.annotation",
event_id: eventID,
key
}
})
return new Promise(resolve => {
buttons.push({roomID, eventID, mxid, key, resolve, created: Date.now()})
})
}
// Clear out old buttons every so often to free memory
setInterval(() => {
const now = Date.now()
buttons = buttons.filter(b => now - b.created < 2*60*60*1000)
}, 10*60*1000)
/** @param {Ty.Event.Outer<Ty.Event.M_Reaction>} event */
function onReactionAdd(event) {
const button = buttons.find(b => b.roomID === event.room_id && b.mxid === event.sender && b.eventID === event.content["m.relates_to"]?.event_id && b.key === event.content["m.relates_to"]?.key)
if (button) {
buttons = buttons.filter(b => b !== button) // remove button data so it can't be clicked again
button.resolve(event)
}
}
/**
* @callback CommandExecute
* @param {Ty.Event.Outer_M_Room_Message} event
* @param {string} realBody
* @param {string[]} words
* @param {any} [ctx]
*/
/**
* @typedef Command
* @property {string[]} aliases
* @property {CommandExecute} execute
*/
/** @param {CommandExecute} execute */
function replyctx(execute) {
/** @type {CommandExecute} */
return function(event, realBody, words, ctx = {}) {
ctx["m.relates_to"] = {
"m.in_reply_to": {
event_id: event.event_id
}
}
return execute(event, realBody, words, ctx)
}
}
/** @type {Command[]} */
const commands = [{
aliases: ["emoji"],
execute: replyctx(
async (event, realBody, words, ctx) => {
// Guard
/** @type {string} */ // @ts-ignore
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
const guildID = discord.channels.get(channelID)?.["guild_id"]
let matrixOnlyReason = null
const matrixOnlyConclusion = "So the emoji will be uploaded on Matrix-side only. It will still be usable over the bridge, but may have degraded functionality."
// Check if we can/should upload to Discord, for various causes
if (!guildID) {
matrixOnlyReason = "NOT_BRIDGED"
} else {
const guild = discord.guilds.get(guildID)
assert(guild)
const slots = getSlotCount(guild.premium_tier)
const permissions = dUtils.getPermissions([], guild.roles)
if (guild.emojis.length >= slots) {
matrixOnlyReason = "CAPACITY"
} else if (!(permissions & 0x40000000n)) { // MANAGE_GUILD_EXPRESSIONS (apparently CREATE_GUILD_EXPRESSIONS isn't good enough...)
matrixOnlyReason = "USER_PERMISSIONS"
}
}
if (matrixOnlyReason) {
// If uploading to Matrix, check if we have permission
const state = await api.getAllState(event.room_id)
const kstate = ks.stateToKState(state)
const powerLevels = kstate["m.room.power_levels/"]
const required = powerLevels.events["im.ponies.room_emotes"] ?? powerLevels.state_default ?? 50
const have = powerLevels.users[`@${reg.sender_localpart}:${reg.ooye.server_name}`] ?? powerLevels.users_default ?? 0
if (have < required) {
return api.sendEvent(event.room_id, "m.room.message", {
...ctx,
msgtype: "m.text",
body: "I don't have sufficient permissions in this Matrix room to edit emojis."
})
}
}
/** @type {{url: string, name: string}[]} */
const toUpload = []
const nameMatch = realBody.match(/:([a-zA-Z0-9_]{2,}):/)
const mxcMatch = realBody.match(/(mxc:\/\/.*?)\b/)
if (event.content["m.relates_to"]?.["m.in_reply_to"]?.event_id) {
const repliedToEventID = event.content["m.relates_to"]["m.in_reply_to"].event_id
const repliedToEvent = await api.getEvent(event.room_id, repliedToEventID)
if (nameMatch && repliedToEvent.type === "m.room.message" && repliedToEvent.content.msgtype === "m.image" && repliedToEvent.content.url) {
toUpload.push({url: repliedToEvent.content.url, name: nameMatch[1]})
} else if (repliedToEvent.type === "m.room.message" && repliedToEvent.content.msgtype === "m.text" && "formatted_body" in repliedToEvent.content) {
const namePrefixMatch = realBody.match(/:([a-zA-Z0-9_]{2,})(?:\b|:)/)
const imgMatches = [...repliedToEvent.content.formatted_body.matchAll(/<img [^>]*>/g)]
for (const match of imgMatches) {
const e = match[0]
const url = e.match(/src="([^"]*)"/)?.[1]
let name = e.match(/title=":?([^":]*):?"/)?.[1]
if (!url || !name) continue
if (namePrefixMatch) name = namePrefixMatch[1] + name
toUpload.push({url, name})
}
}
}
if (!toUpload.length && mxcMatch && nameMatch) {
toUpload.push({url: mxcMatch[1], name: nameMatch[1]})
}
if (!toUpload.length) {
return api.sendEvent(event.room_id, "m.room.message", {
...ctx,
msgtype: "m.text",
body: "Not sure what image you wanted to add. Try replying to an uploaded image when you use the command, or write an mxc:// URL in your message. You should specify the new name :like_this:."
})
}
const b = new mxUtils.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")
.addLine(`Ⓜ️ *If you were a Discord user, you wouldn't have permission to create emojis. ${matrixOnlyConclusion}`, `Ⓜ️ <em>If you were a Discord user, you wouldn't have permission to create emojis. ${matrixOnlyConclusion}</em>`, matrixOnlyReason === "CAPACITY")
.addLine("[Preview not available in plain text.]", "Preview:")
for (const e of toUpload) {
b.add("", `<img data-mx-emoticon height="48" src="${e.url}" title=":${e.name}:" alt=":${e.name}:">`)
}
b.addLine("Hit ✅ to add it.")
const sent = await api.sendEvent(event.room_id, "m.room.message", {
...ctx,
...b.get()
})
addButton(event.room_id, sent, "✅", event.sender).then(async () => {
if (matrixOnlyReason) {
// Edit some state
const type = "im.ponies.room_emotes"
const key = "moe.cadence.ooye.pack.matrix"
let pack
try {
pack = await api.getStateEvent(event.room_id, type, key)
} catch (e) {
pack = {
pack: {
display_name: "Non-Discord Emojis",
usage: ["emoticon", "sticker"]
}
}
}
if (!("images" in pack)) pack.images = {}
const b = new mxUtils.MatrixStringBuilder()
.addLine(`Created ${toUpload.length} emojis`, "")
for (const e of toUpload) {
pack.images[e.name] = {
url: e.url // Directly use the same file that the Matrix user uploaded. Don't need to worry about dimensions/filesize because clients already request their preferred resized version from the homeserver.
}
b.add("", `<img data-mx-emoticon height="48" src="${e.url}" title=":${e.name}:" alt=":${e.name}:">`)
}
await api.sendState(event.room_id, type, key, pack)
api.sendEvent(event.room_id, "m.room.message", {
...ctx,
...b.get()
})
} else {
// Upload it to Discord and have the bridge sync it back to Matrix again
for (const e of toUpload) {
const publicUrl = mxUtils.getPublicUrlForMxc(e.url)
// @ts-ignore
const resizeInput = await fetch(publicUrl, {agent: false}).then(res => res.arrayBuffer())
const resizeOutput = await sharp(resizeInput)
.resize(EMOJI_SIZE, EMOJI_SIZE, {fit: "inside", withoutEnlargement: true, background: {r: 0, g: 0, b: 0, alpha: 0}})
.png()
.toBuffer({resolveWithObject: true})
console.log(`uploading emoji ${resizeOutput.data.length} bytes to :${e.name}:`)
const emoji = await discord.snow.guildAssets.createEmoji(guildID, {name: e.name, image: "data:image/png;base64," + resizeOutput.data.toString("base64")})
}
api.sendEvent(event.room_id, "m.room.message", {
...ctx,
msgtype: "m.text",
body: `Created ${toUpload.length} emojis`
})
}
})
}
)
}, {
aliases: ["thread"],
execute: replyctx(
async (event, realBody, words, ctx) => {
// Guard
/** @type {string} */ // @ts-ignore
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
const guildID = discord.channels.get(channelID)?.["guild_id"]
if (!guildID) {
return api.sendEvent(event.room_id, "m.room.message", {
...ctx,
msgtype: "m.text",
body: "This room isn't bridged to the other side."
})
}
const guild = discord.guilds.get(guildID)
assert(guild)
const permissions = dUtils.getPermissions([], guild.roles)
if (!(permissions & 0x800000000n)) { // CREATE_PUBLIC_THREADS
return api.sendEvent(event.room_id, "m.room.message", {
...ctx,
msgtype: "m.text",
body: "This command creates a thread on Discord. But you aren't allowed to do this, because if you were a Discord user, you wouldn't have the Create Public Threads permission."
})
}
await discord.snow.channel.createThreadWithoutMessage(channelID, {type: 11, name: words.slice(1).join(" ")})
}
)
}]
/** @type {CommandExecute} */
async function execute(event) {
let realBody = event.content.body
while (realBody.startsWith("> ")) {
const i = realBody.indexOf("\n")
if (i === -1) return
realBody = realBody.slice(i + 1)
}
realBody = realBody.replace(/^\s*/, "")
let words
for (const prefix of PREFIXES) {
if (realBody.startsWith(prefix)) {
words = realBody.slice(prefix.length).split(" ")
break
}
}
if (!words) return
const commandName = words[0]
const command = commands.find(c => c.aliases.includes(commandName))
if (!command) return
await command.execute(event, realBody, words)
}
module.exports.execute = execute
module.exports.onReactionAdd = onReactionAdd

83
src/matrix/mreq.js Normal file
View file

@ -0,0 +1,83 @@
// @ts-check
const fetch = require("node-fetch").default
const mixin = require("@cloudrac3r/mixin-deep")
const stream = require("stream")
const getStream = require("get-stream")
const {reg} = require("./read-registration.js")
const baseUrl = `${reg.ooye.server_origin}/_matrix`
class MatrixServerError extends Error {
constructor(data, opts) {
super(data.error || data.errcode)
this.data = data
/** @type {string} */
this.errcode = data.errcode
this.opts = opts
}
}
/**
* @param {string} method
* @param {string} url
* @param {any} [body]
* @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,
headers: {
Authorization: `Bearer ${reg.as_token}`
}
}, extra)
// console.log(baseUrl + url, opts)
const res = await fetch(baseUrl + url, opts)
const root = await res.json()
if (!res.ok || root.errcode) {
if (root.error?.includes("Content-Length")) {
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})
}
return root
}
/**
* JavaScript doesn't have Racket-like parameters with dynamic scoping, so
* do NOT do anything else at the same time as this.
* @template T
* @param {string} token
* @param {(...arg: any[]) => Promise<T>} callback
* @returns {Promise<T>}
*/
async function withAccessToken(token, callback) {
const prevToken = reg.as_token
reg.as_token = token
try {
return await callback()
} finally {
reg.as_token = prevToken
}
}
module.exports.MatrixServerError = MatrixServerError
module.exports.baseUrl = baseUrl
module.exports.mreq = mreq
module.exports.withAccessToken = withAccessToken

36
src/matrix/power.js Normal file
View file

@ -0,0 +1,36 @@
// @ts-check
const {db, from} = require("../passthrough")
const {reg} = require("./read-registration")
const ks = require("./kstate")
const {applyKStateDiffToRoom, roomToKState} = require("../d2m/actions/create-room")
/** Apply global power level requests across ALL rooms where the member cache entry exists but the power level has not been applied yet. */
function _getAffectedRooms() {
return from("member_cache")
.join("member_power", "mxid")
.join("channel_room", "room_id") // only include rooms that are bridged
.and("where member_power.room_id = '*' and member_cache.power_level != member_power.power_level")
.selectUnsafe("mxid", "member_cache.room_id", "member_power.power_level")
.all()
}
async function applyPower() {
// Migrate reg.ooye.invite setting to database
for (const mxid of reg.ooye.invite) {
db.prepare("INSERT OR IGNORE INTO member_power (mxid, room_id, power_level) VALUES (?, ?, 100)").run(mxid, "*")
}
const rows = _getAffectedRooms()
for (const row of rows) {
const kstate = await roomToKState(row.room_id)
const diff = ks.diffKState(kstate, {"m.room.power_levels/": {users: {[row.mxid]: row.power_level}}})
await applyKStateDiffToRoom(row.room_id, diff)
// There is a listener on m.room.power_levels to do this same update,
// but we update it here anyway since the homeserver does not always deliver the event round-trip.
db.prepare("UPDATE member_cache SET power_level = ? WHERE room_id = ? AND mxid = ?").run(row.power_level, row.room_id, row.mxid)
}
}
module.exports._getAffectedRooms = _getAffectedRooms
module.exports.applyPower = applyPower

12
src/matrix/power.test.js Normal file
View file

@ -0,0 +1,12 @@
// @ts-check
const {test} = require("supertape")
const power = require("./power")
test("power: get affected rooms", t => {
t.deepEqual(power._getAffectedRooms(), [{
mxid: "@test_auto_invite:example.org",
power_level: 100,
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
}])
})

View file

@ -0,0 +1,85 @@
// @ts-check
const fs = require("fs")
const crypto = require("crypto")
const assert = require("assert").strict
const path = require("path")
const yaml = require("js-yaml")
const registrationFilePath = path.join(process.cwd(), "registration.yaml")
/** @param {import("../types").AppServiceRegistrationConfig} reg */
function checkRegistration(reg) {
reg["ooye"].invite = (reg.ooye.invite || []).filter(mxid => mxid.endsWith(`:${reg.ooye.server_name}`)) // one day I will understand why typescript disagrees with dot notation on this line
assert(reg.ooye?.max_file_size)
assert(reg.ooye?.namespace_prefix)
assert(reg.ooye?.server_name)
assert(reg.sender_localpart?.startsWith(reg.ooye.namespace_prefix), "appservice's localpart must be in the namespace it controls")
assert(reg.ooye?.server_origin.match(/^https?:\/\//), "server origin must start with http or https")
assert.notEqual(reg.ooye?.server_origin.slice(-1), "/", "server origin must not end in slash")
assert.match(reg.url, /^https?:/, "url must start with http:// or https://")
}
/** @param {import("../types").AppServiceRegistrationConfig} reg */
function writeRegistration(reg) {
fs.writeFileSync(registrationFilePath, JSON.stringify(reg, null, 2))
}
/** @returns {import("../types").InitialAppServiceRegistrationConfig} reg */
function getTemplateRegistration() {
return {
id: crypto.randomBytes(16).toString("hex"),
as_token: crypto.randomBytes(16).toString("hex"),
hs_token: crypto.randomBytes(16).toString("hex"),
namespaces: {
users: [{
exclusive: true,
regex: "@_ooye_.*:cadence.moe"
}],
aliases: [{
exclusive: true,
regex: "#_ooye_.*:cadence.moe"
}]
},
protocols: [
"discord"
],
sender_localpart: "_ooye_bot",
rate_limited: false,
ooye: {
namespace_prefix: "_ooye_",
max_file_size: 5000000,
content_length_workaround: false,
include_user_id_in_mxid: false,
invite: []
}
}
}
function readRegistration() {
/** @type {import("../types").AppServiceRegistrationConfig} */ // @ts-ignore
let result = null
if (fs.existsSync(registrationFilePath)) {
const content = fs.readFileSync(registrationFilePath, "utf8")
if (content.startsWith("{")) { // Use JSON parser
result = JSON.parse(content)
checkRegistration(result)
} else { // Use YAML parser
result = yaml.load(content)
checkRegistration(result)
// Convert to JSON
writeRegistration(result)
}
}
return result
}
/** @type {import("../types").AppServiceRegistrationConfig} */ // @ts-ignore
let reg = readRegistration()
module.exports.registrationFilePath = registrationFilePath
module.exports.readRegistration = readRegistration
module.exports.getTemplateRegistration = getTemplateRegistration
module.exports.writeRegistration = writeRegistration
module.exports.checkRegistration = checkRegistration
module.exports.reg = reg

View file

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

7
src/matrix/txnid.js Normal file
View file

@ -0,0 +1,7 @@
// @ts-check
let now = Date.now()
module.exports.makeTxnId = function makeTxnId() {
return now++
}

12
src/matrix/txnid.test.js Normal file
View file

@ -0,0 +1,12 @@
// @ts-check
const {test} = require("supertape")
const txnid = require("./txnid")
test("txnid: generates different values each run", t => {
const one = txnid.makeTxnId()
t.ok(one)
const two = txnid.makeTxnId()
t.ok(two)
t.notEqual(two, one)
})

17
src/passthrough.js Normal file
View file

@ -0,0 +1,17 @@
// @ts-check
/**
* @typedef {Object} Passthrough
* @property {import("repl").REPLServer} repl
* @property {typeof import("./config")} config
* @property {import("./d2m/discord-client")} discord
* @property {import("heatsync").default} sync
* @property {import("better-sqlite3/lib/database")} db
* @property {import("@cloudrac3r/in-your-element").AppService} as
* @property {import("./db/orm").from} from
* @property {import("./db/orm").select} select
*/
/** @type {Passthrough} */
// @ts-ignore
const pt = {}
module.exports = pt

68
src/stdin.js Normal file
View file

@ -0,0 +1,68 @@
// @ts-check
const repl = require("repl")
const util = require("util")
const {addbot} = require("./addbot")
const passthrough = require("./passthrough")
const {discord, config, sync, db} = passthrough
const data = sync.require("./test/data")
const createSpace = sync.require("./d2m/actions/create-space")
const createRoom = sync.require("./d2m/actions/create-room")
const registerUser = sync.require("./d2m/actions/register-user")
const mreq = sync.require("./matrix/mreq")
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"
const extraContext = {}
if (process.stdin.isTTY) {
setImmediate(() => { // assign after since old extraContext data will get removed
if (!passthrough.repl) {
const cli = repl.start({ prompt: "", eval: customEval, writer: s => s })
Object.assign(cli.context, extraContext, passthrough)
passthrough.repl = cli
} else {
Object.assign(passthrough.repl.context, extraContext)
}
sync.addTemporaryListener(passthrough.repl, "exit", () => process.exit())
})
}
/**
* @param {string} input
* @param {import("vm").Context} _context
* @param {string} _filename
* @param {(err: Error | null, result: unknown) => unknown} callback
*/
async function customEval(input, _context, _filename, callback) {
let depth = 0
if (input === "exit\n") return process.exit()
if (input === "addbot\n") return callback(null, addbot())
if (input.startsWith(":")) {
const depthOverwrite = input.split(" ")[0]
depth = +depthOverwrite.slice(1)
input = input.slice(depthOverwrite.length + 1)
}
let result
try {
result = await eval(input)
const output = util.inspect(result, false, depth, true)
return callback(null, output)
} catch (e) {
return callback(null, util.inspect(e, false, 100, true))
}
}
sync.events.once(__filename, () => {
for (const key in extraContext) {
delete passthrough.repl.context[key]
}
})

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