mirror of
https://gitdab.com/cadence/out-of-your-element.git
synced 2025-09-10 20:32:50 +02:00
Move everything to src folder... it had to happen
This commit is contained in:
parent
decc32f7e6
commit
4247a3114a
103 changed files with 1 additions and 1 deletions
44
src/d2m/actions/add-reaction.js
Normal file
44
src/d2m/actions/add-reaction.js
Normal 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
|
27
src/d2m/actions/announce-thread.js
Normal file
27
src/d2m/actions/announce-thread.js
Normal 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
|
457
src/d2m/actions/create-room.js
Normal file
457
src/d2m/actions/create-room.js
Normal 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
|
144
src/d2m/actions/create-room.test.js
Normal file
144
src/d2m/actions/create-room.test.js
Normal 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"]
|
||||
)
|
||||
})
|
244
src/d2m/actions/create-space.js
Normal file
244
src/d2m/actions/create-space.js
Normal 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
|
38
src/d2m/actions/create-space.test.js
Normal file
38
src/d2m/actions/create-space.test.js
Normal 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
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
46
src/d2m/actions/delete-message.js
Normal file
46
src/d2m/actions/delete-message.js
Normal 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
|
80
src/d2m/actions/edit-message.js
Normal file
80
src/d2m/actions/edit-message.js
Normal 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
|
82
src/d2m/actions/expression.js
Normal file
82
src/d2m/actions/expression.js
Normal 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
53
src/d2m/actions/lottie.js
Normal 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
|
164
src/d2m/actions/register-pk-user.js
Normal file
164
src/d2m/actions/register-pk-user.js
Normal 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
|
247
src/d2m/actions/register-user.js
Normal file
247
src/d2m/actions/register-user.js
Normal 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
|
63
src/d2m/actions/register-user.test.js
Normal file
63
src/d2m/actions/register-user.test.js
Normal 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"
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
72
src/d2m/actions/remove-reaction.js
Normal file
72
src/d2m/actions/remove-reaction.js
Normal 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
|
61
src/d2m/actions/retrigger.js
Normal file
61
src/d2m/actions/retrigger.js
Normal 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
|
80
src/d2m/actions/send-message.js
Normal file
80
src/d2m/actions/send-message.js
Normal 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
|
68
src/d2m/actions/speedbump.js
Normal file
68
src/d2m/actions/speedbump.js
Normal 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
|
37
src/d2m/actions/update-pins.js
Normal file
37
src/d2m/actions/update-pins.js
Normal 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
|
190
src/d2m/converters/edit-to-changes.js
Normal file
190
src/d2m/converters/edit-to-changes.js
Normal 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
|
327
src/d2m/converters/edit-to-changes.test.js
Normal file
327
src/d2m/converters/edit-to-changes.test.js
Normal 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")
|
||||
})
|
38
src/d2m/converters/emoji-to-key.js
Normal file
38
src/d2m/converters/emoji-to-key.js
Normal 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
|
21
src/d2m/converters/emoji-to-key.test.js
Normal file
21
src/d2m/converters/emoji-to-key.test.js
Normal 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")
|
||||
})
|
48
src/d2m/converters/lottie.js
Normal file
48
src/d2m/converters/lottie.js
Normal 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
|
34
src/d2m/converters/lottie.test.js
Normal file
34
src/d2m/converters/lottie.test.js
Normal 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)
|
||||
}
|
||||
})
|
353
src/d2m/converters/message-to-event.embeds.test.js
Normal file
353
src/d2m/converters/message-to-event.embeds.test.js
Normal 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&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&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/&) https://a.co/&<script>"
|
||||
+ "\n| \n| ## <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&) https://a.co/&<script>"
|
||||
+ "\n| \n| <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&)"
|
||||
+ "\n| \n| ### <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&)"
|
||||
+ "\n| <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&)"
|
||||
+ "\n| — <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&)",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `<blockquote>`
|
||||
+ `<p><strong><a href="https://a.co/&amp;<script>">⏺️ <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;)</a></strong></p>`
|
||||
+ `<p><strong><a href=\"https://a.co/&amp;<script>"><strong>[<span data-mx-color='#123456'>Hey<script>](<a href="https://a.co/&amp">https://a.co/&amp</a>;)</a></strong></p>`
|
||||
+ `<p><strong><a href="https://a.co/&amp;"><span data-mx-color='#123456'>Hey<script></a></p>`
|
||||
+ `<p><strong><strong>[<span data-mx-color='#123456'>Hey<script>](<a href=\"https://a.co/&amp\">https://a.co/&amp</a>;)</strong>`
|
||||
+ `<br><strong><a href="https://a.co/&amp;"><span data-mx-color='#123456'>Hey<script></a></p>`
|
||||
+ `— <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&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| ## McDonald’s🤝@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">McDonald’s🤝@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&via=matrix.org">https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU?via=cadence.moe&via=matrix.org</a>)`,
|
||||
"m.mentions": {}
|
||||
}])
|
||||
})
|
647
src/d2m/converters/message-to-event.js
Normal file
647
src/d2m/converters/message-to-event.js
Normal 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
|
134
src/d2m/converters/message-to-event.pk.test.js
Normal file
134
src/d2m/converters/message-to-event.pk.test.js
Normal 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"
|
||||
}
|
||||
}
|
||||
}])
|
||||
})
|
967
src/d2m/converters/message-to-event.test.js
Normal file
967
src/d2m/converters/message-to-event.test.js
Normal 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 <@&B> 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&via=super.invalid">https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg?via=cadence.moe&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&via=matrix.org">https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$E8IQDGFqYzOU7BwY5Z74Bg-cwaU9OthXSroaWtgYc7U?via=cadence.moe&via=matrix.org</a>'
|
||||
}])
|
||||
t.equal(called, 3, "getEventForTimestamp, getStateEvent, and getJoinedMembers should be called once each")
|
||||
})
|
||||
|
||||
test("message2event: message link from another server", async t => {
|
||||
const events = await messageToEvent(data.message.message_link_from_another_server, data.guild.general)
|
||||
t.deepEqual(events, [{
|
||||
$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'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 <-- 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 <-- 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": {}
|
||||
}])
|
||||
})
|
19
src/d2m/converters/pins-to-list.js
Normal file
19
src/d2m/converters/pins-to-list.js
Normal 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
|
12
src/d2m/converters/pins-to-list.test.js
Normal file
12
src/d2m/converters/pins-to-list.test.js
Normal 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"
|
||||
])
|
||||
})
|
88
src/d2m/converters/remove-reaction.js
Normal file
88
src/d2m/converters/remove-reaction.js
Normal 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
|
170
src/d2m/converters/remove-reaction.test.js
Normal file
170
src/d2m/converters/remove-reaction.test.js
Normal 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"
|
||||
}])
|
||||
})
|
2635
src/d2m/converters/rlottie-wasm.js
Normal file
2635
src/d2m/converters/rlottie-wasm.js
Normal file
File diff suppressed because it is too large
Load diff
19
src/d2m/converters/rlottie-wasm.license
Normal file
19
src/d2m/converters/rlottie-wasm.license
Normal 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.
|
BIN
src/d2m/converters/rlottie-wasm.wasm
Normal file
BIN
src/d2m/converters/rlottie-wasm.wasm
Normal file
Binary file not shown.
47
src/d2m/converters/thread-to-announcement.js
Normal file
47
src/d2m/converters/thread-to-announcement.js
Normal 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
|
171
src/d2m/converters/thread-to-announcement.test.js
Normal file
171
src/d2m/converters/thread-to-announcement.test.js
Normal 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"
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
88
src/d2m/converters/user-to-mxid.js
Normal file
88
src/d2m/converters/user-to-mxid.js
Normal 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
|
54
src/d2m/converters/user-to-mxid.test.js
Normal file
54
src/d2m/converters/user-to-mxid.test.js
Normal 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
66
src/d2m/discord-client.js
Normal 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
193
src/d2m/discord-packets.js
Normal 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
368
src/d2m/event-dispatcher.js
Normal 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)
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue