Compare commits
1 commit
Author | SHA1 | Date | |
---|---|---|---|
1d7ec4315e |
172 changed files with 6991 additions and 20681 deletions
|
@ -1,8 +0,0 @@
|
||||||
{
|
|
||||||
"watermarks": {
|
|
||||||
"statements": [60, 100],
|
|
||||||
"lines": [60, 100],
|
|
||||||
"functions": [60, 100],
|
|
||||||
"branches": [60, 100]
|
|
||||||
}
|
|
||||||
}
|
|
15
.gitignore
vendored
15
.gitignore
vendored
|
@ -1,16 +1,5 @@
|
||||||
# Secrets
|
node_modules
|
||||||
config.js
|
config.js
|
||||||
registration.yaml
|
registration.yaml
|
||||||
ooye.db*
|
|
||||||
events.db*
|
|
||||||
|
|
||||||
# Automatically generated
|
|
||||||
node_modules
|
|
||||||
coverage
|
coverage
|
||||||
test/res/*
|
db/ooye.db*
|
||||||
!test/res/lottie*
|
|
||||||
icon.svg
|
|
||||||
*~
|
|
||||||
.#*
|
|
||||||
\#*#
|
|
||||||
launch.json
|
|
||||||
|
|
9
addbot.js
Executable file → Normal file
9
addbot.js
Executable file → Normal file
|
@ -1,18 +1,15 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const {reg} = require("./src/matrix/read-registration")
|
const config = require("./config")
|
||||||
const token = reg.ooye.discord_token
|
|
||||||
const id = Buffer.from(token.split(".")[0], "base64").toString()
|
|
||||||
|
|
||||||
function addbot() {
|
function addbot() {
|
||||||
|
const token = config.discordToken
|
||||||
|
const id = Buffer.from(token.split(".")[0], "base64")
|
||||||
return `Open this link to add the bot to a Discord server:\nhttps://discord.com/oauth2/authorize?client_id=${id}&scope=bot&permissions=1610883072 `
|
return `Open this link to add the bot to a Discord server:\nhttps://discord.com/oauth2/authorize?client_id=${id}&scope=bot&permissions=1610883072 `
|
||||||
}
|
}
|
||||||
|
|
||||||
/* c8 ignore next 3 */
|
|
||||||
if (process.argv.find(a => a.endsWith("addbot") || a.endsWith("addbot.js"))) {
|
if (process.argv.find(a => a.endsWith("addbot") || a.endsWith("addbot.js"))) {
|
||||||
console.log(addbot())
|
console.log(addbot())
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.id = id
|
|
||||||
module.exports.addbot = addbot
|
module.exports.addbot = addbot
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
#!/usr/bin/env sh
|
#!/usr/bin/env sh
|
||||||
echo "Open this link to add the bot to a Discord server:"
|
echo "Open this link to add the bot to a Discord server:"
|
||||||
echo "https://discord.com/oauth2/authorize?client_id=$(grep discord_token registration.yaml | sed -E 's!.*: ["'\'']([A-Za-z0-9+=/_-]*).*!\1!g' | base64 -d)&scope=bot&permissions=1610883072"
|
echo "https://discord.com/oauth2/authorize?client_id=$(grep discordToken config.js | sed -E 's!.*: ["'\'']([A-Za-z0-9+=/_-]*).*!\1!g' | base64 -d)&scope=bot&permissions=1610883072"
|
||||||
|
|
3
config.example.js
Normal file
3
config.example.js
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
module.exports = {
|
||||||
|
discordToken: "yes"
|
||||||
|
}
|
|
@ -8,8 +8,6 @@ const {discord, sync, db, select} = passthrough
|
||||||
const threadToAnnouncement = sync.require("../converters/thread-to-announcement")
|
const threadToAnnouncement = sync.require("../converters/thread-to-announcement")
|
||||||
/** @type {import("../../matrix/api")} */
|
/** @type {import("../../matrix/api")} */
|
||||||
const api = sync.require("../../matrix/api")
|
const api = sync.require("../../matrix/api")
|
||||||
/** @type {import("./register-user")} */
|
|
||||||
const registerUser = sync.require("./register-user")
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} parentRoomID
|
* @param {string} parentRoomID
|
||||||
|
@ -17,10 +15,10 @@ const registerUser = sync.require("./register-user")
|
||||||
* @param {import("discord-api-types/v10").APIThreadChannel} thread
|
* @param {import("discord-api-types/v10").APIThreadChannel} thread
|
||||||
*/
|
*/
|
||||||
async function announceThread(parentRoomID, threadRoomID, thread) {
|
async function announceThread(parentRoomID, threadRoomID, thread) {
|
||||||
assert(thread.owner_id)
|
const creatorMxid = select("sim", "mxid", {user_id: thread.owner_id}).pluck().get()
|
||||||
// @ts-ignore
|
|
||||||
const creatorMxid = await registerUser.ensureSimJoined({id: thread.owner_id}, parentRoomID)
|
|
||||||
const content = await threadToAnnouncement.threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, {api})
|
const content = await threadToAnnouncement.threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, {api})
|
||||||
|
|
||||||
await api.sendEvent(parentRoomID, "m.room.message", content, creatorMxid)
|
await api.sendEvent(parentRoomID, "m.room.message", content, creatorMxid)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,8 +2,7 @@
|
||||||
|
|
||||||
const assert = require("assert").strict
|
const assert = require("assert").strict
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const Ty = require("../../types")
|
const reg = require("../../matrix/read-registration")
|
||||||
const {reg} = require("../../matrix/read-registration")
|
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {discord, sync, db, select} = passthrough
|
const {discord, sync, db, select} = passthrough
|
||||||
|
@ -13,8 +12,8 @@ const file = sync.require("../../matrix/file")
|
||||||
const api = sync.require("../../matrix/api")
|
const api = sync.require("../../matrix/api")
|
||||||
/** @type {import("../../matrix/kstate")} */
|
/** @type {import("../../matrix/kstate")} */
|
||||||
const ks = sync.require("../../matrix/kstate")
|
const ks = sync.require("../../matrix/kstate")
|
||||||
/** @type {import("../../discord/utils")} */
|
/** @type {import("./create-space")}) */
|
||||||
const utils = sync.require("../../discord/utils")
|
const createSpace = sync.require("./create-space") // watch out for the require loop
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* There are 3 levels of room privacy:
|
* There are 3 levels of room privacy:
|
||||||
|
@ -50,24 +49,21 @@ async function roomToKState(roomID) {
|
||||||
* @param {string} roomID
|
* @param {string} roomID
|
||||||
* @param {any} kstate
|
* @param {any} kstate
|
||||||
*/
|
*/
|
||||||
async function applyKStateDiffToRoom(roomID, kstate) {
|
function applyKStateDiffToRoom(roomID, kstate) {
|
||||||
const events = await ks.kstateToState(kstate)
|
const events = ks.kstateToState(kstate)
|
||||||
return Promise.all(events.map(({type, state_key, content}) =>
|
return Promise.all(events.map(({type, state_key, content}) =>
|
||||||
api.sendState(roomID, 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, name: string, topic?: string?, type: number}} channel
|
||||||
* @param {{id: string}} guild
|
* @param {{id: string}} guild
|
||||||
* @param {string | null | undefined} customName
|
* @param {string | null | undefined} customName
|
||||||
*/
|
*/
|
||||||
function convertNameAndTopic(channel, guild, customName) {
|
function convertNameAndTopic(channel, guild, customName) {
|
||||||
// @ts-ignore
|
|
||||||
const parentChannel = discord.channels.get(channel.parent_id)
|
|
||||||
let channelPrefix =
|
let channelPrefix =
|
||||||
( parentChannel?.type === DiscordTypes.ChannelType.GuildForum ? ""
|
( channel.type === DiscordTypes.ChannelType.PublicThread ? "[⛓️] "
|
||||||
: channel.type === DiscordTypes.ChannelType.PublicThread ? "[⛓️] "
|
|
||||||
: channel.type === DiscordTypes.ChannelType.PrivateThread ? "[🔒⛓️] "
|
: channel.type === DiscordTypes.ChannelType.PrivateThread ? "[🔒⛓️] "
|
||||||
: channel.type === DiscordTypes.ChannelType.GuildVoice ? "[🔊] "
|
: channel.type === DiscordTypes.ChannelType.GuildVoice ? "[🔊] "
|
||||||
: "")
|
: "")
|
||||||
|
@ -88,36 +84,26 @@ function convertNameAndTopic(channel, guild, customName) {
|
||||||
* Async because it may create the guild and/or upload the guild icon to mxc.
|
* Async because it may create the guild and/or upload the guild icon to mxc.
|
||||||
* @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel
|
* @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel
|
||||||
* @param {DiscordTypes.APIGuild} guild
|
* @param {DiscordTypes.APIGuild} guild
|
||||||
* @param {{api: {getStateEvent: typeof api.getStateEvent}}} di simple-as-nails dependency injection for the matrix API
|
|
||||||
*/
|
*/
|
||||||
async function channelToKState(channel, guild, di) {
|
async function channelToKState(channel, guild) {
|
||||||
// @ts-ignore
|
const spaceID = await createSpace.ensureSpace(guild)
|
||||||
const parentChannel = discord.channels.get(channel.parent_id)
|
assert(typeof spaceID === "string")
|
||||||
const guildRow = select("guild_space", ["space_id", "privacy_level"], {guild_id: guild.id}).get()
|
const privacyLevel = select("guild_space", "privacy_level", {space_id: spaceID}).pluck().get()
|
||||||
assert(guildRow)
|
assert(typeof privacyLevel === "number")
|
||||||
|
|
||||||
/** Used for membership/permission checks. */
|
const row = select("channel_room", ["nick", "custom_avatar"], {channel_id: channel.id}).get()
|
||||||
let guildSpaceID = guildRow.space_id
|
const customName = row?.nick
|
||||||
/** 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. */
|
const customAvatar = row?.custom_avatar
|
||||||
let parentSpaceID = guildSpaceID
|
|
||||||
if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum) {
|
|
||||||
parentSpaceID = await ensureRoom(channel.parent_id)
|
|
||||||
assert(typeof parentSpaceID === "string")
|
|
||||||
}
|
|
||||||
|
|
||||||
const channelRow = select("channel_room", ["nick", "custom_avatar"], {channel_id: channel.id}).get()
|
|
||||||
const customName = channelRow?.nick
|
|
||||||
const customAvatar = channelRow?.custom_avatar
|
|
||||||
const [convertedName, convertedTopic] = convertNameAndTopic(channel, guild, customName)
|
const [convertedName, convertedTopic] = convertNameAndTopic(channel, guild, customName)
|
||||||
|
|
||||||
const avatarEventContent = {}
|
const avatarEventContent = {}
|
||||||
if (customAvatar) {
|
if (customAvatar) {
|
||||||
avatarEventContent.url = customAvatar
|
avatarEventContent.url = customAvatar
|
||||||
} else if (guild.icon) {
|
} else if (guild.icon) {
|
||||||
avatarEventContent.url = {$url: file.guildIcon(guild)}
|
avatarEventContent.discord_path = file.guildIcon(guild)
|
||||||
|
avatarEventContent.url = await file.uploadDiscordFileToMxc(avatarEventContent.discord_path) // TODO: somehow represent future values in kstate (callbacks?), while still allowing for diffing, so test cases don't need to touch the media API
|
||||||
}
|
}
|
||||||
|
|
||||||
const privacyLevel = guildRow.privacy_level
|
|
||||||
let history_visibility = PRIVACY_ENUMS.ROOM_HISTORY_VISIBILITY[privacyLevel]
|
let history_visibility = PRIVACY_ENUMS.ROOM_HISTORY_VISIBILITY[privacyLevel]
|
||||||
if (channel["thread_metadata"]) history_visibility = "world_readable"
|
if (channel["thread_metadata"]) history_visibility = "world_readable"
|
||||||
|
|
||||||
|
@ -126,40 +112,30 @@ async function channelToKState(channel, guild, di) {
|
||||||
join_rule: "restricted",
|
join_rule: "restricted",
|
||||||
allow: [{
|
allow: [{
|
||||||
type: "m.room_membership",
|
type: "m.room_membership",
|
||||||
room_id: guildSpaceID
|
room_id: spaceID
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
if (PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel] !== "restricted") {
|
if (PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel] !== "restricted") {
|
||||||
join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]}
|
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 = {
|
const channelKState = {
|
||||||
"m.room.name/": {name: convertedName},
|
"m.room.name/": {name: convertedName},
|
||||||
"m.room.topic/": {topic: convertedTopic},
|
"m.room.topic/": {topic: convertedTopic},
|
||||||
"m.room.avatar/": avatarEventContent,
|
"m.room.avatar/": avatarEventContent,
|
||||||
"m.room.guest_access/": {guest_access: PRIVACY_ENUMS.GUEST_ACCESS[privacyLevel]},
|
"m.room.guest_access/": {guest_access: PRIVACY_ENUMS.GUEST_ACCESS[privacyLevel]},
|
||||||
"m.room.history_visibility/": {history_visibility},
|
"m.room.history_visibility/": {history_visibility},
|
||||||
[`m.space.parent/${parentSpaceID}`]: {
|
[`m.space.parent/${spaceID}`]: {
|
||||||
via: [reg.ooye.server_name],
|
via: [reg.ooye.server_name],
|
||||||
canonical: true
|
canonical: true
|
||||||
},
|
},
|
||||||
/** @type {{join_rule: string, [x: string]: any}} */
|
/** @type {{join_rule: string, [x: string]: any}} */
|
||||||
"m.room.join_rules/": join_rules,
|
"m.room.join_rules/": join_rules,
|
||||||
"m.room.power_levels/": {
|
"m.room.power_levels/": {
|
||||||
notifications: {
|
events: {
|
||||||
room: everyoneCanMentionEveryone ? 0 : 20
|
"m.room.avatar": 0
|
||||||
},
|
},
|
||||||
users: {...spacePower, ...globalAdminPower}
|
users: reg.ooye.invite.reduce((a, c) => (a[c] = 100, a), {})
|
||||||
},
|
},
|
||||||
"chat.schildi.hide_ui/read_receipts": {
|
"chat.schildi.hide_ui/read_receipts": {
|
||||||
hidden: true
|
hidden: true
|
||||||
|
@ -173,7 +149,7 @@ async function channelToKState(channel, guild, di) {
|
||||||
network: {
|
network: {
|
||||||
id: guild.id,
|
id: guild.id,
|
||||||
displayname: guild.name,
|
displayname: guild.name,
|
||||||
avatar_url: {$url: file.guildIcon(guild)}
|
avatar_url: await file.uploadDiscordFileToMxc(file.guildIcon(guild))
|
||||||
},
|
},
|
||||||
channel: {
|
channel: {
|
||||||
id: channel.id,
|
id: channel.id,
|
||||||
|
@ -183,7 +159,7 @@ async function channelToKState(channel, guild, di) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {spaceID: parentSpaceID, privacyLevel, channelKState}
|
return {spaceID, privacyLevel, channelKState}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -199,9 +175,6 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) {
|
||||||
let threadParent = null
|
let threadParent = null
|
||||||
if (channel.type === DiscordTypes.ChannelType.PublicThread) threadParent = channel.parent_id
|
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
|
// Name and topic can be done earlier in room creation rather than in initial_state
|
||||||
// https://spec.matrix.org/latest/client-server-api/#creation
|
// https://spec.matrix.org/latest/client-server-api/#creation
|
||||||
const name = kstate["m.room.name/"].name
|
const name = kstate["m.room.name/"].name
|
||||||
|
@ -218,8 +191,7 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) {
|
||||||
preset: PRIVACY_ENUMS.PRESET[privacyLevel], // This is closest to what we want, but properties from kstate override it anyway
|
preset: PRIVACY_ENUMS.PRESET[privacyLevel], // This is closest to what we want, but properties from kstate override it anyway
|
||||||
visibility: PRIVACY_ENUMS.VISIBILITY[privacyLevel],
|
visibility: PRIVACY_ENUMS.VISIBILITY[privacyLevel],
|
||||||
invite: [],
|
invite: [],
|
||||||
initial_state: await ks.kstateToState(kstate),
|
initial_state: 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)
|
db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent) VALUES (?, ?, ?, NULL, ?)").run(channel.id, roomID, channel.name, threadParent)
|
||||||
|
@ -272,61 +244,6 @@ function channelToGuild(channel) {
|
||||||
return guild
|
return guild
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* This function handles whether it's allowed to bridge messages in this channel, and if so, where to.
|
|
||||||
* This has to account for whether self-service is enabled for the guild or not.
|
|
||||||
* This also has to account for different channel types, like forum channels (which need the
|
|
||||||
* parent forum to already exist, and ignore the self-service setting), or thread channels (which
|
|
||||||
* need the parent channel to already exist, and ignore the self-service setting).
|
|
||||||
* @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel text channel or thread
|
|
||||||
* @param {string} guildID
|
|
||||||
* @returns obj if bridged; 1 if autocreatable; null/undefined if guild is not bridged; 0 if self-service and not autocreatable thread
|
|
||||||
*/
|
|
||||||
function existsOrAutocreatable(channel, guildID) {
|
|
||||||
// 1. If the channel is already linked somewhere, it's always okay to bridge to that destination, no matter what. Yippee!
|
|
||||||
const existing = select("channel_room", ["room_id", "thread_parent"], {channel_id: channel.id}).get()
|
|
||||||
if (existing) return existing
|
|
||||||
|
|
||||||
// 2. If the guild is an autocreate guild, it's always okay to bridge to that destination, and
|
|
||||||
// we'll need to create any dependent resources recursively.
|
|
||||||
const autocreate = select("guild_active", "autocreate", {guild_id: guildID}).pluck().get()
|
|
||||||
if (autocreate === 1) return autocreate
|
|
||||||
|
|
||||||
// 3. If the guild is not approved for bridging yet, we can't bridge there.
|
|
||||||
// They need to decide one way or another whether it's self-service before we can continue.
|
|
||||||
if (autocreate == null) return autocreate
|
|
||||||
|
|
||||||
// 4. If we got here, the guild is in self-service mode.
|
|
||||||
// New channels won't be able to create new rooms. But forum threads or channel threads could be fine.
|
|
||||||
if ([DiscordTypes.ChannelType.PublicThread, DiscordTypes.ChannelType.PrivateThread, DiscordTypes.ChannelType.AnnouncementThread].includes(channel.type)) {
|
|
||||||
// In self-service mode, threads rely on the parent resource already existing.
|
|
||||||
/** @type {DiscordTypes.APIGuildTextChannel} */ // @ts-ignore
|
|
||||||
const parent = discord.channels.get(channel.parent_id)
|
|
||||||
assert(parent)
|
|
||||||
const parentExisting = existsOrAutocreatable(parent, guildID)
|
|
||||||
if (parentExisting) return 1 // Autocreatable
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. If we got here, the guild is in self-service mode and the channel is truly not bridged.
|
|
||||||
return autocreate
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel text channel or thread
|
|
||||||
* @param {string} guildID
|
|
||||||
* @returns obj if bridged; 1 if autocreatable. (throws if not autocreatable)
|
|
||||||
*/
|
|
||||||
function assertExistsOrAutocreatable(channel, guildID) {
|
|
||||||
const existing = existsOrAutocreatable(channel, guildID)
|
|
||||||
if (existing === 0) {
|
|
||||||
throw new Error(`Guild ${guildID} is self-service, so won't create a Matrix room for channel ${channel.id}`)
|
|
||||||
}
|
|
||||||
if (!existing) {
|
|
||||||
throw new Error(`Guild ${guildID} is not bridged, so won't create a Matrix room for channel ${channel.id}`)
|
|
||||||
}
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Ensure flow:
|
Ensure flow:
|
||||||
1. Get IDs
|
1. Get IDs
|
||||||
|
@ -345,7 +262,6 @@ function assertExistsOrAutocreatable(channel, guildID) {
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create room and/or sync room data. Please check that a channel_room entry exists or autocreate = 1 before calling this.
|
|
||||||
* @param {string} channelID
|
* @param {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)
|
* @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
|
* @returns {Promise<string>} room ID
|
||||||
|
@ -360,11 +276,11 @@ async function _syncRoom(channelID, shouldActuallySync) {
|
||||||
await inflightRoomCreate.get(channelID) // just waiting, and then doing a new db query afterwards, is the simplest way of doing it
|
await inflightRoomCreate.get(channelID) // just waiting, and then doing a new db query afterwards, is the simplest way of doing it
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = assertExistsOrAutocreatable(channel, guild.id)
|
const existing = select("channel_room", ["room_id", "thread_parent"], {channel_id: channelID}).get()
|
||||||
|
|
||||||
if (existing === 1) {
|
if (!existing) {
|
||||||
const creation = (async () => {
|
const creation = (async () => {
|
||||||
const {spaceID, privacyLevel, channelKState} = await channelToKState(channel, guild, {api})
|
const {spaceID, privacyLevel, channelKState} = await channelToKState(channel, guild)
|
||||||
const roomID = await createRoom(channel, guild, spaceID, channelKState, privacyLevel)
|
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
|
inflightRoomCreate.delete(channelID) // OK to release inflight waiters now. they will read the correct `existing` row
|
||||||
return roomID
|
return roomID
|
||||||
|
@ -381,7 +297,7 @@ async function _syncRoom(channelID, shouldActuallySync) {
|
||||||
|
|
||||||
console.log(`[room sync] to matrix: ${channel.name}`)
|
console.log(`[room sync] to matrix: ${channel.name}`)
|
||||||
|
|
||||||
const {spaceID, channelKState} = await channelToKState(channel, guild, {api}) // calling this in both branches because we don't want to calculate this if not syncing
|
const {spaceID, channelKState} = await channelToKState(channel, guild) // calling this in both branches because we don't want to calculate this if not syncing
|
||||||
|
|
||||||
// sync channel state to room
|
// sync channel state to room
|
||||||
const roomKState = await roomToKState(roomID)
|
const roomKState = await roomToKState(roomID)
|
||||||
|
@ -402,12 +318,12 @@ async function _syncRoom(channelID, shouldActuallySync) {
|
||||||
return roomID
|
return roomID
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ensures the room exists. If it doesn't, creates the room with an accurate initial state. Please check that a channel_room entry exists or guild autocreate = 1 before calling this. */
|
/** Ensures the room exists. If it doesn't, creates the room with an accurate initial state. */
|
||||||
function ensureRoom(channelID) {
|
function ensureRoom(channelID) {
|
||||||
return _syncRoom(channelID, false)
|
return _syncRoom(channelID, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Actually syncs. Gets all room state from the homeserver in order to diff, and uploads the icon to mxc if it has changed. Please check that a channel_room entry exists or guild autocreate = 1 before calling this. */
|
/** Actually syncs. Gets all room state from the homeserver in order to diff, and uploads the icon to mxc if it has changed. */
|
||||||
function syncRoom(channelID) {
|
function syncRoom(channelID) {
|
||||||
return _syncRoom(channelID, true)
|
return _syncRoom(channelID, true)
|
||||||
}
|
}
|
||||||
|
@ -416,16 +332,11 @@ async function _unbridgeRoom(channelID) {
|
||||||
/** @ts-ignore @type {DiscordTypes.APIGuildChannel} */
|
/** @ts-ignore @type {DiscordTypes.APIGuildChannel} */
|
||||||
const channel = discord.channels.get(channelID)
|
const channel = discord.channels.get(channelID)
|
||||||
assert.ok(channel)
|
assert.ok(channel)
|
||||||
assert.ok(channel.guild_id)
|
return unbridgeDeletedChannel(channel.id, channel.guild_id)
|
||||||
return unbridgeDeletedChannel(channel, channel.guild_id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function unbridgeDeletedChannel(channelID, guildID) {
|
||||||
* @param {{id: string, topic?: string?}} channel
|
const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
|
||||||
* @param {string} guildID
|
|
||||||
*/
|
|
||||||
async function unbridgeDeletedChannel(channel, guildID) {
|
|
||||||
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
|
|
||||||
assert.ok(roomID)
|
assert.ok(roomID)
|
||||||
const spaceID = select("guild_space", "space_id", {guild_id: guildID}).pluck().get()
|
const spaceID = select("guild_space", "space_id", {guild_id: guildID}).pluck().get()
|
||||||
assert.ok(spaceID)
|
assert.ok(spaceID)
|
||||||
|
@ -435,11 +346,7 @@ async function unbridgeDeletedChannel(channel, guildID) {
|
||||||
await api.sendState(spaceID, "m.space.child", roomID, {})
|
await api.sendState(spaceID, "m.space.child", roomID, {})
|
||||||
|
|
||||||
// remove declaration that the room is bridged
|
// remove declaration that the room is bridged
|
||||||
await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channel.id}`, {})
|
await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channelID}`, {})
|
||||||
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
|
// send a notification in the room
|
||||||
await api.sendEvent(roomID, "m.room.message", {
|
await api.sendEvent(roomID, "m.room.message", {
|
||||||
|
@ -451,7 +358,8 @@ async function unbridgeDeletedChannel(channel, guildID) {
|
||||||
await api.leaveRoom(roomID)
|
await api.leaveRoom(roomID)
|
||||||
|
|
||||||
// delete room from database
|
// delete room from database
|
||||||
db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channel.id)
|
const {changes} = db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channelID)
|
||||||
|
assert.equal(changes, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -504,5 +412,3 @@ module.exports.postApplyPowerLevels = postApplyPowerLevels
|
||||||
module.exports._convertNameAndTopic = convertNameAndTopic
|
module.exports._convertNameAndTopic = convertNameAndTopic
|
||||||
module.exports._unbridgeRoom = _unbridgeRoom
|
module.exports._unbridgeRoom = _unbridgeRoom
|
||||||
module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel
|
module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel
|
||||||
module.exports.existsOrAutocreatable = existsOrAutocreatable
|
|
||||||
module.exports.assertExistsOrAutocreatable = assertExistsOrAutocreatable
|
|
|
@ -1,97 +1,42 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const mixin = require("@cloudrac3r/mixin-deep")
|
|
||||||
const {channelToKState, _convertNameAndTopic} = require("./create-room")
|
const {channelToKState, _convertNameAndTopic} = require("./create-room")
|
||||||
const {kstateStripConditionals} = require("../../matrix/kstate")
|
const {kstateStripConditionals} = require("../../matrix/kstate")
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
const testData = require("../../../test/data")
|
const testData = require("../../test/data")
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {db} = passthrough
|
const {db} = passthrough
|
||||||
|
|
||||||
|
|
||||||
test("channel2room: discoverable privacy room", async t => {
|
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()
|
db.prepare("UPDATE guild_space SET privacy_level = 2").run()
|
||||||
t.deepEqual(
|
t.deepEqual(
|
||||||
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)),
|
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general).then(x => x.channelKState)),
|
||||||
Object.assign({}, testData.room.general, {
|
Object.assign({}, testData.room.general, {
|
||||||
"m.room.guest_access/": {guest_access: "forbidden"},
|
"m.room.guest_access/": {guest_access: "forbidden"},
|
||||||
"m.room.join_rules/": {join_rule: "public"},
|
"m.room.join_rules/": {join_rule: "public"},
|
||||||
"m.room.history_visibility/": {history_visibility: "world_readable"},
|
"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 => {
|
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()
|
db.prepare("UPDATE guild_space SET privacy_level = 1").run()
|
||||||
t.deepEqual(
|
t.deepEqual(
|
||||||
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)),
|
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general).then(x => x.channelKState)),
|
||||||
Object.assign({}, testData.room.general, {
|
Object.assign({}, testData.room.general, {
|
||||||
"m.room.guest_access/": {guest_access: "forbidden"},
|
"m.room.guest_access/": {guest_access: "forbidden"},
|
||||||
"m.room.join_rules/": {join_rule: "public"},
|
"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 => {
|
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()
|
db.prepare("UPDATE guild_space SET privacy_level = 0").run()
|
||||||
t.deepEqual(
|
t.deepEqual(
|
||||||
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)),
|
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general).then(x => x.channelKState)),
|
||||||
Object.assign({}, testData.room.general, {
|
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 => {
|
test("convertNameAndTopic: custom name and topic", t => {
|
|
@ -1,10 +1,8 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert").strict
|
const assert = require("assert").strict
|
||||||
const {isDeepStrictEqual} = require("util")
|
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const Ty = require("../../types")
|
const reg = require("../../matrix/read-registration")
|
||||||
const {reg} = require("../../matrix/read-registration")
|
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {discord, sync, db, select} = passthrough
|
const {discord, sync, db, select} = passthrough
|
||||||
|
@ -14,8 +12,8 @@ const api = sync.require("../../matrix/api")
|
||||||
const file = sync.require("../../matrix/file")
|
const file = sync.require("../../matrix/file")
|
||||||
/** @type {import("./create-room")} */
|
/** @type {import("./create-room")} */
|
||||||
const createRoom = sync.require("./create-room")
|
const createRoom = sync.require("./create-room")
|
||||||
/** @type {import("./expression")} */
|
/** @type {import("../converters/expression")} */
|
||||||
const expression = sync.require("./expression")
|
const expression = sync.require("../converters/expression")
|
||||||
/** @type {import("../../matrix/kstate")} */
|
/** @type {import("../../matrix/kstate")} */
|
||||||
const ks = sync.require("../../matrix/kstate")
|
const ks = sync.require("../../matrix/kstate")
|
||||||
|
|
||||||
|
@ -23,7 +21,7 @@ const ks = sync.require("../../matrix/kstate")
|
||||||
const inflightSpaceCreate = new Map()
|
const inflightSpaceCreate = new Map()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {DiscordTypes.RESTGetAPIGuildResult} guild
|
* @param {import("discord-api-types/v10").RESTGetAPIGuildResult} guild
|
||||||
* @param {any} kstate
|
* @param {any} kstate
|
||||||
*/
|
*/
|
||||||
async function createSpace(guild, kstate) {
|
async function createSpace(guild, kstate) {
|
||||||
|
@ -31,8 +29,6 @@ async function createSpace(guild, kstate) {
|
||||||
const topic = kstate["m.room.topic/"]?.topic || undefined
|
const topic = kstate["m.room.topic/"]?.topic || undefined
|
||||||
assert(name)
|
assert(name)
|
||||||
|
|
||||||
const globalAdmins = select("member_power", "mxid", {room_id: "*"}).pluck().all()
|
|
||||||
|
|
||||||
const roomID = await createRoom.postApplyPowerLevels(kstate, async kstate => {
|
const roomID = await createRoom.postApplyPowerLevels(kstate, async kstate => {
|
||||||
return api.createRoom({
|
return api.createRoom({
|
||||||
name,
|
name,
|
||||||
|
@ -42,12 +38,12 @@ async function createSpace(guild, kstate) {
|
||||||
events_default: 100, // space can only be managed by bridge
|
events_default: 100, // space can only be managed by bridge
|
||||||
invite: 0 // any existing member can invite others
|
invite: 0 // any existing member can invite others
|
||||||
},
|
},
|
||||||
invite: globalAdmins,
|
invite: reg.ooye.invite,
|
||||||
topic,
|
topic,
|
||||||
creation_content: {
|
creation_content: {
|
||||||
type: "m.space"
|
type: "m.space"
|
||||||
},
|
},
|
||||||
initial_state: await ks.kstateToState(kstate)
|
initial_state: ks.kstateToState(kstate)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
db.prepare("INSERT INTO guild_space (guild_id, space_id) VALUES (?, ?)").run(guild.id, roomID)
|
db.prepare("INSERT INTO guild_space (guild_id, space_id) VALUES (?, ?)").run(guild.id, roomID)
|
||||||
|
@ -59,18 +55,19 @@ async function createSpace(guild, kstate) {
|
||||||
* @param {number} privacyLevel
|
* @param {number} privacyLevel
|
||||||
*/
|
*/
|
||||||
async function guildToKState(guild, privacyLevel) {
|
async function guildToKState(guild, privacyLevel) {
|
||||||
assert.equal(typeof privacyLevel, "number")
|
const avatarEventContent = {}
|
||||||
const globalAdmins = select("member_power", ["mxid", "power_level"], {room_id: "*"}).all()
|
if (guild.icon) {
|
||||||
|
avatarEventContent.discord_path = file.guildIcon(guild)
|
||||||
|
avatarEventContent.url = await file.uploadDiscordFileToMxc(avatarEventContent.discord_path) // TODO: somehow represent future values in kstate (callbacks?), while still allowing for diffing, so test cases don't need to touch the media API
|
||||||
|
}
|
||||||
|
|
||||||
const guildKState = {
|
const guildKState = {
|
||||||
"m.room.name/": {name: guild.name},
|
"m.room.name/": {name: guild.name},
|
||||||
"m.room.avatar/": {
|
"m.room.avatar/": avatarEventContent,
|
||||||
$if: guild.icon,
|
|
||||||
url: {$url: file.guildIcon(guild)}
|
|
||||||
},
|
|
||||||
"m.room.guest_access/": {guest_access: createRoom.PRIVACY_ENUMS.GUEST_ACCESS[privacyLevel]},
|
"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.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.join_rules/": {join_rule: createRoom.PRIVACY_ENUMS.SPACE_JOIN_RULES[privacyLevel]},
|
||||||
"m.room.power_levels/": {users: globalAdmins.reduce((a, c) => (a[c.mxid] = c.power_level, a), {})} // used in guild initial creation postApplyPowerLevels
|
"m.room.power_levels/": {users: reg.ooye.invite.reduce((a, c) => (a[c] = 100, a), {})}
|
||||||
}
|
}
|
||||||
|
|
||||||
return guildKState
|
return guildKState
|
||||||
|
@ -92,9 +89,6 @@ async function _syncSpace(guild, shouldActuallySync) {
|
||||||
const row = select("guild_space", ["space_id", "privacy_level"], {guild_id: guild.id}).get()
|
const row = select("guild_space", ["space_id", "privacy_level"], {guild_id: guild.id}).get()
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
const autocreate = select("guild_active", "autocreate", {guild_id: guild.id}).pluck().get()
|
|
||||||
assert.equal(autocreate, 1, `refusing to implicitly create guild ${guild.id}. set the guild_active data first before calling ensureSpace/syncSpace.`)
|
|
||||||
|
|
||||||
const creation = (async () => {
|
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 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)
|
const spaceID = await createSpace(guild, guildKState)
|
||||||
|
@ -127,8 +121,7 @@ async function _syncSpace(guild, shouldActuallySync) {
|
||||||
// don't try to update rooms with custom avatars though
|
// 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 roomsWithCustomAvatars = select("channel_room", "room_id", {}, "WHERE custom_avatar IS NOT NULL").pluck().all()
|
||||||
|
|
||||||
const state = await ks.kstateToState(spaceKState)
|
const childRooms = ks.kstateToState(spaceKState).filter(({type, state_key, content}) => {
|
||||||
const childRooms = state.filter(({type, state_key, content}) => {
|
|
||||||
return type === "m.space.child" && "via" in content && !roomsWithCustomAvatars.includes(state_key)
|
return type === "m.space.child" && "via" in content && !roomsWithCustomAvatars.includes(state_key)
|
||||||
}).map(({state_key}) => state_key)
|
}).map(({state_key}) => state_key)
|
||||||
|
|
||||||
|
@ -187,15 +180,17 @@ async function syncSpaceFully(guildID) {
|
||||||
const spaceDiff = ks.diffKState(spaceKState, guildKState)
|
const spaceDiff = ks.diffKState(spaceKState, guildKState)
|
||||||
await createRoom.applyKStateDiffToRoom(spaceID, spaceDiff)
|
await createRoom.applyKStateDiffToRoom(spaceID, spaceDiff)
|
||||||
|
|
||||||
const childRooms = await api.getFullHierarchy(spaceID)
|
const childRooms = ks.kstateToState(spaceKState).filter(({type, content}) => {
|
||||||
|
return type === "m.space.child" && "via" in content
|
||||||
|
}).map(({state_key}) => state_key)
|
||||||
|
|
||||||
for (const {room_id} of childRooms) {
|
for (const roomID of childRooms) {
|
||||||
const channelID = select("channel_room", "channel_id", {room_id}).pluck().get()
|
const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get()
|
||||||
if (!channelID) continue
|
if (!channelID) continue
|
||||||
if (discord.channels.has(channelID)) {
|
if (discord.channels.has(channelID)) {
|
||||||
await createRoom.syncRoom(channelID)
|
await createRoom.syncRoom(channelID)
|
||||||
} else {
|
} else {
|
||||||
await createRoom.unbridgeDeletedChannel({id: channelID}, guildID)
|
await createRoom.unbridgeDeletedChannel(channelID, guildID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -203,40 +198,23 @@ async function syncSpaceFully(guildID) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {DiscordTypes.GatewayGuildEmojisUpdateDispatchData | DiscordTypes.GatewayGuildStickersUpdateDispatchData} data
|
* @param {import("discord-api-types/v10").GatewayGuildEmojisUpdateDispatchData | import("discord-api-types/v10").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) {
|
async function syncSpaceExpressions(data) {
|
||||||
// No need for kstate here. Each of these maps to a single state event, which will always overwrite what was there before. I can just send the state event.
|
// 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()
|
const spaceID = select("guild_space", "space_id", {guild_id: data.guild_id}).pluck().get()
|
||||||
if (!spaceID) return
|
if (!spaceID) return
|
||||||
|
|
||||||
/**
|
if ("emojis" in data && data.emojis.length) {
|
||||||
* @typedef {DiscordTypes.GatewayGuildEmojisUpdateDispatchData & DiscordTypes.GatewayGuildStickersUpdateDispatchData} Expressions
|
const content = await expression.emojisToState(data.emojis)
|
||||||
* @param {string} spaceID
|
api.sendState(spaceID, "im.ponies.room_emotes", "moe.cadence.ooye.pack.emojis", content)
|
||||||
* @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)
|
if ("stickers" in data && data.stickers.length) {
|
||||||
update(spaceID, "stickers", "moe.cadence.ooye.pack.stickers", expression.stickersToState)
|
const content = await expression.stickersToState(data.stickers)
|
||||||
|
api.sendState(spaceID, "im.ponies.room_emotes", "moe.cadence.ooye.pack.stickers", content)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.createSpace = createSpace
|
module.exports.createSpace = createSpace
|
23
d2m/actions/delete-message.js
Normal file
23
d2m/actions/delete-message.js
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const passthrough = require("../../passthrough")
|
||||||
|
const {sync, db, select} = passthrough
|
||||||
|
/** @type {import("../../matrix/api")} */
|
||||||
|
const api = sync.require("../../matrix/api")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import("discord-api-types/v10").GatewayMessageDeleteDispatchData} data
|
||||||
|
*/
|
||||||
|
async function deleteMessage(data) {
|
||||||
|
const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get()
|
||||||
|
if (!roomID) return
|
||||||
|
|
||||||
|
const eventsToRedact = select("event_message", "event_id", {message_id: data.id}).pluck().all()
|
||||||
|
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(roomID, eventID)
|
||||||
|
db.prepare("DELETE FROM event_message WHERE event_id = ?").run(eventID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.deleteMessage = deleteMessage
|
|
@ -1,32 +1,18 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert").strict
|
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {sync, db, select} = passthrough
|
const {sync, db, select} = passthrough
|
||||||
/** @type {import("../converters/edit-to-changes")} */
|
/** @type {import("../converters/edit-to-changes")} */
|
||||||
const editToChanges = sync.require("../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")} */
|
/** @type {import("../../matrix/api")} */
|
||||||
const api = sync.require("../../matrix/api")
|
const api = sync.require("../../matrix/api")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
|
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
|
||||||
* @param {import("discord-api-types/v10").APIGuild} guild
|
* @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) {
|
async function editMessage(message, guild) {
|
||||||
let {roomID, eventsToRedact, eventsToReplace, eventsToSend, senderMxid, promotions} = await editToChanges.editToChanges(message, guild, api)
|
const {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.
|
// 1. Replace all the things.
|
||||||
for (const {oldID, newContent} of eventsToReplace) {
|
for (const {oldID, newContent} of eventsToReplace) {
|
||||||
|
@ -53,7 +39,7 @@ async function editMessage(message, guild, row) {
|
||||||
const sendNewEventParts = new Set()
|
const sendNewEventParts = new Set()
|
||||||
for (const promotion of promotions) {
|
for (const promotion of promotions) {
|
||||||
if ("eventID" in promotion) {
|
if ("eventID" in promotion) {
|
||||||
db.prepare(`UPDATE event_message SET ${promotion.column} = ? WHERE event_id = ?`).run(promotion.value ?? 0, promotion.eventID)
|
db.prepare(`UPDATE event_message SET ${promotion.column} = 0 WHERE event_id = ?`).run(promotion.eventID)
|
||||||
} else if ("nextEvent" in promotion) {
|
} else if ("nextEvent" in promotion) {
|
||||||
sendNewEventParts.add(promotion.column)
|
sendNewEventParts.add(promotion.column)
|
||||||
}
|
}
|
|
@ -1,9 +1,7 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert").strict
|
const assert = require("assert")
|
||||||
const {reg} = require("../../matrix/read-registration")
|
const reg = require("../../matrix/read-registration")
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
|
||||||
const mixin = require("@cloudrac3r/mixin-deep")
|
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {discord, sync, db, select} = passthrough
|
const {discord, sync, db, select} = passthrough
|
||||||
|
@ -11,8 +9,6 @@ const {discord, sync, db, select} = passthrough
|
||||||
const api = sync.require("../../matrix/api")
|
const api = sync.require("../../matrix/api")
|
||||||
/** @type {import("../../matrix/file")} */
|
/** @type {import("../../matrix/file")} */
|
||||||
const file = sync.require("../../matrix/file")
|
const file = sync.require("../../matrix/file")
|
||||||
/** @type {import("../../discord/utils")} */
|
|
||||||
const utils = sync.require("../../discord/utils")
|
|
||||||
/** @type {import("../converters/user-to-mxid")} */
|
/** @type {import("../converters/user-to-mxid")} */
|
||||||
const userToMxid = sync.require("../converters/user-to-mxid")
|
const userToMxid = sync.require("../converters/user-to-mxid")
|
||||||
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
|
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
|
||||||
|
@ -22,7 +18,7 @@ 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.
|
* A sim is an account that is being simulated by the bridge to copy events from the other side.
|
||||||
* @param {DiscordTypes.APIUser} user
|
* @param {import("discord-api-types/v10").APIUser} user
|
||||||
* @returns mxid
|
* @returns mxid
|
||||||
*/
|
*/
|
||||||
async function createSim(user) {
|
async function createSim(user) {
|
||||||
|
@ -40,7 +36,7 @@ async function createSim(user) {
|
||||||
await api.register(localpart)
|
await api.register(localpart)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If user creation fails, manually undo the database change. Still isn't perfect, but should help.
|
// If user creation fails, manually undo the database change. Still isn't perfect, but should help.
|
||||||
// (I would prefer a transaction, but it's not safe to leave transactions open across event loop ticks.)
|
// (A transaction would be preferable, but I don't think it's safe to leave transaction open across event loop ticks.)
|
||||||
db.prepare("DELETE FROM sim WHERE user_id = ?").run(user.id)
|
db.prepare("DELETE FROM sim WHERE user_id = ?").run(user.id)
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
|
@ -50,7 +46,7 @@ async function createSim(user) {
|
||||||
/**
|
/**
|
||||||
* Ensure a sim is registered for the user.
|
* 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.
|
* If there is already a sim, use that one. If there isn't one yet, register a new sim.
|
||||||
* @param {DiscordTypes.APIUser} user
|
* @param {import("discord-api-types/v10").APIUser} user
|
||||||
* @returns {Promise<string>} mxid
|
* @returns {Promise<string>} mxid
|
||||||
*/
|
*/
|
||||||
async function ensureSim(user) {
|
async function ensureSim(user) {
|
||||||
|
@ -66,7 +62,7 @@ async function ensureSim(user) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure a sim is registered for the user and is joined to the room.
|
* Ensure a sim is registered for the user and is joined to the room.
|
||||||
* @param {DiscordTypes.APIUser} user
|
* @param {import("discord-api-types/v10").APIUser} user
|
||||||
* @param {string} roomID
|
* @param {string} roomID
|
||||||
* @returns {Promise<string>} mxid
|
* @returns {Promise<string>} mxid
|
||||||
*/
|
*/
|
||||||
|
@ -96,8 +92,8 @@ async function ensureSimJoined(user, roomID) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {DiscordTypes.APIUser} user
|
* @param {import("discord-api-types/v10").APIUser} user
|
||||||
* @param {Omit<DiscordTypes.APIGuildMember, "user">} member
|
* @param {Omit<import("discord-api-types/v10").APIGuildMember, "user">} member
|
||||||
*/
|
*/
|
||||||
async function memberToStateContent(user, member, guildID) {
|
async function memberToStateContent(user, member, guildID) {
|
||||||
let displayname = user.username
|
let displayname = user.username
|
||||||
|
@ -127,46 +123,8 @@ async function memberToStateContent(user, member, guildID) {
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function hashProfileContent(content) {
|
||||||
* https://gitdab.com/cadence/out-of-your-element/issues/9
|
const unsignedHash = hasher.h64(`${content.displayname}\u0000${content.avatar_url}`)
|
||||||
* @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
|
const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
|
||||||
return signedHash
|
return signedHash
|
||||||
}
|
}
|
||||||
|
@ -175,72 +133,52 @@ function _hashProfileContent(content, powerLevel) {
|
||||||
* Sync profile data for a sim user. This function follows the following process:
|
* Sync profile data for a sim user. This function follows the following process:
|
||||||
* 1. Join the sim to the room if needed
|
* 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
|
* 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
|
* 3. Compare against the previously known state content, which is helpfully stored in the database
|
||||||
* 4. 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
|
||||||
* 5. If the state content or power level have changed, send them to Matrix and update them in the database for next time
|
* @param {import("discord-api-types/v10").APIUser} user
|
||||||
* @param {DiscordTypes.APIUser} user
|
* @param {Omit<import("discord-api-types/v10").APIGuildMember, "user">} member
|
||||||
* @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
|
* @returns {Promise<string>} mxid of the updated sim
|
||||||
*/
|
*/
|
||||||
async function syncUser(user, member, channel, guild, roomID) {
|
async function syncUser(user, member, guildID, roomID) {
|
||||||
const mxid = await ensureSimJoined(user, roomID)
|
const mxid = await ensureSimJoined(user, roomID)
|
||||||
const content = await memberToStateContent(user, member, guild.id)
|
const content = await memberToStateContent(user, member, guildID)
|
||||||
const powerLevel = memberToPowerLevel(user, member, guild, channel)
|
const currentHash = hashProfileContent(content)
|
||||||
const currentHash = _hashProfileContent(content, powerLevel)
|
|
||||||
const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get()
|
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
|
// only do the actual sync if the hash has changed since we last looked
|
||||||
if (existingHash !== currentHash) {
|
if (existingHash !== currentHash) {
|
||||||
// Update room member state
|
|
||||||
await api.sendState(roomID, "m.room.member", mxid, content, mxid)
|
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)
|
db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid)
|
||||||
}
|
}
|
||||||
return mxid
|
return mxid
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} roomID
|
|
||||||
*/
|
|
||||||
async function syncAllUsersInRoom(roomID) {
|
async function syncAllUsersInRoom(roomID) {
|
||||||
const mxids = select("sim_member", "mxid", {room_id: roomID}).pluck().all()
|
const mxids = select("sim_member", "mxid", {room_id: roomID}).pluck().all()
|
||||||
|
|
||||||
const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get()
|
const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get()
|
||||||
assert.ok(typeof channelID === "string")
|
assert.ok(typeof channelID === "string")
|
||||||
|
|
||||||
/** @ts-ignore @type {DiscordTypes.APIGuildChannel} */
|
/** @ts-ignore @type {import("discord-api-types/v10").APIGuildChannel} */
|
||||||
const channel = discord.channels.get(channelID)
|
const channel = discord.channels.get(channelID)
|
||||||
const guildID = channel.guild_id
|
const guildID = channel.guild_id
|
||||||
assert.ok(typeof guildID === "string")
|
assert.ok(typeof guildID === "string")
|
||||||
/** @ts-ignore @type {DiscordTypes.APIGuild} */
|
|
||||||
const guild = discord.guilds.get(guildID)
|
|
||||||
|
|
||||||
for (const mxid of mxids) {
|
for (const mxid of mxids) {
|
||||||
const userID = select("sim", "user_id", {mxid}).pluck().get()
|
const userID = select("sim", "user_id", {mxid}).pluck().get()
|
||||||
assert.ok(typeof userID === "string")
|
assert.ok(typeof userID === "string")
|
||||||
|
|
||||||
/** @ts-ignore @type {Required<DiscordTypes.APIGuildMember>} */
|
/** @ts-ignore @type {Required<import("discord-api-types/v10").APIGuildMember>} */
|
||||||
const member = await discord.snow.guild.getGuildMember(guildID, userID)
|
const member = await discord.snow.guild.getGuildMember(guildID, userID)
|
||||||
/** @ts-ignore @type {Required<DiscordTypes.APIUser>} user */
|
/** @ts-ignore @type {Required<import("discord-api-types/v10").APIUser>} user */
|
||||||
const user = member.user
|
const user = member.user
|
||||||
assert.ok(user)
|
assert.ok(user)
|
||||||
|
|
||||||
console.log(`[user sync] to matrix: ${user.username} in ${channel.name}`)
|
console.log(`[user sync] to matrix: ${user.username} in ${channel.name}`)
|
||||||
await syncUser(user, member, channel, guild, roomID)
|
await syncUser(user, member, guildID, roomID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports._memberToStateContent = memberToStateContent
|
module.exports._memberToStateContent = memberToStateContent
|
||||||
module.exports._hashProfileContent = _hashProfileContent
|
|
||||||
module.exports.ensureSim = ensureSim
|
module.exports.ensureSim = ensureSim
|
||||||
module.exports.ensureSimJoined = ensureSimJoined
|
module.exports.ensureSimJoined = ensureSimJoined
|
||||||
module.exports.syncUser = syncUser
|
module.exports.syncUser = syncUser
|
|
@ -1,6 +1,6 @@
|
||||||
const {_memberToStateContent} = require("./register-user")
|
const {_memberToStateContent} = require("./register-user")
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
const testData = require("../../../test/data")
|
const testData = require("../../test/data")
|
||||||
|
|
||||||
test("member2state: without member nick or avatar", async t => {
|
test("member2state: without member nick or avatar", async t => {
|
||||||
t.deepEqual(
|
t.deepEqual(
|
|
@ -23,7 +23,16 @@ async function removeSomeReactions(data) {
|
||||||
const eventIDForMessage = select("event_message", "event_id", {message_id: data.message_id, reaction_part: 0}).pluck().get()
|
const eventIDForMessage = select("event_message", "event_id", {message_id: data.message_id, reaction_part: 0}).pluck().get()
|
||||||
if (!eventIDForMessage) return
|
if (!eventIDForMessage) return
|
||||||
|
|
||||||
const reactions = await api.getFullRelations(roomID, eventIDForMessage, "m.annotation")
|
/** @type {Ty.Event.Outer<Ty.Event.M_Reaction>[]} */
|
||||||
|
let reactions = []
|
||||||
|
/** @type {string | undefined} */
|
||||||
|
let nextBatch = undefined
|
||||||
|
do {
|
||||||
|
/** @type {Ty.Pagination<Ty.Event.Outer<Ty.Event.M_Reaction>>} */
|
||||||
|
const res = await api.getRelations(roomID, eventIDForMessage, {from: nextBatch}, "m.annotation")
|
||||||
|
reactions = reactions.concat(res.chunk)
|
||||||
|
nextBatch = res.next_batch
|
||||||
|
} while (nextBatch)
|
||||||
|
|
||||||
// Run the proper strategy and any strategy-specific database changes
|
// Run the proper strategy and any strategy-specific database changes
|
||||||
const removals = await
|
const removals = await
|
|
@ -1,7 +1,6 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert").strict
|
const assert = require("assert")
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const { discord, sync, db } = passthrough
|
const { discord, sync, db } = passthrough
|
||||||
|
@ -11,37 +10,25 @@ const messageToEvent = sync.require("../converters/message-to-event")
|
||||||
const api = sync.require("../../matrix/api")
|
const api = sync.require("../../matrix/api")
|
||||||
/** @type {import("./register-user")} */
|
/** @type {import("./register-user")} */
|
||||||
const registerUser = sync.require("./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")} */
|
/** @type {import("../actions/create-room")} */
|
||||||
const createRoom = sync.require("../actions/create-room")
|
const createRoom = sync.require("../actions/create-room")
|
||||||
/** @type {import("../../discord/utils")} */
|
/** @type {import("../../discord/utils")} */
|
||||||
const dUtils = sync.require("../../discord/utils")
|
const dUtils = sync.require("../../discord/utils")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message
|
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
|
||||||
* @param {DiscordTypes.APIGuildChannel} channel
|
* @param {import("discord-api-types/v10").APIGuild} guild
|
||||||
* @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) {
|
async function sendMessage(message, guild) {
|
||||||
const roomID = await createRoom.ensureRoom(message.channel_id)
|
const roomID = await createRoom.ensureRoom(message.channel_id)
|
||||||
|
|
||||||
let senderMxid = null
|
let senderMxid = null
|
||||||
if (!dUtils.isWebhookMessage(message)) {
|
if (!dUtils.isWebhookMessage(message)) {
|
||||||
if (message.author.id === discord.application.id) {
|
if (message.member) { // available on a gateway message create event
|
||||||
// no need to sync the bot's own user
|
senderMxid = await registerUser.syncUser(message.author, message.member, message.guild_id, roomID)
|
||||||
} 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...
|
} else { // well, good enough...
|
||||||
senderMxid = await registerUser.ensureSimJoined(message.author, roomID)
|
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 events = await messageToEvent.messageToEvent(message, guild, {}, {api})
|
22
d2m/actions/update-pins.js
Normal file
22
d2m/actions/update-pins.js
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const passthrough = require("../../passthrough")
|
||||||
|
const {discord, sync} = passthrough
|
||||||
|
/** @type {import("../converters/pins-to-list")} */
|
||||||
|
const pinsToList = sync.require("../converters/pins-to-list")
|
||||||
|
/** @type {import("../../matrix/api")} */
|
||||||
|
const api = sync.require("../../matrix/api")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} channelID
|
||||||
|
* @param {string} roomID
|
||||||
|
*/
|
||||||
|
async function updatePins(channelID, roomID) {
|
||||||
|
const pins = await discord.snow.channel.getChannelPinnedMessages(channelID)
|
||||||
|
const eventIDs = pinsToList.pinsToList(pins)
|
||||||
|
await api.sendState(roomID, "m.room.pinned_events", "", {
|
||||||
|
pinned: eventIDs
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.updatePins = updatePins
|
|
@ -3,55 +3,30 @@
|
||||||
const assert = require("assert").strict
|
const assert = require("assert").strict
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {sync, select, from} = passthrough
|
const {discord, sync, db, select, from} = passthrough
|
||||||
/** @type {import("./message-to-event")} */
|
/** @type {import("./message-to-event")} */
|
||||||
const messageToEvent = sync.require("../converters/message-to-event")
|
const messageToEvent = sync.require("../converters/message-to-event")
|
||||||
/** @type {import("../../m2d/converters/utils")} */
|
/** @type {import("../actions/register-user")} */
|
||||||
const utils = sync.require("../../m2d/converters/utils")
|
const registerUser = sync.require("../actions/register-user")
|
||||||
|
/** @type {import("../actions/create-room")} */
|
||||||
function eventCanBeEdited(ev) {
|
const createRoom = sync.require("../actions/create-room")
|
||||||
// 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").GatewayMessageCreateDispatchData} message
|
||||||
|
* IMPORTANT: This may not have all the normal fields! The API documentation doesn't provide possible types, just says it's all optional!
|
||||||
|
* Since I don't have a spec, I will have to capture some real traffic and add it as test cases... I hope they don't change anything later...
|
||||||
* @param {import("discord-api-types/v10").APIGuild} guild
|
* @param {import("discord-api-types/v10").APIGuild} guild
|
||||||
* @param {import("../../matrix/api")} api simple-as-nails dependency injection for the matrix API
|
* @param {import("../../matrix/api")} api simple-as-nails dependency injection for the matrix API
|
||||||
*/
|
*/
|
||||||
async function editToChanges(message, guild, 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
|
// Figure out what events we will be replacing
|
||||||
|
|
||||||
const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
|
const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
|
||||||
assert(roomID)
|
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. */
|
/** @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
|
const senderMxid = from("sim").join("sim_member", "mxid").where({user_id: message.author.id, room_id: roomID}).pluck("mxid").get() || null
|
||||||
if (message.author) {
|
|
||||||
senderMxid = from("sim").join("sim_member", "mxid").where({user_id: message.author.id, room_id: roomID}).pluck("mxid").get() || null
|
const oldEventRows = select("event_message", ["event_id", "event_type", "event_subtype", "part", "reaction_part"], {message_id: message.id}).all()
|
||||||
} 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
|
// Figure out what we will be replacing them with
|
||||||
|
|
||||||
|
@ -73,8 +48,7 @@ async function editToChanges(message, guild, api) {
|
||||||
let eventsToRedact = []
|
let eventsToRedact = []
|
||||||
/** 3. Events that are present in the new version only, and should be sent as new, with references back to the context */
|
/** 3. Events that are present in the new version only, and should be sent as new, with references back to the context */
|
||||||
let eventsToSend = []
|
let eventsToSend = []
|
||||||
/** 4. Events that are matched and have definitely not changed, so they don't need to be edited or replaced at all. */
|
// 4. Events that are matched and have definitely not changed, so they don't need to be edited or replaced at all. This is represented as nothing.
|
||||||
let unchangedEvents = []
|
|
||||||
|
|
||||||
function shift() {
|
function shift() {
|
||||||
newFallbackContent.shift()
|
newFallbackContent.shift()
|
||||||
|
@ -107,61 +81,44 @@ async function editToChanges(message, guild, api) {
|
||||||
shift()
|
shift()
|
||||||
}
|
}
|
||||||
// Anything remaining in oldEventRows is present in the old version only and should be redacted.
|
// Anything remaining in oldEventRows is present in the old version only and should be redacted.
|
||||||
eventsToRedact = oldEventRows.map(e => ({old: e}))
|
eventsToRedact = oldEventRows
|
||||||
|
|
||||||
// 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.
|
// We want to maintain exactly one part = 0 and one reaction_part = 0 database row at all times.
|
||||||
// This would be disrupted if existing events that are (reaction_)part = 0 will be redacted.
|
/** @type {({column: string, eventID: string} | {column: string, nextEvent: true})[]} */
|
||||||
// If that is the case, pick a different existing or newly sent event to be (reaction_)part = 0.
|
|
||||||
/** @type {({column: string, eventID: string, value?: number} | {column: string, nextEvent: true})[]} */
|
|
||||||
const promotions = []
|
const promotions = []
|
||||||
for (const column of ["part", "reaction_part"]) {
|
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 no events with part = 0 exist (or will exist), we need to do some management.
|
||||||
if (!candidatesForParts.some(e => e.old[column] === 0)) {
|
if (!eventsToReplace.some(e => e.old[column] === 0)) {
|
||||||
// Try to find an existing event to promote. Bigger order is better.
|
if (eventsToReplace.length) {
|
||||||
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")
|
const order = e => 2*+(e.event_type === "m.room.message") + 1*+(e.event_subtype === "m.text")
|
||||||
candidatesForParts.sort((a, b) => order(b) - order(a))
|
eventsToReplace.sort((a, b) => order(b) - order(a))
|
||||||
if (column === "part") {
|
promotions.push({column, eventID: eventsToReplace[0].old.event_id})
|
||||||
promotions.push({column, eventID: candidatesForParts[0].old.event_id}) // part should be the first one
|
|
||||||
} else if (eventsToSend.length) {
|
|
||||||
promotions.push({column, nextEvent: true}) // reaction_part should be the last one
|
|
||||||
} else {
|
} else {
|
||||||
promotions.push({column, eventID: candidatesForParts[candidatesForParts.length - 1].old.event_id}) // reaction_part should be the last one
|
// No existing events to promote, but new events are being sent. Whatever gets sent will be the next part = 0.
|
||||||
}
|
|
||||||
}
|
|
||||||
// Or, if there are no existing events to promote and new events will be sent, whatever gets sent will be the next part = 0.
|
|
||||||
else {
|
|
||||||
promotions.push({column, nextEvent: true})
|
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)
|
// Now, everything in eventsToSend and eventsToRedact is a real change, but everything in eventsToReplace might not have actually changed!
|
||||||
if (eventsToSend.length && !promotions.length) {
|
// (Example: a MESSAGE_UPDATE for a text+image message - Discord does not allow the image to be changed, but the text might have been.)
|
||||||
const existingReaction = select("reaction", "message_id", {message_id: message.id}).pluck().get()
|
// So we'll remove entries from eventsToReplace that *definitely* cannot have changed. (This is category 4 mentioned above.) Everything remaining *may* have changed.
|
||||||
if (!existingReaction) {
|
eventsToReplace = eventsToReplace.filter(ev => {
|
||||||
const existingPartZero = unchangedEvents.concat(eventsToReplace).find(p => p.old.reaction_part === 0)
|
// Discord does not allow files, images, attachments, or videos to be edited.
|
||||||
assert(existingPartZero) // will exist because a reaction_part=0 always exists and no events are being removed
|
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") {
|
||||||
promotions.push({column: "reaction_part", eventID: existingPartZero.old.event_id, value: 1}) // update the current reaction_part to 1
|
return false
|
||||||
promotions.push({column: "reaction_part", nextEvent: true}) // the newly created event will have reaction_part = 0
|
|
||||||
}
|
}
|
||||||
|
// Discord does not allow stickers to be edited.
|
||||||
|
if (ev.old.event_type === "m.sticker") {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
// Anything else is fair game.
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
// Removing unnecessary properties before returning
|
// Removing unnecessary properties before returning
|
||||||
eventsToRedact = eventsToRedact.map(e => e.old.event_id)
|
eventsToRedact = eventsToRedact.map(e => e.event_id)
|
||||||
eventsToReplace = eventsToReplace.map(e => ({oldID: e.old.event_id, newContent: makeReplacementEventContent(e.old.event_id, e.newFallbackContent, e.newInnerContent)}))
|
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}
|
return {roomID, eventsToReplace, eventsToRedact, eventsToSend, senderMxid, promotions}
|
177
d2m/converters/edit-to-changes.test.js
Normal file
177
d2m/converters/edit-to-changes.test.js
Normal file
|
@ -0,0 +1,177 @@
|
||||||
|
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", 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}])
|
||||||
|
})
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
const {emojiToKey} = require("./emoji-to-key")
|
const {emojiToKey} = require("./emoji-to-key")
|
||||||
const data = require("../../../test/data")
|
const data = require("../../test/data")
|
||||||
const Ty = require("../../types")
|
const Ty = require("../../types")
|
||||||
|
|
||||||
test("emoji2key: unicode emoji works", async t => {
|
test("emoji2key: unicode emoji works", async t => {
|
|
@ -1,9 +1,10 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
|
const assert = require("assert").strict
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {sync, db} = passthrough
|
const {discord, sync, db, select} = passthrough
|
||||||
/** @type {import("../../matrix/file")} */
|
/** @type {import("../../matrix/file")} */
|
||||||
const file = sync.require("../../matrix/file")
|
const file = sync.require("../../matrix/file")
|
||||||
|
|
||||||
|
@ -30,7 +31,7 @@ async function emojisToState(emojis) {
|
||||||
}
|
}
|
||||||
db.prepare("INSERT OR IGNORE INTO emoji (emoji_id, name, animated, mxc_url) VALUES (?, ?, ?, ?)").run(emoji.id, emoji.name, +!!emoji.animated, url)
|
db.prepare("INSERT OR IGNORE INTO emoji (emoji_id, name, animated, mxc_url) VALUES (?, ?, ?, ?)").run(emoji.id, emoji.name, +!!emoji.animated, url)
|
||||||
}).catch(e => {
|
}).catch(e => {
|
||||||
if (e.data?.errcode === "M_TOO_LARGE") { // Very unlikely to happen. Only possible for 3x-series emojis uploaded shortly after animated emojis were introduced, when there was no 256 KB size limit.
|
if (e.data.errcode === "M_TOO_LARGE") { // Very unlikely to happen. Only possible for 3x-series emojis uploaded shortly after animated emojis were introduced, when there was no 256 KB size limit.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.error(`Trying to handle emoji ${emoji.name} (${emoji.id}), but...`)
|
console.error(`Trying to handle emoji ${emoji.name} (${emoji.id}), but...`)
|
||||||
|
@ -66,7 +67,7 @@ async function stickersToState(stickers) {
|
||||||
while (shortcodes.includes(shortcode)) shortcode = shortcode + "~"
|
while (shortcodes.includes(shortcode)) shortcode = shortcode + "~"
|
||||||
shortcodes.push(shortcode)
|
shortcodes.push(shortcode)
|
||||||
|
|
||||||
result.images[shortcode] = {
|
result.images[shortcodes] = {
|
||||||
info: {
|
info: {
|
||||||
mimetype: file.stickerFormat.get(sticker.format_type)?.mime || "image/png"
|
mimetype: file.stickerFormat.get(sticker.format_type)?.mime || "image/png"
|
||||||
},
|
},
|
|
@ -3,40 +3,63 @@
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const Ty = require("../../types")
|
const Ty = require("../../types")
|
||||||
const assert = require("assert").strict
|
const assert = require("assert").strict
|
||||||
|
const {PNG} = require("pngjs")
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {sync, db, select} = passthrough
|
const {sync, db, discord, select} = passthrough
|
||||||
/** @type {import("../../matrix/file")} */
|
/** @type {import("../../matrix/file")} */
|
||||||
const file = sync.require("../../matrix/file")
|
const file = sync.require("../../matrix/file")
|
||||||
/** @type {import("../../matrix/mreq")} */
|
//** @type {import("../../matrix/mreq")} */
|
||||||
const mreq = sync.require("../../matrix/mreq")
|
const mreq = sync.require("../../matrix/mreq")
|
||||||
/** @type {import("../converters/lottie")} */
|
|
||||||
const convertLottie = sync.require("../converters/lottie")
|
const SIZE = 160 // Discord's display size on 1x displays is 160
|
||||||
|
|
||||||
const INFO = {
|
const INFO = {
|
||||||
mimetype: "image/png",
|
mimetype: "image/png",
|
||||||
w: convertLottie.SIZE,
|
w: SIZE,
|
||||||
h: convertLottie.SIZE
|
h: SIZE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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 {DiscordTypes.APIStickerItem} stickerItem
|
* @param {DiscordTypes.APIStickerItem} stickerItem
|
||||||
* @returns {Promise<{mxc_url: string, info: typeof INFO}>}
|
* @returns {Promise<{mxc_url: string, info: typeof INFO}>}
|
||||||
*/
|
*/
|
||||||
async function convert(stickerItem) {
|
async function convert(stickerItem) {
|
||||||
// Reuse sticker if already converted and uploaded
|
|
||||||
const existingMxc = select("lottie", "mxc_url", {sticker_id: stickerItem.id}).pluck().get()
|
const existingMxc = select("lottie", "mxc_url", {sticker_id: stickerItem.id}).pluck().get()
|
||||||
if (existingMxc) return {mxc_url: existingMxc, info: INFO}
|
if (existingMxc) return {mxc_url: existingMxc, info: INFO}
|
||||||
|
const r = await Rlottie
|
||||||
// Fetch sticker data from Discord
|
|
||||||
const res = await fetch(file.DISCORD_IMAGES_BASE + file.sticker(stickerItem))
|
const res = await fetch(file.DISCORD_IMAGES_BASE + file.sticker(stickerItem))
|
||||||
if (res.status !== 200) throw new Error("Sticker data file not found.")
|
if (res.status !== 200) throw new Error("Sticker data file not found.")
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
|
/** @type RlottieWasm */
|
||||||
// Convert to PNG (readable stream)
|
const rh = new r.RlottieWasm()
|
||||||
const readablePng = await convertLottie.convert(text)
|
const status = rh.load(text)
|
||||||
|
if (!status) throw new Error(`Rlottie unable to load ${text.length} byte data file.`)
|
||||||
// Upload to MXC
|
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)
|
||||||
|
// @ts-ignore wrong type from pngjs
|
||||||
|
const readablePng = png.pack()
|
||||||
/** @type {Ty.R.FileUploaded} */
|
/** @type {Ty.R.FileUploaded} */
|
||||||
const root = await mreq.mreq("POST", "/media/v3/upload", readablePng, {
|
const root = await mreq.mreq("POST", "/media/v3/upload", readablePng, {
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -44,8 +67,6 @@ async function convert(stickerItem) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
assert(root.content_uri)
|
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)
|
db.prepare("INSERT INTO lottie (sticker_id, mxc_url) VALUES (?, ?)").run(stickerItem.id, root.content_uri)
|
||||||
return {mxc_url: root.content_uri, info: INFO}
|
return {mxc_url: root.content_uri, info: INFO}
|
||||||
}
|
}
|
101
d2m/converters/message-to-event.embeds.test.js
Normal file
101
d2m/converters/message-to-event.embeds.test.js
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
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 embeds: nothing but a field", async t => {
|
||||||
|
const events = await messageToEvent(data.message_with_embeds.nothing_but_a_field, data.guild.general, {})
|
||||||
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
"m.mentions": {},
|
||||||
|
msgtype: "m.notice",
|
||||||
|
body: "> **Amanda 🎵#2192 :online:"
|
||||||
|
+ "\n> willow tree, branch 0**"
|
||||||
|
+ "\n> **❯ Uptime:**\n> 3m 55s\n> **❯ Memory:**\n> 64.45MB",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: '<blockquote><strong>Amanda 🎵#2192 <img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/LCEqjStXCxvRQccEkuslXEyZ\" title=\":online:\" alt=\":online:\">'
|
||||||
|
+ '<br>willow tree, branch 0</strong>'
|
||||||
|
+ '<br><strong>❯ Uptime:</strong><br>3m 55s'
|
||||||
|
+ '<br><strong>❯ Memory:</strong><br>64.45MB</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> **https://twitter.com/i/status/1707484191963648161**"
|
||||||
|
+ "\n> \n> does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?"
|
||||||
|
+ "\n> \n> **Retweets**"
|
||||||
|
+ "\n> 119"
|
||||||
|
+ "\n> \n> **Likes**"
|
||||||
|
+ "\n> 5581"
|
||||||
|
+ "\n> \n> — Twitter",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: '<blockquote><a href="https://twitter.com/i/user/719631291747078145"><strong>⏺️ dynastic (@dynastic)</strong></a>'
|
||||||
|
+ '<br><br><strong><a href="https://twitter.com/i/status/1707484191963648161">https://twitter.com/i/status/1707484191963648161</a></strong>'
|
||||||
|
+ '<br><br>does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?'
|
||||||
|
+ '<br><br><strong>Retweets</strong><br>119<br><br><strong>Likes</strong><br>5581<br><br>— Twitter</blockquote>'
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
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": {}
|
||||||
|
}])
|
||||||
|
})
|
481
d2m/converters/message-to-event.js
Normal file
481
d2m/converters/message-to-event.js
Normal file
|
@ -0,0 +1,481 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const assert = require("assert").strict
|
||||||
|
const markdown = require("discord-markdown")
|
||||||
|
const pb = require("prettier-bytes")
|
||||||
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
|
||||||
|
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("./lottie")} */
|
||||||
|
const lottie = sync.require("./lottie")
|
||||||
|
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 username = message.mentions.find(ment => ment.id === node.id)?.username || node.id
|
||||||
|
if (mxid && useHTML) {
|
||||||
|
return `<a href="https://matrix.to/#/${mxid}">@${username}</a>`
|
||||||
|
} else {
|
||||||
|
return `@${username}:`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/** @param {{id: string, type: "discordChannel"}} node */
|
||||||
|
channel: node => {
|
||||||
|
const row = select("channel_room", ["room_id", "name", "nick"], {channel_id: node.id}).get()
|
||||||
|
if (!row) {
|
||||||
|
return `<#${node.id}>` // fallback for when this channel is not bridged
|
||||||
|
} else if (useHTML) {
|
||||||
|
return `<a href="https://matrix.to/#/${row.room_id}">#${row.nick || row.name}</a>`
|
||||||
|
} else {
|
||||||
|
return `#${row.nick || row.name}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/** @param {{animated: boolean, name: string, id: string, type: "discordEmoji"}} node */
|
||||||
|
emoji: node => {
|
||||||
|
if (useHTML) {
|
||||||
|
const mxc = select("emoji", "mxc_url", {emoji_id: node.id}).pluck().get()
|
||||||
|
if (mxc) {
|
||||||
|
return `<img data-mx-emoticon height="32" src="${mxc}" title=":${node.name}:" alt=":${node.name}:">`
|
||||||
|
} else { // We shouldn't get here since all emojis should have been added ahead of time in the messageToEvent function.
|
||||||
|
return `<img src="mxc://cadence.moe/${node.id}" data-mx-emoticon alt=":${node.name}:" title=":${node.name}:" height="24">`
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return `:${node.name}:`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
role: node => {
|
||||||
|
const role = guild.roles.find(r => r.id === node.id)
|
||||||
|
if (!role) {
|
||||||
|
return "@&" + node.id // fallback for if the cache breaks. if this happens, fix discord-packets.js to store the role info.
|
||||||
|
} else if (useHTML && role.color) {
|
||||||
|
return `<font color="#${role.color.toString(16)}">@${role.name}</font>`
|
||||||
|
} else if (useHTML) {
|
||||||
|
return `<span data-mx-color="#ffffff" data-mx-bg-color="#414eef">@${role.name}</span>`
|
||||||
|
} else {
|
||||||
|
return `@${role.name}:`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
everyone: node =>
|
||||||
|
"@room",
|
||||||
|
here: node =>
|
||||||
|
"@here"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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 = []
|
||||||
|
|
||||||
|
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
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
@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
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addTextEvent(content, msgtype, {scanMentions}) {
|
||||||
|
content = content.replace(/https:\/\/(?:ptb\.|canary\.|www\.)?discord(?:app)?\.com\/channels\/([0-9]+)\/([0-9]+)\/([0-9]+)/, (whole, guildID, channelID, messageID) => {
|
||||||
|
const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get()
|
||||||
|
const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
|
||||||
|
if (eventID && roomID) {
|
||||||
|
return `https://matrix.to/#/${roomID}/${eventID}`
|
||||||
|
} else {
|
||||||
|
return `${whole} [event not found]`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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?):([^:>]{2,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
|
||||||
|
}))
|
||||||
|
|
||||||
|
let html = markdown.toHTML(content, {
|
||||||
|
discordCallback: getDiscordParseCallbacks(message, guild, true)
|
||||||
|
}, null, null)
|
||||||
|
|
||||||
|
let body = markdown.toHTML(content, {
|
||||||
|
discordCallback: getDiscordParseCallbacks(message, guild, false),
|
||||||
|
discordOnly: true,
|
||||||
|
escapeHTML: false,
|
||||||
|
}, null, null)
|
||||||
|
|
||||||
|
// Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention.
|
||||||
|
if (scanMentions) {
|
||||||
|
const matches = [...content.matchAll(/@ ?([a-z0-9._]+)\b/gi)]
|
||||||
|
if (matches.length && matches.some(m => m[1].match(/[a-z]/i))) {
|
||||||
|
const writtenMentionsText = matches.map(m => m[1].toLowerCase())
|
||||||
|
const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
|
||||||
|
assert(roomID)
|
||||||
|
const {joined} = await di.api.getJoinedMembers(roomID)
|
||||||
|
for (const [mxid, member] of Object.entries(joined)) {
|
||||||
|
if (!userRegex.some(rx => mxid.match(rx))) {
|
||||||
|
const localpart = mxid.match(/@([^:]*)/)
|
||||||
|
assert(localpart)
|
||||||
|
const displayName = member.display_name || localpart[1]
|
||||||
|
if (writtenMentionsText.includes(localpart[1].toLowerCase()) || writtenMentionsText.includes(displayName.toLowerCase())) addMention(mxid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = 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?.startsWith("> <:L1:")) {
|
||||||
|
// If the Discord user is replying to a Matrix user's reply, the fallback is going to contain the emojis and stuff from the bridged rep of the Matrix user's reply quote.
|
||||||
|
// Need to remove that previous reply rep from this fallback body. The fallbody body should only contain the Matrix user's actual message.
|
||||||
|
repliedToContent = repliedToContent.split("\n").slice(2).join("\n")
|
||||||
|
}
|
||||||
|
if (repliedToContent == "") repliedToContent = "[Media]"
|
||||||
|
else if (!repliedToContent) repliedToContent = "[Replied-to message content wasn't provided by Discord]"
|
||||||
|
const repliedToHtml = markdown.toHTML(repliedToContent, {
|
||||||
|
discordCallback: getDiscordParseCallbacks(message, guild, true)
|
||||||
|
}, null, null)
|
||||||
|
const repliedToBody = markdown.toHTML(repliedToContent, {
|
||||||
|
discordCallback: getDiscordParseCallbacks(message, guild, false),
|
||||||
|
discordOnly: true,
|
||||||
|
escapeHTML: false,
|
||||||
|
}, null, null)
|
||||||
|
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 + "**"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text content appears first
|
||||||
|
if (message.content) {
|
||||||
|
await addTextEvent(message.content, msgtype, {scanMentions: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then attachments
|
||||||
|
const attachmentEvents = await Promise.all(message.attachments.map(async attachment => {
|
||||||
|
const emoji =
|
||||||
|
attachment.content_type?.startsWith("image/jp") ? "📸"
|
||||||
|
: attachment.content_type?.startsWith("image/") ? "🖼️"
|
||||||
|
: attachment.content_type?.startsWith("video/") ? "🎞️"
|
||||||
|
: attachment.content_type?.startsWith("text/") ? "📝"
|
||||||
|
: attachment.content_type?.startsWith("audio/") ? "🎶"
|
||||||
|
: "📄"
|
||||||
|
// no native media spoilers in Element, so we'll post a link instead, forcing it to not preview using a blockquote
|
||||||
|
if (attachment.filename.startsWith("SPOILER_")) {
|
||||||
|
return {
|
||||||
|
$type: "m.room.message",
|
||||||
|
"m.mentions": mentions,
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: `${emoji} Uploaded SPOILER file: ${attachment.url} (${pb(attachment.size)})`,
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: `<blockquote>${emoji} Uploaded SPOILER file: <span data-mx-spoiler><a href="${attachment.url}">View</a></span> (${pb(attachment.size)})</blockquote>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// for large files, always link them instead of uploading so I don't use up all the space in the content repo
|
||||||
|
else if (attachment.size > reg.ooye.max_file_size) {
|
||||||
|
return {
|
||||||
|
$type: "m.room.message",
|
||||||
|
"m.mentions": mentions,
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: `${emoji} Uploaded file: ${attachment.url} (${pb(attachment.size)})`,
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: `${emoji} Uploaded file: <a href="${attachment.url}">${attachment.filename}</a> (${pb(attachment.size)})`
|
||||||
|
}
|
||||||
|
} else if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) {
|
||||||
|
return {
|
||||||
|
$type: "m.room.message",
|
||||||
|
"m.mentions": mentions,
|
||||||
|
msgtype: "m.image",
|
||||||
|
url: await file.uploadDiscordFileToMxc(attachment.url),
|
||||||
|
external_url: attachment.url,
|
||||||
|
body: attachment.filename,
|
||||||
|
filename: attachment.filename,
|
||||||
|
info: {
|
||||||
|
mimetype: attachment.content_type,
|
||||||
|
w: attachment.width,
|
||||||
|
h: attachment.height,
|
||||||
|
size: attachment.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (attachment.content_type?.startsWith("video/") && attachment.width && attachment.height) {
|
||||||
|
return {
|
||||||
|
$type: "m.room.message",
|
||||||
|
"m.mentions": mentions,
|
||||||
|
msgtype: "m.video",
|
||||||
|
url: await file.uploadDiscordFileToMxc(attachment.url),
|
||||||
|
external_url: attachment.url,
|
||||||
|
body: attachment.description || attachment.filename,
|
||||||
|
filename: attachment.filename,
|
||||||
|
info: {
|
||||||
|
mimetype: attachment.content_type,
|
||||||
|
w: attachment.width,
|
||||||
|
h: attachment.height,
|
||||||
|
size: attachment.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (attachment.content_type?.startsWith("audio/")) {
|
||||||
|
return {
|
||||||
|
$type: "m.room.message",
|
||||||
|
"m.mentions": mentions,
|
||||||
|
msgtype: "m.audio",
|
||||||
|
url: await file.uploadDiscordFileToMxc(attachment.url),
|
||||||
|
external_url: attachment.url,
|
||||||
|
body: attachment.description || attachment.filename,
|
||||||
|
filename: attachment.filename,
|
||||||
|
info: {
|
||||||
|
mimetype: attachment.content_type,
|
||||||
|
size: attachment.size,
|
||||||
|
duration: attachment.duration_secs ? attachment.duration_secs * 1000 : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
$type: "m.room.message",
|
||||||
|
"m.mentions": mentions,
|
||||||
|
msgtype: "m.file",
|
||||||
|
url: await file.uploadDiscordFileToMxc(attachment.url),
|
||||||
|
external_url: attachment.url,
|
||||||
|
body: attachment.filename,
|
||||||
|
filename: attachment.filename,
|
||||||
|
info: {
|
||||||
|
mimetype: attachment.content_type,
|
||||||
|
size: attachment.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
events.push(...attachmentEvents)
|
||||||
|
|
||||||
|
// Then embeds
|
||||||
|
for (const embed of message.embeds || []) {
|
||||||
|
if (embed.type === "image") {
|
||||||
|
continue // Matrix already does a fine enough job of providing image embeds.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start building up a replica ("rep") of the embed in Discord-markdown format, which we will convert into both plaintext and formatted body at once
|
||||||
|
let repParagraphs = []
|
||||||
|
const makeUrlTitle = (text, url) =>
|
||||||
|
( text && url ? `[**${text}**](${url})`
|
||||||
|
: text ? `**${text}**`
|
||||||
|
: url ? `**${url}**`
|
||||||
|
: "")
|
||||||
|
|
||||||
|
let authorNameText = embed.author?.name || ""
|
||||||
|
if (authorNameText && embed.author?.icon_url) authorNameText = `⏺️ ${authorNameText}` // not using the real image
|
||||||
|
let authorTitle = makeUrlTitle(authorNameText, embed.author?.url)
|
||||||
|
if (authorTitle) repParagraphs.push(authorTitle)
|
||||||
|
|
||||||
|
let title = makeUrlTitle(embed.title, embed.url)
|
||||||
|
if (title) repParagraphs.push(title)
|
||||||
|
|
||||||
|
if (embed.image?.url) repParagraphs.push(`📸 ${embed.image.url}`)
|
||||||
|
if (embed.video?.url) repParagraphs.push(`🎞️ ${embed.video.url}`)
|
||||||
|
|
||||||
|
if (embed.description) repParagraphs.push(embed.description)
|
||||||
|
for (const field of embed.fields || []) {
|
||||||
|
repParagraphs.push(`**${field.name}**\n${field.value}`)
|
||||||
|
}
|
||||||
|
if (embed.footer?.text) repParagraphs.push(`— ${embed.footer.text}`)
|
||||||
|
const repContent = repParagraphs.join("\n\n")
|
||||||
|
const repContentQuoted = repContent.split("\n").map(l => "> " + l).join("\n")
|
||||||
|
|
||||||
|
// Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person
|
||||||
|
await addTextEvent(repContentQuoted, "m.notice", {scanMentions: false})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then stickers
|
||||||
|
if (message.sticker_items) {
|
||||||
|
const stickerEvents = await Promise.all(message.sticker_items.map(async stickerItem => {
|
||||||
|
const format = file.stickerFormat.get(stickerItem.format_type)
|
||||||
|
if (format?.mime === "lottie") {
|
||||||
|
try {
|
||||||
|
const {mxc_url, info} = await lottie.convert(stickerItem)
|
||||||
|
return {
|
||||||
|
$type: "m.sticker",
|
||||||
|
"m.mentions": mentions,
|
||||||
|
body: stickerItem.name,
|
||||||
|
info,
|
||||||
|
url: mxc_url
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
$type: "m.room.message",
|
||||||
|
"m.mentions": mentions,
|
||||||
|
msgtype: "m.notice",
|
||||||
|
body: `Failed to convert Lottie sticker:\n${e.toString()}\n${e.stack}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (format?.mime) {
|
||||||
|
let body = stickerItem.name
|
||||||
|
const sticker = guild.stickers.find(sticker => sticker.id === stickerItem.id)
|
||||||
|
if (sticker && sticker.description) body += ` - ${sticker.description}`
|
||||||
|
return {
|
||||||
|
$type: "m.sticker",
|
||||||
|
"m.mentions": mentions,
|
||||||
|
body,
|
||||||
|
info: {
|
||||||
|
mimetype: format.mime
|
||||||
|
},
|
||||||
|
url: await file.uploadDiscordFileToMxc(file.sticker(stickerItem))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
$type: "m.room.message",
|
||||||
|
"m.mentions": mentions,
|
||||||
|
msgtype: "m.notice",
|
||||||
|
body: `Unsupported sticker format ${format?.mime}. Name: ${stickerItem.name}`
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
events.push(...stickerEvents)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
556
d2m/converters/message-to-event.test.js
Normal file
556
d2m/converters/message-to-event.test.js
Normal file
|
@ -0,0 +1,556 @@
|
||||||
|
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 => {
|
||||||
|
const events = await messageToEvent(data.message.simple_room_mention, data.guild.general, {})
|
||||||
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
"m.mentions": {},
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "#main",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: '<a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe">#main</a>'
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
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: simple message link", async t => {
|
||||||
|
const events = await messageToEvent(data.message.simple_message_link, data.guild.general, {})
|
||||||
|
t.deepEqual(events, [{
|
||||||
|
$type: "m.room.message",
|
||||||
|
"m.mentions": {},
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: '<a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg">https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg</a>'
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
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: <span data-mx-spoiler><a href=\"https://cdn.discordapp.com/attachments/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci\">View</a></span> (74 KB)</blockquote>"
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
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: 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: 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: 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</a><br>What about them?</blockquote></mx-reply>Well, they don't seem to...",
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
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"
|
||||||
|
}])
|
||||||
|
})
|
|
@ -12,7 +12,6 @@ function pinsToList(pins) {
|
||||||
const eventID = select("event_message", "event_id", {message_id: message.id, part: 0}).pluck().get()
|
const eventID = select("event_message", "event_id", {message_id: message.id, part: 0}).pluck().get()
|
||||||
if (eventID) result.push(eventID)
|
if (eventID) result.push(eventID)
|
||||||
}
|
}
|
||||||
result.reverse()
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
const data = require("../../../test/data")
|
const data = require("../../test/data")
|
||||||
const {pinsToList} = require("./pins-to-list")
|
const {pinsToList} = require("./pins-to-list")
|
||||||
|
|
||||||
test("pins2list: converts known IDs, ignores unknown IDs", t => {
|
test("pins2list: converts known IDs, ignores unknown IDs", t => {
|
||||||
const result = pinsToList(data.pins.faked)
|
const result = pinsToList(data.pins.faked)
|
||||||
t.deepEqual(result, [
|
t.deepEqual(result, [
|
||||||
"$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4",
|
"$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg",
|
||||||
"$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA",
|
"$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA",
|
||||||
"$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg"
|
"$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4"
|
||||||
])
|
])
|
||||||
})
|
})
|
|
@ -12,7 +12,7 @@ const utils = sync.require("../../m2d/converters/utils")
|
||||||
* @typedef ReactionRemoveRequest
|
* @typedef ReactionRemoveRequest
|
||||||
* @prop {string} eventID
|
* @prop {string} eventID
|
||||||
* @prop {string | null} mxid
|
* @prop {string | null} mxid
|
||||||
* @prop {bigint} [hash]
|
* @prop {BigInt} [hash]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
|
@ -10,9 +10,7 @@ function fakeSpecificReactionRemoval(userID, emoji, emojiID) {
|
||||||
channel_id: "THE_CHANNEL",
|
channel_id: "THE_CHANNEL",
|
||||||
message_id: "THE_MESSAGE",
|
message_id: "THE_MESSAGE",
|
||||||
user_id: userID,
|
user_id: userID,
|
||||||
emoji: {id: emojiID, name: emoji},
|
emoji: {id: emojiID, name: emoji}
|
||||||
burst: false,
|
|
||||||
type: 0
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
1
d2m/converters/rlottie-wasm.js
Normal file
1
d2m/converters/rlottie-wasm.js
Normal file
File diff suppressed because one or more lines are too long
BIN
src/d2m/converters/rlottie-wasm.wasm → d2m/converters/rlottie-wasm.wasm
Normal file → Executable file
BIN
src/d2m/converters/rlottie-wasm.wasm → d2m/converters/rlottie-wasm.wasm
Normal file → Executable file
Binary file not shown.
|
@ -4,9 +4,8 @@ const assert = require("assert").strict
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {discord, sync, db, select} = passthrough
|
const {discord, sync, db, select} = passthrough
|
||||||
/** @type {import("../../m2d/converters/utils")} */
|
/** @type {import("../../matrix/read-registration")} */
|
||||||
const mxUtils = sync.require("../../m2d/converters/utils")
|
const reg = sync.require("../../matrix/read-registration.js")
|
||||||
const {reg} = require("../../matrix/read-registration.js")
|
|
||||||
|
|
||||||
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
|
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
|
||||||
|
|
||||||
|
@ -30,9 +29,8 @@ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thr
|
||||||
|
|
||||||
const msgtype = creatorMxid ? "m.emote" : "m.text"
|
const msgtype = creatorMxid ? "m.emote" : "m.text"
|
||||||
const template = creatorMxid ? "started a thread:" : "Thread started:"
|
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}`
|
||||||
let body = `${template} ${thread.name} https://matrix.to/#/${threadRoomID}?${via.toString()}`
|
let html = `${template} <a href="https://matrix.to/#/${threadRoomID}">${thread.name}</a>`
|
||||||
let html = `${template} <a href="https://matrix.to/#/${threadRoomID}?${via.toString()}">${thread.name}</a>`
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
msgtype,
|
msgtype,
|
|
@ -1,6 +1,6 @@
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
const {threadToAnnouncement} = require("./thread-to-announcement")
|
const {threadToAnnouncement} = require("./thread-to-announcement")
|
||||||
const data = require("../../../test/data")
|
const data = require("../../test/data")
|
||||||
const Ty = require("../../types")
|
const Ty = require("../../types")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -29,34 +29,16 @@ function mockGetEvent(t, roomID_in, eventID_in, outer) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const viaApi = {
|
|
||||||
async getStateEvent(roomID, type, key) {
|
|
||||||
return {
|
|
||||||
users: {
|
|
||||||
"@_ooye_bot:cadence.moe": 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async getJoinedMembers(roomID) {
|
|
||||||
return {
|
|
||||||
joined: {
|
|
||||||
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
|
|
||||||
"@user:matrix.org": {display_name: null, avatar_url: null}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("thread2announcement: no known creator, no branched from event", async t => {
|
test("thread2announcement: no known creator, no branched from event", async t => {
|
||||||
const content = await threadToAnnouncement("!parent", "!thread", null, {
|
const content = await threadToAnnouncement("!parent", "!thread", null, {
|
||||||
name: "test thread",
|
name: "test thread",
|
||||||
id: "-1"
|
id: "-1"
|
||||||
}, {api: viaApi})
|
})
|
||||||
t.deepEqual(content, {
|
t.deepEqual(content, {
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
|
body: "Thread started: test thread https://matrix.to/#/!thread",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org">test thread</a>`,
|
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread">test thread</a>`,
|
||||||
"m.mentions": {}
|
"m.mentions": {}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -65,12 +47,12 @@ test("thread2announcement: known creator, no branched from event", async t => {
|
||||||
const content = await threadToAnnouncement("!parent", "!thread", "@_ooye_crunch_god:cadence.moe", {
|
const content = await threadToAnnouncement("!parent", "!thread", "@_ooye_crunch_god:cadence.moe", {
|
||||||
name: "test thread",
|
name: "test thread",
|
||||||
id: "-1"
|
id: "-1"
|
||||||
}, {api: viaApi})
|
})
|
||||||
t.deepEqual(content, {
|
t.deepEqual(content, {
|
||||||
msgtype: "m.emote",
|
msgtype: "m.emote",
|
||||||
body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
|
body: "started a thread: test thread https://matrix.to/#/!thread",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: `started a thread: <a href="https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org">test thread</a>`,
|
formatted_body: `started a thread: <a href="https://matrix.to/#/!thread">test thread</a>`,
|
||||||
"m.mentions": {}
|
"m.mentions": {}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -88,15 +70,14 @@ test("thread2announcement: no known creator, branched from discord event", async
|
||||||
msgtype: 'm.text',
|
msgtype: 'm.text',
|
||||||
body: 'testing testing testing'
|
body: 'testing testing testing'
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
...viaApi
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
t.deepEqual(content, {
|
t.deepEqual(content, {
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
|
body: "Thread started: test thread https://matrix.to/#/!thread",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org">test thread</a>`,
|
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread">test thread</a>`,
|
||||||
"m.mentions": {},
|
"m.mentions": {},
|
||||||
"m.relates_to": {
|
"m.relates_to": {
|
||||||
"m.in_reply_to": {
|
"m.in_reply_to": {
|
||||||
|
@ -119,15 +100,14 @@ test("thread2announcement: known creator, branched from discord event", async t
|
||||||
msgtype: 'm.text',
|
msgtype: 'm.text',
|
||||||
body: 'testing testing testing'
|
body: 'testing testing testing'
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
...viaApi
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
t.deepEqual(content, {
|
t.deepEqual(content, {
|
||||||
msgtype: "m.emote",
|
msgtype: "m.emote",
|
||||||
body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
|
body: "started a thread: test thread https://matrix.to/#/!thread",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: `started a thread: <a href="https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org">test thread</a>`,
|
formatted_body: `started a thread: <a href="https://matrix.to/#/!thread">test thread</a>`,
|
||||||
"m.mentions": {},
|
"m.mentions": {},
|
||||||
"m.relates_to": {
|
"m.relates_to": {
|
||||||
"m.in_reply_to": {
|
"m.in_reply_to": {
|
||||||
|
@ -150,15 +130,14 @@ test("thread2announcement: no known creator, branched from matrix event", async
|
||||||
body: "so can you reply to my webhook uwu"
|
body: "so can you reply to my webhook uwu"
|
||||||
},
|
},
|
||||||
sender: "@cadence:cadence.moe"
|
sender: "@cadence:cadence.moe"
|
||||||
}),
|
})
|
||||||
...viaApi
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
t.deepEqual(content, {
|
t.deepEqual(content, {
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
|
body: "Thread started: test thread https://matrix.to/#/!thread",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org">test thread</a>`,
|
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread">test thread</a>`,
|
||||||
"m.mentions": {
|
"m.mentions": {
|
||||||
user_ids: ["@cadence:cadence.moe"]
|
user_ids: ["@cadence:cadence.moe"]
|
||||||
},
|
},
|
|
@ -1,15 +1,10 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert")
|
const assert = require("assert")
|
||||||
const {reg} = require("../../matrix/read-registration")
|
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {select} = 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.
|
* Downcased and stripped username. Can only include a basic set of characters.
|
||||||
* https://spec.matrix.org/v1.6/appendices/#user-identifiers
|
* https://spec.matrix.org/v1.6/appendices/#user-identifiers
|
||||||
|
@ -25,10 +20,6 @@ function downcaseUsername(user) {
|
||||||
.replace(/[^a-z0-9._=/-]*/g, "")
|
.replace(/[^a-z0-9._=/-]*/g, "")
|
||||||
// remove leading and trailing dashes and underscores...
|
// remove leading and trailing dashes and underscores...
|
||||||
.replace(/(?:^[_-]*|[_-]*$)/g, "")
|
.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)
|
// The new length must be at least 2 characters (in other words, it should have some content)
|
||||||
if (downcased.length < 2) {
|
if (downcased.length < 2) {
|
||||||
downcased = user.id
|
downcased = user.id
|
||||||
|
@ -39,7 +30,7 @@ function downcaseUsername(user) {
|
||||||
/** @param {string[]} preferences */
|
/** @param {string[]} preferences */
|
||||||
function* generateLocalpartAlternatives(preferences) {
|
function* generateLocalpartAlternatives(preferences) {
|
||||||
const best = preferences[0]
|
const best = preferences[0]
|
||||||
assert(best)
|
assert.ok(best)
|
||||||
// First, suggest the preferences...
|
// First, suggest the preferences...
|
||||||
for (const localpart of preferences) {
|
for (const localpart of preferences) {
|
||||||
yield localpart
|
yield localpart
|
||||||
|
@ -59,18 +50,15 @@ function* generateLocalpartAlternatives(preferences) {
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
function userToSimName(user) {
|
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")
|
||||||
assert.notEqual(user.discriminator, "0000", `cannot create user for a webhook: ${JSON.stringify(user)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Is sim user already registered?
|
// 1. Is sim user already registered?
|
||||||
const existing = select("sim", "sim_name", {user_id: user.id}).pluck().get()
|
const existing = select("sim", "sim_name", {user_id: user.id}).pluck().get()
|
||||||
assert.equal(existing, null, "Shouldn't try to create a new name for an existing sim")
|
if (existing) return existing
|
||||||
|
|
||||||
// 2. Register based on username (could be new or old format)
|
// 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 downcased = downcaseUsername(user)
|
||||||
const preferences = SPECIAL_USER_MAPPINGS.get(user.id) || [downcased]
|
const preferences = [downcased]
|
||||||
if (user.discriminator.length === 4) { // Old style tag? If user.username is unavailable, try the full tag next
|
if (user.discriminator.length === 4) { // Old style tag? If user.username is unavailable, try the full tag next
|
||||||
preferences.push(downcased + user.discriminator)
|
preferences.push(downcased + user.discriminator)
|
||||||
}
|
}
|
|
@ -1,7 +1,6 @@
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
const tryToCatch = require("try-to-catch")
|
const tryToCatch = require("try-to-catch")
|
||||||
const assert = require("assert")
|
const assert = require("assert")
|
||||||
const data = require("../../../test/data")
|
|
||||||
const {userToSimName} = require("./user-to-mxid")
|
const {userToSimName} = require("./user-to-mxid")
|
||||||
|
|
||||||
test("user2name: cannot create user for a webhook", async t => {
|
test("user2name: cannot create user for a webhook", async t => {
|
||||||
|
@ -40,15 +39,3 @@ test("user2name: uses ID if name becomes too short", t => {
|
||||||
test("user2name: uses ID when name has only disallowed characters", t => {
|
test("user2name: uses ID when name has only disallowed characters", t => {
|
||||||
t.equal(userToSimName({username: "!@#$%^&*", discriminator: "0001", id: "9"}), "9")
|
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
|
|
||||||
})
|
|
|
@ -1,12 +1,16 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const { SnowTransfer } = require("snowtransfer")
|
const registration = require("../matrix/read-registration")
|
||||||
|
const { Endpoints, SnowTransfer } = require("snowtransfer")
|
||||||
const { Client: CloudStorm } = require("cloudstorm")
|
const { Client: CloudStorm } = require("cloudstorm")
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
Endpoints.BASE_HOST = registration.ooye.discord_origin; Endpoints.CDN_URL = registration.ooye.discord_cdn_origin
|
||||||
|
|
||||||
const passthrough = require("../passthrough")
|
const passthrough = require("../passthrough")
|
||||||
const { sync } = passthrough
|
const { sync } = passthrough
|
||||||
|
|
||||||
/** @type {import("./discord-packets")} */
|
/** @type {typeof import("./discord-packets")} */
|
||||||
const discordPackets = sync.require("./discord-packets")
|
const discordPackets = sync.require("./discord-packets")
|
||||||
|
|
||||||
class DiscordClient {
|
class DiscordClient {
|
||||||
|
@ -47,19 +51,7 @@ class DiscordClient {
|
||||||
if (listen !== "no") {
|
if (listen !== "no") {
|
||||||
this.cloud.on("event", message => discordPackets.onPacket(this, message, listen))
|
this.cloud.on("event", message => discordPackets.onPacket(this, message, listen))
|
||||||
}
|
}
|
||||||
|
this.cloud.on("error", console.error)
|
||||||
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)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,11 +4,7 @@
|
||||||
|
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const passthrough = require("../passthrough")
|
const passthrough = require("../passthrough")
|
||||||
const {sync, db} = passthrough
|
const { sync } = passthrough
|
||||||
|
|
||||||
function populateGuildID(guildID, channelID) {
|
|
||||||
db.prepare("UPDATE channel_room SET guild_id = ? WHERE channel_id = ?").run(guildID, channelID)
|
|
||||||
}
|
|
||||||
|
|
||||||
const utils = {
|
const utils = {
|
||||||
/**
|
/**
|
||||||
|
@ -20,8 +16,6 @@ const utils = {
|
||||||
// requiring this later so that the client is already constructed by the time event-dispatcher is loaded
|
// requiring this later so that the client is already constructed by the time event-dispatcher is loaded
|
||||||
/** @type {typeof import("./event-dispatcher")} */
|
/** @type {typeof import("./event-dispatcher")} */
|
||||||
const eventDispatcher = sync.require("./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
|
// Client internals, keep track of the state we need
|
||||||
if (message.t === "READY") {
|
if (message.t === "READY") {
|
||||||
|
@ -40,19 +34,14 @@ const utils = {
|
||||||
channel.guild_id = message.d.id
|
channel.guild_id = message.d.id
|
||||||
arr.push(channel.id)
|
arr.push(channel.id)
|
||||||
client.channels.set(channel.id, channel)
|
client.channels.set(channel.id, channel)
|
||||||
populateGuildID(message.d.id, channel.id)
|
|
||||||
}
|
}
|
||||||
for (const thread of message.d.threads || []) {
|
for (const thread of message.d.threads || []) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
thread.guild_id = message.d.id
|
thread.guild_id = message.d.id
|
||||||
arr.push(thread.id)
|
arr.push(thread.id)
|
||||||
client.channels.set(thread.id, thread)
|
client.channels.set(thread.id, thread)
|
||||||
populateGuildID(message.d.id, thread.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (listen === "full") {
|
if (listen === "full") {
|
||||||
eventDispatcher.checkMissedExpressions(message.d)
|
|
||||||
eventDispatcher.checkMissedPins(client, message.d)
|
|
||||||
eventDispatcher.checkMissedMessages(client, message.d)
|
eventDispatcher.checkMissedMessages(client, message.d)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -98,23 +87,12 @@ const utils = {
|
||||||
|
|
||||||
} else if (message.t === "THREAD_CREATE") {
|
} else if (message.t === "THREAD_CREATE") {
|
||||||
client.channels.set(message.d.id, message.d)
|
client.channels.set(message.d.id, message.d)
|
||||||
if (message.d["guild_id"]) {
|
|
||||||
populateGuildID(message.d["guild_id"], message.d.id)
|
|
||||||
const channels = client.guildChannelMap.get(message.d["guild_id"])
|
|
||||||
if (channels && !channels.includes(message.d.id)) channels.push(message.d.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if (message.t === "CHANNEL_UPDATE" || message.t === "THREAD_UPDATE") {
|
} else if (message.t === "CHANNEL_UPDATE" || message.t === "THREAD_UPDATE") {
|
||||||
client.channels.set(message.d.id, message.d)
|
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") {
|
} else if (message.t === "GUILD_DELETE") {
|
||||||
client.guilds.delete(message.d.id)
|
client.guilds.delete(message.d.id)
|
||||||
const channels = client.guildChannelMap.get(message.d.id)
|
const channels = client.guildChannelMap.get(message.d.id)
|
||||||
|
@ -124,15 +102,14 @@ const utils = {
|
||||||
client.guildChannelMap.delete(message.d.id)
|
client.guildChannelMap.delete(message.d.id)
|
||||||
|
|
||||||
|
|
||||||
} else if (message.t === "CHANNEL_CREATE") {
|
} else if (message.t === "CHANNEL_CREATE" || message.t === "CHANNEL_DELETE") {
|
||||||
|
if (message.t === "CHANNEL_CREATE") {
|
||||||
client.channels.set(message.d.id, message.d)
|
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
|
if (message.d["guild_id"]) { // obj[prop] notation can be used to access a property without typescript complaining that it doesn't exist on all values something can have
|
||||||
populateGuildID(message.d["guild_id"], message.d.id)
|
|
||||||
const channels = client.guildChannelMap.get(message.d["guild_id"])
|
const channels = client.guildChannelMap.get(message.d["guild_id"])
|
||||||
if (channels && !channels.includes(message.d.id)) channels.push(message.d.id)
|
if (channels && !channels.includes(message.d.id)) channels.push(message.d.id)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
} else if (message.t === "CHANNEL_DELETE") {
|
|
||||||
client.channels.delete(message.d.id)
|
client.channels.delete(message.d.id)
|
||||||
if (message.d["guild_id"]) {
|
if (message.d["guild_id"]) {
|
||||||
const channels = client.guildChannelMap.get(message.d["guild_id"])
|
const channels = client.guildChannelMap.get(message.d["guild_id"])
|
||||||
|
@ -142,6 +119,7 @@ const utils = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Event dispatcher for OOYE bridge operations
|
// Event dispatcher for OOYE bridge operations
|
||||||
if (listen === "full") {
|
if (listen === "full") {
|
||||||
|
@ -158,9 +136,6 @@ const utils = {
|
||||||
} else if (message.t === "CHANNEL_PINS_UPDATE") {
|
} else if (message.t === "CHANNEL_PINS_UPDATE") {
|
||||||
await eventDispatcher.onChannelPinsUpdate(client, message.d)
|
await eventDispatcher.onChannelPinsUpdate(client, message.d)
|
||||||
|
|
||||||
} else if (message.t === "CHANNEL_DELETE") {
|
|
||||||
await eventDispatcher.onChannelDelete(client, message.d)
|
|
||||||
|
|
||||||
} else if (message.t === "THREAD_CREATE") {
|
} else if (message.t === "THREAD_CREATE") {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
await eventDispatcher.onThreadCreate(client, message.d)
|
await eventDispatcher.onThreadCreate(client, message.d)
|
||||||
|
@ -177,9 +152,6 @@ const utils = {
|
||||||
} else if (message.t === "MESSAGE_DELETE") {
|
} else if (message.t === "MESSAGE_DELETE") {
|
||||||
await eventDispatcher.onMessageDelete(client, message.d)
|
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") {
|
} else if (message.t === "TYPING_START") {
|
||||||
await eventDispatcher.onTypingStart(client, message.d)
|
await eventDispatcher.onTypingStart(client, message.d)
|
||||||
|
|
||||||
|
@ -188,14 +160,10 @@ const utils = {
|
||||||
|
|
||||||
} else if (message.t === "MESSAGE_REACTION_REMOVE" || message.t === "MESSAGE_REACTION_REMOVE_EMOJI" || message.t === "MESSAGE_REACTION_REMOVE_ALL") {
|
} 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)
|
await eventDispatcher.onSomeReactionsRemoved(client, message.d)
|
||||||
|
|
||||||
} else if (message.t === "INTERACTION_CREATE") {
|
|
||||||
await interactions.dispatchInteraction(message.d)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Let OOYE try to handle errors too
|
// Let OOYE try to handle errors too
|
||||||
await eventDispatcher.onError(client, e, message)
|
eventDispatcher.onError(client, e, message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,5 +1,3 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const assert = require("assert").strict
|
const assert = require("assert").strict
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const util = require("util")
|
const util = require("util")
|
||||||
|
@ -25,18 +23,8 @@ const createSpace = sync.require("./actions/create-space")
|
||||||
const updatePins = sync.require("./actions/update-pins")
|
const updatePins = sync.require("./actions/update-pins")
|
||||||
/** @type {import("../matrix/api")}) */
|
/** @type {import("../matrix/api")}) */
|
||||||
const api = sync.require("../matrix/api")
|
const api = sync.require("../matrix/api")
|
||||||
/** @type {import("../discord/utils")} */
|
/** @type {import("../discord/discord-command-handler")}) */
|
||||||
const dUtils = sync.require("../discord/utils")
|
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
|
let lastReportedEvent = 0
|
||||||
|
|
||||||
|
@ -48,7 +36,7 @@ module.exports = {
|
||||||
* @param {Error} e
|
* @param {Error} e
|
||||||
* @param {import("cloudstorm").IGatewayMessage} gatewayMessage
|
* @param {import("cloudstorm").IGatewayMessage} gatewayMessage
|
||||||
*/
|
*/
|
||||||
async onError(client, e, gatewayMessage) {
|
onError(client, e, gatewayMessage) {
|
||||||
console.error("hit event-dispatcher's error handler with this exception:")
|
console.error("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(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.error(`while handling this ${gatewayMessage.t} gateway event:`)
|
||||||
|
@ -59,30 +47,27 @@ module.exports = {
|
||||||
if (Date.now() - lastReportedEvent < 5000) return
|
if (Date.now() - lastReportedEvent < 5000) return
|
||||||
lastReportedEvent = Date.now()
|
lastReportedEvent = Date.now()
|
||||||
|
|
||||||
const channelID = gatewayMessage.d["channel_id"]
|
const channelID = gatewayMessage.d.channel_id
|
||||||
if (!channelID) return
|
if (!channelID) return
|
||||||
const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
|
const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
|
||||||
if (!roomID) return
|
if (!roomID) return
|
||||||
|
|
||||||
let stackLines = null
|
let stackLines = e.stack.split("\n")
|
||||||
if (e.stack) {
|
|
||||||
stackLines = e.stack.split("\n")
|
|
||||||
let cloudstormLine = stackLines.findIndex(l => l.includes("/node_modules/cloudstorm/"))
|
let cloudstormLine = stackLines.findIndex(l => l.includes("/node_modules/cloudstorm/"))
|
||||||
if (cloudstormLine !== -1) {
|
if (cloudstormLine !== -1) {
|
||||||
stackLines = stackLines.slice(0, cloudstormLine - 2)
|
stackLines = stackLines.slice(0, cloudstormLine - 2)
|
||||||
}
|
}
|
||||||
}
|
api.sendEvent(roomID, "m.room.message", {
|
||||||
|
msgtype: "m.text",
|
||||||
const builder = new mxUtils.MatrixStringBuilder()
|
body: "\u26a0 Bridged event from Discord not delivered. See formatted content for full details.",
|
||||||
builder.addLine("\u26a0 Bridged event from Discord not delivered", "\u26a0 <strong>Bridged event from Discord not delivered</strong>")
|
format: "org.matrix.custom.html",
|
||||||
builder.addLine(`Gateway event: ${gatewayMessage.t}`)
|
formatted_body: "\u26a0 <strong>Bridged event from Discord not delivered</strong>"
|
||||||
builder.addLine(e.toString())
|
+ `<br>Gateway event: ${gatewayMessage.t}`
|
||||||
if (stackLines) {
|
+ `<br>${e.toString()}`
|
||||||
builder.addLine(`Error trace:\n${stackLines.join("\n")}`, `<details><summary>Error trace</summary><pre>${stackLines.join("\n")}</pre></details>`)
|
+ `<br><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>`)
|
+ `<details><summary>Original payload</summary>`
|
||||||
await api.sendEvent(roomID, "m.room.message", {
|
+ `<pre>${util.inspect(gatewayMessage.d, false, 4, false)}</pre></details>`,
|
||||||
...builder.get(),
|
|
||||||
"moe.cadence.ooye.error": {
|
"moe.cadence.ooye.error": {
|
||||||
source: "discord",
|
source: "discord",
|
||||||
payload: gatewayMessage
|
payload: gatewayMessage
|
||||||
|
@ -106,17 +91,10 @@ module.exports = {
|
||||||
const prepared = select("event_message", "event_id", {}, "WHERE message_id = ?").pluck()
|
const prepared = select("event_message", "event_id", {}, "WHERE message_id = ?").pluck()
|
||||||
for (const channel of guild.channels.concat(guild.threads)) {
|
for (const channel of guild.channels.concat(guild.threads)) {
|
||||||
if (!bridgedChannels.includes(channel.id)) continue
|
if (!bridgedChannels.includes(channel.id)) continue
|
||||||
if (!("last_message_id" in channel) || !channel.last_message_id) continue
|
if (!channel.last_message_id) continue
|
||||||
const latestWasBridged = prepared.get(channel.last_message_id)
|
const latestWasBridged = prepared.get(channel.last_message_id)
|
||||||
if (latestWasBridged) continue
|
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. */
|
/** 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`)
|
// 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
|
let messages
|
||||||
|
@ -138,6 +116,7 @@ module.exports = {
|
||||||
for (let i = Math.min(messages.length, latestBridgedMessageIndex)-1; i >= 0; i--) {
|
for (let i = Math.min(messages.length, latestBridgedMessageIndex)-1; i >= 0; i--) {
|
||||||
const simulatedGatewayDispatchData = {
|
const simulatedGatewayDispatchData = {
|
||||||
guild_id: guild.id,
|
guild_id: guild.id,
|
||||||
|
mentions: [],
|
||||||
backfill: true,
|
backfill: true,
|
||||||
...messages[i]
|
...messages[i]
|
||||||
}
|
}
|
||||||
|
@ -146,42 +125,6 @@ module.exports = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* When logging back in, check if the pins on Matrix-side are up to date. If they aren't, update all pins.
|
|
||||||
* Rather than query every room on Matrix-side, we cache the latest pinned message in the database and compare against that.
|
|
||||||
* @param {import("./discord-client")} client
|
|
||||||
* @param {DiscordTypes.GatewayGuildCreateDispatchData} guild
|
|
||||||
*/
|
|
||||||
async checkMissedPins(client, guild) {
|
|
||||||
if (guild.unavailable) return
|
|
||||||
const member = guild.members.find(m => m.user?.id === client.user.id)
|
|
||||||
if (!member) return
|
|
||||||
for (const channel of guild.channels) {
|
|
||||||
if (!("last_pin_timestamp" in channel) || !channel.last_pin_timestamp) continue // Only care about channels that have pins
|
|
||||||
if (!("permission_overwrites" in channel)) continue
|
|
||||||
const lastPin = updatePins.convertTimestamp(channel.last_pin_timestamp)
|
|
||||||
|
|
||||||
// Permissions check
|
|
||||||
const permissions = dUtils.getPermissions(member.roles, guild.roles, client.user.id, channel.permission_overwrites)
|
|
||||||
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.
|
* 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"
|
* See notes.md, "Ignore MESSAGE_UPDATE and bridge THREAD_CREATE as the announcement"
|
||||||
|
@ -189,9 +132,8 @@ module.exports = {
|
||||||
* @param {DiscordTypes.APIThreadChannel} thread
|
* @param {DiscordTypes.APIThreadChannel} thread
|
||||||
*/
|
*/
|
||||||
async onThreadCreate(client, thread) {
|
async onThreadCreate(client, thread) {
|
||||||
const channelID = thread.parent_id || undefined
|
const parentRoomID = select("channel_room", "room_id", {channel_id: thread.parent_id}).pluck().get()
|
||||||
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
|
||||||
if (!parentRoomID) return // Not interested in a thread if we aren't interested in its wider channel (won't autocreate)
|
|
||||||
const threadRoomID = await createRoom.syncRoom(thread.id) // Create room (will share the same inflight as the initial message to the thread)
|
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)
|
await announceThread.announceThread(parentRoomID, threadRoomID, thread)
|
||||||
},
|
},
|
||||||
|
@ -224,21 +166,7 @@ module.exports = {
|
||||||
async onChannelPinsUpdate(client, data) {
|
async onChannelPinsUpdate(client, data) {
|
||||||
const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get()
|
const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get()
|
||||||
if (!roomID) return // No target room to update pins in
|
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)
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -247,28 +175,20 @@ module.exports = {
|
||||||
*/
|
*/
|
||||||
async onMessageCreate(client, message) {
|
async onMessageCreate(client, message) {
|
||||||
if (message.author.username === "Deleted User") return // Nothing we can do for deleted users.
|
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) {
|
if (message.webhook_id) {
|
||||||
const row = select("webhook", "webhook_id", {webhook_id: message.webhook_id}).pluck().get()
|
const row = select("webhook", "webhook_id", {webhook_id: message.webhook_id}).pluck().get()
|
||||||
if (row) return // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it.
|
if (row) {
|
||||||
|
// The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it.
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
/** @type {DiscordTypes.APIGuildChannel} */
|
||||||
|
const channel = client.channels.get(message.channel_id)
|
||||||
|
if (!channel.guild_id) return // Nothing we can do in direct messages.
|
||||||
|
const guild = client.guilds.get(channel.guild_id)
|
||||||
|
|
||||||
if (dUtils.isEphemeralMessage(message)) return // Ephemeral messages are for the eyes of the receiver only!
|
await sendMessage.sendMessage(message, guild),
|
||||||
|
await discordCommandHandler.execute(message, channel, guild)
|
||||||
if (!createRoom.existsOrAutocreatable(channel, guild.id)) return // Check that the sending-to room exists or is autocreatable
|
|
||||||
|
|
||||||
const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id)
|
|
||||||
if (affected) return
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
await sendMessage.sendMessage(message, channel, guild, row)
|
|
||||||
|
|
||||||
retrigger.messageFinishedBridging(message.id)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -276,35 +196,24 @@ module.exports = {
|
||||||
* @param {DiscordTypes.GatewayMessageUpdateDispatchData} data
|
* @param {DiscordTypes.GatewayMessageUpdateDispatchData} data
|
||||||
*/
|
*/
|
||||||
async onMessageUpdate(client, 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
|
|
||||||
|
|
||||||
if (data.webhook_id) {
|
if (data.webhook_id) {
|
||||||
const row = select("webhook", "webhook_id", {webhook_id: data.webhook_id}).pluck().get()
|
const row = select("webhook", "webhook_id", {webhook_id: data.webhook_id}).pluck().get()
|
||||||
if (row) return // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it.
|
if (row) {
|
||||||
|
// The update was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it.
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (dUtils.isEphemeralMessage(data)) return // Ephemeral messages are for the eyes of the receiver only!
|
// 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.
|
||||||
// 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.
|
if (typeof data.content === "string") {
|
||||||
const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id)
|
|
||||||
if (affected) return
|
|
||||||
|
|
||||||
// Check that the sending-to room exists, and deal with Eventual Consistency(TM)
|
|
||||||
if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.onMessageUpdate, client, data)) return
|
|
||||||
|
|
||||||
/** @type {DiscordTypes.GatewayMessageCreateDispatchData} */
|
/** @type {DiscordTypes.GatewayMessageCreateDispatchData} */
|
||||||
// @ts-ignore
|
|
||||||
const message = data
|
const message = data
|
||||||
|
/** @type {DiscordTypes.APIGuildChannel} */
|
||||||
const channel = client.channels.get(message.channel_id)
|
const channel = client.channels.get(message.channel_id)
|
||||||
if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages.
|
if (!channel.guild_id) return // Nothing we can do in direct messages.
|
||||||
const guild = client.guilds.get(channel.guild_id)
|
const guild = client.guilds.get(channel.guild_id)
|
||||||
assert(guild)
|
await editMessage.editMessage(message, guild)
|
||||||
|
}
|
||||||
// @ts-ignore
|
|
||||||
await retrigger.pauseChanges(message.id, editMessage.editMessage(message, guild, row))
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -313,6 +222,7 @@ module.exports = {
|
||||||
*/
|
*/
|
||||||
async onReactionAdd(client, 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.
|
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)
|
await addReaction.addReaction(data)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -329,21 +239,11 @@ module.exports = {
|
||||||
* @param {DiscordTypes.GatewayMessageDeleteDispatchData} data
|
* @param {DiscordTypes.GatewayMessageDeleteDispatchData} data
|
||||||
*/
|
*/
|
||||||
async onMessageDelete(client, data) {
|
async onMessageDelete(client, data) {
|
||||||
speedbump.onMessageDelete(data.id)
|
|
||||||
if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.onMessageDelete, client, data)) return
|
|
||||||
await deleteMessage.deleteMessage(data)
|
await deleteMessage.deleteMessage(data)
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("./discord-client")} client
|
* @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
|
* @param {DiscordTypes.GatewayTypingStartDispatchData} data
|
||||||
*/
|
*/
|
||||||
async onTypingStart(client, data) {
|
async onTypingStart(client, data) {
|
||||||
|
@ -362,6 +262,6 @@ module.exports = {
|
||||||
* @param {DiscordTypes.GatewayGuildEmojisUpdateDispatchData | DiscordTypes.GatewayGuildStickersUpdateDispatchData} data
|
* @param {DiscordTypes.GatewayGuildEmojisUpdateDispatchData | DiscordTypes.GatewayGuildStickersUpdateDispatchData} data
|
||||||
*/
|
*/
|
||||||
async onExpressionsUpdate(client, data) {
|
async onExpressionsUpdate(client, data) {
|
||||||
await createSpace.syncSpaceExpressions(data, false)
|
await createSpace.syncSpaceExpressions(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -3,7 +3,6 @@ module.exports = async function(db) {
|
||||||
const contents = db.prepare("SELECT distinct hashed_profile_content FROM sim_member WHERE hashed_profile_content IS NOT NULL").pluck().all()
|
const contents = db.prepare("SELECT distinct hashed_profile_content FROM sim_member WHERE hashed_profile_content IS NOT NULL").pluck().all()
|
||||||
const stmt = db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE hashed_profile_content = ?")
|
const stmt = db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE hashed_profile_content = ?")
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
/* c8 ignore next 6 */
|
|
||||||
for (let s of contents) {
|
for (let s of contents) {
|
||||||
let b = Buffer.isBuffer(s) ? Uint8Array.from(s) : Uint8Array.from(Buffer.from(s))
|
let b = Buffer.isBuffer(s) ? Uint8Array.from(s) : Uint8Array.from(Buffer.from(s))
|
||||||
const unsignedHash = hasher.h64Raw(b)
|
const unsignedHash = hasher.h64Raw(b)
|
33
src/db/orm-defs.d.ts → db/orm-defs.d.ts
vendored
33
src/db/orm-defs.d.ts → db/orm-defs.d.ts
vendored
|
@ -6,10 +6,6 @@ export type Models = {
|
||||||
nick: string | null
|
nick: string | null
|
||||||
thread_parent: string | null
|
thread_parent: string | null
|
||||||
custom_avatar: string | null
|
custom_avatar: string | null
|
||||||
last_bridged_pin_timestamp: number | null
|
|
||||||
speedbump_id: string | null
|
|
||||||
speedbump_webhook_id: string | null
|
|
||||||
speedbump_checked: number | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
event_message: {
|
event_message: {
|
||||||
|
@ -33,11 +29,6 @@ export type Models = {
|
||||||
privacy_level: number
|
privacy_level: number
|
||||||
}
|
}
|
||||||
|
|
||||||
guild_active: {
|
|
||||||
guild_id: string
|
|
||||||
autocreate: 0 | 1
|
|
||||||
}
|
|
||||||
|
|
||||||
lottie: {
|
lottie: {
|
||||||
sticker_id: string
|
sticker_id: string
|
||||||
mxc_url: string
|
mxc_url: string
|
||||||
|
@ -47,14 +38,7 @@ export type Models = {
|
||||||
room_id: string
|
room_id: string
|
||||||
mxid: string
|
mxid: string
|
||||||
displayname: string | null
|
displayname: string | null
|
||||||
avatar_url: string | null,
|
avatar_url: string | null
|
||||||
power_level: number
|
|
||||||
}
|
|
||||||
|
|
||||||
member_power: {
|
|
||||||
mxid: string
|
|
||||||
room_id: string
|
|
||||||
power_level: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
message_channel: {
|
message_channel: {
|
||||||
|
@ -75,12 +59,6 @@ export type Models = {
|
||||||
hashed_profile_content: number
|
hashed_profile_content: number
|
||||||
}
|
}
|
||||||
|
|
||||||
sim_proxy: {
|
|
||||||
user_id: string
|
|
||||||
proxy_owner_id: string
|
|
||||||
displayname: string
|
|
||||||
}
|
|
||||||
|
|
||||||
webhook: {
|
webhook: {
|
||||||
channel_id: string
|
channel_id: string
|
||||||
webhook_id: string
|
webhook_id: string
|
||||||
|
@ -105,10 +83,6 @@ export type Models = {
|
||||||
emoji_id: string
|
emoji_id: string
|
||||||
guild_id: string
|
guild_id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
media_proxy: {
|
|
||||||
permitted_hash: number
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Prepared<Row> = {
|
export type Prepared<Row> = {
|
||||||
|
@ -116,12 +90,9 @@ export type Prepared<Row> = {
|
||||||
safeIntegers: () => Prepared<{[K in keyof Row]: Row[K] extends number ? BigInt : Row[K]}>
|
safeIntegers: () => Prepared<{[K in keyof Row]: Row[K] extends number ? BigInt : Row[K]}>
|
||||||
raw: () => Prepared<Row[keyof Row][]>
|
raw: () => Prepared<Row[keyof Row][]>
|
||||||
all: (..._: any[]) => Row[]
|
all: (..._: any[]) => Row[]
|
||||||
get: (..._: any[]) => Row | null | undefined
|
get: (..._: any[]) => Row | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AllKeys<U> = U extends any ? keyof U : never
|
export type AllKeys<U> = U extends any ? keyof U : never
|
||||||
export type PickTypeOf<T, K extends AllKeys<T>> = T extends { [k in K]?: any } ? T[K] : never
|
export type PickTypeOf<T, K extends AllKeys<T>> = T extends { [k in K]?: any } ? T[K] : never
|
||||||
export type Merge<U> = {[x in AllKeys<U>]: PickTypeOf<U, x>}
|
export type Merge<U> = {[x in AllKeys<U>]: PickTypeOf<U, x>}
|
||||||
export type Nullable<T> = {[k in keyof T]: T[k] | null}
|
|
||||||
export type Numberish<T> = {[k in keyof T]: T[k] extends number ? (number | bigint) : T[k]}
|
|
||||||
export type ValueOrArray<T> = {[k in keyof T]: T[k][] | T[k]}
|
|
|
@ -8,20 +8,15 @@ const U = require("./orm-defs")
|
||||||
* @template {keyof U.Models[Table]} Col
|
* @template {keyof U.Models[Table]} Col
|
||||||
* @param {Table} table
|
* @param {Table} table
|
||||||
* @param {Col[] | Col} cols
|
* @param {Col[] | Col} cols
|
||||||
* @param {Partial<U.ValueOrArray<U.Numberish<U.Models[Table]>>>} where
|
* @param {Partial<U.Models[Table]>} where
|
||||||
* @param {string} [e]
|
* @param {string} [e]
|
||||||
*/
|
*/
|
||||||
function select(table, cols, where = {}, e = "") {
|
function select(table, cols, where = {}, e = "") {
|
||||||
if (!Array.isArray(cols)) cols = [cols]
|
if (!Array.isArray(cols)) cols = [cols]
|
||||||
const parameters = []
|
const parameters = []
|
||||||
const wheres = Object.entries(where).map(([col, value]) => {
|
const wheres = Object.entries(where).map(([col, value]) => {
|
||||||
if (Array.isArray(value)) {
|
|
||||||
parameters.push(...value)
|
|
||||||
return `"${col}" IN (` + Array(value.length).fill("?").join(", ") + ")"
|
|
||||||
} else {
|
|
||||||
parameters.push(value)
|
parameters.push(value)
|
||||||
return `"${col}" = ?`
|
return `"${col}" = ?`
|
||||||
}
|
|
||||||
})
|
})
|
||||||
const whereString = wheres.length ? " WHERE " + wheres.join(" AND ") : ""
|
const whereString = wheres.length ? " WHERE " + wheres.join(" AND ") : ""
|
||||||
/** @type {U.Prepared<Pick<U.Models[Table], Col>>} */
|
/** @type {U.Prepared<Pick<U.Models[Table], Col>>} */
|
||||||
|
@ -43,14 +38,10 @@ class From {
|
||||||
/** @private @type {Table[]} */
|
/** @private @type {Table[]} */
|
||||||
this.tables = [table]
|
this.tables = [table]
|
||||||
/** @private */
|
/** @private */
|
||||||
this.directions = []
|
|
||||||
/** @private */
|
|
||||||
this.sql = ""
|
this.sql = ""
|
||||||
/** @private */
|
/** @private */
|
||||||
this.cols = []
|
this.cols = []
|
||||||
/** @private */
|
/** @private */
|
||||||
this.makeColsSafe = true
|
|
||||||
/** @private */
|
|
||||||
this.using = []
|
this.using = []
|
||||||
/** @private */
|
/** @private */
|
||||||
this.isPluck = false
|
this.isPluck = false
|
||||||
|
@ -62,14 +53,12 @@ class From {
|
||||||
* @template {keyof U.Models} Table2
|
* @template {keyof U.Models} Table2
|
||||||
* @param {Table2} table
|
* @param {Table2} table
|
||||||
* @param {Col & (keyof U.Models[Table2])} col
|
* @param {Col & (keyof U.Models[Table2])} col
|
||||||
* @param {"inner" | "left"} [direction]
|
|
||||||
*/
|
*/
|
||||||
join(table, col, direction = "inner") {
|
join(table, col) {
|
||||||
/** @type {From<Table | Table2, keyof U.Merge<U.Models[Table | Table2]>>} */
|
/** @type {From<Table | Table2, keyof U.Merge<U.Models[Table | Table2]>>} */
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const r = this
|
const r = this
|
||||||
r.tables.push(table)
|
r.tables.push(table)
|
||||||
r.directions.push(direction.toUpperCase())
|
|
||||||
r.using.push(col)
|
r.using.push(col)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
@ -85,12 +74,6 @@ class From {
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
selectUnsafe(...cols) {
|
|
||||||
this.cols = cols
|
|
||||||
this.makeColsSafe = false
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @template {Col} Select
|
* @template {Col} Select
|
||||||
* @param {Select} col
|
* @param {Select} col
|
||||||
|
@ -99,6 +82,7 @@ class From {
|
||||||
/** @type {Pluck<Table, Select>} */
|
/** @type {Pluck<Table, Select>} */
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const r = this
|
const r = this
|
||||||
|
r.constructor = Pluck
|
||||||
r.cols = [col]
|
r.cols = [col]
|
||||||
r.isPluck = true
|
r.isPluck = true
|
||||||
return r
|
return r
|
||||||
|
@ -113,7 +97,7 @@ class From {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Partial<U.Numberish<U.Models[Table]>>} conditions
|
* @param {Partial<U.Models[Table]>} conditions
|
||||||
*/
|
*/
|
||||||
where(conditions) {
|
where(conditions) {
|
||||||
const wheres = Object.entries(conditions).map(([col, value]) => {
|
const wheres = Object.entries(conditions).map(([col, value]) => {
|
||||||
|
@ -125,13 +109,11 @@ class From {
|
||||||
}
|
}
|
||||||
|
|
||||||
prepare() {
|
prepare() {
|
||||||
if (this.makeColsSafe) this.cols = this.cols.map(k => `"${k}"`)
|
let sql = `SELECT ${this.cols.map(k => `"${k}"`).join(", ")} FROM ${this.tables[0]} `
|
||||||
let sql = `SELECT ${this.cols.join(", ")} FROM ${this.tables[0]} `
|
|
||||||
for (let i = 1; i < this.tables.length; i++) {
|
for (let i = 1; i < this.tables.length; i++) {
|
||||||
const table = this.tables[i]
|
const table = this.tables[i]
|
||||||
const col = this.using[i-1]
|
const col = this.using[i-1]
|
||||||
const direction = this.directions[i-1]
|
sql += `INNER JOIN ${table} USING (${col}) `
|
||||||
sql += `${direction} JOIN ${table} USING (${col}) `
|
|
||||||
}
|
}
|
||||||
sql += this.sql
|
sql += this.sql
|
||||||
/** @type {U.Prepared<Pick<U.Merge<U.Models[Table]>, Col>>} */
|
/** @type {U.Prepared<Pick<U.Merge<U.Models[Table]>, Col>>} */
|
||||||
|
@ -151,7 +133,6 @@ class From {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* c8 ignore start - this code is only used for types and does not actually execute */
|
|
||||||
/**
|
/**
|
||||||
* @template {keyof U.Models} Table
|
* @template {keyof U.Models} Table
|
||||||
* @template {keyof U.Merge<U.Models[Table]>} Col
|
* @template {keyof U.Merge<U.Models[Table]>} Col
|
||||||
|
@ -175,7 +156,6 @@ class Pluck extends From {
|
||||||
return prepared.all(..._)
|
return prepared.all(..._)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* c8 ignore stop */
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @template {keyof U.Models} Table
|
* @template {keyof U.Models} Table
|
|
@ -1,7 +1,7 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
const data = require("../../test/data")
|
const data = require("../test/data")
|
||||||
|
|
||||||
const {db, select, from} = require("../passthrough")
|
const {db, select, from} = require("../passthrough")
|
||||||
|
|
||||||
|
@ -30,11 +30,6 @@ test("orm: select: all, where and pluck works on multiple columns", t => {
|
||||||
t.deepEqual(names, ["cadence [they]"])
|
t.deepEqual(names, ["cadence [they]"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("orm: select: in array works", t => {
|
|
||||||
const ids = select("emoji", "emoji_id", {name: ["online", "upstinky"]}).pluck().all()
|
|
||||||
t.deepEqual(ids, ["288858540888686602", "606664341298872324"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("orm: from: get pluck works", t => {
|
test("orm: from: get pluck works", t => {
|
||||||
const guildID = from("guild_space").pluck("guild_id").and("WHERE space_id = ?").get("!jjWAGMeQdNrVZSSfvz:cadence.moe")
|
const guildID = from("guild_space").pluck("guild_id").and("WHERE space_id = ?").get("!jjWAGMeQdNrVZSSfvz:cadence.moe")
|
||||||
t.equal(guildID, data.guild.general.id)
|
t.equal(guildID, data.guild.general.id)
|
||||||
|
@ -49,22 +44,3 @@ test("orm: from: where and pluck works", t => {
|
||||||
const subtypes = from("event_message").where({message_id: "1141501302736695316"}).pluck("event_subtype").all()
|
const subtypes = from("event_message").where({message_id: "1141501302736695316"}).pluck("event_subtype").all()
|
||||||
t.deepEqual(subtypes.sort(), ["m.image", "m.text"])
|
t.deepEqual(subtypes.sort(), ["m.image", "m.text"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("orm: from: join direction works", t => {
|
|
||||||
const hasOwner = from("sim").join("sim_proxy", "user_id", "left").select("user_id", "proxy_owner_id").where({sim_name: "_pk_zoego"}).get()
|
|
||||||
t.deepEqual(hasOwner, {user_id: "43d378d5-1183-47dc-ab3c-d14e21c3fe58", proxy_owner_id: "196188877885538304"})
|
|
||||||
const hasNoOwner = from("sim").join("sim_proxy", "user_id", "left").select("user_id", "proxy_owner_id").where({sim_name: "crunch_god"}).get()
|
|
||||||
t.deepEqual(hasNoOwner, {user_id: "820865262526005258", proxy_owner_id: null})
|
|
||||||
const hasNoOwnerInner = from("sim").join("sim_proxy", "user_id", "inner").select("user_id", "proxy_owner_id").where({sim_name: "crunch_god"}).get()
|
|
||||||
t.deepEqual(hasNoOwnerInner, undefined)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("orm: select unsafe works (to select complex column names that can't be type verified)", t => {
|
|
||||||
const results = from("member_cache")
|
|
||||||
.join("member_power", "mxid")
|
|
||||||
.join("channel_room", "room_id") // only include rooms that are bridged
|
|
||||||
.and("where member_power.room_id = '*' and member_cache.power_level != member_power.power_level")
|
|
||||||
.selectUnsafe("mxid", "member_cache.room_id", "member_power.power_level")
|
|
||||||
.all()
|
|
||||||
t.equal(results[0].power_level, 100)
|
|
||||||
})
|
|
273
discord/discord-command-handler.js
Normal file
273
discord/discord-command-handler.js
Normal file
|
@ -0,0 +1,273 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const assert = require("assert").strict
|
||||||
|
const util = require("util")
|
||||||
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
const reg = require("../matrix/read-registration")
|
||||||
|
const {addbot} = require("../addbot")
|
||||||
|
|
||||||
|
const {discord, sync, db, select} = require("../passthrough")
|
||||||
|
/** @type {import("../matrix/api")}) */
|
||||||
|
const api = sync.require("../matrix/api")
|
||||||
|
/** @type {import("../matrix/file")} */
|
||||||
|
const file = sync.require("../matrix/file")
|
||||||
|
/** @type {import("../d2m/actions/create-space")} */
|
||||||
|
const createSpace = sync.require("../d2m/actions/create-space")
|
||||||
|
/** @type {import("./utils")} */
|
||||||
|
const utils = sync.require("./utils")
|
||||||
|
|
||||||
|
const PREFIX = "//"
|
||||||
|
|
||||||
|
let buttons = []
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} channelID where to add the button
|
||||||
|
* @param {string} messageID where to add the button
|
||||||
|
* @param {string} emoji emoji to add as a button
|
||||||
|
* @param {string} userID only listen for responses from this user
|
||||||
|
* @returns {Promise<import("discord-api-types/v10").GatewayMessageReactionAddDispatchData>}
|
||||||
|
*/
|
||||||
|
async function addButton(channelID, messageID, emoji, userID) {
|
||||||
|
await discord.snow.channel.createReaction(channelID, messageID, emoji)
|
||||||
|
return new Promise(resolve => {
|
||||||
|
buttons.push({channelID, messageID, userID, resolve, created: Date.now()})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear out old buttons every so often to free memory
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now()
|
||||||
|
buttons = buttons.filter(b => now - b.created < 2*60*60*1000)
|
||||||
|
}, 10*60*1000)
|
||||||
|
|
||||||
|
/** @param {import("discord-api-types/v10").GatewayMessageReactionAddDispatchData} data */
|
||||||
|
function onReactionAdd(data) {
|
||||||
|
const button = buttons.find(b => b.channelID === data.channel_id && b.messageID === data.message_id && b.userID === data.user_id)
|
||||||
|
if (button) {
|
||||||
|
buttons = buttons.filter(b => b !== button) // remove button data so it can't be clicked again
|
||||||
|
button.resolve(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @callback CommandExecute
|
||||||
|
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message
|
||||||
|
* @param {DiscordTypes.APIGuildTextChannel} channel
|
||||||
|
* @param {DiscordTypes.APIGuild} guild
|
||||||
|
* @param {Partial<DiscordTypes.RESTPostAPIChannelMessageJSONBody>} [ctx]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef Command
|
||||||
|
* @property {string[]} aliases
|
||||||
|
* @property {(message: DiscordTypes.GatewayMessageCreateDispatchData, channel: DiscordTypes.APIGuildTextChannel, guild: DiscordTypes.APIGuild) => Promise<any>} execute
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @param {CommandExecute} execute */
|
||||||
|
function replyctx(execute) {
|
||||||
|
/** @type {CommandExecute} */
|
||||||
|
return function(message, channel, guild, ctx = {}) {
|
||||||
|
ctx.message_reference = {
|
||||||
|
message_id: message.id,
|
||||||
|
channel_id: channel.id,
|
||||||
|
guild_id: guild.id,
|
||||||
|
fail_if_not_exists: false
|
||||||
|
}
|
||||||
|
return execute(message, channel, guild, ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {Command[]} */
|
||||||
|
const commands = [{
|
||||||
|
aliases: ["icon", "avatar", "roomicon", "roomavatar", "channelicon", "channelavatar"],
|
||||||
|
execute: replyctx(
|
||||||
|
async (message, channel, guild, ctx) => {
|
||||||
|
// Guard
|
||||||
|
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
|
||||||
|
if (!roomID) return discord.snow.channel.createMessage(channel.id, {
|
||||||
|
...ctx,
|
||||||
|
content: "This channel isn't bridged to the other side."
|
||||||
|
})
|
||||||
|
|
||||||
|
// Current avatar
|
||||||
|
const avatarEvent = await api.getStateEvent(roomID, "m.room.avatar", "")
|
||||||
|
const avatarURLParts = avatarEvent?.url.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
|
||||||
|
let currentAvatarMessage =
|
||||||
|
( avatarURLParts ? `Current room-specific avatar: ${reg.ooye.server_origin}/_matrix/media/r0/download/${avatarURLParts[1]}/${avatarURLParts[2]}`
|
||||||
|
: "No avatar. Now's your time to strike. Use `//icon` again with a link or upload to set the room-specific avatar.")
|
||||||
|
|
||||||
|
// Next potential avatar
|
||||||
|
const nextAvatarURL = message.attachments.find(a => a.content_type?.startsWith("image/"))?.url || message.content.match(/https?:\/\/[^ ]+\.[^ ]+\.(?:png|jpg|jpeg|webp)\b/)?.[0]
|
||||||
|
let nextAvatarMessage =
|
||||||
|
( nextAvatarURL ? `\nYou want to set it to: ${nextAvatarURL}\nHit ✅ to make it happen.`
|
||||||
|
: "")
|
||||||
|
|
||||||
|
const sent = await discord.snow.channel.createMessage(channel.id, {
|
||||||
|
...ctx,
|
||||||
|
content: currentAvatarMessage + nextAvatarMessage
|
||||||
|
})
|
||||||
|
|
||||||
|
if (nextAvatarURL) {
|
||||||
|
addButton(channel.id, sent.id, "✅", message.author.id).then(async data => {
|
||||||
|
const mxcUrl = await file.uploadDiscordFileToMxc(nextAvatarURL)
|
||||||
|
await api.sendState(roomID, "m.room.avatar", "", {
|
||||||
|
url: mxcUrl
|
||||||
|
})
|
||||||
|
db.prepare("UPDATE channel_room SET custom_avatar = ? WHERE channel_id = ?").run(mxcUrl, channel.id)
|
||||||
|
await discord.snow.channel.createMessage(channel.id, {
|
||||||
|
...ctx,
|
||||||
|
content: "Your creation is unleashed. Any complaints will be redirected to Grelbo."
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}, {
|
||||||
|
aliases: ["invite"],
|
||||||
|
execute: replyctx(
|
||||||
|
async (message, channel, guild, ctx) => {
|
||||||
|
// Check guild is bridged
|
||||||
|
const spaceID = select("guild_space", "space_id", {guild_id: guild.id}).pluck().get()
|
||||||
|
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
|
||||||
|
if (!spaceID || !roomID) return discord.snow.channel.createMessage(channel.id, {
|
||||||
|
...ctx,
|
||||||
|
content: "This server isn't bridged to Matrix, so you can't invite Matrix users."
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check CREATE_INSTANT_INVITE permission
|
||||||
|
assert(message.member)
|
||||||
|
const guildPermissions = utils.getPermissions(message.member.roles, guild.roles)
|
||||||
|
if (!(guildPermissions & BigInt(1))) {
|
||||||
|
return discord.snow.channel.createMessage(channel.id, {
|
||||||
|
...ctx,
|
||||||
|
content: "You don't have permission to invite people to this Discord server."
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard against accidental mentions instead of the MXID
|
||||||
|
if (message.content.match(/<[@#:].*>/)) return discord.snow.channel.createMessage(channel.id, {
|
||||||
|
...ctx,
|
||||||
|
content: "You have to say the Matrix ID of the person you want to invite, but you mentioned a Discord user in your message.\nOne way to fix this is by writing `` ` `` backticks `` ` `` around the Matrix ID."
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get named MXID
|
||||||
|
const mxid = message.content.match(/@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)/)?.[0]
|
||||||
|
if (!mxid) return discord.snow.channel.createMessage(channel.id, {
|
||||||
|
...ctx,
|
||||||
|
content: "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`"
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check for existing invite to the space
|
||||||
|
let spaceMember
|
||||||
|
try {
|
||||||
|
spaceMember = await api.getStateEvent(spaceID, "m.room.member", mxid)
|
||||||
|
} catch (e) {}
|
||||||
|
if (spaceMember && spaceMember.membership === "invite") {
|
||||||
|
return discord.snow.channel.createMessage(channel.id, {
|
||||||
|
...ctx,
|
||||||
|
content: `\`${mxid}\` already has an invite, which they haven't accepted yet.`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invite Matrix user if not in space
|
||||||
|
if (!spaceMember || spaceMember.membership !== "join") {
|
||||||
|
await api.inviteToRoom(spaceID, mxid)
|
||||||
|
return discord.snow.channel.createMessage(channel.id, {
|
||||||
|
...ctx,
|
||||||
|
content: `You invited \`${mxid}\` to the server.`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Matrix user *is* in the space, maybe we want to invite them to this channel?
|
||||||
|
let roomMember
|
||||||
|
try {
|
||||||
|
roomMember = await api.getStateEvent(roomID, "m.room.member", mxid)
|
||||||
|
} catch (e) {}
|
||||||
|
if (!roomMember || (roomMember.membership !== "join" && roomMember.membership !== "invite")) {
|
||||||
|
const sent = await discord.snow.channel.createMessage(channel.id, {
|
||||||
|
...ctx,
|
||||||
|
content: `\`${mxid}\` is already in this server. Would you like to additionally invite them to this specific channel?\nHit ✅ to make it happen.`
|
||||||
|
})
|
||||||
|
return addButton(channel.id, sent.id, "✅", message.author.id).then(async data => {
|
||||||
|
await api.inviteToRoom(roomID, mxid)
|
||||||
|
await discord.snow.channel.createMessage(channel.id, {
|
||||||
|
...ctx,
|
||||||
|
content: `You invited \`${mxid}\` to the channel.`
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Matrix user *is* in the space and in the channel.
|
||||||
|
await discord.snow.channel.createMessage(channel.id, {
|
||||||
|
...ctx,
|
||||||
|
content: `\`${mxid}\` is already in this server and this channel.`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}, {
|
||||||
|
aliases: ["addbot"],
|
||||||
|
execute: replyctx(
|
||||||
|
async (message, channel, guild, ctx) => {
|
||||||
|
return discord.snow.channel.createMessage(channel.id, {
|
||||||
|
...ctx,
|
||||||
|
content: addbot()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}, {
|
||||||
|
aliases: ["privacy", "discoverable", "publish", "published"],
|
||||||
|
execute: replyctx(
|
||||||
|
async (message, channel, guild, ctx) => {
|
||||||
|
const current = select("guild_space", "privacy_level", {guild_id: guild.id}).pluck().get()
|
||||||
|
if (current == null) {
|
||||||
|
return discord.snow.channel.createMessage(channel.id, {
|
||||||
|
...ctx,
|
||||||
|
content: "This server isn't bridged to the other side."
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const levels = ["invite", "link", "directory"]
|
||||||
|
const level = levels.findIndex(x => message.content.includes(x))
|
||||||
|
if (level === -1) {
|
||||||
|
return discord.snow.channel.createMessage(channel.id, {
|
||||||
|
...ctx,
|
||||||
|
content: "**Usage: `//privacy <level>`**. This will set who can join the space on Matrix-side. There are three levels:"
|
||||||
|
+ "\n`invite`: Can only join with a direct in-app invite from another Matrix user, or the //invite command."
|
||||||
|
+ "\n`link`: Matrix links can be created and shared like Discord's invite links. `invite` features also work."
|
||||||
|
+ "\n`directory`: Publishes to the Matrix in-app directory, like Server Discovery. Preview enabled. `invite` and `link` also work."
|
||||||
|
+ `\n**Current privacy level: \`${levels[current]}\`**`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(message.member)
|
||||||
|
const guildPermissions = utils.getPermissions(message.member.roles, guild.roles)
|
||||||
|
if (guild.owner_id !== message.author.id && !(guildPermissions & BigInt(0x28))) { // MANAGE_GUILD | ADMINISTRATOR
|
||||||
|
return discord.snow.channel.createMessage(channel.id, {
|
||||||
|
...ctx,
|
||||||
|
content: "You don't have permission to change the privacy level. You need Manage Server or Administrator."
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare("UPDATE guild_space SET privacy_level = ? WHERE guild_id = ?").run(level, guild.id)
|
||||||
|
discord.snow.channel.createMessage(channel.id, {
|
||||||
|
...ctx,
|
||||||
|
content: `Privacy level updated to \`${levels[level]}\`. Changes will apply shortly.`
|
||||||
|
})
|
||||||
|
await createSpace.syncSpaceFully(guild.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}]
|
||||||
|
|
||||||
|
/** @type {CommandExecute} */
|
||||||
|
async function execute(message, channel, guild) {
|
||||||
|
if (!message.content.startsWith(PREFIX)) return
|
||||||
|
const words = message.content.slice(PREFIX.length).split(" ")
|
||||||
|
const commandName = words[0]
|
||||||
|
const command = commands.find(c => c.aliases.includes(commandName))
|
||||||
|
if (!command) return
|
||||||
|
|
||||||
|
await command.execute(message, channel, guild)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.execute = execute
|
||||||
|
module.exports.onReactionAdd = onReactionAdd
|
60
discord/utils.js
Normal file
60
discord/utils.js
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string[]} userRoles
|
||||||
|
* @param {DiscordTypes.APIGuild["roles"]} guildRoles
|
||||||
|
* @param {string} [userID]
|
||||||
|
* @param {DiscordTypes.APIGuildChannel["permission_overwrites"]} [channelOverwrites]
|
||||||
|
*/
|
||||||
|
function getPermissions(userRoles, guildRoles, userID, channelOverwrites) {
|
||||||
|
let allowed = BigInt(0)
|
||||||
|
let everyoneID
|
||||||
|
// Guild allows
|
||||||
|
for (const role of guildRoles) {
|
||||||
|
if (role.name === "@everyone") {
|
||||||
|
allowed |= BigInt(role.permissions)
|
||||||
|
everyoneID = role.id
|
||||||
|
}
|
||||||
|
if (userRoles.includes(role.id)) {
|
||||||
|
allowed |= BigInt(role.permissions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channelOverwrites) {
|
||||||
|
/** @type {((overwrite: Required<DiscordTypes.APIGuildChannel>["permission_overwrites"][0]) => any)[]} */
|
||||||
|
const actions = [
|
||||||
|
// Channel @everyone deny
|
||||||
|
overwrite => overwrite.id === everyoneID && (allowed &= ~BigInt(overwrite.deny)),
|
||||||
|
// Channel @everyone allow
|
||||||
|
overwrite => overwrite.id === everyoneID && (allowed |= BigInt(overwrite.allow)),
|
||||||
|
// Role deny
|
||||||
|
overwrite => userRoles.includes(overwrite.id) && (allowed &= ~BigInt(overwrite.deny)),
|
||||||
|
// Role allow
|
||||||
|
overwrite => userRoles.includes(overwrite.id) && (allowed |= ~BigInt(overwrite.allow)),
|
||||||
|
// User deny
|
||||||
|
overwrite => overwrite.id === userID && (allowed &= ~BigInt(overwrite.deny)),
|
||||||
|
// User allow
|
||||||
|
overwrite => overwrite.id === userID && (allowed |= BigInt(overwrite.allow))
|
||||||
|
]
|
||||||
|
for (let i = 0; i < actions.length; i++) {
|
||||||
|
for (const overwrite of channelOverwrites) {
|
||||||
|
actions[i](overwrite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command interaction responses have a webhook_id for some reason, but still have real author info of a real bot user in the server.
|
||||||
|
* @param {DiscordTypes.APIMessage} message
|
||||||
|
*/
|
||||||
|
function isWebhookMessage(message) {
|
||||||
|
const isInteractionResponse = message.type === 20
|
||||||
|
return message.webhook_id && !isInteractionResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.getPermissions = getPermissions
|
||||||
|
module.exports.isWebhookMessage = isWebhookMessage
|
|
@ -32,13 +32,13 @@ What does it look like on Discord-side?
|
||||||
|
|
||||||
This is an API request to get the pinned messages. To update this, an API request will pin or unpin any specific message, adding or removing it from the list.
|
This is an API request to get the pinned messages. To update this, an API request will pin or unpin any specific message, adding or removing it from the list.
|
||||||
|
|
||||||
## What will the converter do?
|
## What will the converter look like?
|
||||||
|
|
||||||
The converter will be very different in both directions.
|
The converter will be very different in both directions.
|
||||||
|
|
||||||
**For d2m, we will get the list of pinned messages, we will convert each message ID into the ID of an event we already have, and then we will set the entire `m.room.pinned_events` state to that list.**
|
For d2m, we will get the list of pinned messages, we will convert each message ID into the ID of an event we already have, and then we will set the entire `m.room.pinned_events` state to that list.
|
||||||
|
|
||||||
**For m2d, we will have to diff the list of pinned messages against the previous version of the list, and for each event that was pinned or unpinned, we will send an API request to Discord to change its st**ate.
|
For m2d, we will have to diff the list of pinned messages against the previous version of the list, and for each event that was pinned or unpinned, we will send an API request to Discord to change its state.
|
||||||
|
|
||||||
## Missing messages
|
## Missing messages
|
||||||
|
|
||||||
|
@ -53,7 +53,7 @@ In this situation we need to stop and think about the possible paths forward we
|
||||||
|
|
||||||
The latter method would still make the message appear at the bottom of the timeline for most Matrix clients, since for most the timestamp doesn't determine the actual _order._ It would then be confusing why an odd message suddenly appeared, because a pins change isn't that noticable in the room.
|
The latter method would still make the message appear at the bottom of the timeline for most Matrix clients, since for most the timestamp doesn't determine the actual _order._ It would then be confusing why an odd message suddenly appeared, because a pins change isn't that noticable in the room.
|
||||||
|
|
||||||
To avoid this problem, I'll just go with the former method and ignore the message, so Matrix will only have some of the pins that Discord has. We will need to watch out if a Matrix user edits this list of partial pins, because if we _only_ pinned things on Discord that were pinned on Matrix, then pins Matrix doesn't know about would be lost from Discord side.
|
To avoid this problem, I'll just go with the former method and ignore the message, so Matrix will only have some of the pins that Discord has. We will need to watch out if a Matrix user edits this list of partial pins, because if we _only_ pinned things on Discord that were pinned on Matrix, those partial pins Discord would be lost from Discord side.
|
||||||
|
|
||||||
In this situation I will prefer to keep the pins list inconsistent between both sides and only bridge _changes_ to the list.
|
In this situation I will prefer to keep the pins list inconsistent between both sides and only bridge _changes_ to the list.
|
||||||
|
|
||||||
|
@ -61,9 +61,7 @@ If you were implementing this for real, you might have made different decisions
|
||||||
|
|
||||||
## Test data for the d2m converter
|
## Test data for the d2m converter
|
||||||
|
|
||||||
Let's start writing the d2m converter. It's helpful to write automated tests for Out Of Your Element, since this lets you check if it worked without having to start up a local copy of the bridge or mess around with the interface.
|
Let's start writing the d2m converter. It's helpful to write unit tests for Out Of Your Element, since this lets you check if it worked without having to start up a local copy of the bridge or play around with the interface.
|
||||||
|
|
||||||
To test the Discord-to-Matrix pin converter, we'll need some samples of Discord message objects. Then we can put these sample message objects through the converter and check what comes out the other side.
|
|
||||||
|
|
||||||
Normally for getting test data, I would `curl` the Discord API to grab some real data and put it into `data.js` (and possibly also `ooye-test-data.sql`. But this time, I'll fabricate some test data. Here it is:
|
Normally for getting test data, I would `curl` the Discord API to grab some real data and put it into `data.js` (and possibly also `ooye-test-data.sql`. But this time, I'll fabricate some test data. Here it is:
|
||||||
|
|
||||||
|
@ -76,7 +74,7 @@ Normally for getting test data, I would `curl` the Discord API to grab some real
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
"These aren't message objects!" I hear you cry. Correct. I already know that my implementation is not going to care about any properties on these message object other than the IDs, so to save time, I'm just making a list of IDs.
|
"These aren't message objects!" I hear you cry. Correct. I already know that my implementation is not going to care about any properties on these message object other than the IDs, so I'm just making a list of IDs to save time.
|
||||||
|
|
||||||
These IDs were carefully chosen. The first three are already in `ooye-test-data.sql` and are associated with event IDs. This is great, because in our test case, the Discord IDs will be converted to those event IDs. The fourth ID doesn't exist on Matrix-side. This is to test that partial pins are handled as expected, like I wrote in the previous section.
|
These IDs were carefully chosen. The first three are already in `ooye-test-data.sql` and are associated with event IDs. This is great, because in our test case, the Discord IDs will be converted to those event IDs. The fourth ID doesn't exist on Matrix-side. This is to test that partial pins are handled as expected, like I wrote in the previous section.
|
||||||
|
|
||||||
|
@ -106,7 +104,7 @@ index c36f252..4919beb 100644
|
||||||
|
|
||||||
## Writing the d2m converter
|
## Writing the d2m converter
|
||||||
|
|
||||||
We can write a function that operates on this data to convert it to events. This is a _converter,_ not an _action._ It won't _do_ anything by itself. So it goes in the converters folder. I've already planned (in the "What will the converter do?" section) what to do, so writing the function is pretty simple:
|
We can write a function that operates on this data to convert it to events. This is a _converter,_ not an _action._ it won't _do_ anything by itself. So it goes in the converters folder. The actual function is pretty simple since I've already planned what to do:
|
||||||
|
|
||||||
```diff
|
```diff
|
||||||
diff --git a/d2m/converters/pins-to-list.js b/d2m/converters/pins-to-list.js
|
diff --git a/d2m/converters/pins-to-list.js b/d2m/converters/pins-to-list.js
|
||||||
|
@ -135,36 +133,9 @@ index 0000000..e4107be
|
||||||
+module.exports.pinsToList = pinsToList
|
+module.exports.pinsToList = pinsToList
|
||||||
```
|
```
|
||||||
|
|
||||||
### Explaining the code
|
|
||||||
|
|
||||||
All converters have a `function` which does the work, and the function is added to `module.exports` so that other files can use it.
|
|
||||||
|
|
||||||
Importing `select` from `passthrough` lets us do database access. Calling the `select` function can select from OOYE's own SQLite database. If you want to see what's in the database, look at `ooye-test-data.sql` for test data, or open `ooye.db` for real data from your own bridge.
|
|
||||||
|
|
||||||
The comments `// @ts-check`, `/** @type ... */`, and `/** @param ... */` provide type-based autosuggestions when editing in Visual Studio Code.
|
|
||||||
|
|
||||||
Here's the code I haven't yet discussed:
|
|
||||||
|
|
||||||
```js
|
|
||||||
function pinsToList(pins) {
|
|
||||||
const result = []
|
|
||||||
for (const message of pins) {
|
|
||||||
const eventID = select("event_message", "event_id", {message_id: message.id}).pluck().get()
|
|
||||||
if (eventID) result.push(eventID)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
It will go through each `message` in `pins`. For each message, it will look up the corresponding Matrix event in the database, and if found, it will add it to `result`.
|
|
||||||
|
|
||||||
The `select` line will run this SQL: `SELECT event_id FROM event_message WHERE message_id = {the message ID}` and will return the event ID as a string or null.
|
|
||||||
|
|
||||||
For any database experts worried about an SQL query inside a loop, the N+1 problem does not apply to SQLite because the queries are executed in the same process rather than crossing a process (and network) boundary. https://www.sqlite.org/np1queryprob.html
|
|
||||||
|
|
||||||
## Test case for the d2m converter
|
## Test case for the d2m converter
|
||||||
|
|
||||||
There's not much room for bugs in this function. A single manual test that it works would be good enough for me. But since this is an example of how you can add your own, let's add a test case for this. The testing code will take the data we just prepared and process it through the `pinsToList` function we just wrote. Then, it will check the result is what we expected.
|
There's not much room for bugs in this function. A single manual test that it works would be good enough for me. But since this is an example of how you can add your own, let's add a test case for this. We'll take the data we just prepared and process it through the function we just wrote:
|
||||||
|
|
||||||
```diff
|
```diff
|
||||||
diff --git a/d2m/converters/pins-to-list.test.js b/d2m/converters/pins-to-list.test.js
|
diff --git a/d2m/converters/pins-to-list.test.js b/d2m/converters/pins-to-list.test.js
|
||||||
|
@ -206,18 +177,6 @@ index 5cc851e..280503d 100644
|
||||||
|
|
||||||
Good to go.
|
Good to go.
|
||||||
|
|
||||||
### Explaining the code
|
|
||||||
|
|
||||||
`require("supertape")` is a library that helps with testing and printing test results. `data = require("../../test/data")` is the file we edited earlier in the "Test data for the d2m converter" section. `require("./pins-to-list")` is the function we want to test.
|
|
||||||
|
|
||||||
Here is how you declare a test: `test("pins2list: converts known IDs, ignores unknown IDs", t => {` The string describes what you are trying to test and it will be displayed if the test fails.
|
|
||||||
|
|
||||||
`result = pinsToList(data.pins.faked)` is calling the implementation function we wrote.
|
|
||||||
|
|
||||||
`t.deepEqual(actual, expected)` will check whether the `actual` result value is the same as our `expected` result value. If it's not, it'll mark that as a failed test.
|
|
||||||
|
|
||||||
### Run the test!
|
|
||||||
|
|
||||||
```
|
```
|
||||||
><> $ npm t
|
><> $ npm t
|
||||||
|
|
||||||
|
@ -242,19 +201,15 @@ Here is how you declare a test: `test("pins2list: converts known IDs, ignores un
|
||||||
at module.exports (out-of-your-element/node_modules/try-to-catch/lib/try-to-catch.js:7:29)
|
at module.exports (out-of-your-element/node_modules/try-to-catch/lib/try-to-catch.js:7:29)
|
||||||
```
|
```
|
||||||
|
|
||||||
Oh no! (I promise I didn't make it fail for demonstration purposes, this was actually an accident!) Let's see what this bug is. It's returning the right number of IDs, but 2 out of the 3 are incorrect. The green `-` lines are "expected" and the red `+` lines are "actual". The wrong ID `$51f...` must have been taken from _somewhere_ in the test data, so I'll first search the codebase and find where it came from:
|
Oh, this was actually an accident, I didn't make it fail for demonstration purposes! Let's see what this bug is. It's returning the right number of IDs, but 2 out of the 3 are incorrect. The green `-` lines are "expected" and the red `+` lines are "actual". I should check where that wrong ID `$51f...` got taken from.
|
||||||
|
|
||||||
```sql
|
```
|
||||||
-- snipped from ooye-test-data.sql
|
- snip - ooye-test-data.sql
|
||||||
('$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA', 'm.room.message', 'm.text', '1141501302736695316', 0, 1),
|
('$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA', 'm.room.message', 'm.text', '1141501302736695316', 0, 1),
|
||||||
('$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCI', 'm.room.message', 'm.image', '1141501302736695316', 1, 1),
|
('$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCI', 'm.room.message', 'm.image', '1141501302736695316', 1, 1),
|
||||||
```
|
```
|
||||||
|
|
||||||
Explanation: This Discord message `1141501302736695316` is actually part of 2 different Matrix events, `$mtR...` and `$51f...`. This often happens when a Discord user uploads an image with a caption. Matrix doesn't support combined image+text events, so the image and the text have to be bridged to separate events.
|
Context: This Discord message `1141501302736695316` is actually part of 2 different Matrix events, `$mtR...` and `$51f...`. This often happens when a Discord user uploads an image with a caption. Matrix doesn't support combined image+text events, so the image and the text have to be bridged to separate events. We should consider the text to be the primary part, and pin that, and consider the image to be the secondary part, and not pin that.
|
||||||
|
|
||||||
In the current code, `pinsToList` is picking ALL the associated event IDs, and then `.get` is forcing it to limit that list to 1. It doesn't care which, so it's essentially random which event it wants to pin.
|
|
||||||
|
|
||||||
We should make a decision on which event is more important. You can make whatever decision you want - you could even make it pin every event associated with a message - but I've decided that the text should be the primary part and be pinned, and the image should be considered a secondary part and left unpinned.
|
|
||||||
|
|
||||||
We already have a column `part` in the `event_message` table for this reason! When `part = 0`, that's the primary part. I'll edit the converter to actually use that column:
|
We already have a column `part` in the `event_message` table for this reason! When `part = 0`, that's the primary part. I'll edit the converter to actually use that column:
|
||||||
|
|
||||||
|
@ -274,8 +229,6 @@ index e4107be..f401de2 100644
|
||||||
return result
|
return result
|
||||||
```
|
```
|
||||||
|
|
||||||
As long as the database is consistent, this new `select` will return at most 1 event, always choosing the primary part.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
><> $ npm t
|
><> $ npm t
|
||||||
|
|
||||||
|
@ -316,7 +269,7 @@ index 83c31cd..4de84d9 100644
|
||||||
await eventDispatcher.onThreadCreate(client, message.d)
|
await eventDispatcher.onThreadCreate(client, message.d)
|
||||||
```
|
```
|
||||||
|
|
||||||
`event-dispatcher.js` will now check if the event seems reasonable and is allowed in this context. For example, we can only update pins if the channel is actually bridged somewhere. After the check, we'll call the action:
|
`event-dispatcher.js` will now check if the event seems reasonable and is allowed in this context. For example, we can only update pins if the channel is actually bridged somewhere. This should be another quick check which passes to an action to do the API calls:
|
||||||
|
|
||||||
```diff
|
```diff
|
||||||
diff --git a/d2m/event-dispatcher.js b/d2m/event-dispatcher.js
|
diff --git a/d2m/event-dispatcher.js b/d2m/event-dispatcher.js
|
||||||
|
@ -351,7 +304,7 @@ index 0f9f1e6..6e91e9e 100644
|
||||||
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message
|
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message
|
||||||
```
|
```
|
||||||
|
|
||||||
And now I can write the `update-pins.js` action:
|
And now I can create the `update-pins.js` action:
|
||||||
|
|
||||||
```diff
|
```diff
|
||||||
diff --git a/d2m/actions/update-pins.js b/d2m/actions/update-pins.js
|
diff --git a/d2m/actions/update-pins.js b/d2m/actions/update-pins.js
|
||||||
|
@ -386,76 +339,6 @@ index 0000000..40cc358
|
||||||
|
|
||||||
I try to keep as much logic as possible out of the actions and in the converters. This should mean I *never have to unit test the actions themselves.* The actions will be tested manually with the real bot.
|
I try to keep as much logic as possible out of the actions and in the converters. This should mean I *never have to unit test the actions themselves.* The actions will be tested manually with the real bot.
|
||||||
|
|
||||||
## See if it works
|
|
||||||
|
|
||||||
Since the automated tests pass, let's start up the bridge and run our nice new code:
|
|
||||||
|
|
||||||
```
|
|
||||||
node start.js
|
|
||||||
```
|
|
||||||
|
|
||||||
We can try these things and see if they are bridged to Matrix:
|
|
||||||
|
|
||||||
- Pin a recent message on Discord-side
|
|
||||||
- Pin an old message on Discord-side
|
|
||||||
- Unpin a message on Discord-side
|
|
||||||
|
|
||||||
It works like I'd expect!
|
|
||||||
|
|
||||||
## Order of pinned messages
|
|
||||||
|
|
||||||
I expected that to be the end of the guide, but after some time, I noticed a new problem: The pins are in reverse order. How could this happen?
|
|
||||||
|
|
||||||
[After some investigation,](https://gitdab.com/cadence/out-of-your-element/issues/16) it turns out Discord puts the most recently pinned message at the start of the array and displays the array in forwards order, while Matrix puts the most recently pinned message at the end of the array and displays the array in reverse order.
|
|
||||||
|
|
||||||
We can fix this by reversing the order of the list of pins before we store it. The converter can do this:
|
|
||||||
|
|
||||||
```diff
|
|
||||||
diff --git a/d2m/converters/pins-to-list.js b/d2m/converters/pins-to-list.js
|
|
||||||
index f401de2..047bb9f 100644
|
|
||||||
--- a/d2m/converters/pins-to-list.js
|
|
||||||
+++ b/d2m/converters/pins-to-list.js
|
|
||||||
@@ -12,6 +12,7 @@ function pinsToList(pins) {
|
|
||||||
const eventID = select("event_message", "event_id", {message_id: message.id, part: 0}).pluck().get()
|
|
||||||
if (eventID) result.push(eventID)
|
|
||||||
}
|
|
||||||
+ result.reverse()
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Since the results have changed, I'll need to update the test so it expects the new result:
|
|
||||||
|
|
||||||
```diff
|
|
||||||
diff --git a/d2m/converters/pins-to-list.test.js b/d2m/converters/pins-to-list.test.js
|
|
||||||
index c2e3774..92e5678 100644
|
|
||||||
--- a/d2m/converters/pins-to-list.test.js
|
|
||||||
+++ b/d2m/converters/pins-to-list.test.js
|
|
||||||
@@ -5,8 +5,8 @@ const {pinsToList} = require("./pins-to-list")
|
|
||||||
test("pins2list: converts known IDs, ignores unknown IDs", t => {
|
|
||||||
const result = pinsToList(data.pins.faked)
|
|
||||||
t.deepEqual(result, [
|
|
||||||
- "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg",
|
|
||||||
- "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA",
|
|
||||||
- "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4"
|
|
||||||
+ "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4",
|
|
||||||
+ "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA",
|
|
||||||
+ "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg"
|
|
||||||
])
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
><> $ npm t
|
|
||||||
|
|
||||||
144 tests
|
|
||||||
232 passed
|
|
||||||
|
|
||||||
Pass!
|
|
||||||
```
|
|
||||||
|
|
||||||
Next time a message is pinned or unpinned on Discord, OOYE should update the order of all the pins on Matrix.
|
|
||||||
|
|
||||||
## Notes on missed events
|
## Notes on missed events
|
||||||
|
|
||||||
Note that this will only sync pins _when the pins change._ Existing pins from Discord will not be backfilled to Matrix rooms. If I wanted, there's a couple of ways I could address this:
|
Note that this will only sync pins _when the pins change._ Existing pins from Discord will not be backfilled to Matrix rooms. If I wanted, there's a couple of ways I could address this:
|
||||||
|
@ -463,4 +346,4 @@ Note that this will only sync pins _when the pins change._ Existing pins from Di
|
||||||
* I could create a one-shot script in `scripts/update-pins.js` which will sync pins for _all_ Discord channels right away. I can run this after finishing the feature, or if the bot has been offline for some time.
|
* I could create a one-shot script in `scripts/update-pins.js` which will sync pins for _all_ Discord channels right away. I can run this after finishing the feature, or if the bot has been offline for some time.
|
||||||
* I could create a database table that holds the timestamp of the most recently detected pin for each channel - the `last_pin_timestamp` field from the gateway. Every time the bot starts, it would automatically compare the database table against every channel, and if the pins have changed since it last looked, it could automatically update them.
|
* I could create a database table that holds the timestamp of the most recently detected pin for each channel - the `last_pin_timestamp` field from the gateway. Every time the bot starts, it would automatically compare the database table against every channel, and if the pins have changed since it last looked, it could automatically update them.
|
||||||
|
|
||||||
I already have code to backfill missed messages when the bridge starts up. The second option above would add a similar feature for backfilling missed pins. It would be worth considering.
|
I already have a mechanism for backfilling missed messages when the bridge starts up. Option 2 there would add a similar feature for backfilling missed pins. That could be worth considering, but it's less important and more complex. Perhaps we'll come back to it.
|
||||||
|
|
|
@ -1,98 +0,0 @@
|
||||||
## What is PluralKit
|
|
||||||
|
|
||||||
PluralKit is a Discord bot. After a Discord user registers with PK, PK will delete and repost their messages. The reposted messages will be sent by a webhook with a custom display name and avatar. This effectively lets a person assume a custom display name and avatar at will on a per-message basis. People use this for roleplaying and/or dissociative-identity-disorder things. PK is extremely popular.
|
|
||||||
|
|
||||||
## PK terminology
|
|
||||||
|
|
||||||
- **Proxying:** The act of deleting and reposting messages.
|
|
||||||
- **Member:** Identity that messages will be posted by.
|
|
||||||
- **System:** Systems contain members. A system is usually controlled by one Discord account, but it's also possible to have multiple accounts be part of the same system.
|
|
||||||
|
|
||||||
## PK API schema
|
|
||||||
|
|
||||||
https://pluralkit.me/api/models/
|
|
||||||
|
|
||||||
## Experience on OOYE without special PK handling
|
|
||||||
|
|
||||||
1. Message is sent by Discord user and copied to Matrix-side.
|
|
||||||
1. The message is immediately deleted by PK and deleted from Matrix-side.
|
|
||||||
1. The message is resent by the PK webhook and copied to Matrix-side (by @_ooye_bot) with limited authorship information.
|
|
||||||
|
|
||||||
## Experience on Half-Shot's bridge without special PK handling
|
|
||||||
|
|
||||||
1. Message is sent by Discord user and copied to Matrix-side.
|
|
||||||
1. The message is immediately deleted by PK and deleted from Matrix-side.
|
|
||||||
1. The message is resent by the PK webhook and copied to Matrix-side _by a dedicated sim user for that webhook's username._
|
|
||||||
|
|
||||||
If a PK system member changes their display name, the webhook display name will change too. But Half-Shot's bridge can't keep track of webhook message authorship. It uses the webhook's display name to determine whether to reuse the previous sim user account. This makes Half-Shot's bridge create a brand new sim user for the same system member, and causes the Matrix-side member list to eventually fill up with lots of abandoned sim users named @_discord_NUMBERS_NUMBERS_GARBLED_NAME.
|
|
||||||
|
|
||||||
## Goals of special PK handling
|
|
||||||
|
|
||||||
1. Avoid bridging the send-delete-send dance (solution: the speedbump)
|
|
||||||
2. Attribute message authorship to the actual PK system member (solution: system member mapping)
|
|
||||||
3. Avoid creating too many sim users (solution: OOYE sending other webhook messages as @_ooye_bot)
|
|
||||||
|
|
||||||
## What is the speedbump (goal 1)
|
|
||||||
|
|
||||||
When a Discord user sends a message, we can't know whether or not it's about to be deleted by PK.
|
|
||||||
|
|
||||||
If PK doesn't plan to delete the message, we should deliver it straight away to Matrix-side.
|
|
||||||
|
|
||||||
But if PK does plan to delete the message, we shouldn't bridge it at all. We should wait until the PK webhook sends the replacement message, then deliver _that_ message to Matrix-side.
|
|
||||||
|
|
||||||
Unfortunately, we can't see into the future. We don't know if PK will delete the message or not.
|
|
||||||
|
|
||||||
My solution is the speedbump. In speedbump-enabled channels, OOYE will wait a few seconds before delivering the message. The **purpose of the speedbump is to avoid the send-delete-send dance** by not bridging a message until we know it's supposed to stay.
|
|
||||||
|
|
||||||
## Configuring the speedbump
|
|
||||||
|
|
||||||
Nuh-uh. Offering configuration creates an opportunity for misconfiguration. OOYE wants to act in the best possible way with the default settings. In general, everything in OOYE should work in an intelligent, predictable way without having to think about it.
|
|
||||||
|
|
||||||
Since it slows down messages, the speedbump has a negative impact on user experience if it's not needed. So OOYE will automatically activate and deactivate the speedbump if it's necessary. Here's how it works.
|
|
||||||
|
|
||||||
When a message is deleted in a channel, the following logic is triggered:
|
|
||||||
|
|
||||||
1. Discord API: Get the list of webhooks in this channel.
|
|
||||||
1. If there is a webhook owned by PK, speedbump mode is now ON. Otherwise, speedbump mode is now OFF.
|
|
||||||
|
|
||||||
This check is only done every so often to avoid encountering the Discord API's rate limits.
|
|
||||||
|
|
||||||
## PK system member mapping (goal 2)
|
|
||||||
|
|
||||||
PK system members need to be mapped to individual Matrix sim users, so we need to map the member data to all the fields of a Matrix profile. (This will replace the existing logic of `userToSimName`.) I'll map them in this way:
|
|
||||||
|
|
||||||
- **Matrix ID:** `@_ooye_pk_[FIVE_CHAR_ID].example.org`
|
|
||||||
- **Display name:** `[NAME] [[PRONOUNS]]`
|
|
||||||
- **Avatar:** webhook_avatar_url ?? avatar_url
|
|
||||||
|
|
||||||
I'll get this data by calling the PK API for each message: https://api.pluralkit.me/v2/messages/[PK_WEBHOOK_MESSAGE_ID]
|
|
||||||
|
|
||||||
## Special code paths for PK users
|
|
||||||
|
|
||||||
When a message is deleted, re-evaluate speedbump mode if necessary, and store who the PK webhook is for this channel if exists.
|
|
||||||
|
|
||||||
When a message is received and the speedbump is enabled, put it into a queue to be sent a few seconds later.
|
|
||||||
|
|
||||||
When a message is deleted, remove it from the queue.
|
|
||||||
|
|
||||||
When a message is received, if it's from a webhook, and the webhook is in the "speedbump_webhook" table, and the webhook user ID is the public PK instance, then look up member details in the PK API, and use a different MXID mapping algorithm based on those details.
|
|
||||||
|
|
||||||
### Edits should Just Work without any special code paths
|
|
||||||
|
|
||||||
Proxied messages are edited by sending "pk;edit blah blah" as a reply to the message to edit. PK will delete the edit command and use the webhook edit endpoint to update the message.
|
|
||||||
|
|
||||||
OOYE's speedbump will prevent the edit command appearing at all on Matrix-side, and OOYE already understands how to do webhook edits.
|
|
||||||
|
|
||||||
## Database schema
|
|
||||||
|
|
||||||
* channel_room
|
|
||||||
+ speedbump_id - the ID of the webhook that may be proxying in this channel
|
|
||||||
+ speedbump_checked - time in unix seconds when the webhooks were last queried
|
|
||||||
|
|
||||||
## Unsolved problems
|
|
||||||
|
|
||||||
- Improve the contents of PK's reply embeds to be the actual reply text, not the OOYE context preamble
|
|
||||||
- Possibly change OOYE's reply context to be an embed (for visual consistency with replies from PK users)
|
|
||||||
- Possibly extract information from OOYE's reply embed and transform it into an mx-reply structure for Matrix users
|
|
||||||
- Unused or removed system members should be removed from the member list too.
|
|
||||||
- When a Discord user leaves a server, all their system members should leave the member list too. (I also have to solve this for regular non-PK users.)
|
|
|
@ -1,74 +0,0 @@
|
||||||
# Self-service room creation rules
|
|
||||||
|
|
||||||
Before version 3 of Out Of Your Element, new Matrix rooms would be created on-demand when a Discord channel is spoken in for the first time. This has worked pretty well.
|
|
||||||
|
|
||||||
This is done through functions like ensureRoom and ensureSpace in actions:
|
|
||||||
|
|
||||||
```js
|
|
||||||
async function sendMessage(message, channel, guild, row) {
|
|
||||||
const roomID = await createRoom.ensureRoom(message.channel_id)
|
|
||||||
...
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensures the room exists. If it doesn't, creates the room with an accurate initial state.
|
|
||||||
* @param {string} channelID
|
|
||||||
* @returns {Promise<string>} Matrix room ID
|
|
||||||
*/
|
|
||||||
function ensureRoom(channelID) {
|
|
||||||
return _syncRoom(channelID, /* shouldActuallySync */ false) /* calls ensureSpace */
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensures the space exists. If it doesn't, creates the space with an accurate initial state.
|
|
||||||
* @param {DiscordTypes.APIGuild} guild
|
|
||||||
* @returns {Promise<string>} Matrix space ID
|
|
||||||
*/
|
|
||||||
function ensureSpace(guild) {
|
|
||||||
return _syncSpace(guild, /* shouldActuallySync */ false)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
With the introduction of self-service mode, we still want to retain this as a possible mode of operation, since some people prefer to have OOYE handle this administrative work. However, other people prefer to manage the links between channels and rooms themselves, and have control over what new rooms get linked up to.
|
|
||||||
|
|
||||||
Visibly, this is managed through the web interface. The web interface lets moderators enable/disable auto-creation of new rooms, as well as set which channels and rooms are linked together.
|
|
||||||
|
|
||||||
There is a small complication. Not only are Matrix rooms created automatically, their Matrix spaces are also created automatically during room sync: ensureRoom calls ensureSpace. If a user opts in to self-service mode by clicking the specific button in the web portal, we must ensure the _space is not created automatically either,_ because the Matrix user will provide a space to link to.
|
|
||||||
|
|
||||||
To solve this, we need a way to suppress specific guilds from having auto-created spaces. The natural way to represent this is a column on guild_space, but that doesn't work, because each guild_space row requires a guild and space to be linked, and we _don't want_ them to be linked.
|
|
||||||
|
|
||||||
So, internally, OOYE keeps track of this through a new table:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE "guild_active" (
|
|
||||||
"guild_id" TEXT NOT NULL, -- only guilds that are bridged are present in this table
|
|
||||||
"autocreate" INTEGER NOT NULL, -- 0 or 1
|
|
||||||
PRIMARY KEY("guild_id")
|
|
||||||
) WITHOUT ROWID;
|
|
||||||
```
|
|
||||||
|
|
||||||
There is one more complication. When adding a Discord bot through web oauth with a redirect_uri, Discord adds the bot to the server normally, _then_ redirects back to OOYE, and only then does OOYE know which guild the bot was just added to. So, for a short time between the bot being added and the user being redirected, OOYE might receive Discord events in the server before it has the chance to create the guild_active database row.
|
|
||||||
|
|
||||||
So to prevent this, self-service behaviour needs to be an implicit default, and users must firmly choose one system or another to begin using OOYE. It is important for me to design this in a way that doesn't force users to do any extra work or make a choice they don't understand to keep the pre-v3 behaviour.
|
|
||||||
|
|
||||||
So there will be 3 states of whether a guild is self-service or not. At first, it could be absent from the table, in which case events for it will be dropped. Or it could be in the table with autocomplete = 0, in which case only rooms that already exist in channel_room will have messages bridged. Or it could have autocomplete = 1, in which case Matrix rooms will be created as needed, as per the pre-v3 behaviour.
|
|
||||||
|
|
||||||
| Auto-create | Meaning |
|
|
||||||
| -- | ------------ |
|
|
||||||
| 😶🌫️ | Unbridged - waiting |
|
|
||||||
| ❌ | Bridged - self-service |
|
|
||||||
| ✅ | Bridged - auto-create |
|
|
||||||
|
|
||||||
Pressing buttons on web or using the /invite command on a guild will insert a row into guild_active, allowing it to be bridged.
|
|
||||||
|
|
||||||
One more thing. Before v3, when a Matrix room was autocreated it would autocreate the space as well, if it needed to. But now, since nothing will be created until the user takes an action, the guild will always be created directly in response to a request. So room creation can now trust that the guild exists already.
|
|
||||||
|
|
||||||
So here's all the technical changes needed to support self-service in v3:
|
|
||||||
|
|
||||||
- New guild_active table showing whether, and how, a guild is bridged.
|
|
||||||
- When /invite command is used, INSERT OR IGNORE INTO state 1 and ensureRoom + ensureSpace.
|
|
||||||
- When bot is added through "easy mode" web button, REPLACE INTO state 1 and ensureSpace.
|
|
||||||
- When bot is added through "self-service" web button, REPLACE INTO state 0.
|
|
||||||
- Event dispatcher will only ensureRoom if the guild_active state is 1.
|
|
||||||
- createRoom can trust that the space exists because we check that in a calling function.
|
|
||||||
- createRoom will only create other dependencies if the guild is autocreate.
|
|
|
@ -44,13 +44,8 @@ async function ensureWebhook(channelID, forceCreate = false) {
|
||||||
*/
|
*/
|
||||||
async function withWebhook(channelID, callback) {
|
async function withWebhook(channelID, callback) {
|
||||||
const webhook = await ensureWebhook(channelID, false)
|
const webhook = await ensureWebhook(channelID, false)
|
||||||
return callback(webhook).catch(async e => {
|
return callback(webhook).catch(e => {
|
||||||
if (e.message === `{"message": "Unknown Webhook", "code": 10015}`) { // pathetic error handling from SnowTransfer
|
// TODO: check if the error was webhook-related and if webhook.created === false, then: const webhook = ensureWebhook(channelID, true); return callback(webhook)
|
||||||
// Our webhook is gone. Maybe somebody deleted it, or removed and re-added OOYE from the guild.
|
|
||||||
const newWebhook = await ensureWebhook(channelID, true)
|
|
||||||
return callback(newWebhook) // not caught; if the error happens again just throw it instead of looping
|
|
||||||
}
|
|
||||||
|
|
||||||
throw e
|
throw e
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -62,7 +57,7 @@ async function withWebhook(channelID, callback) {
|
||||||
*/
|
*/
|
||||||
async function sendMessageWithWebhook(channelID, data, threadID) {
|
async function sendMessageWithWebhook(channelID, data, threadID) {
|
||||||
const result = await withWebhook(channelID, async webhook => {
|
const result = await withWebhook(channelID, async webhook => {
|
||||||
return discord.snow.webhook.executeWebhook(webhook.id, webhook.token, data, {wait: true, thread_id: threadID})
|
return discord.snow.webhook.executeWebhook(webhook.id, webhook.token, data, {wait: true, thread_id: threadID, disableEveryone: true})
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
}
|
}
|
|
@ -13,10 +13,8 @@ const utils = sync.require("../converters/utils")
|
||||||
*/
|
*/
|
||||||
async function deleteMessage(event) {
|
async function deleteMessage(event) {
|
||||||
const rows = from("event_message").join("message_channel", "message_id").select("channel_id", "message_id").where({event_id: event.redacts}).all()
|
const rows = from("event_message").join("message_channel", "message_id").select("channel_id", "message_id").where({event_id: event.redacts}).all()
|
||||||
db.prepare("DELETE FROM event_message WHERE event_id = ?").run(event.redacts)
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(row.message_id)
|
discord.snow.channel.deleteMessage(row.channel_id, row.message_id, event.content.reason)
|
||||||
await discord.snow.channel.deleteMessage(row.channel_id, row.message_id, event.content.reason)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -25,6 +23,7 @@ async function deleteMessage(event) {
|
||||||
*/
|
*/
|
||||||
async function removeReaction(event) {
|
async function removeReaction(event) {
|
||||||
const hash = utils.getEventIDHash(event.redacts)
|
const hash = utils.getEventIDHash(event.redacts)
|
||||||
|
// TODO: this works but fix the type
|
||||||
const row = from("reaction").join("message_channel", "message_id").select("channel_id", "message_id", "encoded_emoji").where({hashed_event_id: hash}).get()
|
const row = from("reaction").join("message_channel", "message_id").select("channel_id", "message_id", "encoded_emoji").where({hashed_event_id: hash}).get()
|
||||||
if (!row) return
|
if (!row) return
|
||||||
await discord.snow.channel.deleteReactionSelf(row.channel_id, row.message_id, row.encoded_emoji)
|
await discord.snow.channel.deleteReactionSelf(row.channel_id, row.message_id, row.encoded_emoji)
|
|
@ -15,15 +15,9 @@ const channelWebhook = sync.require("./channel-webhook")
|
||||||
const eventToMessage = sync.require("../converters/event-to-message")
|
const eventToMessage = sync.require("../converters/event-to-message")
|
||||||
/** @type {import("../../matrix/api")}) */
|
/** @type {import("../../matrix/api")}) */
|
||||||
const api = sync.require("../../matrix/api")
|
const api = sync.require("../../matrix/api")
|
||||||
/** @type {import("../../d2m/actions/register-user")} */
|
|
||||||
const registerUser = sync.require("../../d2m/actions/register-user")
|
|
||||||
/** @type {import("../../d2m/actions/edit-message")} */
|
|
||||||
const editMessage = sync.require("../../d2m/actions/edit-message")
|
|
||||||
/** @type {import("../actions/emoji-sheet")} */
|
|
||||||
const emojiSheet = sync.require("../actions/emoji-sheet")
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[], pendingFiles?: ({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer | Readable})[]}} message
|
* @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[], pendingFiles?: ({name: string, url: string} | {name: string, url: string, key: string, iv: string} | {name: string, buffer: Buffer | Readable})[]}} message
|
||||||
* @returns {Promise<DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]}>}
|
* @returns {Promise<DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]}>}
|
||||||
*/
|
*/
|
||||||
async function resolvePendingFiles(message) {
|
async function resolvePendingFiles(message) {
|
||||||
|
@ -39,7 +33,7 @@ async function resolvePendingFiles(message) {
|
||||||
// Encrypted file
|
// Encrypted file
|
||||||
const d = crypto.createDecipheriv("aes-256-ctr", Buffer.from(p.key, "base64url"), Buffer.from(p.iv, "base64url"))
|
const d = crypto.createDecipheriv("aes-256-ctr", Buffer.from(p.key, "base64url"), Buffer.from(p.iv, "base64url"))
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
await api.getMedia(p.mxc).then(res => res.body.pipe(d))
|
fetch(p.url).then(res => res.body.pipe(d))
|
||||||
return {
|
return {
|
||||||
name: p.name,
|
name: p.name,
|
||||||
file: d
|
file: d
|
||||||
|
@ -47,7 +41,7 @@ async function resolvePendingFiles(message) {
|
||||||
} else {
|
} else {
|
||||||
// Unencrypted file
|
// Unencrypted file
|
||||||
/** @type {Readable} */ // @ts-ignore
|
/** @type {Readable} */ // @ts-ignore
|
||||||
const body = await api.getMedia(p.mxc).then(res => res.body)
|
const body = await fetch(p.url).then(res => res.body)
|
||||||
return {
|
return {
|
||||||
name: p.name,
|
name: p.name,
|
||||||
file: body
|
file: body
|
||||||
|
@ -79,7 +73,7 @@ async function sendEvent(event) {
|
||||||
|
|
||||||
// no need to sync the matrix member to the other side. but if I did need to, this is where I'd do it
|
// no need to sync the matrix member to the other side. but if I did need to, this is where I'd do it
|
||||||
|
|
||||||
let {messagesToEdit, messagesToSend, messagesToDelete, ensureJoined} = await eventToMessage.eventToMessage(event, guild, {api, snow: discord.snow, mxcDownloader: emojiSheet.getAndConvertEmoji})
|
let {messagesToEdit, messagesToSend, messagesToDelete} = await eventToMessage.eventToMessage(event, guild, {api})
|
||||||
|
|
||||||
messagesToEdit = await Promise.all(messagesToEdit.map(async e => {
|
messagesToEdit = await Promise.all(messagesToEdit.map(async e => {
|
||||||
e.message = await resolvePendingFiles(e.message)
|
e.message = await resolvePendingFiles(e.message)
|
||||||
|
@ -90,7 +84,6 @@ async function sendEvent(event) {
|
||||||
}))
|
}))
|
||||||
|
|
||||||
let eventPart = 0 // 0 is primary, 1 is supporting
|
let eventPart = 0 // 0 is primary, 1 is supporting
|
||||||
const pendingEdits = []
|
|
||||||
|
|
||||||
/** @type {DiscordTypes.APIMessage[]} */
|
/** @type {DiscordTypes.APIMessage[]} */
|
||||||
const messageResponses = []
|
const messageResponses = []
|
||||||
|
@ -101,8 +94,6 @@ async function sendEvent(event) {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const id of messagesToDelete) {
|
for (const id of messagesToDelete) {
|
||||||
db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(id)
|
|
||||||
db.prepare("DELETE FROM event_message WHERE message_id = ?").run(id)
|
|
||||||
await channelWebhook.deleteMessageWithWebhook(channelID, id, threadID)
|
await channelWebhook.deleteMessageWithWebhook(channelID, id, threadID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -114,32 +105,7 @@ async function sendEvent(event) {
|
||||||
|
|
||||||
eventPart = 1
|
eventPart = 1
|
||||||
messageResponses.push(messageResponse)
|
messageResponses.push(messageResponse)
|
||||||
|
|
||||||
/*
|
|
||||||
If the Discord system has a cached link preview embed for one of the links just sent,
|
|
||||||
it will be instantly added as part of `embeds` and there won't be a MESSAGE_UPDATE.
|
|
||||||
To reflect the generated embed back to Matrix, we pretend the message was updated right away.
|
|
||||||
*/
|
|
||||||
const sentEmbedsCount = message.embeds?.length || 0
|
|
||||||
if (messageResponse.embeds.length > sentEmbedsCount) {
|
|
||||||
// not awaiting here because requests to Matrix shouldn't block requests to Discord
|
|
||||||
pendingEdits.push(() =>
|
|
||||||
// @ts-ignore this is a valid message edit payload
|
|
||||||
editMessage.editMessage({
|
|
||||||
id: messageResponse.id,
|
|
||||||
channel_id: messageResponse.channel_id,
|
|
||||||
guild_id: guild.id,
|
|
||||||
embeds: messageResponse.embeds
|
|
||||||
}, guild, null)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for (const user of ensureJoined) {
|
|
||||||
registerUser.ensureSimJoined(user, event.room_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(pendingEdits.map(f => f())) // `await` will propagate any errors during editing
|
|
||||||
|
|
||||||
return messageResponses
|
return messageResponses
|
||||||
}
|
}
|
104
m2d/converters/emoji-sheet.js
Normal file
104
m2d/converters/emoji-sheet.js
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const assert = require("assert").strict
|
||||||
|
const {pipeline} = require("stream").promises
|
||||||
|
const sharp = require("sharp")
|
||||||
|
const {GIFrame} = require("giframe")
|
||||||
|
const utils = require("./utils")
|
||||||
|
const fetch = require("node-fetch").default
|
||||||
|
const streamMimeType = require("stream-mime-type")
|
||||||
|
|
||||||
|
const SIZE = 48
|
||||||
|
const RESULT_WIDTH = 400
|
||||||
|
const IMAGES_ACROSS = Math.floor(RESULT_WIDTH / SIZE)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composite a bunch of Matrix emojis into a kind of spritesheet image to upload to Discord.
|
||||||
|
* @param {string[]} mxcs mxc URLs, in order
|
||||||
|
* @returns {Promise<Buffer>} PNG image
|
||||||
|
*/
|
||||||
|
async function compositeMatrixEmojis(mxcs) {
|
||||||
|
let buffers = await Promise.all(mxcs.map(async mxc => {
|
||||||
|
const abortController = new AbortController()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = utils.getPublicUrlForMxc(mxc)
|
||||||
|
assert(url)
|
||||||
|
|
||||||
|
/** @type {import("node-fetch").Response} res */
|
||||||
|
// If it turns out to be a GIF, we want to abandon the connection without downloading the whole thing.
|
||||||
|
// If we were using connection pooling, we would be forced to download the entire GIF.
|
||||||
|
// So we set no agent to ensure we are not connection pooling.
|
||||||
|
// @ts-ignore the signal is slightly different from the type it wants (still works fine)
|
||||||
|
const res = await fetch(url, {agent: false, signal: abortController.signal})
|
||||||
|
const {stream, mime} = await streamMimeType.getMimeType(res.body)
|
||||||
|
|
||||||
|
if (mime === "image/png" || mime === "image/jpeg" || mime === "image/webp") {
|
||||||
|
/** @type {{info: sharp.OutputInfo, buffer: Buffer}} */
|
||||||
|
const result = await new Promise((resolve, reject) => {
|
||||||
|
const transformer = sharp()
|
||||||
|
.resize(SIZE, SIZE, {fit: "contain", background: {r: 0, g: 0, b: 0, alpha: 0}})
|
||||||
|
.png({compressionLevel: 0})
|
||||||
|
.toBuffer((err, buffer, info) => {
|
||||||
|
/* c8 ignore next */
|
||||||
|
if (err) return reject(err)
|
||||||
|
resolve({info, buffer})
|
||||||
|
})
|
||||||
|
pipeline(
|
||||||
|
stream,
|
||||||
|
transformer
|
||||||
|
)
|
||||||
|
})
|
||||||
|
return result.buffer
|
||||||
|
|
||||||
|
} else if (mime === "image/gif") {
|
||||||
|
const giframe = new GIFrame(0)
|
||||||
|
stream.on("data", chunk => {
|
||||||
|
giframe.feed(chunk)
|
||||||
|
})
|
||||||
|
const frame = await giframe.getFrame()
|
||||||
|
|
||||||
|
const buffer = await sharp(frame.pixels, {raw: {width: frame.width, height: frame.height, channels: 4}})
|
||||||
|
.resize(SIZE, SIZE, {fit: "contain", background: {r: 0, g: 0, b: 0, alpha: 0}})
|
||||||
|
.png({compressionLevel: 0})
|
||||||
|
.toBuffer({resolveWithObject: true})
|
||||||
|
return buffer.data
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// unsupported mime type
|
||||||
|
console.error(`I don't know what a ${mime} emoji is.`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
abortController.abort()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Calculate the size of the final composited image
|
||||||
|
const totalWidth = Math.min(buffers.length, IMAGES_ACROSS) * SIZE
|
||||||
|
const imagesDown = Math.ceil(buffers.length / IMAGES_ACROSS)
|
||||||
|
const totalHeight = imagesDown * SIZE
|
||||||
|
const comp = []
|
||||||
|
let left = 0, top = 0
|
||||||
|
for (const buffer of buffers) {
|
||||||
|
if (Buffer.isBuffer(buffer)) {
|
||||||
|
// Composite the current buffer into the sprite sheet
|
||||||
|
comp.push({left, top, input: buffer})
|
||||||
|
// The next buffer should be placed one slot to the right
|
||||||
|
left += SIZE
|
||||||
|
// If we're out of space to fit the entire next buffer there, wrap to the next line
|
||||||
|
if (left + SIZE > RESULT_WIDTH) {
|
||||||
|
left = 0
|
||||||
|
top += SIZE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = await sharp({create: {width: totalWidth, height: totalHeight, channels: 4, background: {r: 0, g: 0, b: 0, alpha: 0}}})
|
||||||
|
.composite(comp)
|
||||||
|
.png()
|
||||||
|
.toBuffer({resolveWithObject: true})
|
||||||
|
return output.data
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.compositeMatrixEmojis = compositeMatrixEmojis
|
|
@ -40,8 +40,6 @@ function encodeEmoji(input, shortcode) {
|
||||||
"%E2%AD%90", // ⭐
|
"%E2%AD%90", // ⭐
|
||||||
"%F0%9F%90%88", // 🐈
|
"%F0%9F%90%88", // 🐈
|
||||||
"%E2%9D%93", // ❓
|
"%E2%9D%93", // ❓
|
||||||
"%F0%9F%8F%86", // 🏆️
|
|
||||||
"%F0%9F%93%9A", // 📚️
|
|
||||||
]
|
]
|
||||||
|
|
||||||
discordPreferredEncoding =
|
discordPreferredEncoding =
|
528
m2d/converters/event-to-message.js
Normal file
528
m2d/converters/event-to-message.js
Normal file
|
@ -0,0 +1,528 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const Ty = require("../../types")
|
||||||
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
|
const {Readable} = require("stream")
|
||||||
|
const chunk = require("chunk-text")
|
||||||
|
const TurndownService = require("turndown")
|
||||||
|
const assert = require("assert").strict
|
||||||
|
const entities = require("entities")
|
||||||
|
|
||||||
|
const passthrough = require("../../passthrough")
|
||||||
|
const {sync, db, discord, select, from} = passthrough
|
||||||
|
/** @type {import("../converters/utils")} */
|
||||||
|
const utils = sync.require("../converters/utils")
|
||||||
|
/** @type {import("./emoji-sheet")} */
|
||||||
|
const emojiSheet = sync.require("./emoji-sheet")
|
||||||
|
|
||||||
|
/** @type {[RegExp, string][]} */
|
||||||
|
const markdownEscapes = [
|
||||||
|
[/\\/g, '\\\\'],
|
||||||
|
[/\*/g, '\\*'],
|
||||||
|
[/^-/g, '\\-'],
|
||||||
|
[/^\+ /g, '\\+ '],
|
||||||
|
[/^(=+)/g, '\\$1'],
|
||||||
|
[/^(#{1,6}) /g, '\\$1 '],
|
||||||
|
[/`/g, '\\`'],
|
||||||
|
[/^~~~/g, '\\~~~'],
|
||||||
|
[/\[/g, '\\['],
|
||||||
|
[/\]/g, '\\]'],
|
||||||
|
[/^>/g, '\\>'],
|
||||||
|
[/_/g, '\\_'],
|
||||||
|
[/^(\d+)\. /g, '$1\\. ']
|
||||||
|
]
|
||||||
|
|
||||||
|
const turndownService = new TurndownService({
|
||||||
|
hr: "----",
|
||||||
|
headingStyle: "atx",
|
||||||
|
preformattedCode: true,
|
||||||
|
codeBlockStyle: "fenced",
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown characters in the HTML content need to be escaped, though take care not to escape the middle of bare links
|
||||||
|
* @param {string} string
|
||||||
|
*/
|
||||||
|
// @ts-ignore bad type from turndown
|
||||||
|
turndownService.escape = function (string) {
|
||||||
|
const escapedWords = string.split(" ").map(word => {
|
||||||
|
if (word.match(/^https?:\/\//)) {
|
||||||
|
return word
|
||||||
|
} else {
|
||||||
|
return markdownEscapes.reduce(function (accumulator, escape) {
|
||||||
|
return accumulator.replace(escape[0], escape[1])
|
||||||
|
}, word)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return escapedWords.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
turndownService.remove("mx-reply")
|
||||||
|
|
||||||
|
turndownService.addRule("strikethrough", {
|
||||||
|
filter: ["del", "s"],
|
||||||
|
replacement: function (content) {
|
||||||
|
return "~~" + content + "~~"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
turndownService.addRule("underline", {
|
||||||
|
filter: ["u"],
|
||||||
|
replacement: function (content) {
|
||||||
|
return "__" + content + "__"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
turndownService.addRule("blockquote", {
|
||||||
|
filter: "blockquote",
|
||||||
|
replacement: function (content) {
|
||||||
|
content = content.replace(/^\n+|\n+$/g, "")
|
||||||
|
content = content.replace(/^/gm, "> ")
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
turndownService.addRule("spoiler", {
|
||||||
|
filter: function (node, options) {
|
||||||
|
return node.hasAttribute("data-mx-spoiler")
|
||||||
|
},
|
||||||
|
|
||||||
|
replacement: function (content, node) {
|
||||||
|
return "||" + content + "||"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
turndownService.addRule("inlineLink", {
|
||||||
|
filter: function (node, options) {
|
||||||
|
return (
|
||||||
|
node.nodeName === "A" &&
|
||||||
|
node.getAttribute("href")
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
replacement: function (content, node) {
|
||||||
|
if (node.getAttribute("data-user-id")) return `<@${node.getAttribute("data-user-id")}>`
|
||||||
|
if (node.getAttribute("data-channel-id")) return `<#${node.getAttribute("data-channel-id")}>`
|
||||||
|
const href = node.getAttribute("href")
|
||||||
|
let brackets = ["", ""]
|
||||||
|
if (href.startsWith("https://matrix.to")) brackets = ["<", ">"]
|
||||||
|
return "[" + content + "](" + brackets[0] + href + brackets[1] + ")"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @type {string[]} SPRITE SHEET EMOJIS FEATURE: mxc urls for the currently processing message */
|
||||||
|
let endOfMessageEmojis = []
|
||||||
|
turndownService.addRule("emoji", {
|
||||||
|
filter: function (node, options) {
|
||||||
|
if (node.nodeName !== "IMG" || !node.hasAttribute("data-mx-emoticon") || !node.getAttribute("src") || !node.getAttribute("title")) return false
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
|
||||||
|
replacement: function (content, node) {
|
||||||
|
const mxcUrl = node.getAttribute("src")
|
||||||
|
// Get the known emoji from the database. (We may not be able to actually use this if it was from another server.)
|
||||||
|
const row = select("emoji", ["emoji_id", "name", "animated"], {mxc_url: mxcUrl}).get()
|
||||||
|
// Also guess a suitable emoji based on the ID (if available) or name
|
||||||
|
let guess = null
|
||||||
|
const guessedName = node.getAttribute("title").replace(/^:|:$/g, "")
|
||||||
|
for (const guild of discord.guilds.values()) {
|
||||||
|
/** @type {{name: string, id: string, animated: number}[]} */
|
||||||
|
// @ts-ignore
|
||||||
|
const emojis = guild.emojis
|
||||||
|
const match = emojis.find(e => e.id === row?.emoji_id) || emojis.find(e => e.name === guessedName) || emojis.find(e => e.name?.toLowerCase() === guessedName.toLowerCase())
|
||||||
|
if (match) {
|
||||||
|
guess = match
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (guess) {
|
||||||
|
// We know an emoji, and we can use it
|
||||||
|
const animatedChar = guess.animated ? "a" : ""
|
||||||
|
return `<${animatedChar}:${guess.name}:${guess.id}>`
|
||||||
|
} else if (endOfMessageEmojis.includes(mxcUrl)) {
|
||||||
|
// We can't locate or use a suitable emoji. After control returns, it will rewind over this, delete this section, and upload the emojis as a sprite sheet.
|
||||||
|
return `<::>`
|
||||||
|
} else {
|
||||||
|
// We prefer not to upload this as a sprite sheet because the emoji is not at the end of the message, it is in the middle.
|
||||||
|
return `[${node.getAttribute("title")}](${utils.getPublicUrlForMxc(mxcUrl)})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
turndownService.addRule("fencedCodeBlock", {
|
||||||
|
filter: function (node, options) {
|
||||||
|
return (
|
||||||
|
options.codeBlockStyle === "fenced" &&
|
||||||
|
node.nodeName === "PRE" &&
|
||||||
|
node.firstChild &&
|
||||||
|
node.firstChild.nodeName === "CODE"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
replacement: function (content, node, options) {
|
||||||
|
const className = node.firstChild.getAttribute("class") || ""
|
||||||
|
const language = (className.match(/language-(\S+)/) || [null, ""])[1]
|
||||||
|
const code = node.firstChild
|
||||||
|
const visibleCode = code.childNodes.map(c => c.nodeName === "BR" ? "\n" : c.textContent).join("").replace(/\n*$/g, "")
|
||||||
|
|
||||||
|
var fence = "```"
|
||||||
|
|
||||||
|
return (
|
||||||
|
fence + language + "\n" +
|
||||||
|
visibleCode +
|
||||||
|
"\n" + fence
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} roomID
|
||||||
|
* @param {string} mxid
|
||||||
|
* @returns {Promise<{displayname?: string?, avatar_url?: string?}>}
|
||||||
|
*/
|
||||||
|
async function getMemberFromCacheOrHomeserver(roomID, mxid, api) {
|
||||||
|
const row = select("member_cache", ["displayname", "avatar_url"], {room_id: roomID, mxid}).get()
|
||||||
|
if (row) return row
|
||||||
|
return api.getStateEvent(roomID, "m.room.member", mxid).then(event => {
|
||||||
|
db.prepare("REPLACE INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?)").run(roomID, mxid, event?.displayname || null, event?.avatar_url || null)
|
||||||
|
return event
|
||||||
|
}).catch(() => {
|
||||||
|
return {displayname: null, avatar_url: null}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a display name into one chunk containing <=80 characters, and another chunk containing the rest of the characters. Splits on
|
||||||
|
* whitespace if possible.
|
||||||
|
* These chunks, respectively, go in the display name, and at the top of the message.
|
||||||
|
* If the second part isn't empty, it'll also contain boldening markdown and a line break at the end, so that regardless of its value it
|
||||||
|
* can be prepended to the message content as-is.
|
||||||
|
* @summary Splits too-long Matrix names into a display name chunk and a message content chunk.
|
||||||
|
* @param {string} displayName - The Matrix side display name to chop up.
|
||||||
|
* @returns {[string, string]} [shortened display name, display name runoff]
|
||||||
|
*/
|
||||||
|
function splitDisplayName(displayName) {
|
||||||
|
/** @type {string[]} */
|
||||||
|
let displayNameChunks = chunk(displayName, 80)
|
||||||
|
|
||||||
|
if (displayNameChunks.length === 1) {
|
||||||
|
return [displayName, ""]
|
||||||
|
} else {
|
||||||
|
const displayNamePreRunoff = displayNameChunks[0]
|
||||||
|
// displayNameRunoff is a slice of the original rather than a concatenation of the rest of the chunks in order to preserve whatever whitespace it was broken on.
|
||||||
|
const displayNameRunoff = `**${displayName.slice(displayNamePreRunoff.length + 1)}**\n`
|
||||||
|
|
||||||
|
return [displayNamePreRunoff, displayNameRunoff]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* At the time of this executing, we know what the end of message emojis are, and we know that at least one of them is unknown.
|
||||||
|
* This function will strip them from the content and generate the correct pending file of the sprite sheet.
|
||||||
|
* @param {string} content
|
||||||
|
* @param {{id: string, name: string}[]} attachments
|
||||||
|
* @param {({name: string, url: string} | {name: string, url: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} pendingFiles
|
||||||
|
*/
|
||||||
|
async function uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles) {
|
||||||
|
if (!content.includes("<::>")) return content // No unknown emojis, nothing to do
|
||||||
|
// Remove known and unknown emojis from the end of the message
|
||||||
|
const r = /<a?:[a-zA-Z0-9_]*:[0-9]*>\s*$/
|
||||||
|
while (content.match(r)) {
|
||||||
|
content = content.replace(r, "")
|
||||||
|
}
|
||||||
|
// Create a sprite sheet of known and unknown emojis from the end of the message
|
||||||
|
const buffer = await emojiSheet.compositeMatrixEmojis(endOfMessageEmojis)
|
||||||
|
// Attach it
|
||||||
|
const name = "emojis.png"
|
||||||
|
attachments.push({id: "0", name})
|
||||||
|
pendingFiles.push({name, buffer})
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker | Ty.Event.Outer_M_Room_Message_Encrypted_File} event
|
||||||
|
* @param {import("discord-api-types/v10").APIGuild} guild
|
||||||
|
* @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API
|
||||||
|
*/
|
||||||
|
async function eventToMessage(event, guild, di) {
|
||||||
|
/** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]})[]} */
|
||||||
|
let messages = []
|
||||||
|
|
||||||
|
let displayName = event.sender
|
||||||
|
let avatarURL = undefined
|
||||||
|
/** @type {string[]} */
|
||||||
|
let messageIDsToEdit = []
|
||||||
|
let replyLine = ""
|
||||||
|
// Extract a basic display name from the sender
|
||||||
|
const match = event.sender.match(/^@(.*?):/)
|
||||||
|
if (match) displayName = match[1]
|
||||||
|
// Try to extract an accurate display name and avatar URL from the member event
|
||||||
|
const member = await getMemberFromCacheOrHomeserver(event.room_id, event.sender, di?.api)
|
||||||
|
if (member.displayname) displayName = member.displayname
|
||||||
|
if (member.avatar_url) avatarURL = utils.getPublicUrlForMxc(member.avatar_url) || undefined
|
||||||
|
// If the display name is too long to be put into the webhook (80 characters is the maximum),
|
||||||
|
// put the excess characters into displayNameRunoff, later to be put at the top of the message
|
||||||
|
let [displayNameShortened, displayNameRunoff] = splitDisplayName(displayName)
|
||||||
|
// If the message type is m.emote, the full name is already included at the start of the message, so remove any runoff
|
||||||
|
if (event.type === "m.room.message" && event.content.msgtype === "m.emote") {
|
||||||
|
displayNameRunoff = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = event.content.body // ultimate fallback
|
||||||
|
const attachments = []
|
||||||
|
/** @type {({name: string, url: string} | {name: string, url: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} */
|
||||||
|
const pendingFiles = []
|
||||||
|
|
||||||
|
// Convert content depending on what the message is
|
||||||
|
if (event.type === "m.room.message" && (event.content.msgtype === "m.text" || event.content.msgtype === "m.emote")) {
|
||||||
|
// Handling edits. If the edit was an edit of a reply, edits do not include the reply reference, so we need to fetch up to 2 more events.
|
||||||
|
// this event ---is an edit of--> original event ---is a reply to--> past event
|
||||||
|
await (async () => {
|
||||||
|
if (!event.content["m.new_content"]) return
|
||||||
|
const relatesTo = event.content["m.relates_to"]
|
||||||
|
if (!relatesTo) return
|
||||||
|
// Check if we have a pointer to what was edited
|
||||||
|
const relType = relatesTo.rel_type
|
||||||
|
if (relType !== "m.replace") return
|
||||||
|
const originalEventId = relatesTo.event_id
|
||||||
|
if (!originalEventId) return
|
||||||
|
messageIDsToEdit = select("event_message", "message_id", {event_id: originalEventId}, "ORDER BY part").pluck().all()
|
||||||
|
if (!messageIDsToEdit.length) return
|
||||||
|
|
||||||
|
// Ok, it's an edit.
|
||||||
|
event.content = event.content["m.new_content"]
|
||||||
|
|
||||||
|
// Is it editing a reply? We need special handling if it is.
|
||||||
|
// Get the original event, then check if it was a reply
|
||||||
|
const originalEvent = await di.api.getEvent(event.room_id, originalEventId)
|
||||||
|
if (!originalEvent) return
|
||||||
|
const repliedToEventId = originalEvent.content["m.relates_to"]?.["m.in_reply_to"]?.event_id
|
||||||
|
if (!repliedToEventId) return
|
||||||
|
|
||||||
|
// After all that, it's an edit of a reply.
|
||||||
|
// We'll be sneaky and prepare the message data so that the next steps can handle it just like original messages.
|
||||||
|
Object.assign(event.content, {
|
||||||
|
"m.relates_to": {
|
||||||
|
"m.in_reply_to": {
|
||||||
|
event_id: repliedToEventId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
|
||||||
|
// Handling replies. We'll look up the data of the replied-to event from the Matrix homeserver.
|
||||||
|
// Note that an <mx-reply> element is not guaranteed because this might be m.new_content.
|
||||||
|
await (async () => {
|
||||||
|
const repliedToEventId = event.content["m.relates_to"]?.["m.in_reply_to"]?.event_id
|
||||||
|
if (!repliedToEventId) return
|
||||||
|
let repliedToEvent = await di.api.getEvent(event.room_id, repliedToEventId)
|
||||||
|
if (!repliedToEvent) return
|
||||||
|
// @ts-ignore
|
||||||
|
const autoEmoji = new Map(select("auto_emoji", ["name", "emoji_id"], {}, "WHERE name = 'L1' OR name = 'L2'").raw().all())
|
||||||
|
replyLine = `<:L1:${autoEmoji.get("L1")}><:L2:${autoEmoji.get("L2")}>`
|
||||||
|
const row = from("event_message").join("message_channel", "message_id").select("channel_id", "message_id").where({event_id: repliedToEventId}).and("ORDER BY part").get()
|
||||||
|
if (row) {
|
||||||
|
replyLine += `https://discord.com/channels/${guild.id}/${row.channel_id}/${row.message_id} `
|
||||||
|
}
|
||||||
|
const sender = repliedToEvent.sender
|
||||||
|
const authorID = select("sim", "user_id", {mxid: repliedToEvent.sender}).pluck().get()
|
||||||
|
if (authorID) {
|
||||||
|
replyLine += `<@${authorID}>`
|
||||||
|
} else {
|
||||||
|
let senderName = select("member_cache", "displayname", {mxid: repliedToEvent.sender}).pluck().get()
|
||||||
|
if (!senderName) senderName = sender.match(/@([^:]*)/)?.[1] || sender
|
||||||
|
replyLine += `Ⓜ️**${senderName}**`
|
||||||
|
}
|
||||||
|
// If the event has been edited, the homeserver will include the relation in `unsigned`.
|
||||||
|
if (repliedToEvent.unsigned?.["m.relations"]?.["m.replace"]?.content?.["m.new_content"]) {
|
||||||
|
repliedToEvent = repliedToEvent.unsigned["m.relations"]["m.replace"] // Note: this changes which event_id is in repliedToEvent.
|
||||||
|
repliedToEvent.content = repliedToEvent.content["m.new_content"]
|
||||||
|
}
|
||||||
|
let contentPreview
|
||||||
|
const fileReplyContentAlternative =
|
||||||
|
( repliedToEvent.content.msgtype === "m.image" ? "🖼️"
|
||||||
|
: repliedToEvent.content.msgtype === "m.video" ? "🎞️"
|
||||||
|
: repliedToEvent.content.msgtype === "m.audio" ? "🎶"
|
||||||
|
: repliedToEvent.content.msgtype === "m.file" ? "📄"
|
||||||
|
: null)
|
||||||
|
if (fileReplyContentAlternative) {
|
||||||
|
contentPreview = " " + fileReplyContentAlternative
|
||||||
|
} else {
|
||||||
|
const repliedToContent = repliedToEvent.content.formatted_body || repliedToEvent.content.body
|
||||||
|
const contentPreviewChunks = chunk(
|
||||||
|
entities.decodeHTML5Strict( // Remove entities like & "
|
||||||
|
repliedToContent.replace(/.*<\/mx-reply>/, "") // Remove everything before replies, so just use the actual message body
|
||||||
|
.replace(/^\s*<blockquote>.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards
|
||||||
|
.replace(/(?:\n|<br>)+/g, " ") // Should all be on one line
|
||||||
|
.replace(/<span [^>]*data-mx-spoiler\b[^>]*>.*?<\/span>/g, "[spoiler]") // Good enough method of removing spoiler content. (I don't want to break out the HTML parser unless I have to.)
|
||||||
|
.replace(/<[^>]+>/g, "") // Completely strip all HTML tags and formatting.
|
||||||
|
), 50)
|
||||||
|
contentPreview = ":\n> "
|
||||||
|
contentPreview += contentPreviewChunks.length > 1 ? contentPreviewChunks[0] + "..." : contentPreviewChunks[0]
|
||||||
|
}
|
||||||
|
replyLine = `> ${replyLine}${contentPreview}\n`
|
||||||
|
})()
|
||||||
|
|
||||||
|
if (event.content.format === "org.matrix.custom.html" && event.content.formatted_body) {
|
||||||
|
let input = event.content.formatted_body
|
||||||
|
if (event.content.msgtype === "m.emote") {
|
||||||
|
input = `* ${displayName} ${input}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handling mentions of Discord users
|
||||||
|
input = input.replace(/("https:\/\/matrix.to\/#\/(@[^"]+)")>/g, (whole, attributeValue, mxid) => {
|
||||||
|
if (!utils.eventSenderIsFromDiscord(mxid)) return whole
|
||||||
|
const userID = select("sim", "user_id", {mxid: mxid}).pluck().get()
|
||||||
|
if (!userID) return whole
|
||||||
|
return `${attributeValue} data-user-id="${userID}">`
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handling mentions of Discord rooms
|
||||||
|
input = input.replace(/("https:\/\/matrix.to\/#\/(![^"]+)")>/g, (whole, attributeValue, roomID) => {
|
||||||
|
const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get()
|
||||||
|
if (!channelID) return whole
|
||||||
|
return `${attributeValue} data-channel-id="${channelID}">`
|
||||||
|
})
|
||||||
|
|
||||||
|
// Element adds a bunch of <br> before </blockquote> but doesn't render them. I can't figure out how this even works in the browser, so let's just delete those.
|
||||||
|
input = input.replace(/(?:\n|<br ?\/?>\s*)*<\/blockquote>/g, "</blockquote>")
|
||||||
|
|
||||||
|
// The matrix spec hasn't decided whether \n counts as a newline or not, but I'm going to count it, because if it's in the data it's there for a reason.
|
||||||
|
// But I should not count it if it's between block elements.
|
||||||
|
input = input.replace(/(<\/?([^ >]+)[^>]*>)?\n(<\/?([^ >]+)[^>]*>)?/g, (whole, beforeContext, beforeTag, afterContext, afterTag) => {
|
||||||
|
// console.error(beforeContext, beforeTag, afterContext, afterTag)
|
||||||
|
if (typeof beforeTag !== "string" && typeof afterTag !== "string") {
|
||||||
|
return "<br>"
|
||||||
|
}
|
||||||
|
beforeContext = beforeContext || ""
|
||||||
|
beforeTag = beforeTag || ""
|
||||||
|
afterContext = afterContext || ""
|
||||||
|
afterTag = afterTag || ""
|
||||||
|
if (!utils.BLOCK_ELEMENTS.includes(beforeTag.toUpperCase()) && !utils.BLOCK_ELEMENTS.includes(afterTag.toUpperCase())) {
|
||||||
|
return beforeContext + "<br>" + afterContext
|
||||||
|
} else {
|
||||||
|
return whole
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Note: Element's renderers on Web and Android currently collapse whitespace, like the browser does. Turndown also collapses whitespace which is good for me.
|
||||||
|
// If later I'm using a client that doesn't collapse whitespace and I want turndown to follow suit, uncomment the following line of code, and it Just Works:
|
||||||
|
// input = input.replace(/ /g, " ")
|
||||||
|
// There is also a corresponding test to uncomment, named "event2message: whitespace is retained"
|
||||||
|
|
||||||
|
// SPRITE SHEET EMOJIS FEATURE: Emojis at the end of the message that we don't know about will be reuploaded as a sprite sheet.
|
||||||
|
// First we need to determine which emojis are at the end.
|
||||||
|
endOfMessageEmojis = []
|
||||||
|
let match
|
||||||
|
let last = input.length
|
||||||
|
while ((match = input.slice(0, last).match(/<img [^>]*>\s*$/))) {
|
||||||
|
if (!match[0].includes("data-mx-emoticon")) break
|
||||||
|
const mxcUrl = match[0].match(/\bsrc="(mxc:\/\/[^"]+)"/)
|
||||||
|
if (mxcUrl) endOfMessageEmojis.unshift(mxcUrl[1])
|
||||||
|
if (typeof match.index !== "number") break
|
||||||
|
last = match.index
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore bad type from turndown
|
||||||
|
content = turndownService.turndown(input)
|
||||||
|
|
||||||
|
// It's designed for commonmark, we need to replace the space-space-newline with just newline
|
||||||
|
content = content.replace(/ \n/g, "\n")
|
||||||
|
|
||||||
|
// SPRITE SHEET EMOJIS FEATURE:
|
||||||
|
content = await uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles)
|
||||||
|
} else {
|
||||||
|
// Looks like we're using the plaintext body!
|
||||||
|
content = event.content.body
|
||||||
|
|
||||||
|
if (event.content.msgtype === "m.emote") {
|
||||||
|
content = `* ${displayName} ${content}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Markdown needs to be escaped, though take care not to escape the middle of links
|
||||||
|
// @ts-ignore bad type from turndown
|
||||||
|
content = turndownService.escape(content)
|
||||||
|
}
|
||||||
|
} else if (event.type === "m.room.message" && (event.content.msgtype === "m.file" || event.content.msgtype === "m.video" || event.content.msgtype === "m.audio" || event.content.msgtype === "m.image")) {
|
||||||
|
content = ""
|
||||||
|
const filename = event.content.body
|
||||||
|
if ("url" in event.content) {
|
||||||
|
// Unencrypted
|
||||||
|
const url = utils.getPublicUrlForMxc(event.content.url)
|
||||||
|
assert(url)
|
||||||
|
attachments.push({id: "0", filename})
|
||||||
|
pendingFiles.push({name: filename, url})
|
||||||
|
} else {
|
||||||
|
// Encrypted
|
||||||
|
const url = utils.getPublicUrlForMxc(event.content.file.url)
|
||||||
|
assert(url)
|
||||||
|
assert.equal(event.content.file.key.alg, "A256CTR")
|
||||||
|
attachments.push({id: "0", filename})
|
||||||
|
pendingFiles.push({name: filename, url, key: event.content.file.key.k, iv: event.content.file.iv})
|
||||||
|
}
|
||||||
|
} else if (event.type === "m.sticker") {
|
||||||
|
content = ""
|
||||||
|
const url = utils.getPublicUrlForMxc(event.content.url)
|
||||||
|
assert(url)
|
||||||
|
let filename = event.content.body
|
||||||
|
if (event.type === "m.sticker") {
|
||||||
|
let mimetype
|
||||||
|
if (event.content.info?.mimetype?.includes("/")) {
|
||||||
|
mimetype = event.content.info.mimetype
|
||||||
|
} else {
|
||||||
|
const res = await fetch(url, {method: "HEAD"})
|
||||||
|
mimetype = res.headers.get("content-type") || "image/webp"
|
||||||
|
}
|
||||||
|
filename += "." + mimetype.split("/")[1]
|
||||||
|
}
|
||||||
|
attachments.push({id: "0", filename})
|
||||||
|
pendingFiles.push({name: filename, url})
|
||||||
|
}
|
||||||
|
|
||||||
|
content = displayNameRunoff + replyLine + content
|
||||||
|
|
||||||
|
// Split into 2000 character chunks
|
||||||
|
const chunks = chunk(content, 2000)
|
||||||
|
messages = messages.concat(chunks.map(content => ({
|
||||||
|
content,
|
||||||
|
username: displayNameShortened,
|
||||||
|
avatar_url: avatarURL
|
||||||
|
})))
|
||||||
|
|
||||||
|
if (attachments.length) {
|
||||||
|
// If content is empty (should be the case when uploading a file) then chunk-text will create 0 messages.
|
||||||
|
// There needs to be a message to add attachments to.
|
||||||
|
if (!messages.length) messages.push({
|
||||||
|
content,
|
||||||
|
username: displayNameShortened,
|
||||||
|
avatar_url: avatarURL
|
||||||
|
})
|
||||||
|
messages[0].attachments = attachments
|
||||||
|
// @ts-ignore these will be converted to real files when the message is about to be sent
|
||||||
|
messages[0].pendingFiles = pendingFiles
|
||||||
|
}
|
||||||
|
|
||||||
|
const messagesToEdit = []
|
||||||
|
const messagesToSend = []
|
||||||
|
for (let i = 0; i < messages.length; i++) {
|
||||||
|
const next = messageIDsToEdit[0]
|
||||||
|
if (next) {
|
||||||
|
messagesToEdit.push({id: next, message: messages[i]})
|
||||||
|
messageIDsToEdit.shift()
|
||||||
|
} else {
|
||||||
|
messagesToSend.push(messages[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure there is code coverage for adding, editing, and deleting
|
||||||
|
if (messagesToSend.length) void 0
|
||||||
|
if (messagesToEdit.length) void 0
|
||||||
|
if (messageIDsToEdit.length) void 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
messagesToEdit,
|
||||||
|
messagesToSend,
|
||||||
|
messagesToDelete: messageIDsToEdit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.eventToMessage = eventToMessage
|
2070
m2d/converters/event-to-message.test.js
Normal file
2070
m2d/converters/event-to-message.test.js
Normal file
File diff suppressed because it is too large
Load diff
69
m2d/converters/utils.js
Normal file
69
m2d/converters/utils.js
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const reg = require("../../matrix/read-registration")
|
||||||
|
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
|
||||||
|
const assert = require("assert").strict
|
||||||
|
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
|
||||||
|
let hasher = null
|
||||||
|
// @ts-ignore
|
||||||
|
require("xxhash-wasm")().then(h => hasher = h)
|
||||||
|
|
||||||
|
const BLOCK_ELEMENTS = [
|
||||||
|
"ADDRESS", "ARTICLE", "ASIDE", "AUDIO", "BLOCKQUOTE", "BODY", "CANVAS",
|
||||||
|
"CENTER", "DD", "DETAILS", "DIR", "DIV", "DL", "DT", "FIELDSET", "FIGCAPTION", "FIGURE",
|
||||||
|
"FOOTER", "FORM", "FRAMESET", "H1", "H2", "H3", "H4", "H5", "H6", "HEADER",
|
||||||
|
"HGROUP", "HR", "HTML", "ISINDEX", "LI", "MAIN", "MENU", "NAV", "NOFRAMES",
|
||||||
|
"NOSCRIPT", "OL", "OUTPUT", "P", "PRE", "SECTION", "SUMMARY", "TABLE", "TBODY", "TD",
|
||||||
|
"TFOOT", "TH", "THEAD", "TR", "UL"
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether an event is the bridged representation of a discord message.
|
||||||
|
* Such messages shouldn't be bridged again.
|
||||||
|
* @param {string} sender
|
||||||
|
*/
|
||||||
|
function eventSenderIsFromDiscord(sender) {
|
||||||
|
// If it's from a user in the bridge's namespace, then it originated from discord
|
||||||
|
// This includes messages sent by the appservice's bot user, because that is what's used for webhooks
|
||||||
|
// TODO: It would be nice if bridge system messages wouldn't trigger this check and could be bridged from matrix to discord, while webhook reflections would remain ignored...
|
||||||
|
// TODO that only applies to the above todo: But you'd have to watch out for the /icon command, where the bridge bot would set the room avatar, and that shouldn't be reflected into the room a second time.
|
||||||
|
if (userRegex.some(x => sender.match(x))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} mxc
|
||||||
|
* @returns {string?}
|
||||||
|
*/
|
||||||
|
function getPublicUrlForMxc(mxc) {
|
||||||
|
const avatarURLParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
|
||||||
|
if (avatarURLParts) return `${reg.ooye.server_origin}/_matrix/media/r0/download/${avatarURLParts[1]}/${avatarURLParts[2]}`
|
||||||
|
else return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event IDs are really big and have more entropy than we need.
|
||||||
|
* If we want to store the event ID in the database, we can store a more compact version by hashing it with this.
|
||||||
|
* I choose a 64-bit non-cryptographic hash as only a 32-bit hash will see birthday collisions unreasonably frequently: https://en.wikipedia.org/wiki/Birthday_attack#Mathematics
|
||||||
|
* xxhash outputs an unsigned 64-bit integer.
|
||||||
|
* Converting to a signed 64-bit integer with no bit loss so that it can be stored in an SQLite integer field as-is: https://www.sqlite.org/fileformat2.html#record_format
|
||||||
|
* This should give very efficient storage with sufficient entropy.
|
||||||
|
* @param {string} eventID
|
||||||
|
*/
|
||||||
|
function getEventIDHash(eventID) {
|
||||||
|
assert(hasher, "xxhash is not ready yet")
|
||||||
|
if (eventID[0] === "$" && eventID.length >= 13) {
|
||||||
|
eventID = eventID.slice(1) // increase entropy per character to potentially help xxhash
|
||||||
|
}
|
||||||
|
const unsignedHash = hasher.h64(eventID)
|
||||||
|
const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
|
||||||
|
return signedHash
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.BLOCK_ELEMENTS = BLOCK_ELEMENTS
|
||||||
|
module.exports.eventSenderIsFromDiscord = eventSenderIsFromDiscord
|
||||||
|
module.exports.getPublicUrlForMxc = getPublicUrlForMxc
|
||||||
|
module.exports.getEventIDHash = getEventIDHash
|
25
m2d/converters/utils.test.js
Normal file
25
m2d/converters/utils.test.js
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const {test} = require("supertape")
|
||||||
|
const {eventSenderIsFromDiscord, getEventIDHash} = require("./utils")
|
||||||
|
|
||||||
|
test("sender type: matrix user", t => {
|
||||||
|
t.notOk(eventSenderIsFromDiscord("@cadence:cadence.moe"))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("sender type: ooye bot", t => {
|
||||||
|
t.ok(eventSenderIsFromDiscord("@_ooye_bot:cadence.moe"))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("sender type: ooye puppet", t => {
|
||||||
|
t.ok(eventSenderIsFromDiscord("@_ooye_sheep:cadence.moe"))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("event hash: hash is the same each time", t => {
|
||||||
|
const eventID = "$example"
|
||||||
|
t.equal(getEventIDHash(eventID), getEventIDHash(eventID))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("event hash: hash is different for different inputs", t => {
|
||||||
|
t.notEqual(getEventIDHash("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe1"), getEventIDHash("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe2"))
|
||||||
|
})
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
const util = require("util")
|
const util = require("util")
|
||||||
const Ty = require("../types")
|
const Ty = require("../types")
|
||||||
const {discord, db, sync, as, select} = require("../passthrough")
|
const {discord, db, sync, as} = require("../passthrough")
|
||||||
|
|
||||||
/** @type {import("./actions/send-event")} */
|
/** @type {import("./actions/send-event")} */
|
||||||
const sendEvent = sync.require("./actions/send-event")
|
const sendEvent = sync.require("./actions/send-event")
|
||||||
|
@ -20,7 +20,8 @@ const matrixCommandHandler = sync.require("../matrix/matrix-command-handler")
|
||||||
const utils = sync.require("./converters/utils")
|
const utils = sync.require("./converters/utils")
|
||||||
/** @type {import("../matrix/api")}) */
|
/** @type {import("../matrix/api")}) */
|
||||||
const api = sync.require("../matrix/api")
|
const api = sync.require("../matrix/api")
|
||||||
const {reg} = require("../matrix/read-registration")
|
/** @type {import("../matrix/read-registration")}) */
|
||||||
|
const reg = sync.require("../matrix/read-registration")
|
||||||
|
|
||||||
let lastReportedEvent = 0
|
let lastReportedEvent = 0
|
||||||
|
|
||||||
|
@ -61,34 +62,15 @@ function guard(type, fn) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function retry(roomID, eventID) {
|
||||||
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>} reactionEvent
|
const event = await api.getEvent(roomID, eventID)
|
||||||
*/
|
|
||||||
async function onRetryReactionAdd(reactionEvent) {
|
|
||||||
const roomID = reactionEvent.room_id
|
|
||||||
const event = await api.getEvent(roomID, reactionEvent.content["m.relates_to"]?.event_id)
|
|
||||||
|
|
||||||
// Check that it's a real error from OOYE
|
|
||||||
const error = event.content["moe.cadence.ooye.error"]
|
const error = event.content["moe.cadence.ooye.error"]
|
||||||
if (event.sender !== `@${reg.sender_localpart}:${reg.ooye.server_name}` || !error) return
|
if (event.sender !== `@${reg.sender_localpart}:${reg.ooye.server_name}` || !error) return
|
||||||
|
|
||||||
// To stop people injecting misleading messages, the reaction needs to come from either the original sender or a room moderator
|
|
||||||
if (reactionEvent.sender !== event.sender) {
|
|
||||||
// Check if it's a room moderator
|
|
||||||
const powerLevelsStateContent = await api.getStateEvent(roomID, "m.room.power_levels", "")
|
|
||||||
const powerLevel = powerLevelsStateContent.users?.[reactionEvent.sender] || 0
|
|
||||||
if (powerLevel < 50) return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retry
|
|
||||||
if (error.source === "matrix") {
|
if (error.source === "matrix") {
|
||||||
as.emit(`type:${error.payload.type}`, error.payload)
|
as.emit("type:" + error.payload.type, error.payload)
|
||||||
} else if (error.source === "discord") {
|
} else if (error.source === "discord") {
|
||||||
discord.cloud.emit("event", error.payload)
|
discord.cloud.emit("event", error.payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redact the error to stop people from executing multiple retries
|
|
||||||
api.redactEvent(roomID, event.event_id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message",
|
sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message",
|
||||||
|
@ -121,7 +103,7 @@ async event => {
|
||||||
if (utils.eventSenderIsFromDiscord(event.sender)) return
|
if (utils.eventSenderIsFromDiscord(event.sender)) return
|
||||||
if (event.content["m.relates_to"].key === "🔁") {
|
if (event.content["m.relates_to"].key === "🔁") {
|
||||||
// Try to bridge a failed event again?
|
// Try to bridge a failed event again?
|
||||||
await onRetryReactionAdd(event)
|
await retry(event.room_id, event.content["m.relates_to"].event_id)
|
||||||
} else {
|
} else {
|
||||||
matrixCommandHandler.onReactionAdd(event)
|
matrixCommandHandler.onReactionAdd(event)
|
||||||
await addReaction.addReaction(event)
|
await addReaction.addReaction(event)
|
||||||
|
@ -166,29 +148,5 @@ sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member",
|
||||||
async event => {
|
async event => {
|
||||||
if (event.state_key[0] !== "@") return
|
if (event.state_key[0] !== "@") return
|
||||||
if (utils.eventSenderIsFromDiscord(event.state_key)) return
|
if (utils.eventSenderIsFromDiscord(event.state_key)) return
|
||||||
if (event.content.membership === "leave" || event.content.membership === "ban") {
|
db.prepare("REPLACE INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?)").run(event.room_id, event.state_key, event.content.displayname || null, event.content.avatar_url || null)
|
||||||
// Member is gone
|
|
||||||
db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key)
|
|
||||||
} else {
|
|
||||||
// Member is here
|
|
||||||
db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?")
|
|
||||||
.run(
|
|
||||||
event.room_id, event.state_key,
|
|
||||||
event.content.displayname || null, event.content.avatar_url || null,
|
|
||||||
event.content.displayname || null, event.content.avatar_url || null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
sync.addTemporaryListener(as, "type:m.room.power_levels", guard("m.room.power_levels",
|
|
||||||
/**
|
|
||||||
* @param {Ty.Event.StateOuter<Ty.Event.M_Power_Levels>} event
|
|
||||||
*/
|
|
||||||
async event => {
|
|
||||||
if (event.state_key !== "") return
|
|
||||||
const existingPower = select("member_cache", "mxid", {room_id: event.room_id}).pluck().all()
|
|
||||||
const newPower = event.content.users || {}
|
|
||||||
for (const mxid of existingPower) {
|
|
||||||
db.prepare("UPDATE member_cache SET power_level = ? WHERE room_id = ? AND mxid = ?").run(newPower[mxid] || 0, event.room_id, mxid)
|
|
||||||
}
|
|
||||||
}))
|
}))
|
|
@ -3,15 +3,14 @@
|
||||||
const Ty = require("../types")
|
const Ty = require("../types")
|
||||||
const assert = require("assert").strict
|
const assert = require("assert").strict
|
||||||
|
|
||||||
const fetch = require("node-fetch").default
|
|
||||||
|
|
||||||
const passthrough = require("../passthrough")
|
const passthrough = require("../passthrough")
|
||||||
const { discord, sync, db } = passthrough
|
const { discord, sync, db } = passthrough
|
||||||
/** @type {import("./mreq")} */
|
/** @type {import("./mreq")} */
|
||||||
const mreq = sync.require("./mreq")
|
const mreq = sync.require("./mreq")
|
||||||
|
/** @type {import("./file")} */
|
||||||
|
const file = sync.require("./file")
|
||||||
/** @type {import("./txnid")} */
|
/** @type {import("./txnid")} */
|
||||||
const makeTxnId = sync.require("./txnid")
|
const makeTxnId = sync.require("./txnid")
|
||||||
const {reg} = require("./read-registration.js")
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} p endpoint to access
|
* @param {string} p endpoint to access
|
||||||
|
@ -20,17 +19,15 @@ const {reg} = require("./read-registration.js")
|
||||||
* @returns {string} the new endpoint
|
* @returns {string} the new endpoint
|
||||||
*/
|
*/
|
||||||
function path(p, mxid, otherParams = {}) {
|
function path(p, mxid, otherParams = {}) {
|
||||||
|
if (!mxid) return p
|
||||||
const u = new URL(p, "http://localhost")
|
const u = new URL(p, "http://localhost")
|
||||||
if (mxid) u.searchParams.set("user_id", mxid)
|
u.searchParams.set("user_id", mxid)
|
||||||
for (const entry of Object.entries(otherParams)) {
|
for (const entry of Object.entries(otherParams)) {
|
||||||
if (entry[1] != undefined) {
|
if (entry[1] != undefined) {
|
||||||
u.searchParams.set(entry[0], entry[1])
|
u.searchParams.set(entry[0], entry[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let result = u.pathname
|
return u.pathname + "?" + u.searchParams.toString()
|
||||||
const str = u.searchParams.toString()
|
|
||||||
if (str) result += "?" + str
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -60,7 +57,7 @@ async function createRoom(content) {
|
||||||
*/
|
*/
|
||||||
async function joinRoom(roomIDOrAlias, mxid) {
|
async function joinRoom(roomIDOrAlias, mxid) {
|
||||||
/** @type {Ty.R.RoomJoined} */
|
/** @type {Ty.R.RoomJoined} */
|
||||||
const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid), {})
|
const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid))
|
||||||
return root.room_id
|
return root.room_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -71,7 +68,6 @@ async function inviteToRoom(roomID, mxidToInvite, mxid) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function leaveRoom(roomID, mxid) {
|
async function leaveRoom(roomID, mxid) {
|
||||||
console.log(`[api] leave: ${roomID}: ${mxid}`)
|
|
||||||
await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, mxid), {})
|
await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, mxid), {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -86,16 +82,6 @@ async function getEvent(roomID, eventID) {
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} roomID
|
|
||||||
* @param {number} ts unix silliseconds
|
|
||||||
*/
|
|
||||||
async function getEventForTimestamp(roomID, ts) {
|
|
||||||
/** @type {{event_id: string, origin_server_ts: number}} */
|
|
||||||
const root = await mreq.mreq("GET", path(`/client/v1/rooms/${roomID}/timestamp_to_event`, null, {ts}))
|
|
||||||
return root
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} roomID
|
* @param {string} roomID
|
||||||
* @returns {Promise<Ty.Event.BaseStateEvent[]>}
|
* @returns {Promise<Ty.Event.BaseStateEvent[]>}
|
||||||
|
@ -117,54 +103,12 @@ function getStateEvent(roomID, type, key) {
|
||||||
/**
|
/**
|
||||||
* "Any of the AS's users must be in the room. This API is primarily for Application Services and should be faster to respond than /members as it can be implemented more efficiently on the server."
|
* "Any of the AS's users must be in the room. This API is primarily for Application Services and should be faster to respond than /members as it can be implemented more efficiently on the server."
|
||||||
* @param {string} roomID
|
* @param {string} roomID
|
||||||
* @returns {Promise<{joined: {[mxid: string]: {avatar_url: string?, display_name: string?}}}>}
|
* @returns {Promise<{joined: {[mxid: string]: {avatar_url?: string, display_name?: string}}}>}
|
||||||
*/
|
*/
|
||||||
function getJoinedMembers(roomID) {
|
function getJoinedMembers(roomID) {
|
||||||
return mreq.mreq("GET", `/client/v3/rooms/${roomID}/joined_members`)
|
return mreq.mreq("GET", `/client/v3/rooms/${roomID}/joined_members`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* "Get the list of members for this room." This includes joined, invited, knocked, left, and banned members unless a filter is provided.
|
|
||||||
* The endpoint also supports `at` and `not_membership` URL parameters, but they are not exposed in this wrapper yet.
|
|
||||||
* @param {string} roomID
|
|
||||||
* @param {"join" | "invite" | "knock" | "leave" | "ban"} [membership] The kind of membership to filter for. Only one choice allowed.
|
|
||||||
* @returns {Promise<{chunk: Ty.Event.Outer<Ty.Event.M_Room_Member>[]}>}
|
|
||||||
*/
|
|
||||||
function getMembers(roomID, membership) {
|
|
||||||
return mreq.mreq("GET", `/client/v3/rooms/${roomID}/members`, undefined, {membership})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} roomID
|
|
||||||
* @param {{from?: string, limit?: any}} pagination
|
|
||||||
* @returns {Promise<Ty.HierarchyPagination<Ty.R.Hierarchy>>}
|
|
||||||
*/
|
|
||||||
function getHierarchy(roomID, pagination) {
|
|
||||||
let path = `/client/v1/rooms/${roomID}/hierarchy`
|
|
||||||
if (!pagination.from) delete pagination.from
|
|
||||||
if (!pagination.limit) pagination.limit = 50
|
|
||||||
path += `?${new URLSearchParams(pagination)}`
|
|
||||||
return mreq.mreq("GET", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Like `getHierarchy` but collects all pages for you.
|
|
||||||
* @param {string} roomID
|
|
||||||
*/
|
|
||||||
async function getFullHierarchy(roomID) {
|
|
||||||
/** @type {Ty.R.Hierarchy[]} */
|
|
||||||
let rooms = []
|
|
||||||
/** @type {string | undefined} */
|
|
||||||
let nextBatch = undefined
|
|
||||||
do {
|
|
||||||
/** @type {Ty.HierarchyPagination<Ty.R.Hierarchy>} */
|
|
||||||
const res = await getHierarchy(roomID, {from: nextBatch})
|
|
||||||
rooms.push(...res.rooms)
|
|
||||||
nextBatch = res.next_batch
|
|
||||||
} while (nextBatch)
|
|
||||||
return rooms
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} roomID
|
* @param {string} roomID
|
||||||
* @param {string} eventID
|
* @param {string} eventID
|
||||||
|
@ -181,26 +125,6 @@ function getRelations(roomID, eventID, pagination, relType) {
|
||||||
return mreq.mreq("GET", path)
|
return mreq.mreq("GET", path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Like `getRelations` but collects and filters all pages for you.
|
|
||||||
* @param {string} roomID
|
|
||||||
* @param {string} eventID
|
|
||||||
* @param {string?} [relType] type of relations to filter, e.g. "m.annotation" for reactions
|
|
||||||
*/
|
|
||||||
async function getFullRelations(roomID, eventID, relType) {
|
|
||||||
/** @type {Ty.Event.Outer<Ty.Event.M_Reaction>[]} */
|
|
||||||
let reactions = []
|
|
||||||
/** @type {string | undefined} */
|
|
||||||
let nextBatch = undefined
|
|
||||||
do {
|
|
||||||
/** @type {Ty.Pagination<Ty.Event.Outer<Ty.Event.M_Reaction>>} */
|
|
||||||
const res = await getRelations(roomID, eventID, {from: nextBatch}, relType)
|
|
||||||
reactions = reactions.concat(res.chunk)
|
|
||||||
nextBatch = res.next_batch
|
|
||||||
} while (nextBatch)
|
|
||||||
return reactions
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} roomID
|
* @param {string} roomID
|
||||||
* @param {string} type
|
* @param {string} type
|
||||||
|
@ -292,53 +216,6 @@ async function setUserPower(roomID, mxid, power) {
|
||||||
return powerLevels
|
return powerLevels
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Set a user's power level for a whole room hierarchy.
|
|
||||||
* @param {string} roomID
|
|
||||||
* @param {string} mxid
|
|
||||||
* @param {number} power
|
|
||||||
*/
|
|
||||||
async function setUserPowerCascade(roomID, mxid, power) {
|
|
||||||
assert(roomID[0] === "!")
|
|
||||||
assert(mxid[0] === "@")
|
|
||||||
const rooms = await getFullHierarchy(roomID)
|
|
||||||
for (const room of rooms) {
|
|
||||||
await setUserPower(room.room_id, mxid, power)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ping() {
|
|
||||||
// not using mreq so that we can read the status code
|
|
||||||
const res = await fetch(`${mreq.baseUrl}/client/v1/appservice/${reg.id}/ping`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${reg.as_token}`
|
|
||||||
},
|
|
||||||
body: "{}"
|
|
||||||
})
|
|
||||||
const root = await res.json()
|
|
||||||
return {
|
|
||||||
ok: res.ok,
|
|
||||||
status: res.status,
|
|
||||||
root
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} mxc
|
|
||||||
* @param {fetch.RequestInit} [init]
|
|
||||||
*/
|
|
||||||
function getMedia(mxc, init = {}) {
|
|
||||||
const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
|
|
||||||
assert(mediaParts)
|
|
||||||
return fetch(`${mreq.baseUrl}/client/v1/media/download/${mediaParts[1]}/${mediaParts[2]}`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${reg.as_token}`
|
|
||||||
},
|
|
||||||
...init
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.path = path
|
module.exports.path = path
|
||||||
module.exports.register = register
|
module.exports.register = register
|
||||||
module.exports.createRoom = createRoom
|
module.exports.createRoom = createRoom
|
||||||
|
@ -346,15 +223,10 @@ module.exports.joinRoom = joinRoom
|
||||||
module.exports.inviteToRoom = inviteToRoom
|
module.exports.inviteToRoom = inviteToRoom
|
||||||
module.exports.leaveRoom = leaveRoom
|
module.exports.leaveRoom = leaveRoom
|
||||||
module.exports.getEvent = getEvent
|
module.exports.getEvent = getEvent
|
||||||
module.exports.getEventForTimestamp = getEventForTimestamp
|
|
||||||
module.exports.getAllState = getAllState
|
module.exports.getAllState = getAllState
|
||||||
module.exports.getStateEvent = getStateEvent
|
module.exports.getStateEvent = getStateEvent
|
||||||
module.exports.getJoinedMembers = getJoinedMembers
|
module.exports.getJoinedMembers = getJoinedMembers
|
||||||
module.exports.getMembers = getMembers
|
|
||||||
module.exports.getHierarchy = getHierarchy
|
|
||||||
module.exports.getFullHierarchy = getFullHierarchy
|
|
||||||
module.exports.getRelations = getRelations
|
module.exports.getRelations = getRelations
|
||||||
module.exports.getFullRelations = getFullRelations
|
|
||||||
module.exports.sendState = sendState
|
module.exports.sendState = sendState
|
||||||
module.exports.sendEvent = sendEvent
|
module.exports.sendEvent = sendEvent
|
||||||
module.exports.redactEvent = redactEvent
|
module.exports.redactEvent = redactEvent
|
||||||
|
@ -362,6 +234,3 @@ module.exports.sendTyping = sendTyping
|
||||||
module.exports.profileSetDisplayname = profileSetDisplayname
|
module.exports.profileSetDisplayname = profileSetDisplayname
|
||||||
module.exports.profileSetAvatarUrl = profileSetAvatarUrl
|
module.exports.profileSetAvatarUrl = profileSetAvatarUrl
|
||||||
module.exports.setUserPower = setUserPower
|
module.exports.setUserPower = setUserPower
|
||||||
module.exports.setUserPowerCascade = setUserPowerCascade
|
|
||||||
module.exports.ping = ping
|
|
||||||
module.exports.getMedia = getMedia
|
|
|
@ -20,7 +20,3 @@ test("api path: existing query parameters with mxid", t => {
|
||||||
test("api path: real world mxid", t => {
|
test("api path: real world mxid", t => {
|
||||||
t.equal(path("/hello/world", "@cookie_monster:cadence.moe"), "/hello/world?user_id=%40cookie_monster%3Acadence.moe")
|
t.equal(path("/hello/world", "@cookie_monster:cadence.moe"), "/hello/world?user_id=%40cookie_monster%3Acadence.moe")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("api path: extras number works", t => {
|
|
||||||
t.equal(path(`/client/v3/rooms/!example/timestamp_to_event`, null, {ts: 1687324651120}), "/client/v3/rooms/!example/timestamp_to_event?ts=1687324651120")
|
|
||||||
})
|
|
8
matrix/appservice.js
Normal file
8
matrix/appservice.js
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
const reg = require("../matrix/read-registration")
|
||||||
|
const AppService = require("matrix-appservice").AppService
|
||||||
|
const as = new AppService({
|
||||||
|
homeserverToken: reg.hs_token
|
||||||
|
})
|
||||||
|
as.listen(+(new URL(reg.url).port))
|
||||||
|
|
||||||
|
module.exports = as
|
|
@ -1,15 +1,9 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert").strict
|
const assert = require("assert").strict
|
||||||
const mixin = require("@cloudrac3r/mixin-deep")
|
const mixin = require("mixin-deep")
|
||||||
const {isDeepStrictEqual} = require("util")
|
|
||||||
|
|
||||||
const passthrough = require("../passthrough")
|
/** Mutates the input. */
|
||||||
const {sync} = passthrough
|
|
||||||
/** @type {import("./file")} */
|
|
||||||
const file = sync.require("./file")
|
|
||||||
|
|
||||||
/** Mutates the input. Not recursive - can only include or exclude entire state events. */
|
|
||||||
function kstateStripConditionals(kstate) {
|
function kstateStripConditionals(kstate) {
|
||||||
for (const [k, content] of Object.entries(kstate)) {
|
for (const [k, content] of Object.entries(kstate)) {
|
||||||
// conditional for whether a key is even part of the kstate (doing this declaratively on json is hard, so represent it as a property instead.)
|
// conditional for whether a key is even part of the kstate (doing this declaratively on json is hard, so represent it as a property instead.)
|
||||||
|
@ -21,33 +15,9 @@ function kstateStripConditionals(kstate) {
|
||||||
return kstate
|
return kstate
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mutates the input. Works recursively through object tree. */
|
function kstateToState(kstate) {
|
||||||
async function kstateUploadMxc(obj) {
|
|
||||||
const promises = []
|
|
||||||
function inner(obj) {
|
|
||||||
for (const [k, v] of Object.entries(obj)) {
|
|
||||||
if (v == null || typeof v !== "object") continue
|
|
||||||
|
|
||||||
if (v.$url) {
|
|
||||||
promises.push(
|
|
||||||
file.uploadDiscordFileToMxc(v.$url)
|
|
||||||
.then(mxc => obj[k] = mxc)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
inner(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
inner(obj)
|
|
||||||
await Promise.all(promises)
|
|
||||||
return obj
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Automatically strips conditionals and uploads URLs to mxc. */
|
|
||||||
async function kstateToState(kstate) {
|
|
||||||
const events = []
|
const events = []
|
||||||
kstateStripConditionals(kstate)
|
kstateStripConditionals(kstate)
|
||||||
await kstateUploadMxc(kstate)
|
|
||||||
for (const [k, content] of Object.entries(kstate)) {
|
for (const [k, content] of Object.entries(kstate)) {
|
||||||
const slashIndex = k.indexOf("/")
|
const slashIndex = k.indexOf("/")
|
||||||
assert(slashIndex > 0)
|
assert(slashIndex > 0)
|
||||||
|
@ -80,14 +50,18 @@ function diffKState(actual, target) {
|
||||||
// Special handling for power levels, we want to deep merge the actual and target into the final state.
|
// Special handling for power levels, we want to deep merge the actual and target into the final state.
|
||||||
if (!(key in actual)) throw new Error(`want to apply a power levels diff, but original power level data is missing\nstarted with: ${JSON.stringify(actual)}\nwant to apply: ${JSON.stringify(target)}`)
|
if (!(key in actual)) throw new Error(`want to apply a power levels diff, but original power level data is missing\nstarted with: ${JSON.stringify(actual)}\nwant to apply: ${JSON.stringify(target)}`)
|
||||||
const temp = mixin({}, actual[key], target[key])
|
const temp = mixin({}, actual[key], target[key])
|
||||||
if (!isDeepStrictEqual(actual[key], temp)) {
|
try {
|
||||||
|
assert.deepEqual(actual[key], temp)
|
||||||
|
} catch (e) {
|
||||||
// they differ. use the newly prepared object as the diff.
|
// they differ. use the newly prepared object as the diff.
|
||||||
diff[key] = temp
|
diff[key] = temp
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if (key in actual) {
|
} else if (key in actual) {
|
||||||
// diff
|
// diff
|
||||||
if (!isDeepStrictEqual(actual[key], target[key])) {
|
try {
|
||||||
|
assert.deepEqual(actual[key], target[key])
|
||||||
|
} catch (e) {
|
||||||
// they differ. use the target as the diff.
|
// they differ. use the target as the diff.
|
||||||
diff[key] = target[key]
|
diff[key] = target[key]
|
||||||
}
|
}
|
||||||
|
@ -103,7 +77,6 @@ function diffKState(actual, target) {
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.kstateStripConditionals = kstateStripConditionals
|
module.exports.kstateStripConditionals = kstateStripConditionals
|
||||||
module.exports.kstateUploadMxc = kstateUploadMxc
|
|
||||||
module.exports.kstateToState = kstateToState
|
module.exports.kstateToState = kstateToState
|
||||||
module.exports.stateToKState = stateToKState
|
module.exports.stateToKState = stateToKState
|
||||||
module.exports.diffKState = diffKState
|
module.exports.diffKState = diffKState
|
|
@ -1,5 +1,4 @@
|
||||||
const assert = require("assert")
|
const {kstateToState, stateToKState, diffKState, kstateStripConditionals} = require("./kstate")
|
||||||
const {kstateToState, stateToKState, diffKState, kstateStripConditionals, kstateUploadMxc} = require("./kstate")
|
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
|
|
||||||
test("kstate strip: strips false conditions", t => {
|
test("kstate strip: strips false conditions", t => {
|
||||||
|
@ -21,53 +20,8 @@ test("kstate strip: keeps true conditions while removing $if", t => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("kstateUploadMxc: sets the mxc", async t => {
|
test("kstate2state: general", t => {
|
||||||
const input = {
|
t.deepEqual(kstateToState({
|
||||||
"m.room.avatar/": {
|
|
||||||
url: {$url: "https://cdn.discordapp.com/guilds/112760669178241024/users/134826546694193153/avatars/38dd359aa12bcd52dd3164126c587f8c.png?size=1024"},
|
|
||||||
test1: {
|
|
||||||
test2: {
|
|
||||||
test3: {$url: "https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await kstateUploadMxc(input)
|
|
||||||
t.deepEqual(input, {
|
|
||||||
"m.room.avatar/": {
|
|
||||||
url: "mxc://cadence.moe/rfemHmAtcprjLEiPiEuzPhpl",
|
|
||||||
test1: {
|
|
||||||
test2: {
|
|
||||||
test3: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("kstateUploadMxc and strip: work together", async t => {
|
|
||||||
const input = {
|
|
||||||
"m.room.avatar/yes": {
|
|
||||||
$if: true,
|
|
||||||
url: {$url: "https://cdn.discordapp.com/guilds/112760669178241024/users/134826546694193153/avatars/38dd359aa12bcd52dd3164126c587f8c.png?size=1024"}
|
|
||||||
},
|
|
||||||
"m.room.avatar/no": {
|
|
||||||
$if: false,
|
|
||||||
url: {$url: "https://cdn.discordapp.com/avatars/320067006521147393/5fc4ad85c1ea876709e9a7d3374a78a1.png?size=1024"}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
kstateStripConditionals(input)
|
|
||||||
await kstateUploadMxc(input)
|
|
||||||
t.deepEqual(input, {
|
|
||||||
"m.room.avatar/yes": {
|
|
||||||
url: "mxc://cadence.moe/rfemHmAtcprjLEiPiEuzPhpl"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
test("kstate2state: general", async t => {
|
|
||||||
t.deepEqual(await kstateToState({
|
|
||||||
"m.room.name/": {name: "test name"},
|
"m.room.name/": {name: "test name"},
|
||||||
"m.room.member/@cadence:cadence.moe": {membership: "join"},
|
"m.room.member/@cadence:cadence.moe": {membership: "join"},
|
||||||
"uk.half-shot.bridge/org.matrix.appservice-irc://irc/epicord.net/#general": {creator: "@cadence:cadence.moe"}
|
"uk.half-shot.bridge/org.matrix.appservice-irc://irc/epicord.net/#general": {creator: "@cadence:cadence.moe"}
|
||||||
|
@ -208,29 +162,3 @@ test("diffKState: power levels are mixed together", t => {
|
||||||
})
|
})
|
||||||
t.notDeepEqual(original, result)
|
t.notDeepEqual(original, result)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("diffKState: cannot merge power levels if original power levels are missing", t => {
|
|
||||||
const original = {}
|
|
||||||
assert.throws(() =>
|
|
||||||
diffKState(original, {
|
|
||||||
"m.room.power_levels/": {
|
|
||||||
"events": {
|
|
||||||
"m.room.avatar": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
, /original power level data is missing/)
|
|
||||||
t.pass()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("diffKState: kstate keys must contain a slash separator", t => {
|
|
||||||
assert.throws(() =>
|
|
||||||
diffKState({
|
|
||||||
"m.room.name/": {name: "test name"},
|
|
||||||
}, {
|
|
||||||
"m.room.name/": {name: "test name"},
|
|
||||||
"new": {a: 2}
|
|
||||||
})
|
|
||||||
, /does not contain a slash separator/)
|
|
||||||
t.pass()
|
|
||||||
})
|
|
|
@ -14,7 +14,7 @@ const mxUtils = sync.require("../m2d/converters/utils")
|
||||||
const dUtils = sync.require("../discord/utils")
|
const dUtils = sync.require("../discord/utils")
|
||||||
/** @type {import("./kstate")} */
|
/** @type {import("./kstate")} */
|
||||||
const ks = sync.require("./kstate")
|
const ks = sync.require("./kstate")
|
||||||
const {reg} = require("./read-registration")
|
const reg = require("./read-registration")
|
||||||
|
|
||||||
const PREFIXES = ["//", "/"]
|
const PREFIXES = ["//", "/"]
|
||||||
|
|
||||||
|
@ -96,6 +96,55 @@ function replyctx(execute) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NEWLINE_ELEMENTS = mxUtils.BLOCK_ELEMENTS.concat(["BR"])
|
||||||
|
|
||||||
|
class MatrixStringBuilder {
|
||||||
|
constructor() {
|
||||||
|
this.body = ""
|
||||||
|
this.formattedBody = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} body
|
||||||
|
* @param {string} formattedBody
|
||||||
|
* @param {any} [condition]
|
||||||
|
*/
|
||||||
|
add(body, formattedBody, condition = true) {
|
||||||
|
if (condition) {
|
||||||
|
if (formattedBody == undefined) formattedBody = body
|
||||||
|
this.body += body
|
||||||
|
this.formattedBody += formattedBody
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} body
|
||||||
|
* @param {string} [formattedBody]
|
||||||
|
* @param {any} [condition]
|
||||||
|
*/
|
||||||
|
addLine(body, formattedBody, condition = true) {
|
||||||
|
if (condition) {
|
||||||
|
if (formattedBody == undefined) formattedBody = body
|
||||||
|
if (this.body.length && this.body.slice(-1) !== "\n") this.body += "\n"
|
||||||
|
this.body += body
|
||||||
|
const match = this.formattedBody.match(/<\/?([a-zA-Z]+[a-zA-Z0-9]*)[^>]*>\s*$/)
|
||||||
|
if (this.formattedBody.length && (!match || !NEWLINE_ELEMENTS.includes(match[1].toUpperCase()))) this.formattedBody += "<br>"
|
||||||
|
this.formattedBody += formattedBody
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
get() {
|
||||||
|
return {
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: this.body,
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: this.formattedBody
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {Command[]} */
|
/** @type {Command[]} */
|
||||||
const commands = [{
|
const commands = [{
|
||||||
aliases: ["emoji"],
|
aliases: ["emoji"],
|
||||||
|
@ -170,7 +219,7 @@ const commands = [{
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const b = new mxUtils.MatrixStringBuilder()
|
const b = new MatrixStringBuilder()
|
||||||
.addLine("## Emoji preview", "<h2>Emoji preview</h2>")
|
.addLine("## Emoji preview", "<h2>Emoji preview</h2>")
|
||||||
.addLine(`Ⓜ️ This room isn't bridged to Discord. ${matrixOnlyConclusion}`, `Ⓜ️ <em>This room isn't bridged to Discord. ${matrixOnlyConclusion}</em>`, matrixOnlyReason === "NOT_BRIDGED")
|
.addLine(`Ⓜ️ This room isn't bridged to Discord. ${matrixOnlyConclusion}`, `Ⓜ️ <em>This room isn't bridged to Discord. ${matrixOnlyConclusion}</em>`, matrixOnlyReason === "NOT_BRIDGED")
|
||||||
.addLine(`Ⓜ️ *Discord ran out of space for emojis. ${matrixOnlyConclusion}`, `Ⓜ️ <em>Discord ran out of space for emojis. ${matrixOnlyConclusion}</em>`, matrixOnlyReason === "CAPACITY")
|
.addLine(`Ⓜ️ *Discord ran out of space for emojis. ${matrixOnlyConclusion}`, `Ⓜ️ <em>Discord ran out of space for emojis. ${matrixOnlyConclusion}</em>`, matrixOnlyReason === "CAPACITY")
|
||||||
|
@ -201,7 +250,7 @@ const commands = [{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!("images" in pack)) pack.images = {}
|
if (!("images" in pack)) pack.images = {}
|
||||||
const b = new mxUtils.MatrixStringBuilder()
|
const b = new MatrixStringBuilder()
|
||||||
.addLine(`Created ${toUpload.length} emojis`, "")
|
.addLine(`Created ${toUpload.length} emojis`, "")
|
||||||
for (const e of toUpload) {
|
for (const e of toUpload) {
|
||||||
pack.images[e.name] = {
|
pack.images[e.name] = {
|
||||||
|
@ -217,8 +266,9 @@ const commands = [{
|
||||||
} else {
|
} else {
|
||||||
// Upload it to Discord and have the bridge sync it back to Matrix again
|
// Upload it to Discord and have the bridge sync it back to Matrix again
|
||||||
for (const e of toUpload) {
|
for (const e of toUpload) {
|
||||||
|
const publicUrl = mxUtils.getPublicUrlForMxc(e.url)
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const resizeInput = await api.getMedia(e.url, {agent: false}).then(res => res.arrayBuffer())
|
const resizeInput = await fetch(publicUrl, {agent: false}).then(res => res.arrayBuffer())
|
||||||
const resizeOutput = await sharp(resizeInput)
|
const resizeOutput = await sharp(resizeInput)
|
||||||
.resize(EMOJI_SIZE, EMOJI_SIZE, {fit: "inside", withoutEnlargement: true, background: {r: 0, g: 0, b: 0, alpha: 0}})
|
.resize(EMOJI_SIZE, EMOJI_SIZE, {fit: "inside", withoutEnlargement: true, background: {r: 0, g: 0, b: 0, alpha: 0}})
|
||||||
.png()
|
.png()
|
|
@ -1,11 +1,12 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const fetch = require("node-fetch").default
|
const fetch = require("node-fetch").default
|
||||||
const mixin = require("@cloudrac3r/mixin-deep")
|
const mixin = require("mixin-deep")
|
||||||
const stream = require("stream")
|
|
||||||
const getStream = require("get-stream")
|
|
||||||
|
|
||||||
const {reg, writeRegistration} = require("./read-registration.js")
|
const passthrough = require("../passthrough")
|
||||||
|
const { sync } = passthrough
|
||||||
|
/** @type {import("./read-registration")} */
|
||||||
|
const reg = sync.require("./read-registration.js")
|
||||||
|
|
||||||
const baseUrl = `${reg.ooye.server_origin}/_matrix`
|
const baseUrl = `${reg.ooye.server_origin}/_matrix`
|
||||||
|
|
||||||
|
@ -26,15 +27,9 @@ class MatrixServerError extends Error {
|
||||||
* @param {any} [extra]
|
* @param {any} [extra]
|
||||||
*/
|
*/
|
||||||
async function mreq(method, url, body, extra = {}) {
|
async function mreq(method, url, body, extra = {}) {
|
||||||
if (body == undefined || Object.is(body.constructor, Object)) {
|
|
||||||
body = JSON.stringify(body)
|
|
||||||
} else if (body instanceof stream.Readable && reg.ooye.content_length_workaround) {
|
|
||||||
body = await getStream.buffer(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
const opts = mixin({
|
const opts = mixin({
|
||||||
method,
|
method,
|
||||||
body,
|
body: (body == undefined || Object.is(body.constructor, Object)) ? JSON.stringify(body) : body,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${reg.as_token}`
|
Authorization: `Bearer ${reg.as_token}`
|
||||||
}
|
}
|
||||||
|
@ -44,20 +39,7 @@ async function mreq(method, url, body, extra = {}) {
|
||||||
const res = await fetch(baseUrl + url, opts)
|
const res = await fetch(baseUrl + url, opts)
|
||||||
const root = await res.json()
|
const root = await res.json()
|
||||||
|
|
||||||
if (!res.ok || root.errcode) {
|
if (!res.ok || root.errcode) throw new MatrixServerError(root, opts)
|
||||||
if (root.error?.includes("Content-Length") && !reg.ooye.content_length_workaround) {
|
|
||||||
reg.ooye.content_length_workaround = true
|
|
||||||
const root = await mreq(method, url, body, extra)
|
|
||||||
console.error("OOYE cannot stream uploads to Synapse. The `content_length_workaround` option"
|
|
||||||
+ "\nhas been activated in registration.yaml, which works around the problem, but"
|
|
||||||
+ "\nhalves the speed of bridging d->m files. A better way to resolve this problem"
|
|
||||||
+ "\nis to run an nginx reverse proxy to Synapse and re-run OOYE setup.")
|
|
||||||
writeRegistration(reg)
|
|
||||||
return root
|
|
||||||
}
|
|
||||||
delete opts.headers.Authorization
|
|
||||||
throw new MatrixServerError(root, {baseUrl, url, ...opts})
|
|
||||||
}
|
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -80,6 +62,5 @@ async function withAccessToken(token, callback) {
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.MatrixServerError = MatrixServerError
|
module.exports.MatrixServerError = MatrixServerError
|
||||||
module.exports.baseUrl = baseUrl
|
|
||||||
module.exports.mreq = mreq
|
module.exports.mreq = mreq
|
||||||
module.exports.withAccessToken = withAccessToken
|
module.exports.withAccessToken = withAccessToken
|
14
matrix/read-registration.js
Normal file
14
matrix/read-registration.js
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const fs = require("fs")
|
||||||
|
const assert = require("assert").strict
|
||||||
|
const yaml = require("js-yaml")
|
||||||
|
|
||||||
|
/** @ts-ignore @type {import("../types").AppServiceRegistrationConfig} */
|
||||||
|
const reg = yaml.load(fs.readFileSync("registration.yaml", "utf8"))
|
||||||
|
reg["ooye"].invite = (reg.ooye.invite || []).filter(mxid => mxid.endsWith(`:${reg.ooye.server_name}`)) // one day I will understand why typescript disagrees with dot notation on this line
|
||||||
|
assert(reg.ooye.max_file_size)
|
||||||
|
assert(reg.ooye.namespace_prefix)
|
||||||
|
assert(reg.ooye.server_name)
|
||||||
|
|
||||||
|
module.exports = reg
|
10
matrix/read-registration.test.js
Normal file
10
matrix/read-registration.test.js
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
const {test} = require("supertape")
|
||||||
|
const reg = require("./read-registration")
|
||||||
|
|
||||||
|
test("reg: has necessary parameters", t => {
|
||||||
|
const propertiesToCheck = ["sender_localpart", "id", "as_token", "ooye"]
|
||||||
|
t.deepEqual(
|
||||||
|
propertiesToCheck.filter(p => p in reg),
|
||||||
|
propertiesToCheck
|
||||||
|
)
|
||||||
|
})
|
3141
package-lock.json
generated
3141
package-lock.json
generated
File diff suppressed because it is too large
Load diff
54
package.json
54
package.json
|
@ -14,58 +14,42 @@
|
||||||
],
|
],
|
||||||
"author": "Cadence, PapiOphidian",
|
"author": "Cadence, PapiOphidian",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chriscdn/promise-semaphore": "^2.0.1",
|
"@chriscdn/promise-semaphore": "^2.0.1",
|
||||||
"@cloudrac3r/discord-markdown": "^2.6.3",
|
"better-sqlite3": "^9.0.0",
|
||||||
"@cloudrac3r/giframe": "^0.4.3",
|
|
||||||
"@cloudrac3r/html-template-tag": "^5.0.1",
|
|
||||||
"@cloudrac3r/in-your-element": "^1.0.0",
|
|
||||||
"@cloudrac3r/mixin-deep": "^3.0.0",
|
|
||||||
"@cloudrac3r/pngjs": "^7.0.3",
|
|
||||||
"@cloudrac3r/pug": "^4.0.4",
|
|
||||||
"@cloudrac3r/turndown": "^7.1.4",
|
|
||||||
"@stackoverflow/stacks": "^2.5.7",
|
|
||||||
"@stackoverflow/stacks-icons": "^6.0.2",
|
|
||||||
"ansi-colors": "^4.1.3",
|
|
||||||
"better-sqlite3": "^11.1.2",
|
|
||||||
"chunk-text": "^2.0.1",
|
"chunk-text": "^2.0.1",
|
||||||
"cloudstorm": "^0.10.10",
|
"cloudstorm": "^0.9.0",
|
||||||
"domino": "^2.1.6",
|
"discord-markdown": "git+https://git.sr.ht/~cadence/nodejs-discord-markdown#abc56d544072a1dc5624adfea455b0e902adf7b3",
|
||||||
"enquirer": "^2.4.1",
|
"entities": "^4.5.0",
|
||||||
"entities": "^5.0.0",
|
"giframe": "github:cloudrac3r/giframe#v0.4.1",
|
||||||
"get-stream": "^6.0.1",
|
"heatsync": "^2.4.1",
|
||||||
"h3": "^1.12.0",
|
"js-yaml": "^4.1.0",
|
||||||
"heatsync": "^2.5.5",
|
"matrix-appservice": "^2.0.0",
|
||||||
"lru-cache": "^10.4.3",
|
|
||||||
"minimist": "^1.2.8",
|
"minimist": "^1.2.8",
|
||||||
|
"mixin-deep": "github:cloudrac3r/mixin-deep#v3.0.0",
|
||||||
"node-fetch": "^2.6.7",
|
"node-fetch": "^2.6.7",
|
||||||
|
"pngjs": "^7.0.0",
|
||||||
"prettier-bytes": "^1.0.4",
|
"prettier-bytes": "^1.0.4",
|
||||||
"sharp": "^0.33.4",
|
"sharp": "^0.32.6",
|
||||||
"snowtransfer": "^0.10.5",
|
"snowtransfer": "^0.9.0",
|
||||||
"stream-mime-type": "^1.0.2",
|
"stream-mime-type": "^1.0.2",
|
||||||
"try-to-catch": "^3.0.1",
|
"try-to-catch": "^3.0.1",
|
||||||
"uqr": "^0.1.2",
|
"turndown": "^7.1.2",
|
||||||
"xxhash-wasm": "^1.0.2",
|
"xxhash-wasm": "^1.0.2"
|
||||||
"zod": "^3.23.8"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudrac3r/tap-dot": "^2.0.3",
|
|
||||||
"@types/node": "^18.16.0",
|
"@types/node": "^18.16.0",
|
||||||
"@types/node-fetch": "^2.6.3",
|
"@types/node-fetch": "^2.6.3",
|
||||||
"c8": "^10.1.2",
|
"c8": "^8.0.1",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"discord-api-types": "^0.37.60",
|
"discord-api-types": "^0.37.60",
|
||||||
"supertape": "^10.4.0"
|
"supertape": "^8.3.0",
|
||||||
|
"tap-dot": "github:cloudrac3r/tap-dot#9dd7750ececeae3a96afba91905be812b6b2cc2d"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node start.js",
|
|
||||||
"setup": "node scripts/setup.js",
|
|
||||||
"addbot": "node addbot.js",
|
"addbot": "node addbot.js",
|
||||||
"test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap test/test.js | tap-dot",
|
"test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap test/test.js | tap-dot",
|
||||||
"test-slow": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js -- --slow | tap-dot",
|
"test-slow": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap test/test.js -- --slow | tap-dot",
|
||||||
"cover": "c8 -o test/coverage --skip-full -x db/migrations -x src/matrix/file.js -x src/matrix/api.js -x src/matrix/mreq.js -x src/d2m/converters/rlottie-wasm.js -r html -r text supertape --no-check-assertions-count --format fail --no-worker test/test.js -- --slow"
|
"cover": "c8 --skip-full -x db/migrations -x matrix/file.js -x matrix/api.js -x matrix/mreq.js -r html -r text supertape --no-check-assertions-count --format fail test/test.js -- --slow"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,10 +3,11 @@
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} Passthrough
|
* @typedef {Object} Passthrough
|
||||||
* @property {import("repl").REPLServer} repl
|
* @property {import("repl").REPLServer} repl
|
||||||
|
* @property {typeof import("./config")} config
|
||||||
* @property {import("./d2m/discord-client")} discord
|
* @property {import("./d2m/discord-client")} discord
|
||||||
* @property {import("heatsync").default} sync
|
* @property {import("heatsync")} sync
|
||||||
* @property {import("better-sqlite3/lib/database")} db
|
* @property {import("better-sqlite3/lib/database")} db
|
||||||
* @property {import("@cloudrac3r/in-your-element").AppService} as
|
* @property {import("matrix-appservice").AppService} as
|
||||||
* @property {import("./db/orm").from} from
|
* @property {import("./db/orm").from} from
|
||||||
* @property {import("./db/orm").select} select
|
* @property {import("./db/orm").select} select
|
||||||
*/
|
*/
|
121
readme.md
121
readme.md
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
<img src="docs/img/icon.png" height="128" width="128">
|
<img src="docs/img/icon.png" height="128" width="128">
|
||||||
|
|
||||||
Modern Matrix-to-Discord appservice bridge, created by [@cadence:cadence.moe](https://matrix.to/#/@cadence:cadence.moe)
|
Modern Matrix-to-Discord appservice bridge.
|
||||||
|
|
||||||
[![Releases](https://img.shields.io/gitea/v/release/cadence/out-of-your-element?gitea_url=https%3A%2F%2Fgitdab.com&style=plastic&color=green)](https://gitdab.com/cadence/out-of-your-element/releases) [![Discuss on Matrix](https://img.shields.io/badge/discuss-%23out--of--your--element-white?style=plastic)](https://matrix.to/#/#out-of-your-element:cadence.moe)
|
Created by [@cadence:cadence.moe](https://matrix.to/#/@cadence:cadence.moe) // Discuss in [#out-of-your-element:cadence.moe](https://matrix.to/#/#out-of-your-element:cadence.moe)
|
||||||
|
|
||||||
## Docs
|
## Docs
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@ This readme has the most important info. The rest is [in the docs folder.](https
|
||||||
* Modern: Supports new Discord features like replies, threads and stickers, and new Matrix features like edits, spaces and space membership.
|
* Modern: Supports new Discord features like replies, threads and stickers, and new Matrix features like edits, spaces and space membership.
|
||||||
* Efficient: Special attention has been given to memory usage, database indexes, disk footprint, runtime algorithms, and queries to the homeserver.
|
* Efficient: Special attention has been given to memory usage, database indexes, disk footprint, runtime algorithms, and queries to the homeserver.
|
||||||
* Reliable: Any errors on either side are notified on Matrix and can be retried.
|
* Reliable: Any errors on either side are notified on Matrix and can be retried.
|
||||||
* Tested: A test suite and code coverage make sure all the logic and special cases work.
|
* Tested: A test suite and code coverage make sure all the core logic works.
|
||||||
* Simple development: No build step (it's JavaScript, not TypeScript), minimal/lightweight dependencies, and abstraction only where necessary so that less background knowledge is required. No need to learn about Intents or library functions.
|
* Simple development: No build step (it's JavaScript, not TypeScript), minimal/lightweight dependencies, and abstraction only where necessary so that less background knowledge is required. No need to learn about Intents or library functions.
|
||||||
* No locking algorithm: Other bridges use a locking algorithm which is a source of frequent bugs. This bridge avoids the need for one.
|
* No locking algorithm: Other bridges use a locking algorithm which is a source of frequent bugs. This bridge avoids the need for one.
|
||||||
* Latest API: Being on the latest Discord API version lets it access all features, without the risk of deprecated API versions being removed.
|
* Latest API: Being on the latest Discord API version lets it access all features, without the risk of deprecated API versions being removed.
|
||||||
|
@ -41,31 +41,30 @@ Most features you'd expect in both directions, plus a little extra spice:
|
||||||
* Custom emoji list syncing
|
* Custom emoji list syncing
|
||||||
* Custom emojis in messages
|
* Custom emojis in messages
|
||||||
* Custom room names/avatars can be applied on Matrix-side
|
* Custom room names/avatars can be applied on Matrix-side
|
||||||
* Larger files from Discord are linked instead of reuploaded to Matrix (links don't expire)
|
* Larger files from Discord are linked instead of reuploaded to Matrix
|
||||||
* Simulated user accounts are named @the_persons_username rather than @112233445566778899
|
|
||||||
|
|
||||||
For more information about features, [see the user guide.](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/user-guide.md)
|
For more information about features, [see the user guide.](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/user-guide.md)
|
||||||
|
|
||||||
## Caveats
|
## Caveats
|
||||||
|
|
||||||
* This bridge is not designed for puppetting.
|
* This bridge is not designed for puppetting.
|
||||||
* Direct Messaging is not supported until I figure out a good way of doing it.
|
* Direct Messaging is not supported yet.
|
||||||
|
|
||||||
## Efficiency details
|
## Efficiency details
|
||||||
|
|
||||||
Using WeatherStack as a thin layer between the bridge application and the Discord API lets us control exactly what data is cached in memory. Only necessary information is cached. For example, member data, user data, message content, and past edits are never stored in memory. This keeps the memory usage low and also prevents it ballooning in size over the bridge's runtime.
|
Using WeatherStack as a thin layer between the bridge application and the Discord API lets us control exactly what data is cached. Only necessary information is cached. For example, member data, user data, message content, and past edits are never stored in memory. This keeps the memory usage low and also prevents it ballooning in size over the bridge's runtime.
|
||||||
|
|
||||||
The bridge uses a small SQLite database to store relationships like which Discord messages correspond to which Matrix messages. This is so the bridge knows what to edit when some message is edited on Discord. Using `without rowid` on the database tables stores the index and the data in the same B-tree. Since Matrix and Discord's internal IDs are quite long, this vastly reduces storage space because those IDs do not have to be stored twice separately. Some event IDs and URLs are actually stored as xxhash integers to reduce storage requirements even more. On my personal instance of OOYE, every 300,000 messages (representing a year of conversations) requires 47.3 MB of storage space in the SQLite database.
|
The bridge uses a small SQLite database to store relationships like which Discord messages correspond to which Matrix messages. This is so the bridge knows what to edit when some message is edited on Discord. Using `without rowid` on the database tables stores the index and the data in the same B-tree. Since Matrix and Discord's internal IDs are quite long, this vastly reduces storage space because those IDs do not have to be stored twice separately. Some event IDs are actually stored as xxhash integers to reduce storage requirements even more. On my personal instance of OOYE, every 100,000 messages require 16.1 MB of storage space in the SQLite database.
|
||||||
|
|
||||||
Only necessary data and columns are queried from the database. We only contact the homeserver API if the database doesn't contain what we need.
|
Only necessary data and columns are queried from the database. We only contact the homeserver API if the database doesn't contain what we need.
|
||||||
|
|
||||||
File uploads (like avatars from bridged members) are checked locally and deduplicated. Only brand new files are uploaded to the homeserver. This saves loads of space in the homeserver's media repo, especially for Synapse.
|
File uploads (like avatars from bridged members) are checked locally and deduplicated. Only brand new files are uploaded to the homeserver. This saves loads of space in the homeserver's media repo, especially for Synapse.
|
||||||
|
|
||||||
Switching to [WAL mode](https://www.sqlite.org/wal.html) could improve your database access speed even more. Run `node scripts/wal.js` if you want to switch to WAL mode. This will also enable `synchronous = NORMAL`.
|
Switching to [WAL mode](https://www.sqlite.org/wal.html) could improve your database access speed even more. Run `node scripts/wal.js` if you want to switch to WAL mode.
|
||||||
|
|
||||||
# Setup
|
# Setup
|
||||||
|
|
||||||
If you get stuck, you're welcome to message [#out-of-your-element:cadence.moe](https://matrix.to/#/#out-of-your-element:cadence.moe) or [@cadence:cadence.moe](https://matrix.to/#/@cadence:cadence.moe) to ask for help setting up OOYE!
|
If you get stuck, you're welcome to message @cadence:cadence.moe to ask for help setting up OOYE!
|
||||||
|
|
||||||
You'll need:
|
You'll need:
|
||||||
|
|
||||||
|
@ -74,50 +73,47 @@ You'll need:
|
||||||
|
|
||||||
Follow these steps:
|
Follow these steps:
|
||||||
|
|
||||||
1. [Get Node.js version 20 or later](https://nodejs.org/en/download/prebuilt-installer)
|
1. [Get Node.js version 18 or later](https://nodejs.org/en/download/releases) (the version is required by the better-sqlite3 and matrix-appservice dependencies)
|
||||||
|
|
||||||
1. Clone this repo and checkout a specific tag. (Development happens on main. Stable versions are tagged.)
|
1. Clone this repo and checkout a specific tag. (Development happens on main. Stabler versions are tagged.)
|
||||||
* The latest release tag is ![](https://img.shields.io/gitea/v/release/cadence/out-of-your-element?gitea_url=https%3A%2F%2Fgitdab.com&style=flat-square&label=%20&color=black).
|
|
||||||
|
|
||||||
1. Install dependencies: `npm install`
|
1. Install dependencies: `npm install --save-dev` (omit --save-dev if you will not run the automated tests)
|
||||||
|
|
||||||
1. Run `npm run setup` to check your setup and set the bot's initial state. It will prompt you for information. You only need to run this once ever.
|
1. Copy `config.example.js` to `config.js` and fill in Discord token.
|
||||||
|
|
||||||
1. Start the bridge: `npm run start`
|
1. Copy `registration.example.yaml` to `registration.yaml` and fill in bracketed values. You could generate each hex string with `dd if=/dev/urandom bs=32 count=1 2> /dev/null | basenc --base16 | dd conv=lcase 2> /dev/null`. Register the registration in Synapse's `homeserver.yaml` through the usual appservice installation process, then restart Synapse.
|
||||||
|
|
||||||
|
1. Run `node scripts/seed.js` to check your setup and set the bot's initial state. You only need to run this once ever.
|
||||||
|
1. Make sure the tests work by running `npm t`
|
||||||
|
|
||||||
|
1. Start the bridge: `node start.js`
|
||||||
|
|
||||||
1. Add the bot to a server - use any *one* of the following commands for an invite link:
|
1. Add the bot to a server - use any *one* of the following commands for an invite link:
|
||||||
* (in the REPL) `addbot`
|
* (in the REPL) `addbot`
|
||||||
|
* (in a chat) `//addbot`
|
||||||
* $ `node addbot.js`
|
* $ `node addbot.js`
|
||||||
* $ `npm run addbot`
|
* $ `npm run addbot`
|
||||||
* $ `./addbot.sh`
|
* $ `./addbot.sh`
|
||||||
|
|
||||||
Now any message on Discord will create the corresponding rooms on Matrix-side. After the rooms have been created, Matrix and Discord users can chat back and forth.
|
|
||||||
|
|
||||||
To get into the rooms on your Matrix account, use the `/invite [your mxid here]` command on Discord.
|
|
||||||
|
|
||||||
# Development setup
|
# Development setup
|
||||||
|
|
||||||
* Install development dependencies with `npm install --save-dev` so you can run the tests.
|
* Be sure to install dependencies with `--save-dev` so you can run the tests.
|
||||||
* Most files you change, such as actions, converters, and web, will automatically be reloaded.
|
* Any files you change will automatically be reloaded, except for `stdin.js` and `d2m/discord-*.js`.
|
||||||
* If developing on a different computer to the one running the homeserver, use SSH port forwarding so that Synapse can connect on its `localhost:6693` to reach the running bridge on your computer. Example: `ssh -T -v -R 6693:localhost:6693 me@matrix.cadence.moe`
|
* If developing on a different computer to the one running the homeserver, use SSH port forwarding so that Synapse can connect on its `localhost:6693` to reach the running bridge on your computer. Example: `ssh -T -v -R 6693:localhost:6693 me@matrix.cadence.moe`
|
||||||
* I recommend developing in Visual Studio Code so that the JSDoc x TypeScript annotation comments work. I don't know which other editors or language servers support annotations and type inference.
|
* I recommend developing in Visual Studio Code so that the JSDoc x TypeScript annotation comments work. I don't know which other editors or language servers support annotations and type inference.
|
||||||
|
|
||||||
## Repository structure
|
## Repository structure
|
||||||
|
|
||||||
.
|
.
|
||||||
|
* Run this to start the bridge:
|
||||||
|
├── start.js
|
||||||
* Runtime configuration, like tokens and user info:
|
* Runtime configuration, like tokens and user info:
|
||||||
|
├── config.js
|
||||||
├── registration.yaml
|
├── registration.yaml
|
||||||
* You are here! :)
|
|
||||||
├── readme.md
|
|
||||||
* The bridge's SQLite database is stored here:
|
* The bridge's SQLite database is stored here:
|
||||||
├── ooye.db*
|
|
||||||
* Source code
|
|
||||||
└── src
|
|
||||||
* Database schema:
|
|
||||||
├── db
|
├── db
|
||||||
│ ├── orm.js, orm-defs.d.ts
|
│ ├── *.sql, *.db
|
||||||
│ * Migrations change the database schema when you update to a newer version of OOYE:
|
│ * Migrations change the database schema when you update to a newer version of OOYE:
|
||||||
│ ├── migrate.js
|
|
||||||
│ └── migrations
|
│ └── migrations
|
||||||
│ └── *.sql, *.js
|
│ └── *.sql, *.js
|
||||||
* Discord-to-Matrix bridging:
|
* Discord-to-Matrix bridging:
|
||||||
|
@ -132,10 +128,7 @@ To get into the rooms on your Matrix account, use the `/invite [your mxid here]`
|
||||||
│ ├── discord-*.js
|
│ ├── discord-*.js
|
||||||
│ * Listening to events from Discord and dispatching them to the correct `action`:
|
│ * Listening to events from Discord and dispatching them to the correct `action`:
|
||||||
│ └── event-dispatcher.js
|
│ └── event-dispatcher.js
|
||||||
* Discord bot commands and menus:
|
|
||||||
├── discord
|
├── discord
|
||||||
│ ├── interactions
|
|
||||||
│ │ └── *.js
|
|
||||||
│ └── discord-command-handler.js
|
│ └── discord-command-handler.js
|
||||||
* Matrix-to-Discord bridging:
|
* Matrix-to-Discord bridging:
|
||||||
├── m2d
|
├── m2d
|
||||||
|
@ -151,50 +144,34 @@ To get into the rooms on your Matrix account, use the `/invite [your mxid here]`
|
||||||
├── matrix
|
├── matrix
|
||||||
│ └── *.js
|
│ └── *.js
|
||||||
* Various files you can run once if you need them.
|
* Various files you can run once if you need them.
|
||||||
└── scripts
|
├── scripts
|
||||||
* First time running a new bridge? Run this file to set up prerequisites on the Matrix server:
|
│ * First time running a new bridge? Run this file to plant a seed, which will flourish into state for the bridge:
|
||||||
├── setup.js
|
│ ├── seed.js
|
||||||
* Hopefully you won't need the rest of these. Code quality varies wildly.
|
│ * Hopefully you won't need the rest of these. Code quality varies wildly.
|
||||||
└── *.js
|
│ └── *.js
|
||||||
|
* You are here! :)
|
||||||
|
└── readme.md
|
||||||
|
|
||||||
## Dependency justification
|
## Dependency justification
|
||||||
|
|
||||||
Total transitive production dependencies: 147
|
(deduped transitive dependency count) dependency name: explanation
|
||||||
|
|
||||||
### <font size="+2">🦕</font>
|
* (0) @chriscdn/promise-semaphore: It does what I want! I like it!
|
||||||
|
* (42) better-sqlite3: SQLite3 is the best database, and this is the best library for it. Really! I love it.
|
||||||
* (31) better-sqlite3: SQLite3 is the best database, and this is the best library for it.
|
|
||||||
* (27) @cloudrac3r/pug: Language for dynamic web pages. This is my fork. (I released code that hadn't made it to npm, and removed the heavy pug-filters feature.)
|
|
||||||
* (16) stream-mime-type@1: This seems like the best option. Version 1 is used because version 2 is ESM-only.
|
|
||||||
* (14) h3: Web server. OOYE needs this for the appservice listener, authmedia proxy, and more. 14 transitive dependencies is on the low end for a web server.
|
|
||||||
* (11) sharp: Image resizing and compositing. OOYE needs this for the emoji sprite sheets.
|
|
||||||
|
|
||||||
### <font size="-1">🪱</font>
|
|
||||||
|
|
||||||
* (0) @chriscdn/promise-semaphore: It does what I want.
|
|
||||||
* (1) @cloudrac3r/discord-markdown: This is my fork.
|
|
||||||
* (0) @cloudrac3r/giframe: This is my fork.
|
|
||||||
* (1) @cloudrac3r/html-template-tag: This is my fork.
|
|
||||||
* (0) @cloudrac3r/in-your-element: This is my Matrix Appservice API library. It depends on h3 and zod, which are already pulled in by OOYE.
|
|
||||||
* (0) @cloudrac3r/mixin-deep: This is my fork. (It fixes a bug in regular mixin-deep.)
|
|
||||||
* (0) @cloudrac3r/pngjs: Lottie stickers are converted to bitmaps with the vendored Rlottie WASM build, then the bitmaps are converted to PNG with pngjs.
|
|
||||||
* (0) @cloudrac3r/turndown: This HTML-to-Markdown converter looked the most suitable. I forked it to change the escaping logic to match the way Discord works.
|
|
||||||
* (3) @stackoverflow/stacks: Stack Overflow design language and icons.
|
|
||||||
* (0) ansi-colors: Helps with interactive prompting for the initial setup, and it's already pulled in by enquirer.
|
|
||||||
* (1) chunk-text: It does what I want.
|
* (1) chunk-text: It does what I want.
|
||||||
* (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust.
|
* (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust.
|
||||||
* (0) domino: DOM implementation that's already pulled in by turndown.
|
* (8) snowtransfer: Discord API library with bring-your-own-caching that I trust.
|
||||||
* (1) enquirer: Interactive prompting for the initial setup rather than forcing users to edit YAML non-interactively.
|
* (1) discord-markdown: This is my fork! I make sure it does what I want.
|
||||||
* (0) entities: Looks fine. No dependencies.
|
* (0) giframe: This is my fork! It should do what I want.
|
||||||
* (0) get-stream: Only needed if content_length_workaround is true.
|
|
||||||
* (1) heatsync: Module hot-reloader that I trust.
|
* (1) heatsync: Module hot-reloader that I trust.
|
||||||
* (1) js-yaml: Will be removed in the future after registration.yaml is converted to JSON.
|
* (0) entities: Looks fine. No dependencies.
|
||||||
* (0) lru-cache: For holding unused nonce in memory and letting them be overwritten later if never used.
|
* (1) js-yaml: It seems to do what I want, and it's already pulled in by matrix-appservice.
|
||||||
* (0) minimist: It's already pulled in by better-sqlite3->prebuild-install.
|
* (70) matrix-appservice: I wish it didn't pull in express :(
|
||||||
* (3) node-fetch@2: I like it and it does what I want. Version 2 is used because version 3 is ESM-only.
|
* (0) minimist: It's already pulled in by better-sqlite3->prebuild-install
|
||||||
|
* (0) mixin-deep: This is my fork! It fixes a bug in regular mixin-deep.
|
||||||
|
* (3) node-fetch@2: I like it and it does what I want.
|
||||||
|
* (0) pngjs: Lottie stickers are converted to bitmaps with the vendored Rlottie WASM build, then the bitmaps are converted to PNG with pngjs.
|
||||||
* (0) prettier-bytes: It does what I want and has no dependencies.
|
* (0) prettier-bytes: It does what I want and has no dependencies.
|
||||||
* (2) snowtransfer: Discord API library with bring-your-own-caching that I trust.
|
* (51) sharp: Jimp has fewer dependencies, but sharp is faster.
|
||||||
* (0) try-to-catch: Not strictly necessary, but it's already pulled in by supertape, so I may as well.
|
* (0) try-to-catch: Not strictly necessary, but it does what I want and has no dependencies.
|
||||||
* (0) uqr: QR code SVG generator. Used on the website to scan in an invite link.
|
* (1) turndown: I need an HTML-to-Markdown converter and this one looked suitable enough. It has some bugs that I've worked around, so I might switch away from it later.
|
||||||
* (0) xxhash-wasm: Used where cryptographically secure hashing is not required.
|
|
||||||
* (0) zod: Input validation for the web server. It's popular and easy to use.
|
|
||||||
|
|
25
registration.example.yaml
Normal file
25
registration.example.yaml
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
id: de8c56117637cb5d9f4ac216f612dc2adb1de4c09ae8d13553f28c33a28147c7
|
||||||
|
hs_token: [a unique 64 character hex string]
|
||||||
|
as_token: [a unique 64 character hex string]
|
||||||
|
url: http://localhost:6693
|
||||||
|
sender_localpart: _ooye_bot
|
||||||
|
protocols:
|
||||||
|
- discord
|
||||||
|
namespaces:
|
||||||
|
users:
|
||||||
|
- exclusive: true
|
||||||
|
regex: '@_ooye_.*'
|
||||||
|
aliases:
|
||||||
|
- exclusive: true
|
||||||
|
regex: '#_ooye_.*'
|
||||||
|
rate_limited: false
|
||||||
|
ooye:
|
||||||
|
namespace_prefix: _ooye_
|
||||||
|
max_file_size: 5000000
|
||||||
|
server_name: [the part after the colon in your matrix id, like cadence.moe]
|
||||||
|
server_origin: [the full protocol and domain of your actual matrix server's location, with no trailing slash, like https://matrix.cadence.moe]
|
||||||
|
discord_origin: https://discord.com
|
||||||
|
discord_cdn_origin: https://cdn.discordapp.com
|
||||||
|
invite:
|
||||||
|
# uncomment this to auto-invite the named user to newly created spaces and mark them as admin (PL 100) everywhere
|
||||||
|
# - @cadence:cadence.moe
|
13
scripts/capture-message-update-events.js
Executable file → Normal file
13
scripts/capture-message-update-events.js
Executable file → Normal file
|
@ -1,4 +1,3 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
// ****
|
// ****
|
||||||
|
@ -17,16 +16,16 @@ function fieldToPresenceValue(field) {
|
||||||
const sqlite = require("better-sqlite3")
|
const sqlite = require("better-sqlite3")
|
||||||
const HeatSync = require("heatsync")
|
const HeatSync = require("heatsync")
|
||||||
|
|
||||||
const {reg} = require("../src/matrix/read-registration")
|
const config = require("../config")
|
||||||
const passthrough = require("../src/passthrough")
|
const passthrough = require("../passthrough")
|
||||||
|
|
||||||
const sync = new HeatSync({watchFS: false})
|
const sync = new HeatSync({watchFS: false})
|
||||||
|
|
||||||
Object.assign(passthrough, {sync})
|
Object.assign(passthrough, {config, sync})
|
||||||
|
|
||||||
const DiscordClient = require("../src/d2m/discord-client")
|
const DiscordClient = require("../d2m/discord-client")
|
||||||
|
|
||||||
const discord = new DiscordClient(reg.ooye.discord_token, "no")
|
const discord = new DiscordClient(config.discordToken, "no")
|
||||||
passthrough.discord = discord
|
passthrough.discord = discord
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
|
@ -38,7 +37,7 @@ passthrough.discord = discord
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const events = new sqlite("scripts/events.db")
|
const events = new sqlite("scripts/events.db")
|
||||||
const sql = "INSERT INTO update_event (json, " + interestingFields.join(", ") + ") VALUES (" + "?".repeat(interestingFields.length + 1).split("").join(", ") + ")"
|
const sql = "INSERT INTO \"update\" (json, " + interestingFields.join(", ") + ") VALUES (" + "?".repeat(interestingFields.length + 1).split("").join(", ") + ")"
|
||||||
console.log(sql)
|
console.log(sql)
|
||||||
const prepared = events.prepare(sql)
|
const prepared = events.prepare(sql)
|
||||||
|
|
||||||
|
|
10
scripts/check-migrate.js
Executable file → Normal file
10
scripts/check-migrate.js
Executable file → Normal file
|
@ -1,4 +1,3 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
// Trigger the database migration flow and exit after committing.
|
// Trigger the database migration flow and exit after committing.
|
||||||
|
@ -6,10 +5,11 @@
|
||||||
|
|
||||||
const sqlite = require("better-sqlite3")
|
const sqlite = require("better-sqlite3")
|
||||||
|
|
||||||
const passthrough = require("../src/passthrough")
|
const config = require("../config")
|
||||||
const db = new sqlite("ooye.db")
|
const passthrough = require("../passthrough")
|
||||||
const migrate = require("../src/db/migrate")
|
const db = new sqlite("db/ooye.db")
|
||||||
|
const migrate = require("../db/migrate")
|
||||||
|
|
||||||
Object.assign(passthrough, {db})
|
Object.assign(passthrough, {config, db })
|
||||||
|
|
||||||
migrate.migrate(db)
|
migrate.migrate(db)
|
||||||
|
|
23
scripts/migrate-from-old-bridge.js
Executable file → Normal file
23
scripts/migrate-from-old-bridge.js
Executable file → Normal file
|
@ -1,4 +1,3 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert").strict
|
const assert = require("assert").strict
|
||||||
|
@ -7,17 +6,19 @@ const Semaphore = require("@chriscdn/promise-semaphore")
|
||||||
const sqlite = require("better-sqlite3")
|
const sqlite = require("better-sqlite3")
|
||||||
const HeatSync = require("heatsync")
|
const HeatSync = require("heatsync")
|
||||||
|
|
||||||
const passthrough = require("../src/passthrough")
|
const config = require("../config")
|
||||||
|
const passthrough = require("../passthrough")
|
||||||
|
|
||||||
const sync = new HeatSync({watchFS: false})
|
const sync = new HeatSync({watchFS: false})
|
||||||
|
|
||||||
const {reg} = require("../src/matrix/read-registration")
|
/** @type {import("../matrix/read-registration")} */
|
||||||
|
const reg = sync.require("../matrix/read-registration")
|
||||||
assert(reg.old_bridge)
|
assert(reg.old_bridge)
|
||||||
const oldAT = reg.old_bridge.as_token
|
const oldAT = reg.old_bridge.as_token
|
||||||
const newAT = reg.as_token
|
const newAT = reg.as_token
|
||||||
|
|
||||||
const oldDB = new sqlite(reg.old_bridge.database)
|
const oldDB = new sqlite(reg.old_bridge.database)
|
||||||
const db = new sqlite("ooye.db")
|
const db = new sqlite("db/ooye.db")
|
||||||
|
|
||||||
db.exec(`CREATE TABLE IF NOT EXISTS half_shot_migration (
|
db.exec(`CREATE TABLE IF NOT EXISTS half_shot_migration (
|
||||||
discord_channel TEXT NOT NULL,
|
discord_channel TEXT NOT NULL,
|
||||||
|
@ -25,19 +26,19 @@ db.exec(`CREATE TABLE IF NOT EXISTS half_shot_migration (
|
||||||
PRIMARY KEY("discord_channel")
|
PRIMARY KEY("discord_channel")
|
||||||
) WITHOUT ROWID;`)
|
) WITHOUT ROWID;`)
|
||||||
|
|
||||||
Object.assign(passthrough, {sync, db})
|
Object.assign(passthrough, {config, sync, db})
|
||||||
|
|
||||||
const DiscordClient = require("../src/d2m/discord-client")
|
const DiscordClient = require("../d2m/discord-client")
|
||||||
const discord = new DiscordClient(reg.ooye.discord_token, "half")
|
const discord = new DiscordClient(config.discordToken, "half")
|
||||||
passthrough.discord = discord
|
passthrough.discord = discord
|
||||||
|
|
||||||
/** @type {import("../src/d2m/actions/create-space")} */
|
/** @type {import("../d2m/actions/create-space")} */
|
||||||
const createSpace = sync.require("../d2m/actions/create-space")
|
const createSpace = sync.require("../d2m/actions/create-space")
|
||||||
/** @type {import("../src/d2m/actions/create-room")} */
|
/** @type {import("../d2m/actions/create-room")} */
|
||||||
const createRoom = sync.require("../d2m/actions/create-room")
|
const createRoom = sync.require("../d2m/actions/create-room")
|
||||||
/** @type {import("../src/matrix/mreq")} */
|
/** @type {import("../matrix/mreq")} */
|
||||||
const mreq = sync.require("../matrix/mreq")
|
const mreq = sync.require("../matrix/mreq")
|
||||||
/** @type {import("../src/matrix/api")} */
|
/** @type {import("../matrix/api")} */
|
||||||
const api = sync.require("../matrix/api")
|
const api = sync.require("../matrix/api")
|
||||||
|
|
||||||
const sema = new Semaphore()
|
const sema = new Semaphore()
|
||||||
|
|
22
scripts/register.js
Normal file
22
scripts/register.js
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const { AppServiceRegistration } = require("matrix-appservice");
|
||||||
|
|
||||||
|
let id = AppServiceRegistration.generateToken()
|
||||||
|
try {
|
||||||
|
const reg = require("../matrix/read-registration")
|
||||||
|
if (reg.id) id = reg.id
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
// creating registration files
|
||||||
|
const newReg = new AppServiceRegistration(null);
|
||||||
|
newReg.setAppServiceUrl("http://localhost:6693");
|
||||||
|
newReg.setId(id);
|
||||||
|
newReg.setHomeserverToken(AppServiceRegistration.generateToken());
|
||||||
|
newReg.setAppServiceToken(AppServiceRegistration.generateToken());
|
||||||
|
newReg.setSenderLocalpart("_ooye_bot");
|
||||||
|
newReg.addRegexPattern("users", "@_ooye_.*", true);
|
||||||
|
newReg.addRegexPattern("aliases", "#_ooye_.*", true);
|
||||||
|
newReg.setProtocols(["discord"]); // For 3PID lookups
|
||||||
|
newReg.setRateLimited(false);
|
||||||
|
newReg.outputAsYaml("registration.yaml");
|
|
@ -1,39 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const HeatSync = require("heatsync")
|
|
||||||
const sync = new HeatSync({watchFS: false})
|
|
||||||
|
|
||||||
const sqlite = require("better-sqlite3")
|
|
||||||
const db = new sqlite("db/ooye.db")
|
|
||||||
|
|
||||||
const passthrough = require("../src/passthrough")
|
|
||||||
Object.assign(passthrough, {db, sync})
|
|
||||||
|
|
||||||
const api = require("../src/matrix/api")
|
|
||||||
const mreq = require("../src/matrix/mreq")
|
|
||||||
|
|
||||||
const rooms = db.prepare("select room_id from channel_room").pluck().all()
|
|
||||||
|
|
||||||
;(async () => {
|
|
||||||
// Step 5: Kick users starting with @_discord_
|
|
||||||
await mreq.withAccessToken("baby", async () => {
|
|
||||||
for (const roomID of rooms) {
|
|
||||||
try {
|
|
||||||
const members = await api.getJoinedMembers(roomID)
|
|
||||||
for (const mxid of Object.keys(members.joined)) {
|
|
||||||
if (mxid.startsWith("@_discord_") && !mxid.startsWith("@_discord_bot")) {
|
|
||||||
await api.leaveRoom(roomID, mxid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await api.setUserPower(roomID, "@_discord_bot:cadence.moe", 0)
|
|
||||||
await api.leaveRoom(roomID)
|
|
||||||
} catch (e) {
|
|
||||||
if (e.message.includes("Appservice not in room")) {
|
|
||||||
// ok
|
|
||||||
} else {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})()
|
|
13
scripts/save-channel-names-to-db.js
Executable file → Normal file
13
scripts/save-channel-names-to-db.js
Executable file → Normal file
|
@ -1,20 +1,19 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const sqlite = require("better-sqlite3")
|
const sqlite = require("better-sqlite3")
|
||||||
const HeatSync = require("heatsync")
|
const HeatSync = require("heatsync")
|
||||||
|
|
||||||
const {reg} = require("../src/matrix/read-registration")
|
const config = require("../config")
|
||||||
const passthrough = require("../src/passthrough")
|
const passthrough = require("../passthrough")
|
||||||
const db = new sqlite("ooye.db")
|
const db = new sqlite("db/ooye.db")
|
||||||
|
|
||||||
const sync = new HeatSync({watchFS: false})
|
const sync = new HeatSync({watchFS: false})
|
||||||
|
|
||||||
Object.assign(passthrough, {sync, db})
|
Object.assign(passthrough, {config, sync, db})
|
||||||
|
|
||||||
const DiscordClient = require("../src/d2m/discord-client")
|
const DiscordClient = require("../d2m/discord-client")
|
||||||
|
|
||||||
const discord = new DiscordClient(reg.ooye.discord_token, "no")
|
const discord = new DiscordClient(config.discordToken, "no")
|
||||||
passthrough.discord = discord
|
passthrough.discord = discord
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
|
|
7
scripts/save-event-types-to-db.js
Executable file → Normal file
7
scripts/save-event-types-to-db.js
Executable file → Normal file
|
@ -1,17 +1,16 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const sqlite = require("better-sqlite3")
|
const sqlite = require("better-sqlite3")
|
||||||
const HeatSync = require("heatsync")
|
const HeatSync = require("heatsync")
|
||||||
|
|
||||||
const passthrough = require("../src/passthrough")
|
const passthrough = require("../passthrough")
|
||||||
const db = new sqlite("ooye.db")
|
const db = new sqlite("db/ooye.db")
|
||||||
|
|
||||||
const sync = new HeatSync({watchFS: false})
|
const sync = new HeatSync({watchFS: false})
|
||||||
|
|
||||||
Object.assign(passthrough, {sync, db})
|
Object.assign(passthrough, {sync, db})
|
||||||
|
|
||||||
const api = require("../src/matrix/api")
|
const api = require("../matrix/api")
|
||||||
|
|
||||||
/** @type {{event_id: string, room_id: string, event_type: string}[]} */ // @ts-ignore
|
/** @type {{event_id: string, room_id: string, event_type: string}[]} */ // @ts-ignore
|
||||||
const rows = db.prepare("SELECT event_id, room_id, event_type FROM event_message INNER JOIN message_channel USING (message_id) INNER JOIN channel_room USING (channel_id)").all()
|
const rows = db.prepare("SELECT event_id, room_id, event_type FROM event_message INNER JOIN message_channel USING (message_id) INNER JOIN channel_room USING (channel_id)").all()
|
||||||
|
|
125
scripts/seed.js
Normal file
125
scripts/seed.js
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
console.log("This could take up to 30 seconds. Please be patient.")
|
||||||
|
|
||||||
|
const assert = require("assert").strict
|
||||||
|
const fs = require("fs")
|
||||||
|
const sqlite = require("better-sqlite3")
|
||||||
|
const HeatSync = require("heatsync")
|
||||||
|
|
||||||
|
const args = require("minimist")(process.argv.slice(2), {string: ["emoji-guild"]})
|
||||||
|
|
||||||
|
const config = require("../config")
|
||||||
|
const passthrough = require("../passthrough")
|
||||||
|
const db = new sqlite("db/ooye.db")
|
||||||
|
const migrate = require("../db/migrate")
|
||||||
|
|
||||||
|
const sync = new HeatSync({watchFS: false})
|
||||||
|
|
||||||
|
Object.assign(passthrough, { sync, config, db })
|
||||||
|
|
||||||
|
const orm = sync.require("../db/orm")
|
||||||
|
passthrough.from = orm.from
|
||||||
|
passthrough.select = orm.select
|
||||||
|
|
||||||
|
const DiscordClient = require("../d2m/discord-client")
|
||||||
|
const discord = new DiscordClient(config.discordToken, "no")
|
||||||
|
passthrough.discord = discord
|
||||||
|
|
||||||
|
const api = require("../matrix/api")
|
||||||
|
const file = require("../matrix/file")
|
||||||
|
const reg = require("../matrix/read-registration")
|
||||||
|
const utils = require("../m2d/converters/utils")
|
||||||
|
|
||||||
|
function die(message) {
|
||||||
|
console.error(message)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadAutoEmoji(guild, name, filename) {
|
||||||
|
let emoji = guild.emojis.find(e => e.name === name)
|
||||||
|
if (!emoji) {
|
||||||
|
console.log(` Uploading ${name}...`)
|
||||||
|
const data = fs.readFileSync(filename, null)
|
||||||
|
emoji = await discord.snow.guildAssets.createEmoji(guild.id, {name, image: "data:image/png;base64," + data.toString("base64")})
|
||||||
|
} else {
|
||||||
|
console.log(` Reusing ${name}...`)
|
||||||
|
}
|
||||||
|
db.prepare("REPLACE INTO auto_emoji (name, emoji_id, guild_id) VALUES (?, ?, ?)").run(emoji.name, emoji.id, guild.id)
|
||||||
|
return emoji
|
||||||
|
}
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
const mxid = `@${reg.sender_localpart}:${reg.ooye.server_name}`
|
||||||
|
|
||||||
|
// ensure registration is correctly set...
|
||||||
|
assert(reg.sender_localpart.startsWith(reg.ooye.namespace_prefix)) // appservice's localpart must be in the namespace it controls
|
||||||
|
assert(utils.eventSenderIsFromDiscord(mxid)) // appservice's mxid must be in the namespace it controls
|
||||||
|
assert(reg.ooye.server_origin.match(/^https?:\/\//)) // must start with http or https
|
||||||
|
assert.notEqual(reg.ooye.server_origin.slice(-1), "/") // must not end in slash
|
||||||
|
console.log("✅ Configuration looks good...")
|
||||||
|
|
||||||
|
// database ddl...
|
||||||
|
await migrate.migrate(db)
|
||||||
|
|
||||||
|
// add initial rows to database, like adding the bot to sim...
|
||||||
|
db.prepare("INSERT OR IGNORE INTO sim (user_id, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run("0", reg.sender_localpart.slice(reg.ooye.namespace_prefix.length), reg.sender_localpart, mxid)
|
||||||
|
|
||||||
|
console.log("✅ Database is ready...")
|
||||||
|
|
||||||
|
// upload the L1 L2 emojis to some guild
|
||||||
|
const emojis = db.prepare("SELECT name FROM auto_emoji WHERE name = 'L1' OR name = 'L2'").pluck().all()
|
||||||
|
if (emojis.length !== 2) {
|
||||||
|
// If an argument was supplied, always use that one
|
||||||
|
let guild = null
|
||||||
|
if (args["emoji-guild"]) {
|
||||||
|
if (typeof args["emoji-guild"] === "string") {
|
||||||
|
guild = await discord.snow.guild.getGuild(args["emoji-guild"])
|
||||||
|
}
|
||||||
|
if (!guild) return die(`Error: You asked emojis to be uploaded to guild ID ${args["emoji-guild"]}, but the bot isn't in that guild.`)
|
||||||
|
}
|
||||||
|
// Otherwise, check if we have already registered an auto emoji guild
|
||||||
|
if (!guild) {
|
||||||
|
const guildID = passthrough.select("auto_emoji", "guild_id", {name: "_"}).pluck().get()
|
||||||
|
if (guildID) {
|
||||||
|
guild = await discord.snow.guild.getGuild(guildID, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Otherwise, check if we should create a new guild
|
||||||
|
if (!guild) {
|
||||||
|
const guilds = await discord.snow.user.getGuilds({limit: 11, with_counts: false})
|
||||||
|
if (guilds.length < 10) {
|
||||||
|
console.log(" Creating a guild for emojis...")
|
||||||
|
guild = await discord.snow.guild.createGuild({name: "OOYE Emojis"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Otherwise, it's the user's problem
|
||||||
|
if (!guild) {
|
||||||
|
return die(`Error: The bot needs to upload some emojis. Please say where to upload them to. Run seed.js again with --emoji-guild=GUILD_ID`)
|
||||||
|
}
|
||||||
|
// Upload those emojis to the chosen location
|
||||||
|
db.prepare("REPLACE INTO auto_emoji (name, emoji_id, guild_id) VALUES ('_', '_', ?)").run(guild.id)
|
||||||
|
await uploadAutoEmoji(guild, "L1", "docs/img/L1.png")
|
||||||
|
await uploadAutoEmoji(guild, "L2", "docs/img/L2.png")
|
||||||
|
}
|
||||||
|
console.log("✅ Emojis are ready...")
|
||||||
|
|
||||||
|
// ensure homeserver well-known is valid and returns reg.ooye.server_name...
|
||||||
|
|
||||||
|
// upload initial images...
|
||||||
|
const avatarUrl = await file.uploadDiscordFileToMxc("https://cadence.moe/friends/out_of_your_element.png")
|
||||||
|
|
||||||
|
// set profile data on discord...
|
||||||
|
const avatarImageBuffer = await fetch("https://cadence.moe/friends/out_of_your_element.png").then(res => res.arrayBuffer())
|
||||||
|
await discord.snow.user.updateSelf({avatar: "data:image/png;base64," + Buffer.from(avatarImageBuffer).toString("base64")})
|
||||||
|
await discord.snow.requestHandler.request(`/applications/@me`, {}, "patch", "json", {description: "Powered by **Out Of Your Element**\nhttps://gitdab.com/cadence/out-of-your-element"})
|
||||||
|
console.log("✅ Discord profile updated...")
|
||||||
|
|
||||||
|
// set profile data on homeserver...
|
||||||
|
await api.profileSetDisplayname(mxid, "Out Of Your Element")
|
||||||
|
await api.profileSetAvatarUrl(mxid, avatarUrl)
|
||||||
|
console.log("✅ Matrix profile updated...")
|
||||||
|
|
||||||
|
console.log("Good to go. I hope you enjoy Out Of Your Element.")
|
||||||
|
process.exit()
|
||||||
|
})()
|
356
scripts/setup.js
356
scripts/setup.js
|
@ -1,356 +0,0 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const assert = require("assert").strict
|
|
||||||
const fs = require("fs")
|
|
||||||
const sqlite = require("better-sqlite3")
|
|
||||||
const {scheduler} = require("timers/promises")
|
|
||||||
const {isDeepStrictEqual} = require("util")
|
|
||||||
const {createServer} = require("http")
|
|
||||||
const {join} = require("path")
|
|
||||||
|
|
||||||
const {prompt} = require("enquirer")
|
|
||||||
const Input = require("enquirer/lib/prompts/input")
|
|
||||||
const fetch = require("node-fetch").default
|
|
||||||
const {magenta, bold, cyan} = require("ansi-colors")
|
|
||||||
const HeatSync = require("heatsync")
|
|
||||||
const {SnowTransfer} = require("snowtransfer")
|
|
||||||
const {createApp, defineEventHandler, toNodeListener} = require("h3")
|
|
||||||
|
|
||||||
const args = require("minimist")(process.argv.slice(2), {string: ["emoji-guild"]})
|
|
||||||
|
|
||||||
// Move database file if it's still in the old location
|
|
||||||
if (fs.existsSync("db")) {
|
|
||||||
if (fs.existsSync("db/ooye.db")) {
|
|
||||||
fs.renameSync("db/ooye.db", "ooye.db")
|
|
||||||
}
|
|
||||||
const files = fs.readdirSync("db")
|
|
||||||
if (files.length) {
|
|
||||||
console.error("The db folder is deprecated and must be removed. Your ooye.db database file has already been moved to the root of the repo. You must manually move or delete the remaining files:")
|
|
||||||
for (const file of files) {
|
|
||||||
console.error(file)
|
|
||||||
}
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
fs.rmSync("db", {recursive: true})
|
|
||||||
}
|
|
||||||
|
|
||||||
const passthrough = require("../src/passthrough")
|
|
||||||
const db = new sqlite("ooye.db")
|
|
||||||
const migrate = require("../src/db/migrate")
|
|
||||||
|
|
||||||
const sync = new HeatSync({watchFS: false})
|
|
||||||
|
|
||||||
Object.assign(passthrough, {sync, db})
|
|
||||||
|
|
||||||
const orm = sync.require("../src/db/orm")
|
|
||||||
passthrough.from = orm.from
|
|
||||||
passthrough.select = orm.select
|
|
||||||
|
|
||||||
let registration = require("../src/matrix/read-registration")
|
|
||||||
let {reg, getTemplateRegistration, writeRegistration, readRegistration, checkRegistration, registrationFilePath} = registration
|
|
||||||
|
|
||||||
function die(message) {
|
|
||||||
console.error(message)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadAutoEmoji(snow, guild, name, filename) {
|
|
||||||
let emoji = guild.emojis.find(e => e.name === name)
|
|
||||||
if (!emoji) {
|
|
||||||
console.log(` Uploading ${name}...`)
|
|
||||||
const data = fs.readFileSync(filename, null)
|
|
||||||
emoji = await snow.guildAssets.createEmoji(guild.id, {name, image: "data:image/png;base64," + data.toString("base64")})
|
|
||||||
} else {
|
|
||||||
console.log(` Reusing ${name}...`)
|
|
||||||
}
|
|
||||||
db.prepare("REPLACE INTO auto_emoji (name, emoji_id, guild_id) VALUES (?, ?, ?)").run(emoji.name, emoji.id, guild.id)
|
|
||||||
return emoji
|
|
||||||
}
|
|
||||||
|
|
||||||
async function validateHomeserverOrigin(serverUrlPrompt, url) {
|
|
||||||
if (!url.match(/^https?:\/\//)) return "Must be a URL"
|
|
||||||
if (url.match(/\/$/)) return "Must not end with a slash"
|
|
||||||
process.stdout.write(magenta(" checking, please wait..."))
|
|
||||||
try {
|
|
||||||
var json = await fetch(`${url}/.well-known/matrix/client`).then(res => res.json())
|
|
||||||
let baseURL = json["m.homeserver"].base_url.replace(/\/$/, "")
|
|
||||||
if (baseURL && baseURL !== url) {
|
|
||||||
serverUrlPrompt.initial = baseURL
|
|
||||||
return `Did you mean: ${bold(baseURL)}? (Enter to accept)`
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
try {
|
|
||||||
var res = await fetch(`${url}/_matrix/client/versions`)
|
|
||||||
} catch (e) {
|
|
||||||
return e.message
|
|
||||||
}
|
|
||||||
if (res.status !== 200) return `There is no Matrix server at that URL (${url}/_matrix/client/versions returned ${res.status})`
|
|
||||||
try {
|
|
||||||
var json = await res.json()
|
|
||||||
if (!Array.isArray(json?.versions) || !json.versions.includes("v1.11")) {
|
|
||||||
return `OOYE needs Matrix version v1.11, but ${url} doesn't support this`
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
return `There is no Matrix server at that URL (${url}/_matrix/client/versions is not JSON)`
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
;(async () => {
|
|
||||||
// create registration file with prompts...
|
|
||||||
if (!reg) {
|
|
||||||
console.log("What is the name of your homeserver? This is the part after : in your username.")
|
|
||||||
/** @type {{server_name: string}} */
|
|
||||||
const serverNameResponse = await prompt({
|
|
||||||
type: "input",
|
|
||||||
name: "server_name",
|
|
||||||
message: "Homeserver name",
|
|
||||||
validate: serverName => !!serverName.match(/[a-z][a-z.]+[a-z]/)
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log("What is the URL of your homeserver?")
|
|
||||||
const serverOriginPrompt = new Input({
|
|
||||||
type: "input",
|
|
||||||
name: "server_origin",
|
|
||||||
message: "Homeserver URL",
|
|
||||||
initial: () => `https://${serverNameResponse.server_name}`,
|
|
||||||
validate: url => validateHomeserverOrigin(serverOriginPrompt, url)
|
|
||||||
})
|
|
||||||
/** @type {string} */ // @ts-ignore
|
|
||||||
const serverOrigin = await serverOriginPrompt.run()
|
|
||||||
|
|
||||||
const app = createApp()
|
|
||||||
app.use(defineEventHandler(() => "Out Of Your Element is listening.\n"))
|
|
||||||
const server = createServer(toNodeListener(app))
|
|
||||||
await server.listen(6693)
|
|
||||||
|
|
||||||
console.log("OOYE has its own web server. It needs to be accessible on the public internet.")
|
|
||||||
console.log("You need to enter a public URL where you will be able to host this web server.")
|
|
||||||
console.log("OOYE listens on localhost:6693, so you will probably have to set up a reverse proxy.")
|
|
||||||
console.log("Now listening on port 6693. Feel free to send some test requests.")
|
|
||||||
/** @type {{bridge_origin: string}} */
|
|
||||||
const bridgeOriginResponse = await prompt({
|
|
||||||
type: "input",
|
|
||||||
name: "bridge_origin",
|
|
||||||
message: "URL to reach OOYE",
|
|
||||||
initial: () => `https://bridge.${serverNameResponse.server_name}`,
|
|
||||||
validate: async url => {
|
|
||||||
process.stdout.write(magenta(" checking, please wait..."))
|
|
||||||
try {
|
|
||||||
const res = await fetch(url)
|
|
||||||
if (res.status !== 200) return `Server returned status code ${res.status}`
|
|
||||||
const text = await res.text()
|
|
||||||
if (text !== "Out Of Your Element is listening.\n") return `Server does not point to OOYE`
|
|
||||||
return true
|
|
||||||
} catch (e) {
|
|
||||||
return e.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
bridgeOriginResponse.bridge_origin = bridgeOriginResponse.bridge_origin.replace(/\/+$/, "") // remove trailing slash
|
|
||||||
|
|
||||||
await server.close()
|
|
||||||
|
|
||||||
console.log("What is your Discord bot token?")
|
|
||||||
/** @type {SnowTransfer} */ // @ts-ignore
|
|
||||||
let snow = null
|
|
||||||
/** @type {{id: string, redirect_uris: string[]}} */ // @ts-ignore
|
|
||||||
let client = null
|
|
||||||
/** @type {{discord_token: string}} */
|
|
||||||
const discordTokenResponse = await prompt({
|
|
||||||
type: "input",
|
|
||||||
name: "discord_token",
|
|
||||||
message: "Bot token",
|
|
||||||
validate: async token => {
|
|
||||||
process.stdout.write(magenta(" checking, please wait..."))
|
|
||||||
try {
|
|
||||||
snow = new SnowTransfer(token)
|
|
||||||
client = await snow.requestHandler.request(`/applications/@me`, {}, "get")
|
|
||||||
return true
|
|
||||||
} catch (e) {
|
|
||||||
return e.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log("What is your Discord client secret?")
|
|
||||||
console.log(`You can find it on the application page: https://discord.com/developers/applications/${client.id}/oauth2`)
|
|
||||||
/** @type {{discord_client_secret: string}} */
|
|
||||||
const clientSecretResponse = await prompt({
|
|
||||||
type: "input",
|
|
||||||
name: "discord_client_secret",
|
|
||||||
message: "Client secret"
|
|
||||||
})
|
|
||||||
|
|
||||||
const expectedUri = `${bridgeOriginResponse.bridge_origin}/oauth`
|
|
||||||
if (!client.redirect_uris.includes(expectedUri)) {
|
|
||||||
console.log(`On the same application page, go to the Redirects section, and add this URI: ${cyan(expectedUri)}`)
|
|
||||||
await prompt({
|
|
||||||
type: "invisible",
|
|
||||||
name: "redirect_uri",
|
|
||||||
message: "Press Enter when you've added it",
|
|
||||||
validate: async token => {
|
|
||||||
process.stdout.write(magenta("checking, please wait..."))
|
|
||||||
client = await snow.requestHandler.request(`/applications/@me`, {}, "get")
|
|
||||||
if (client.redirect_uris.includes(expectedUri)) {
|
|
||||||
return true
|
|
||||||
} else {
|
|
||||||
return "Redirect URI has not been added yet"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const template = getTemplateRegistration(serverNameResponse.server_name)
|
|
||||||
reg = {
|
|
||||||
...template,
|
|
||||||
url: bridgeOriginResponse.bridge_origin,
|
|
||||||
ooye: {
|
|
||||||
...template.ooye,
|
|
||||||
...bridgeOriginResponse,
|
|
||||||
server_origin: serverOrigin,
|
|
||||||
...discordTokenResponse,
|
|
||||||
...clientSecretResponse
|
|
||||||
}
|
|
||||||
}
|
|
||||||
registration.reg = reg
|
|
||||||
checkRegistration(reg)
|
|
||||||
writeRegistration(reg)
|
|
||||||
console.log(`✅ Registration file saved as ${registrationFilePath}`)
|
|
||||||
} else {
|
|
||||||
console.log(`✅ Valid registration file found at ${registrationFilePath}`)
|
|
||||||
}
|
|
||||||
console.log(` In ${cyan("Synapse")}, you need to add it to homeserver.yaml and ${cyan("restart Synapse")}.`)
|
|
||||||
console.log(" https://element-hq.github.io/synapse/latest/application_services.html")
|
|
||||||
console.log(` In ${cyan("Conduit")}, you need to send the file contents to the #admins room.`)
|
|
||||||
console.log(" https://docs.conduit.rs/appservices.html")
|
|
||||||
console.log()
|
|
||||||
|
|
||||||
// Done with user prompts, reg is now guaranteed to be valid
|
|
||||||
const api = require("../src/matrix/api")
|
|
||||||
const file = require("../src/matrix/file")
|
|
||||||
const utils = require("../src/m2d/converters/utils")
|
|
||||||
const DiscordClient = require("../src/d2m/discord-client")
|
|
||||||
const discord = new DiscordClient(reg.ooye.discord_token, "no")
|
|
||||||
passthrough.discord = discord
|
|
||||||
|
|
||||||
const {as} = require("../src/matrix/appservice")
|
|
||||||
console.log("⏳ Waiting until homeserver registration works... (Ctrl+C to cancel)")
|
|
||||||
|
|
||||||
let itWorks = false
|
|
||||||
let lastError = null
|
|
||||||
do {
|
|
||||||
const result = await api.ping().catch(e => ({ok: false, status: "net", root: e.message}))
|
|
||||||
// If it didn't work, log details and retry after some time
|
|
||||||
itWorks = result.ok
|
|
||||||
if (!itWorks) {
|
|
||||||
// Log the full error data if the error is different to last time
|
|
||||||
if (!isDeepStrictEqual(lastError, result.root)) {
|
|
||||||
if (typeof result.root === "string") {
|
|
||||||
console.log(`\nCannot reach homeserver: ${result.root}`)
|
|
||||||
} else if (result.root.error) {
|
|
||||||
console.log(`\nHomeserver said: [${result.status}] ${result.root.error}`)
|
|
||||||
} else {
|
|
||||||
console.log(`\nHomeserver said: [${result.status}] ${JSON.stringify(result.root)}`)
|
|
||||||
}
|
|
||||||
lastError = result.root
|
|
||||||
} else {
|
|
||||||
process.stderr.write(".")
|
|
||||||
}
|
|
||||||
await scheduler.wait(5000)
|
|
||||||
}
|
|
||||||
} while (!itWorks)
|
|
||||||
console.log("")
|
|
||||||
|
|
||||||
as.close().catch(() => {})
|
|
||||||
|
|
||||||
const mxid = `@${reg.sender_localpart}:${reg.ooye.server_name}`
|
|
||||||
|
|
||||||
// ensure registration is correctly set...
|
|
||||||
assert(reg.sender_localpart.startsWith(reg.ooye.namespace_prefix), "appservice's localpart must be in the namespace it controls")
|
|
||||||
assert(utils.eventSenderIsFromDiscord(mxid), "appservice's mxid must be in the namespace it controls")
|
|
||||||
assert(reg.ooye.server_origin.match(/^https?:\/\//), "server origin must start with http or https")
|
|
||||||
assert.notEqual(reg.ooye.server_origin.slice(-1), "/", "server origin must not end in slash")
|
|
||||||
const botID = Buffer.from(reg.ooye.discord_token.split(".")[0], "base64").toString()
|
|
||||||
assert(botID.match(/^[0-9]{10,}$/), "discord token must follow the correct format")
|
|
||||||
assert.match(reg.url, /^https?:/, "url must start with http:// or https://")
|
|
||||||
|
|
||||||
console.log("✅ Configuration looks good...")
|
|
||||||
|
|
||||||
// database ddl...
|
|
||||||
await migrate.migrate(db)
|
|
||||||
|
|
||||||
// add initial rows to database, like adding the bot to sim...
|
|
||||||
db.prepare("INSERT OR IGNORE INTO sim (user_id, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(botID, reg.sender_localpart.slice(reg.ooye.namespace_prefix.length), reg.sender_localpart, mxid)
|
|
||||||
|
|
||||||
console.log("✅ Database is ready...")
|
|
||||||
|
|
||||||
// ensure appservice bot user is registered...
|
|
||||||
try {
|
|
||||||
await api.register(reg.sender_localpart)
|
|
||||||
} catch (e) {
|
|
||||||
if (e.errcode === "M_USER_IN_USE" || e.data?.error === "Internal server error") {
|
|
||||||
// "Internal server error" is the only OK error because older versions of Synapse say this if you try to register the same username twice.
|
|
||||||
} else {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// upload initial images...
|
|
||||||
const avatarUrl = await file.uploadDiscordFileToMxc("https://cadence.moe/friends/out_of_your_element.png")
|
|
||||||
|
|
||||||
console.log("✅ Matrix appservice login works...")
|
|
||||||
|
|
||||||
// upload the L1 L2 emojis to some guild
|
|
||||||
const emojis = db.prepare("SELECT name FROM auto_emoji WHERE name = 'L1' OR name = 'L2'").pluck().all()
|
|
||||||
if (emojis.length !== 2) {
|
|
||||||
// If an argument was supplied, always use that one
|
|
||||||
let guild = null
|
|
||||||
if (args["emoji-guild"]) {
|
|
||||||
if (typeof args["emoji-guild"] === "string") {
|
|
||||||
guild = await discord.snow.guild.getGuild(args["emoji-guild"])
|
|
||||||
}
|
|
||||||
if (!guild) return die(`Error: You asked emojis to be uploaded to guild ID ${args["emoji-guild"]}, but the bot isn't in that guild.`)
|
|
||||||
}
|
|
||||||
// Otherwise, check if we have already registered an auto emoji guild
|
|
||||||
if (!guild) {
|
|
||||||
const guildID = passthrough.select("auto_emoji", "guild_id", {name: "_"}).pluck().get()
|
|
||||||
if (guildID) {
|
|
||||||
guild = await discord.snow.guild.getGuild(guildID, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Otherwise, check if we should create a new guild
|
|
||||||
if (!guild) {
|
|
||||||
const guilds = await discord.snow.user.getGuilds({limit: 11, with_counts: false})
|
|
||||||
if (guilds.length < 10) {
|
|
||||||
console.log(" Creating a guild for emojis...")
|
|
||||||
guild = await discord.snow.guild.createGuild({name: "OOYE Emojis"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Otherwise, it's the user's problem
|
|
||||||
if (!guild) {
|
|
||||||
return die(`Error: The bot needs to upload some emojis. Please say where to upload them to. Run setup again with --emoji-guild=GUILD_ID`)
|
|
||||||
}
|
|
||||||
// Upload those emojis to the chosen location
|
|
||||||
db.prepare("REPLACE INTO auto_emoji (name, emoji_id, guild_id) VALUES ('_', '_', ?)").run(guild.id)
|
|
||||||
await uploadAutoEmoji(discord.snow, guild, "L1", join(__dirname, "../docs/img/L1.png"))
|
|
||||||
await uploadAutoEmoji(discord.snow, guild, "L2", join(__dirname, "../docs/img/L2.png"))
|
|
||||||
}
|
|
||||||
console.log("✅ Emojis are ready...")
|
|
||||||
|
|
||||||
// set profile data on discord...
|
|
||||||
const avatarImageBuffer = await fetch("https://cadence.moe/friends/out_of_your_element.png").then(res => res.arrayBuffer())
|
|
||||||
await discord.snow.user.updateSelf({avatar: "data:image/png;base64," + Buffer.from(avatarImageBuffer).toString("base64")})
|
|
||||||
await discord.snow.requestHandler.request(`/applications/@me`, {}, "patch", "json", {description: "Powered by **Out Of Your Element**\nhttps://gitdab.com/cadence/out-of-your-element"})
|
|
||||||
console.log("✅ Discord profile updated...")
|
|
||||||
|
|
||||||
// set profile data on homeserver...
|
|
||||||
console.log("⏩ Updating Matrix profile... (If you've joined lots of rooms, this is slow. Please allow at least 30 seconds.)")
|
|
||||||
await api.profileSetDisplayname(mxid, "Out Of Your Element")
|
|
||||||
await api.profileSetAvatarUrl(mxid, avatarUrl)
|
|
||||||
console.log("✅ Matrix profile updated...")
|
|
||||||
|
|
||||||
console.log("Good to go. I hope you enjoy Out Of Your Element.")
|
|
||||||
process.exit()
|
|
||||||
})()
|
|
|
@ -1,41 +0,0 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const {createServer} = require("http")
|
|
||||||
const EventEmitter = require("events")
|
|
||||||
const {createApp, createRouter, toNodeListener} = require("h3")
|
|
||||||
const sqlite = require("better-sqlite3")
|
|
||||||
const migrate = require("../src/db/migrate")
|
|
||||||
const HeatSync = require("heatsync")
|
|
||||||
|
|
||||||
const {reg} = require("../src/matrix/read-registration")
|
|
||||||
const passthrough = require("../src/passthrough")
|
|
||||||
const db = new sqlite("ooye.db")
|
|
||||||
|
|
||||||
const sync = new HeatSync()
|
|
||||||
|
|
||||||
Object.assign(passthrough, {sync, db})
|
|
||||||
|
|
||||||
const DiscordClient = require("../src/d2m/discord-client")
|
|
||||||
|
|
||||||
const discord = new DiscordClient(reg.ooye.discord_token, "half")
|
|
||||||
passthrough.discord = discord
|
|
||||||
|
|
||||||
const app = createApp()
|
|
||||||
const router = createRouter()
|
|
||||||
app.use(router)
|
|
||||||
const server = createServer(toNodeListener(app))
|
|
||||||
server.listen(reg.socket || new URL(reg.url).port)
|
|
||||||
const as = Object.assign(new EventEmitter(), {app, router, server}) // @ts-ignore
|
|
||||||
passthrough.as = as
|
|
||||||
|
|
||||||
const orm = sync.require("../src/db/orm")
|
|
||||||
passthrough.from = orm.from
|
|
||||||
passthrough.select = orm.select
|
|
||||||
|
|
||||||
;(async () => {
|
|
||||||
await migrate.migrate(db)
|
|
||||||
await discord.cloud.connect()
|
|
||||||
console.log("Discord gateway started")
|
|
||||||
sync.require("../src/web/server")
|
|
||||||
})()
|
|
3
scripts/wal.js
Executable file → Normal file
3
scripts/wal.js
Executable file → Normal file
|
@ -1,7 +1,6 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const sqlite = require("better-sqlite3")
|
const sqlite = require("better-sqlite3")
|
||||||
const db = new sqlite("ooye.db", {fileMustExist: true})
|
const db = new sqlite("db/ooye.db", {fileMustExist: true})
|
||||||
db.pragma("journal_mode = wal")
|
db.pragma("journal_mode = wal")
|
||||||
db.close()
|
db.close()
|
||||||
|
|
|
@ -1,38 +0,0 @@
|
||||||
// @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
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
|
@ -1,46 +0,0 @@
|
||||||
// @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
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue