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)
}
}