Compare commits
No commits in common. "main" and "v1.1.1" have entirely different histories.
|
@ -1,8 +0,0 @@
|
||||||
{
|
|
||||||
"watermarks": {
|
|
||||||
"statements": [60, 100],
|
|
||||||
"lines": [60, 100],
|
|
||||||
"functions": [60, 100],
|
|
||||||
"branches": [60, 100]
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -3,5 +3,3 @@ config.js
|
||||||
registration.yaml
|
registration.yaml
|
||||||
coverage
|
coverage
|
||||||
db/ooye.db*
|
db/ooye.db*
|
||||||
test/res/*
|
|
||||||
!test/res/lottie*
|
|
||||||
|
|
|
@ -21,7 +21,7 @@ async function addReaction(data) {
|
||||||
const user = data.member?.user
|
const user = data.member?.user
|
||||||
assert.ok(user && user.username)
|
assert.ok(user && user.username)
|
||||||
|
|
||||||
const parentID = select("event_message", "event_id", {message_id: data.message_id, reaction_part: 0}).pluck().get()
|
const parentID = select("event_message", "event_id", {message_id: data.message_id, part: 0}).pluck().get() // 0 = primary
|
||||||
if (!parentID) return // Nothing can be done if the parent message was never bridged.
|
if (!parentID) return // Nothing can be done if the parent message was never bridged.
|
||||||
assert.equal(typeof parentID, "string")
|
assert.equal(typeof parentID, "string")
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -12,29 +12,9 @@ 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")} */
|
|
||||||
const utils = sync.require("../../discord/utils")
|
|
||||||
/** @type {import("./create-space")}) */
|
/** @type {import("./create-space")}) */
|
||||||
const createSpace = sync.require("./create-space") // watch out for the require loop
|
const createSpace = sync.require("./create-space") // watch out for the require loop
|
||||||
|
|
||||||
/**
|
|
||||||
* There are 3 levels of room privacy:
|
|
||||||
* 0: Room is invite-only.
|
|
||||||
* 1: Anybody can use a link to join.
|
|
||||||
* 2: Room is published in room directory.
|
|
||||||
*/
|
|
||||||
const PRIVACY_ENUMS = {
|
|
||||||
PRESET: ["private_chat", "public_chat", "public_chat"],
|
|
||||||
VISIBILITY: ["private", "private", "public"],
|
|
||||||
SPACE_HISTORY_VISIBILITY: ["invited", "world_readable", "world_readable"], // copying from element client
|
|
||||||
ROOM_HISTORY_VISIBILITY: ["shared", "shared", "world_readable"], // any events sent after <value> are visible, but for world_readable anybody can read without even joining
|
|
||||||
GUEST_ACCESS: ["can_join", "forbidden", "forbidden"], // whether guests can join space if other conditions are met
|
|
||||||
SPACE_JOIN_RULES: ["invite", "public", "public"],
|
|
||||||
ROOM_JOIN_RULES: ["restricted", "public", "public"]
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_PRIVACY_LEVEL = 0
|
|
||||||
|
|
||||||
/** @type {Map<string, Promise<string>>} channel ID -> Promise<room ID> */
|
/** @type {Map<string, Promise<string>>} channel ID -> Promise<room ID> */
|
||||||
const inflightRoomCreate = new Map()
|
const inflightRoomCreate = new Map()
|
||||||
|
|
||||||
|
@ -51,24 +31,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 ? "[🔊] "
|
||||||
: "")
|
: "")
|
||||||
|
@ -91,25 +68,8 @@ function convertNameAndTopic(channel, guild, customName) {
|
||||||
* @param {DiscordTypes.APIGuild} guild
|
* @param {DiscordTypes.APIGuild} guild
|
||||||
*/
|
*/
|
||||||
async function channelToKState(channel, guild) {
|
async function channelToKState(channel, guild) {
|
||||||
// @ts-ignore
|
const spaceID = await createSpace.ensureSpace(guild)
|
||||||
const parentChannel = discord.channels.get(channel.parent_id)
|
assert.ok(typeof spaceID === "string")
|
||||||
/** Used for membership/permission checks. */
|
|
||||||
let guildSpaceID
|
|
||||||
/** Used as the literal parent on Matrix, for categorisation. Will be the same as `guildSpaceID` unless it's a forum channel's thread, in which case a different space is used to group those threads. */
|
|
||||||
let parentSpaceID
|
|
||||||
let privacyLevel
|
|
||||||
if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum) { // it's a forum channel's thread, so use a different space to group those threads
|
|
||||||
guildSpaceID = await createSpace.ensureSpace(guild)
|
|
||||||
parentSpaceID = await ensureRoom(channel.parent_id)
|
|
||||||
privacyLevel = select("guild_space", "privacy_level", {space_id: guildSpaceID}).pluck().get()
|
|
||||||
} else { // otherwise use the guild's space like usual
|
|
||||||
parentSpaceID = await createSpace.ensureSpace(guild)
|
|
||||||
guildSpaceID = parentSpaceID
|
|
||||||
privacyLevel = select("guild_space", "privacy_level", {space_id: parentSpaceID}).pluck().get()
|
|
||||||
}
|
|
||||||
assert(typeof parentSpaceID === "string")
|
|
||||||
assert(typeof guildSpaceID === "string")
|
|
||||||
assert(typeof privacyLevel === "number")
|
|
||||||
|
|
||||||
const row = select("channel_room", ["nick", "custom_avatar"], {channel_id: channel.id}).get()
|
const row = select("channel_room", ["nick", "custom_avatar"], {channel_id: channel.id}).get()
|
||||||
const customName = row?.nick
|
const customName = row?.nick
|
||||||
|
@ -124,44 +84,31 @@ async function channelToKState(channel, 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
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
let history_visibility = PRIVACY_ENUMS.ROOM_HISTORY_VISIBILITY[privacyLevel]
|
let history_visibility = "shared"
|
||||||
if (channel["thread_metadata"]) history_visibility = "world_readable"
|
if (channel["thread_metadata"]) history_visibility = "world_readable"
|
||||||
|
|
||||||
/** @type {{join_rule: string, allow?: any}} */
|
|
||||||
let join_rules = {
|
|
||||||
join_rule: "restricted",
|
|
||||||
allow: [{
|
|
||||||
type: "m.room_membership",
|
|
||||||
room_id: guildSpaceID
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
if (PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel] !== "restricted") {
|
|
||||||
join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]}
|
|
||||||
}
|
|
||||||
|
|
||||||
const everyonePermissions = utils.getPermissions([], guild.roles, undefined, channel.permission_overwrites)
|
|
||||||
const everyoneCanMentionEveryone = utils.hasAllPermissions(everyonePermissions, ["MentionEveryone"])
|
|
||||||
|
|
||||||
const 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: "can_join"},
|
||||||
"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_rule: "restricted",
|
||||||
|
allow: [{
|
||||||
|
type: "m.room_membership",
|
||||||
|
room_id: spaceID
|
||||||
|
}]
|
||||||
|
},
|
||||||
"m.room.power_levels/": {
|
"m.room.power_levels/": {
|
||||||
events: {
|
events: {
|
||||||
"m.room.avatar": 0
|
"m.room.avatar": 0
|
||||||
},
|
}
|
||||||
notifications: {
|
|
||||||
room: everyoneCanMentionEveryone ? 0 : 20
|
|
||||||
},
|
|
||||||
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
|
||||||
|
@ -185,7 +132,7 @@ async function channelToKState(channel, guild) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {spaceID: parentSpaceID, privacyLevel, channelKState}
|
return {spaceID, channelKState}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -194,16 +141,12 @@ async function channelToKState(channel, guild) {
|
||||||
* @param guild
|
* @param guild
|
||||||
* @param {string} spaceID
|
* @param {string} spaceID
|
||||||
* @param {any} kstate
|
* @param {any} kstate
|
||||||
* @param {number} privacyLevel
|
|
||||||
* @returns {Promise<string>} room ID
|
* @returns {Promise<string>} room ID
|
||||||
*/
|
*/
|
||||||
async function createRoom(channel, guild, spaceID, kstate, privacyLevel) {
|
async function createRoom(channel, guild, spaceID, kstate) {
|
||||||
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
|
||||||
|
@ -217,11 +160,10 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) {
|
||||||
const roomID = await api.createRoom({
|
const roomID = await api.createRoom({
|
||||||
name,
|
name,
|
||||||
topic,
|
topic,
|
||||||
preset: PRIVACY_ENUMS.PRESET[privacyLevel], // This is closest to what we want, but properties from kstate override it anyway
|
preset: "private_chat", // This is closest to what we want, but properties from kstate override it anyway
|
||||||
visibility: PRIVACY_ENUMS.VISIBILITY[privacyLevel],
|
visibility: "private", // Not shown in the room directory
|
||||||
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)
|
||||||
|
@ -310,8 +252,8 @@ async function _syncRoom(channelID, shouldActuallySync) {
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
const creation = (async () => {
|
const creation = (async () => {
|
||||||
const {spaceID, privacyLevel, channelKState} = await channelToKState(channel, guild)
|
const {spaceID, channelKState} = await channelToKState(channel, guild)
|
||||||
const roomID = await createRoom(channel, guild, spaceID, channelKState, privacyLevel)
|
const roomID = await createRoom(channel, guild, spaceID, channelKState)
|
||||||
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
|
||||||
})()
|
})()
|
||||||
|
@ -429,8 +371,6 @@ async function createAllForGuild(guildID) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.DEFAULT_PRIVACY_LEVEL = DEFAULT_PRIVACY_LEVEL
|
|
||||||
module.exports.PRIVACY_ENUMS = PRIVACY_ENUMS
|
|
||||||
module.exports.createRoom = createRoom
|
module.exports.createRoom = createRoom
|
||||||
module.exports.ensureRoom = ensureRoom
|
module.exports.ensureRoom = ensureRoom
|
||||||
module.exports.syncRoom = syncRoom
|
module.exports.syncRoom = syncRoom
|
||||||
|
|
|
@ -1,55 +1,17 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const mixin = require("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")
|
test("channel2room: general", async t => {
|
||||||
const {db} = passthrough
|
|
||||||
|
|
||||||
test("channel2room: discoverable privacy room", async t => {
|
|
||||||
db.prepare("UPDATE guild_space SET privacy_level = 2").run()
|
|
||||||
t.deepEqual(
|
|
||||||
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general).then(x => x.channelKState)),
|
|
||||||
Object.assign({}, testData.room.general, {
|
|
||||||
"m.room.guest_access/": {guest_access: "forbidden"},
|
|
||||||
"m.room.join_rules/": {join_rule: "public"},
|
|
||||||
"m.room.history_visibility/": {history_visibility: "world_readable"}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("channel2room: linkable privacy room", async t => {
|
|
||||||
db.prepare("UPDATE guild_space SET privacy_level = 1").run()
|
|
||||||
t.deepEqual(
|
|
||||||
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general).then(x => x.channelKState)),
|
|
||||||
Object.assign({}, testData.room.general, {
|
|
||||||
"m.room.guest_access/": {guest_access: "forbidden"},
|
|
||||||
"m.room.join_rules/": {join_rule: "public"}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("channel2room: invite-only privacy room", async t => {
|
|
||||||
db.prepare("UPDATE guild_space SET privacy_level = 0").run()
|
|
||||||
t.deepEqual(
|
t.deepEqual(
|
||||||
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general).then(x => x.channelKState)),
|
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general).then(x => x.channelKState)),
|
||||||
testData.room.general
|
testData.room.general
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("channel2room: room where limited people can mention everyone", async t => {
|
|
||||||
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}}})
|
|
||||||
t.deepEqual(
|
|
||||||
kstateStripConditionals(await channelToKState(testData.channel.general, limitedGuild).then(x => x.channelKState)),
|
|
||||||
limitedRoom
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("convertNameAndTopic: custom name and topic", t => {
|
test("convertNameAndTopic: custom name and topic", t => {
|
||||||
t.deepEqual(
|
t.deepEqual(
|
||||||
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, "hauntings"),
|
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, "hauntings"),
|
||||||
|
|
|
@ -1,9 +1,7 @@
|
||||||
// @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")
|
||||||
|
@ -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) {
|
||||||
|
@ -34,8 +32,8 @@ async function createSpace(guild, kstate) {
|
||||||
const roomID = await createRoom.postApplyPowerLevels(kstate, async kstate => {
|
const roomID = await createRoom.postApplyPowerLevels(kstate, async kstate => {
|
||||||
return api.createRoom({
|
return api.createRoom({
|
||||||
name,
|
name,
|
||||||
preset: createRoom.PRIVACY_ENUMS.PRESET[createRoom.DEFAULT_PRIVACY_LEVEL], // New spaces will have to use the default privacy level; we obviously can't look up the existing entry
|
preset: "private_chat", // cannot join space unless invited
|
||||||
visibility: createRoom.PRIVACY_ENUMS.VISIBILITY[createRoom.DEFAULT_PRIVACY_LEVEL],
|
visibility: "private",
|
||||||
power_level_content_override: {
|
power_level_content_override: {
|
||||||
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
|
||||||
|
@ -45,7 +43,7 @@ async function createSpace(guild, kstate) {
|
||||||
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)
|
||||||
|
@ -53,22 +51,23 @@ async function createSpace(guild, kstate) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {DiscordTypes.APIGuild} guild
|
* @param {DiscordTypes.APIGuild} guild]
|
||||||
* @param {number} privacyLevel
|
|
||||||
*/
|
*/
|
||||||
async function guildToKState(guild, privacyLevel) {
|
async function guildToKState(guild) {
|
||||||
assert.equal(typeof privacyLevel, "number")
|
const avatarEventContent = {}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
let history_visibility = "invited"
|
||||||
|
if (guild["thread_metadata"]) history_visibility = "world_readable"
|
||||||
|
|
||||||
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,
|
"m.room.guest_access/": {guest_access: "can_join"}, // guests can join space if other conditions are met
|
||||||
discord_path: file.guildIcon(guild),
|
"m.room.history_visibility/": {history_visibility: "invited"} // any events sent after user was invited are visible
|
||||||
url: {$url: file.guildIcon(guild)}
|
|
||||||
},
|
|
||||||
"m.room.guest_access/": {guest_access: createRoom.PRIVACY_ENUMS.GUEST_ACCESS[privacyLevel]},
|
|
||||||
"m.room.history_visibility/": {history_visibility: createRoom.PRIVACY_ENUMS.SPACE_HISTORY_VISIBILITY[privacyLevel]},
|
|
||||||
"m.room.join_rules/": {join_rule: createRoom.PRIVACY_ENUMS.SPACE_JOIN_RULES[privacyLevel]},
|
|
||||||
"m.room.power_levels/": {users: reg.ooye.invite.reduce((a, c) => (a[c] = 100, a), {})}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return guildKState
|
return guildKState
|
||||||
|
@ -87,11 +86,11 @@ async function _syncSpace(guild, shouldActuallySync) {
|
||||||
await inflightSpaceCreate.get(guild.id) // just waiting, and then doing a new db query afterwards, is the simplest way of doing it
|
await inflightSpaceCreate.get(guild.id) // just waiting, and then doing a new db query afterwards, is the simplest way of doing it
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = select("guild_space", ["space_id", "privacy_level"], {guild_id: guild.id}).get()
|
const spaceID = select("guild_space", "space_id", {guild_id: guild.id}).pluck().get()
|
||||||
|
|
||||||
if (!row) {
|
if (!spaceID) {
|
||||||
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)
|
||||||
const spaceID = await createSpace(guild, guildKState)
|
const spaceID = await createSpace(guild, guildKState)
|
||||||
inflightSpaceCreate.delete(guild.id)
|
inflightSpaceCreate.delete(guild.id)
|
||||||
return spaceID
|
return spaceID
|
||||||
|
@ -100,15 +99,13 @@ async function _syncSpace(guild, shouldActuallySync) {
|
||||||
return creation // Naturally, the newly created space is already up to date, so we can always skip syncing here.
|
return creation // Naturally, the newly created space is already up to date, so we can always skip syncing here.
|
||||||
}
|
}
|
||||||
|
|
||||||
const {space_id: spaceID, privacy_level} = row
|
|
||||||
|
|
||||||
if (!shouldActuallySync) {
|
if (!shouldActuallySync) {
|
||||||
return spaceID // only need to ensure space exists, and it does. return the space ID
|
return spaceID // only need to ensure space exists, and it does. return the space ID
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[space sync] to matrix: ${guild.name}`)
|
console.log(`[space sync] to matrix: ${guild.name}`)
|
||||||
|
|
||||||
const guildKState = await guildToKState(guild, privacy_level) // calling this in both branches because we don't want to calculate this if not syncing
|
const guildKState = await guildToKState(guild) // calling this in both branches because we don't want to calculate this if not syncing
|
||||||
|
|
||||||
// sync guild state to space
|
// sync guild state to space
|
||||||
const spaceKState = await createRoom.roomToKState(spaceID)
|
const spaceKState = await createRoom.roomToKState(spaceID)
|
||||||
|
@ -122,8 +119,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)
|
||||||
|
|
||||||
|
@ -156,42 +152,32 @@ function syncSpace(guild) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inefficiently force the space and its existing child rooms to be fully updated.
|
* Inefficiently force the space and its existing child rooms to be fully updated.
|
||||||
* Prefer not to call this as part of the bridge's normal operation.
|
* Should not need to be called as part of the bridge's normal operation.
|
||||||
*/
|
*/
|
||||||
async function syncSpaceFully(guildID) {
|
async function syncSpaceFully(guildID) {
|
||||||
/** @ts-ignore @type {DiscordTypes.APIGuild} */
|
/** @ts-ignore @type {DiscordTypes.APIGuild} */
|
||||||
const guild = discord.guilds.get(guildID)
|
const guild = discord.guilds.get(guildID)
|
||||||
assert.ok(guild)
|
assert.ok(guild)
|
||||||
|
|
||||||
const row = select("guild_space", ["space_id", "privacy_level"], {guild_id: guildID}).get()
|
const spaceID = select("guild_space", "space_id", {guild_id: guildID}).pluck().get()
|
||||||
|
|
||||||
if (!row) {
|
const guildKState = await guildToKState(guild)
|
||||||
const guildKState = await guildToKState(guild, createRoom.DEFAULT_PRIVACY_LEVEL)
|
|
||||||
|
if (!spaceID) {
|
||||||
const spaceID = await createSpace(guild, guildKState)
|
const spaceID = await createSpace(guild, guildKState)
|
||||||
return spaceID // Naturally, the newly created space is already up to date, so we can always skip syncing here.
|
return spaceID // Naturally, the newly created space is already up to date, so we can always skip syncing here.
|
||||||
}
|
}
|
||||||
|
|
||||||
const {space_id: spaceID, privacy_level} = row
|
|
||||||
|
|
||||||
console.log(`[space sync] to matrix: ${guild.name}`)
|
console.log(`[space sync] to matrix: ${guild.name}`)
|
||||||
|
|
||||||
const guildKState = await guildToKState(guild, privacy_level)
|
|
||||||
|
|
||||||
// sync guild state to space
|
// sync guild state to space
|
||||||
const spaceKState = await createRoom.roomToKState(spaceID)
|
const spaceKState = await createRoom.roomToKState(spaceID)
|
||||||
const spaceDiff = ks.diffKState(spaceKState, guildKState)
|
const spaceDiff = ks.diffKState(spaceKState, guildKState)
|
||||||
await createRoom.applyKStateDiffToRoom(spaceID, spaceDiff)
|
await createRoom.applyKStateDiffToRoom(spaceID, spaceDiff)
|
||||||
|
|
||||||
/** @type {string[]} room IDs */
|
const childRooms = ks.kstateToState(spaceKState).filter(({type, content}) => {
|
||||||
let childRooms = []
|
return type === "m.space.child" && "via" in content
|
||||||
/** @type {string | undefined} */
|
}).map(({state_key}) => state_key)
|
||||||
let nextBatch = undefined
|
|
||||||
do {
|
|
||||||
/** @type {Ty.HierarchyPagination<Ty.R.Hierarchy>} */
|
|
||||||
const res = await api.getHierarchy(spaceID, {from: nextBatch})
|
|
||||||
childRooms.push(...res.rooms.map(room => room.room_id))
|
|
||||||
nextBatch = res.next_batch
|
|
||||||
} while (nextBatch)
|
|
||||||
|
|
||||||
for (const roomID of childRooms) {
|
for (const roomID of childRooms) {
|
||||||
const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get()
|
const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get()
|
||||||
|
@ -207,40 +193,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
|
||||||
|
|
|
@ -1,39 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const mixin = require("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/": {
|
|
||||||
discord_path: "/icons/112760669178241024/a_f83622e09ead74f0c5c527fe241f8f8c.png?size=1024",
|
|
||||||
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 +1,23 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {sync, db, select, from} = passthrough
|
const {sync, db, select} = passthrough
|
||||||
/** @type {import("../../matrix/api")} */
|
/** @type {import("../../matrix/api")} */
|
||||||
const api = sync.require("../../matrix/api")
|
const api = sync.require("../../matrix/api")
|
||||||
/** @type {import("./speedbump")} */
|
|
||||||
const speedbump = sync.require("./speedbump")
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("discord-api-types/v10").GatewayMessageDeleteDispatchData} data
|
* @param {import("discord-api-types/v10").GatewayMessageDeleteDispatchData} data
|
||||||
*/
|
*/
|
||||||
async function deleteMessage(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()
|
const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get()
|
||||||
if (!roomID) return
|
if (!roomID) return
|
||||||
|
|
||||||
const sids = JSON.stringify(data.ids)
|
const eventsToRedact = select("event_message", "event_id", {message_id: data.id}).pluck().all()
|
||||||
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) {
|
for (const eventID of eventsToRedact) {
|
||||||
// Awaiting will make it go slower, but since this could be a long-running operation either way, we want to leave rate limit capacity for other operations
|
// Unfortunately, we can't specify a sender to do the redaction as, unless we find out that info via the audit logs
|
||||||
await api.redactEvent(roomID, eventID)
|
await api.redactEvent(roomID, eventID)
|
||||||
|
db.prepare("DELETE FROM event_message WHERE event_id = ?").run(eventID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.deleteMessage = deleteMessage
|
module.exports.deleteMessage = deleteMessage
|
||||||
module.exports.deleteMessageBulk = deleteMessageBulk
|
|
||||||
|
|
|
@ -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, promoteEvent, promoteNextEvent} = 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) {
|
||||||
|
@ -50,30 +36,24 @@ async function editMessage(message, guild, row) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Consistency: Ensure there is exactly one part = 0
|
// 3. Consistency: Ensure there is exactly one part = 0
|
||||||
const sendNewEventParts = new Set()
|
let eventPart = 1
|
||||||
for (const promotion of promotions) {
|
if (promoteEvent) {
|
||||||
if ("eventID" in promotion) {
|
db.prepare("UPDATE event_message SET part = 0 WHERE event_id = ?").run(promoteEvent)
|
||||||
db.prepare(`UPDATE event_message SET ${promotion.column} = 0 WHERE event_id = ?`).run(promotion.eventID)
|
} else if (promoteNextEvent) {
|
||||||
} else if ("nextEvent" in promotion) {
|
eventPart = 0
|
||||||
sendNewEventParts.add(promotion.column)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Send all the things.
|
// 4. Send all the things.
|
||||||
if (eventsToSend.length) {
|
|
||||||
db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id)
|
|
||||||
}
|
|
||||||
for (const content of eventsToSend) {
|
for (const content of eventsToSend) {
|
||||||
const eventType = content.$type
|
const eventType = content.$type
|
||||||
/** @type {Pick<typeof content, Exclude<keyof content, "$type">> & { $type?: string }} */
|
/** @type {Pick<typeof content, Exclude<keyof content, "$type">> & { $type?: string }} */
|
||||||
const contentWithoutType = {...content}
|
const contentWithoutType = {...content}
|
||||||
delete contentWithoutType.$type
|
delete contentWithoutType.$type
|
||||||
delete contentWithoutType.$sender
|
|
||||||
|
|
||||||
const part = sendNewEventParts.has("part") && eventsToSend[0] === content ? 0 : 1
|
|
||||||
const reactionPart = sendNewEventParts.has("reaction_part") && eventsToSend[eventsToSend.length - 1] === content ? 0 : 1
|
|
||||||
const eventID = await api.sendEvent(roomID, eventType, contentWithoutType, senderMxid)
|
const eventID = await api.sendEvent(roomID, eventType, contentWithoutType, senderMxid)
|
||||||
db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 1)").run(eventID, eventType, content.msgtype || null, message.id, part, reactionPart) // source 1 = discord
|
db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, source) VALUES (?, ?, ?, ?, ?, 1)").run(eventID, eventType, content.msgtype || null, message.id, eventPart) // part 1 = supporting; source 1 = discord
|
||||||
|
|
||||||
|
eventPart = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,53 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
|
||||||
const Ty = require("../../types")
|
|
||||||
const assert = require("assert").strict
|
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
|
||||||
const {sync, db, select} = passthrough
|
|
||||||
/** @type {import("../../matrix/file")} */
|
|
||||||
const file = sync.require("../../matrix/file")
|
|
||||||
/** @type {import("../../matrix/mreq")} */
|
|
||||||
const mreq = sync.require("../../matrix/mreq")
|
|
||||||
/** @type {import("../converters/lottie")} */
|
|
||||||
const convertLottie = sync.require("../converters/lottie")
|
|
||||||
|
|
||||||
const INFO = {
|
|
||||||
mimetype: "image/png",
|
|
||||||
w: convertLottie.SIZE,
|
|
||||||
h: convertLottie.SIZE
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {DiscordTypes.APIStickerItem} stickerItem
|
|
||||||
* @returns {Promise<{mxc_url: string, info: typeof INFO}>}
|
|
||||||
*/
|
|
||||||
async function convert(stickerItem) {
|
|
||||||
// Reuse sticker if already converted and uploaded
|
|
||||||
const existingMxc = select("lottie", "mxc_url", {sticker_id: stickerItem.id}).pluck().get()
|
|
||||||
if (existingMxc) return {mxc_url: existingMxc, info: INFO}
|
|
||||||
|
|
||||||
// Fetch sticker data from Discord
|
|
||||||
const res = await fetch(file.DISCORD_IMAGES_BASE + file.sticker(stickerItem))
|
|
||||||
if (res.status !== 200) throw new Error("Sticker data file not found.")
|
|
||||||
const text = await res.text()
|
|
||||||
|
|
||||||
// Convert to PNG (readable stream)
|
|
||||||
const readablePng = await convertLottie.convert(text)
|
|
||||||
|
|
||||||
// Upload to MXC
|
|
||||||
/** @type {Ty.R.FileUploaded} */
|
|
||||||
const root = await mreq.mreq("POST", "/media/v3/upload", readablePng, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": INFO.mimetype
|
|
||||||
}
|
|
||||||
})
|
|
||||||
assert(root.content_uri)
|
|
||||||
|
|
||||||
// Save the link for next time
|
|
||||||
db.prepare("INSERT INTO lottie (sticker_id, mxc_url) VALUES (?, ?)").run(stickerItem.id, root.content_uri)
|
|
||||||
return {mxc_url: root.content_uri, info: INFO}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.convert = convert
|
|
|
@ -1,153 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const assert = require("assert")
|
|
||||||
const reg = require("../../matrix/read-registration")
|
|
||||||
const Ty = require("../../types")
|
|
||||||
const fetch = require("node-fetch").default
|
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
|
||||||
const {discord, sync, db, select} = passthrough
|
|
||||||
/** @type {import("../../matrix/api")} */
|
|
||||||
const api = sync.require("../../matrix/api")
|
|
||||||
/** @type {import("../../matrix/file")} */
|
|
||||||
const file = sync.require("../../matrix/file")
|
|
||||||
/** @type {import("./register-user")} */
|
|
||||||
const registerUser = sync.require("./register-user")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef WebhookAuthor Discord API message->author. A webhook as an author.
|
|
||||||
* @prop {string} username
|
|
||||||
* @prop {string?} avatar
|
|
||||||
* @prop {string} id
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A sim is an account that is being simulated by the bridge to copy events from the other side.
|
|
||||||
* @param {Ty.PkMessage} pkMessage
|
|
||||||
* @returns mxid
|
|
||||||
*/
|
|
||||||
async function createSim(pkMessage) {
|
|
||||||
// Choose sim name
|
|
||||||
const simName = "_pk_" + pkMessage.member.id
|
|
||||||
const localpart = reg.ooye.namespace_prefix + simName
|
|
||||||
const mxid = `@${localpart}:${reg.ooye.server_name}`
|
|
||||||
|
|
||||||
// Save chosen name in the database forever
|
|
||||||
db.prepare("INSERT INTO sim (user_id, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(pkMessage.member.uuid, simName, localpart, mxid)
|
|
||||||
|
|
||||||
// Register matrix user with that name
|
|
||||||
try {
|
|
||||||
await api.register(localpart)
|
|
||||||
} catch (e) {
|
|
||||||
// If user creation fails, manually undo the database change. Still isn't perfect, but should help.
|
|
||||||
// (I would prefer a transaction, but it's not safe to leave transactions open across event loop ticks.)
|
|
||||||
db.prepare("DELETE FROM sim WHERE user_id = ?").run(pkMessage.member.uuid)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
return mxid
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure a sim is registered for the user.
|
|
||||||
* If there is already a sim, use that one. If there isn't one yet, register a new sim.
|
|
||||||
* @param {Ty.PkMessage} pkMessage
|
|
||||||
* @returns {Promise<string>} mxid
|
|
||||||
*/
|
|
||||||
async function ensureSim(pkMessage) {
|
|
||||||
let mxid = null
|
|
||||||
const existing = select("sim", "mxid", {user_id: pkMessage.member.uuid}).pluck().get()
|
|
||||||
if (existing) {
|
|
||||||
mxid = existing
|
|
||||||
} else {
|
|
||||||
mxid = await createSim(pkMessage)
|
|
||||||
}
|
|
||||||
return mxid
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure a sim is registered for the user and is joined to the room.
|
|
||||||
* @param {Ty.PkMessage} pkMessage
|
|
||||||
* @param {string} roomID
|
|
||||||
* @returns {Promise<string>} mxid
|
|
||||||
*/
|
|
||||||
async function ensureSimJoined(pkMessage, roomID) {
|
|
||||||
// Ensure room ID is really an ID, not an alias
|
|
||||||
assert.ok(roomID[0] === "!")
|
|
||||||
|
|
||||||
// Ensure user
|
|
||||||
const mxid = await ensureSim(pkMessage)
|
|
||||||
|
|
||||||
// Ensure joined
|
|
||||||
const existing = select("sim_member", "mxid", {room_id: roomID, mxid}).pluck().get()
|
|
||||||
if (!existing) {
|
|
||||||
try {
|
|
||||||
await api.inviteToRoom(roomID, mxid)
|
|
||||||
await api.joinRoom(roomID, mxid)
|
|
||||||
} catch (e) {
|
|
||||||
if (e.message.includes("is already in the room.")) {
|
|
||||||
// Sweet!
|
|
||||||
} else {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
db.prepare("INSERT OR IGNORE INTO sim_member (room_id, mxid) VALUES (?, ?)").run(roomID, mxid)
|
|
||||||
}
|
|
||||||
return mxid
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Ty.PkMessage} pkMessage
|
|
||||||
* @param {WebhookAuthor} author
|
|
||||||
*/
|
|
||||||
async function memberToStateContent(pkMessage, author) {
|
|
||||||
// We prefer to use the member's avatar URL data since the image upload can be cached across channels,
|
|
||||||
// unlike the userAvatar URL which is unique per channel, due to the webhook ID being in the URL.
|
|
||||||
const avatar = pkMessage.member.avatar_url || pkMessage.member.webhook_avatar_url || pkMessage.system.avatar_url || file.userAvatar(author)
|
|
||||||
|
|
||||||
const content = {
|
|
||||||
displayname: author.username,
|
|
||||||
membership: "join",
|
|
||||||
"moe.cadence.ooye.pk_member": pkMessage.member
|
|
||||||
}
|
|
||||||
if (avatar) content.avatar_url = await file.uploadDiscordFileToMxc(avatar)
|
|
||||||
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sync profile data for a sim user. This function follows the following process:
|
|
||||||
* 1. Join the sim to the room if needed
|
|
||||||
* 2. Make an object of what the new room member state content would be, including uploading the profile picture if it hasn't been done before
|
|
||||||
* 3. Compare against the previously known state content, which is helpfully stored in the database
|
|
||||||
* 4. If the state content has changed, send it to Matrix and update it in the database for next time
|
|
||||||
* @param {WebhookAuthor} author
|
|
||||||
* @param {Ty.PkMessage} pkMessage
|
|
||||||
* @param {string} roomID
|
|
||||||
* @returns {Promise<string>} mxid of the updated sim
|
|
||||||
*/
|
|
||||||
async function syncUser(author, pkMessage, roomID) {
|
|
||||||
const mxid = await ensureSimJoined(pkMessage, roomID)
|
|
||||||
// Update the sim_proxy table, so mentions can look up the original sender later
|
|
||||||
db.prepare("INSERT OR IGNORE INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES (?, ?, ?)").run(pkMessage.member.uuid, pkMessage.sender, author.username)
|
|
||||||
// Sync the member state
|
|
||||||
const content = await memberToStateContent(pkMessage, author)
|
|
||||||
const currentHash = registerUser._hashProfileContent(content, 0)
|
|
||||||
const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get()
|
|
||||||
// only do the actual sync if the hash has changed since we last looked
|
|
||||||
if (existingHash !== currentHash) {
|
|
||||||
await api.sendState(roomID, "m.room.member", mxid, content, mxid)
|
|
||||||
db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid)
|
|
||||||
}
|
|
||||||
return mxid
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @returns {Promise<Ty.PkMessage>} */
|
|
||||||
function fetchMessage(messageID) {
|
|
||||||
return fetch(`https://api.pluralkit.me/v2/messages/${messageID}`).then(res => res.json())
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports._memberToStateContent = memberToStateContent
|
|
||||||
module.exports.ensureSim = ensureSim
|
|
||||||
module.exports.ensureSimJoined = ensureSimJoined
|
|
||||||
module.exports.syncUser = syncUser
|
|
||||||
module.exports.fetchMessage = fetchMessage
|
|
|
@ -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("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,12 +92,12 @@ 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
|
||||||
if (user.global_name) displayname = user.global_name
|
// if (member.nick && member.nick !== displayname) displayname = member.nick + " | " + displayname // prepend nick if present
|
||||||
if (member.nick) displayname = member.nick
|
if (member.nick) displayname = member.nick
|
||||||
|
|
||||||
const content = {
|
const content = {
|
||||||
|
@ -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,70 +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", "")
|
|
||||||
mixin(powerLevelsStateContent, {users: {[mxid]: powerLevel}})
|
|
||||||
if (powerLevel === 0) delete powerLevelsStateContent.users[mxid] // keep the event compact
|
|
||||||
await api.sendState(roomID, "m.room.power_levels", "", powerLevelsStateContent)
|
|
||||||
// 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
|
||||||
|
|
|
@ -22,26 +22,6 @@ test("member2state: without member nick or avatar", async t => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("member2state: with global name, without member nick or avatar", async t => {
|
|
||||||
t.deepEqual(
|
|
||||||
await _memberToStateContent(testData.member.papiophidian.user, testData.member.papiophidian, testData.guild.general.id),
|
|
||||||
{
|
|
||||||
avatar_url: "mxc://cadence.moe/JPzSmALLirnIprlSMKohSSoX",
|
|
||||||
displayname: "PapiOphidian",
|
|
||||||
membership: "join",
|
|
||||||
"moe.cadence.ooye.member": {
|
|
||||||
avatar: "/avatars/320067006521147393/5fc4ad85c1ea876709e9a7d3374a78a1.png?size=1024"
|
|
||||||
},
|
|
||||||
"uk.half-shot.discord.member": {
|
|
||||||
bot: false,
|
|
||||||
displayColor: 1579292,
|
|
||||||
id: "320067006521147393",
|
|
||||||
username: "@papiophidian"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("member2state: with member nick and avatar", async t => {
|
test("member2state: with member nick and avatar", async t => {
|
||||||
t.deepEqual(
|
t.deepEqual(
|
||||||
await _memberToStateContent(testData.member.sheep.user, testData.member.sheep, testData.guild.general.id),
|
await _memberToStateContent(testData.member.sheep.user, testData.member.sheep, testData.guild.general.id),
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const Ty = require("../../types")
|
const Ty = require("../../types")
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
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
|
||||||
|
@ -9,73 +9,92 @@ const {discord, sync, db, select} = passthrough
|
||||||
const api = sync.require("../../matrix/api")
|
const api = sync.require("../../matrix/api")
|
||||||
/** @type {import("../converters/emoji-to-key")} */
|
/** @type {import("../converters/emoji-to-key")} */
|
||||||
const emojiToKey = sync.require("../converters/emoji-to-key")
|
const emojiToKey = sync.require("../converters/emoji-to-key")
|
||||||
|
/** @type {import("../../m2d/converters/utils")} */
|
||||||
|
const utils = sync.require("../../m2d/converters/utils")
|
||||||
/** @type {import("../../m2d/converters/emoji")} */
|
/** @type {import("../../m2d/converters/emoji")} */
|
||||||
const emoji = sync.require("../../m2d/converters/emoji")
|
const emoji = sync.require("../../m2d/converters/emoji")
|
||||||
/** @type {import("../converters/remove-reaction")} */
|
|
||||||
const converter = sync.require("../converters/remove-reaction")
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {DiscordTypes.GatewayMessageReactionRemoveDispatchData | DiscordTypes.GatewayMessageReactionRemoveEmojiDispatchData | DiscordTypes.GatewayMessageReactionRemoveAllDispatchData} data
|
* @param {import("discord-api-types/v10").GatewayMessageReactionRemoveDispatchData} data
|
||||||
*/
|
*/
|
||||||
async function removeSomeReactions(data) {
|
async function removeReaction(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
|
if (!roomID) return
|
||||||
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, part: 0}).pluck().get()
|
||||||
if (!eventIDForMessage) return
|
if (!eventIDForMessage) return
|
||||||
|
|
||||||
/** @type {Ty.Event.Outer<Ty.Event.M_Reaction>[]} */
|
/** @type {Ty.Pagination<Ty.Event.Outer<Ty.Event.M_Reaction>>} */
|
||||||
let reactions = []
|
const relations = await api.getRelations(roomID, eventIDForMessage, "m.annotation")
|
||||||
/** @type {string | undefined} */
|
const key = await emojiToKey.emojiToKey(data.emoji)
|
||||||
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
|
const wantToRemoveMatrixReaction = data.user_id === discord.application.id
|
||||||
const removals = await
|
for (const event of relations.chunk) {
|
||||||
( "user_id" in data ? removeReaction(data, reactions)
|
if (event.content["m.relates_to"].key === key) {
|
||||||
: "emoji" in data ? removeEmojiReaction(data, reactions)
|
const lookingAtMatrixReaction = !utils.eventSenderIsFromDiscord(event.sender)
|
||||||
: removeAllReactions(data, reactions))
|
if (lookingAtMatrixReaction && wantToRemoveMatrixReaction) {
|
||||||
|
// We are removing a Matrix user's reaction, so we need to redact from the correct user ID (not @_ooye_matrix_bridge).
|
||||||
// Redact the events and delete individual stored events in the database
|
// Even though the bridge bot only reacted once on Discord-side, multiple Matrix users may have
|
||||||
for (const removal of removals) {
|
// reacted on Matrix-side. Semantically, we want to remove the reaction from EVERY Matrix user.
|
||||||
await api.redactEvent(roomID, removal.eventID, removal.mxid)
|
await api.redactEvent(roomID, event.event_id)
|
||||||
if (removal.hash) db.prepare("DELETE FROM reaction WHERE hashed_event_id = ?").run(removal.hash)
|
// Clean up the database
|
||||||
|
const hash = utils.getEventIDHash(event.event_id)
|
||||||
|
db.prepare("DELETE FROM reaction WHERE hashed_event_id = ?").run(hash)
|
||||||
|
}
|
||||||
|
if (!lookingAtMatrixReaction && !wantToRemoveMatrixReaction) {
|
||||||
|
// We are removing a Discord user's reaction, so we just make the sim user remove it.
|
||||||
|
const mxid = select("sim", "mxid", {user_id: data.user_id}).pluck().get()
|
||||||
|
if (mxid === event.sender) {
|
||||||
|
await api.redactEvent(roomID, event.event_id, mxid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {DiscordTypes.GatewayMessageReactionRemoveDispatchData} data
|
* @param {import("discord-api-types/v10").GatewayMessageReactionRemoveEmojiDispatchData} data
|
||||||
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>[]} reactions
|
|
||||||
*/
|
*/
|
||||||
async function removeReaction(data, reactions) {
|
async function removeEmojiReaction(data) {
|
||||||
const key = await emojiToKey.emojiToKey(data.emoji)
|
const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get()
|
||||||
return converter.removeReaction(data, reactions, key)
|
if (!roomID) return
|
||||||
}
|
const eventIDForMessage = select("event_message", "event_id", {message_id: data.message_id, part: 0}).pluck().get()
|
||||||
|
if (!eventIDForMessage) return
|
||||||
|
|
||||||
/**
|
/** @type {Ty.Pagination<Ty.Event.Outer<Ty.Event.M_Reaction>>} */
|
||||||
* @param {DiscordTypes.GatewayMessageReactionRemoveEmojiDispatchData} data
|
const relations = await api.getRelations(roomID, eventIDForMessage, "m.annotation")
|
||||||
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>[]} reactions
|
|
||||||
*/
|
|
||||||
async function removeEmojiReaction(data, reactions) {
|
|
||||||
const key = await emojiToKey.emojiToKey(data.emoji)
|
const key = await emojiToKey.emojiToKey(data.emoji)
|
||||||
|
|
||||||
|
for (const event of relations.chunk) {
|
||||||
|
if (event.content["m.relates_to"].key === key) {
|
||||||
|
const mxid = utils.eventSenderIsFromDiscord(event.sender) ? event.sender : undefined
|
||||||
|
await api.redactEvent(roomID, event.event_id, mxid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const discordPreferredEncoding = emoji.encodeEmoji(key, undefined)
|
const discordPreferredEncoding = emoji.encodeEmoji(key, undefined)
|
||||||
db.prepare("DELETE FROM reaction WHERE message_id = ? AND encoded_emoji = ?").run(data.message_id, discordPreferredEncoding)
|
db.prepare("DELETE FROM reaction WHERE message_id = ? AND encoded_emoji = ?").run(data.message_id, discordPreferredEncoding)
|
||||||
|
|
||||||
return converter.removeEmojiReaction(data, reactions, key)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {DiscordTypes.GatewayMessageReactionRemoveAllDispatchData} data
|
* @param {import("discord-api-types/v10").GatewayMessageReactionRemoveAllDispatchData} data
|
||||||
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>[]} reactions
|
|
||||||
*/
|
*/
|
||||||
async function removeAllReactions(data, reactions) {
|
async function removeAllReactions(data) {
|
||||||
db.prepare("DELETE FROM reaction WHERE message_id = ?").run(data.message_id)
|
const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get()
|
||||||
|
if (!roomID) return
|
||||||
|
const eventIDForMessage = select("event_message", "event_id", {message_id: data.message_id, part: 0}).pluck().get()
|
||||||
|
if (!eventIDForMessage) return
|
||||||
|
|
||||||
return converter.removeAllReactions(data, reactions)
|
/** @type {Ty.Pagination<Ty.Event.Outer<Ty.Event.M_Reaction>>} */
|
||||||
|
const relations = await api.getRelations(roomID, eventIDForMessage, "m.annotation")
|
||||||
|
|
||||||
|
for (const event of relations.chunk) {
|
||||||
|
const mxid = utils.eventSenderIsFromDiscord(event.sender) ? event.sender : undefined
|
||||||
|
await api.redactEvent(roomID, event.event_id, mxid)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare("DELETE FROM reaction WHERE message_id = ?").run(data.message_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.removeSomeReactions = removeSomeReactions
|
module.exports.removeReaction = removeReaction
|
||||||
|
module.exports.removeEmojiReaction = removeEmojiReaction
|
||||||
|
module.exports.removeAllReactions = removeAllReactions
|
||||||
|
|
|
@ -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,73 +10,48 @@ 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 root = await registerPkUser.fetchMessage(message.id)
|
|
||||||
// Member is null if member was deleted. We just got this message, so member surely exists.
|
|
||||||
if (!root.member) {
|
|
||||||
const e = new Error("PK API did not return a member")
|
|
||||||
message["__pk_response__"] = root
|
|
||||||
console.error(root)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
senderMxid = await registerPkUser.syncUser(message.author, root, roomID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const events = await messageToEvent.messageToEvent(message, guild, {}, {api})
|
const events = await messageToEvent.messageToEvent(message, guild, {}, {api})
|
||||||
const eventIDs = []
|
const eventIDs = []
|
||||||
|
let eventPart = 0 // 0 is primary, 1 is supporting
|
||||||
if (events.length) {
|
if (events.length) {
|
||||||
db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id)
|
db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id)
|
||||||
if (senderMxid) api.sendTyping(roomID, false, senderMxid)
|
if (senderMxid) api.sendTyping(roomID, false, senderMxid)
|
||||||
}
|
}
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
const part = event === events[0] ? 0 : 1
|
|
||||||
const reactionPart = event === events[events.length - 1] ? 0 : 1
|
|
||||||
|
|
||||||
const eventType = event.$type
|
const eventType = event.$type
|
||||||
if ("$sender" in event) senderMxid = event.$sender
|
/** @type {Pick<typeof event, Exclude<keyof event, "$type">> & { $type?: string }} */
|
||||||
/** @type {Pick<typeof event, Exclude<keyof event, "$type" | "$sender">> & { $type?: string, $sender?: string }} */
|
|
||||||
const eventWithoutType = {...event}
|
const eventWithoutType = {...event}
|
||||||
delete eventWithoutType.$type
|
delete eventWithoutType.$type
|
||||||
delete eventWithoutType.$sender
|
|
||||||
|
|
||||||
const useTimestamp = message["backfill"] ? new Date(message.timestamp).getTime() : undefined
|
const useTimestamp = message["backfill"] ? new Date(message.timestamp).getTime() : undefined
|
||||||
const eventID = await api.sendEvent(roomID, eventType, eventWithoutType, senderMxid, useTimestamp)
|
const eventID = await api.sendEvent(roomID, eventType, eventWithoutType, senderMxid, useTimestamp)
|
||||||
db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 1)").run(eventID, eventType, event.msgtype || null, message.id, part, reactionPart) // source 1 = discord
|
db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, source) VALUES (?, ?, ?, ?, ?, 1)").run(eventID, eventType, event.msgtype || null, message.id, eventPart) // source 1 = discord
|
||||||
|
|
||||||
// The primary event is part = 0 and has the most important and distinct information. It is used to provide reply previews, be pinned, and possibly future uses.
|
// The primary event is part = 0 and has the most important and distinct information. It is used to provide reply previews, be pinned, and possibly future uses.
|
||||||
// The first event is chosen to be the primary part because it is usually the message text content and is more likely to be distinct.
|
// The first event is chosen to be the primary part because it is usually the message text content and is more likely to be distinct.
|
||||||
// For example, "Reply to 'this meme made me think of you'" is more useful than "Replied to image".
|
// For example, "Reply to 'this meme made me think of you'" is more useful than "Replied to image".
|
||||||
|
eventPart = 1
|
||||||
// The last event gets reaction_part = 0. Reactions are managed there because reactions are supposed to appear at the bottom.
|
|
||||||
|
|
||||||
eventIDs.push(eventID)
|
eventIDs.push(eventID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,68 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
|
||||||
const passthrough = require("../../passthrough")
|
|
||||||
const {discord, select, db} = passthrough
|
|
||||||
|
|
||||||
const SPEEDBUMP_SPEED = 4000 // 4 seconds delay
|
|
||||||
const SPEEDBUMP_UPDATE_FREQUENCY = 2 * 60 * 60 // 2 hours
|
|
||||||
|
|
||||||
/** @type {Set<any>} */
|
|
||||||
const KNOWN_BOTS = new Set([
|
|
||||||
"466378653216014359" // PluralKit
|
|
||||||
])
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch new speedbump data for the channel and put it in the database as cache
|
|
||||||
* @param {string} channelID
|
|
||||||
* @param {number?} lastChecked
|
|
||||||
*/
|
|
||||||
async function updateCache(channelID, lastChecked) {
|
|
||||||
const now = Math.floor(Date.now() / 1000)
|
|
||||||
if (lastChecked && now - lastChecked < SPEEDBUMP_UPDATE_FREQUENCY) return
|
|
||||||
const webhooks = await discord.snow.webhook.getChannelWebhooks(channelID)
|
|
||||||
const found = webhooks.find(b => KNOWN_BOTS.has(b.application_id))
|
|
||||||
const foundApplication = found?.application_id
|
|
||||||
const foundWebhook = found?.id
|
|
||||||
db.prepare("UPDATE channel_room SET speedbump_id = ?, speedbump_webhook_id = ?, speedbump_checked = ? WHERE channel_id = ?").run(foundApplication, foundWebhook, now, channelID)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @type {Set<string>} set of messageID */
|
|
||||||
const bumping = new Set()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Slow down a message. After it passes the speedbump, return whether it's okay or if it's been deleted.
|
|
||||||
* @param {string} messageID
|
|
||||||
* @returns whether it was deleted
|
|
||||||
*/
|
|
||||||
async function doSpeedbump(messageID) {
|
|
||||||
bumping.add(messageID)
|
|
||||||
await new Promise(resolve => setTimeout(resolve, SPEEDBUMP_SPEED))
|
|
||||||
return !bumping.delete(messageID)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check whether to slow down a message, and do it. After it passes the speedbump, return whether it's okay or if it's been deleted.
|
|
||||||
* @param {string} channelID
|
|
||||||
* @param {string} messageID
|
|
||||||
* @returns whether it was deleted, and data about the channel's (not thread's) speedbump
|
|
||||||
*/
|
|
||||||
async function maybeDoSpeedbump(channelID, messageID) {
|
|
||||||
let row = select("channel_room", ["thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: channelID}).get()
|
|
||||||
if (row?.thread_parent) row = select("channel_room", ["thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: row.thread_parent}).get() // webhooks belong to the channel, not the thread
|
|
||||||
if (!row?.speedbump_webhook_id) return {affected: false, row: null} // not affected, no speedbump
|
|
||||||
const affected = await doSpeedbump(messageID)
|
|
||||||
return {affected, row} // maybe affected, and there is a speedbump
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} messageID
|
|
||||||
*/
|
|
||||||
function onMessageDelete(messageID) {
|
|
||||||
bumping.delete(messageID)
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.updateCache = updateCache
|
|
||||||
module.exports.doSpeedbump = doSpeedbump
|
|
||||||
module.exports.maybeDoSpeedbump = maybeDoSpeedbump
|
|
||||||
module.exports.onMessageDelete = onMessageDelete
|
|
|
@ -1,37 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
|
||||||
const {discord, sync, db} = passthrough
|
|
||||||
/** @type {import("../converters/pins-to-list")} */
|
|
||||||
const pinsToList = sync.require("../converters/pins-to-list")
|
|
||||||
/** @type {import("../../matrix/api")} */
|
|
||||||
const api = sync.require("../../matrix/api")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @template {string | null | undefined} T
|
|
||||||
* @param {T} timestamp
|
|
||||||
* @returns {T extends string ? number : null}
|
|
||||||
*/
|
|
||||||
function convertTimestamp(timestamp) {
|
|
||||||
// @ts-ignore
|
|
||||||
return typeof timestamp === "string" ? Math.floor(new Date(timestamp).getTime() / 1000) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} channelID
|
|
||||||
* @param {string} roomID
|
|
||||||
* @param {number?} convertedTimestamp
|
|
||||||
*/
|
|
||||||
async function updatePins(channelID, roomID, convertedTimestamp) {
|
|
||||||
const pins = await discord.snow.channel.getChannelPinnedMessages(channelID)
|
|
||||||
const eventIDs = pinsToList.pinsToList(pins)
|
|
||||||
if (pins.length === eventIDs.length || eventIDs.length) {
|
|
||||||
await api.sendState(roomID, "m.room.pinned_events", "", {
|
|
||||||
pinned: eventIDs
|
|
||||||
})
|
|
||||||
}
|
|
||||||
db.prepare("UPDATE channel_room SET last_bridged_pin_timestamp = ? WHERE channel_id = ?").run(convertedTimestamp || 0, channelID)
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.convertTimestamp = convertTimestamp
|
|
||||||
module.exports.updatePins = updatePins
|
|
|
@ -3,24 +3,13 @@
|
||||||
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
|
||||||
|
@ -30,29 +19,14 @@ function eventCanBeEdited(ev) {
|
||||||
* @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.
|
|
||||||
|
|
||||||
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)
|
||||||
/** @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
|
|
||||||
} 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 = select("event_message", "event_id", {message_id: message.id}).pluck().get()
|
|
||||||
assert(eventID) // this should have been checked earlier in a calling function
|
|
||||||
const event = await api.getEvent(roomID, eventID)
|
|
||||||
if (utils.eventSenderIsFromDiscord(event.sender)) {
|
|
||||||
senderMxid = event.sender
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldEventRows = select("event_message", ["event_id", "event_type", "event_subtype", "part", "reaction_part"], {message_id: message.id}).all()
|
const oldEventRows = select("event_message", ["event_id", "event_type", "event_subtype", "part"], {message_id: message.id}).all()
|
||||||
|
|
||||||
// Figure out what we will be replacing them with
|
// Figure out what we will be replacing them with
|
||||||
|
|
||||||
|
@ -74,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()
|
||||||
|
@ -108,48 +81,43 @@ 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 events are being deleted, we might be deleting the part = 0. But we want to have a part = 0 at all times. In this case we choose an existing event to promote.
|
||||||
if (isGeneratedEmbed) {
|
let promoteEvent = null, promoteNextEvent = false
|
||||||
unchangedEvents.push(...eventsToRedact.filter(e => e.old.event_subtype !== "m.notice")) // Move them from eventsToRedact to unchangedEvents.
|
if (eventsToRedact.some(e => e.part === 0)) {
|
||||||
eventsToRedact = eventsToRedact.filter(e => e.old.event_subtype === "m.notice")
|
if (eventsToReplace.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.event_subtype === "m.text")
|
||||||
|
eventsToReplace.sort((a, b) => order(b) - order(a))
|
||||||
|
promoteEvent = eventsToReplace[0].old.event_id
|
||||||
|
} else {
|
||||||
|
// Everything is being deleted. Whatever gets sent in their place will be the new part = 0.
|
||||||
|
promoteNextEvent = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now, everything in eventsToSend and eventsToRedact is a real change, but everything in eventsToReplace might not have actually changed!
|
// 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.)
|
// (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.
|
// 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(ev => {
|
||||||
eventsToReplace = eventsToReplace.filter(eventCanBeEdited)
|
// 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") {
|
||||||
// We want to maintain exactly one part = 0 and one reaction_part = 0 database row at all times.
|
return false
|
||||||
/** @type {({column: string, eventID: string} | {column: string, nextEvent: true})[]} */
|
|
||||||
const promotions = []
|
|
||||||
for (const column of ["part", "reaction_part"]) {
|
|
||||||
const candidatesForParts = unchangedEvents.concat(eventsToReplace)
|
|
||||||
// If no events with part = 0 exist (or will exist), we need to do some management.
|
|
||||||
if (!candidatesForParts.some(e => e.old[column] === 0)) {
|
|
||||||
if (candidatesForParts.length) {
|
|
||||||
// We can choose an existing event to promote. Bigger order is better.
|
|
||||||
const order = e => 2*+(e.event_type === "m.room.message") + 1*+(e.old.event_subtype === "m.text")
|
|
||||||
candidatesForParts.sort((a, b) => order(b) - order(a))
|
|
||||||
if (column === "part") {
|
|
||||||
promotions.push({column, eventID: candidatesForParts[0].old.event_id}) // part should be the first one
|
|
||||||
} else {
|
|
||||||
promotions.push({column, eventID: candidatesForParts[candidatesForParts.length - 1].old.event_id}) // reaction_part should be the last one
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No existing events to promote, but new events are being sent. Whatever gets sent will be the next part = 0.
|
|
||||||
promotions.push({column, nextEvent: true})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
// 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, promoteEvent, promoteNextEvent}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -4,7 +4,7 @@ const data = require("../../test/data")
|
||||||
const Ty = require("../../types")
|
const Ty = require("../../types")
|
||||||
|
|
||||||
test("edit2changes: edit by webhook", async t => {
|
test("edit2changes: edit by webhook", async t => {
|
||||||
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edit_by_webhook, data.guild.general, {})
|
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promoteEvent, promoteNextEvent} = await editToChanges(data.message_update.edit_by_webhook, data.guild.general, {})
|
||||||
t.deepEqual(eventsToRedact, [])
|
t.deepEqual(eventsToRedact, [])
|
||||||
t.deepEqual(eventsToSend, [])
|
t.deepEqual(eventsToSend, [])
|
||||||
t.deepEqual(eventsToReplace, [{
|
t.deepEqual(eventsToReplace, [{
|
||||||
|
@ -27,11 +27,12 @@ test("edit2changes: edit by webhook", async t => {
|
||||||
}
|
}
|
||||||
}])
|
}])
|
||||||
t.equal(senderMxid, null)
|
t.equal(senderMxid, null)
|
||||||
t.deepEqual(promotions, [])
|
t.equal(promoteEvent, null)
|
||||||
|
t.equal(promoteNextEvent, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("edit2changes: bot response", async t => {
|
test("edit2changes: bot response", async t => {
|
||||||
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.bot_response, data.guild.general, {
|
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promoteEvent, promoteNextEvent} = await editToChanges(data.message_update.bot_response, data.guild.general, {
|
||||||
async getJoinedMembers(roomID) {
|
async getJoinedMembers(roomID) {
|
||||||
t.equal(roomID, "!hYnGGlPHlbujVVfktC:cadence.moe")
|
t.equal(roomID, "!hYnGGlPHlbujVVfktC:cadence.moe")
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
|
@ -83,19 +84,21 @@ test("edit2changes: bot response", async t => {
|
||||||
}
|
}
|
||||||
}])
|
}])
|
||||||
t.equal(senderMxid, "@_ooye_bojack_horseman:cadence.moe")
|
t.equal(senderMxid, "@_ooye_bojack_horseman:cadence.moe")
|
||||||
t.deepEqual(promotions, [])
|
t.equal(promoteEvent, null)
|
||||||
|
t.equal(promoteNextEvent, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("edit2changes: remove caption from image", async t => {
|
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, {})
|
const {eventsToRedact, eventsToReplace, eventsToSend, promoteEvent, promoteNextEvent} = await editToChanges(data.message_update.removed_caption_from_image, data.guild.general, {})
|
||||||
t.deepEqual(eventsToRedact, ["$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA"])
|
t.deepEqual(eventsToRedact, ["$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA"])
|
||||||
t.deepEqual(eventsToSend, [])
|
t.deepEqual(eventsToSend, [])
|
||||||
t.deepEqual(eventsToReplace, [])
|
t.deepEqual(eventsToReplace, [])
|
||||||
t.deepEqual(promotions, [{column: "part", eventID: "$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCI"}])
|
t.equal(promoteEvent, "$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCI")
|
||||||
|
t.equal(promoteNextEvent, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("edit2changes: change file type", async t => {
|
test("edit2changes: change file type", async t => {
|
||||||
const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.changed_file_type, data.guild.general, {})
|
const {eventsToRedact, eventsToReplace, eventsToSend, promoteEvent, promoteNextEvent} = await editToChanges(data.message_update.changed_file_type, data.guild.general, {})
|
||||||
t.deepEqual(eventsToRedact, ["$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCJ"])
|
t.deepEqual(eventsToRedact, ["$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCJ"])
|
||||||
t.deepEqual(eventsToSend, [{
|
t.deepEqual(eventsToSend, [{
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
|
@ -106,11 +109,12 @@ test("edit2changes: change file type", async t => {
|
||||||
msgtype: "m.text"
|
msgtype: "m.text"
|
||||||
}])
|
}])
|
||||||
t.deepEqual(eventsToReplace, [])
|
t.deepEqual(eventsToReplace, [])
|
||||||
t.deepEqual(promotions, [{column: "part", nextEvent: true}, {column: "reaction_part", nextEvent: true}])
|
t.equal(promoteEvent, null)
|
||||||
|
t.equal(promoteNextEvent, true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("edit2changes: add caption back to that image", async t => {
|
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, {})
|
const {eventsToRedact, eventsToReplace, eventsToSend, promoteEvent, promoteNextEvent} = await editToChanges(data.message_update.added_caption_to_image, data.guild.general, {})
|
||||||
t.deepEqual(eventsToRedact, [])
|
t.deepEqual(eventsToRedact, [])
|
||||||
t.deepEqual(eventsToSend, [{
|
t.deepEqual(eventsToSend, [{
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
|
@ -119,7 +123,8 @@ test("edit2changes: add caption back to that image", async t => {
|
||||||
"m.mentions": {}
|
"m.mentions": {}
|
||||||
}])
|
}])
|
||||||
t.deepEqual(eventsToReplace, [])
|
t.deepEqual(eventsToReplace, [])
|
||||||
t.deepEqual(promotions, [])
|
t.equal(promoteEvent, null)
|
||||||
|
t.equal(promoteNextEvent, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("edit2changes: stickers and attachments are not changed, only the content can be edited", async t => {
|
test("edit2changes: stickers and attachments are not changed, only the content can be edited", async t => {
|
||||||
|
@ -175,98 +180,3 @@ test("edit2changes: edit of reply to skull webp attachment with content", async
|
||||||
}
|
}
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("edit2changes: edits the text event when multiple rows have part = 0 (should never happen in real life, but make sure the safety net works)", async t => {
|
|
||||||
const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments_but_all_parts_equal_0, data.guild.general, {})
|
|
||||||
t.deepEqual(eventsToRedact, [])
|
|
||||||
t.deepEqual(eventsToSend, [])
|
|
||||||
t.deepEqual(eventsToReplace, [{
|
|
||||||
oldID: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd999",
|
|
||||||
newContent: {
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "* only the content can be edited",
|
|
||||||
"m.mentions": {},
|
|
||||||
// *** Replaced With: ***
|
|
||||||
"m.new_content": {
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "only the content can be edited",
|
|
||||||
"m.mentions": {}
|
|
||||||
},
|
|
||||||
"m.relates_to": {
|
|
||||||
rel_type: "m.replace",
|
|
||||||
event_id: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd999"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("edit2changes: promotes the text event when multiple rows have part = 1 (should never happen in real life, but make sure the safety net works)", async t => {
|
|
||||||
const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments_but_all_parts_equal_1, data.guild.general, {})
|
|
||||||
t.deepEqual(eventsToRedact, [])
|
|
||||||
t.deepEqual(eventsToSend, [])
|
|
||||||
t.deepEqual(eventsToReplace, [{
|
|
||||||
oldID: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd111",
|
|
||||||
newContent: {
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "* only the content can be edited",
|
|
||||||
"m.mentions": {},
|
|
||||||
// *** Replaced With: ***
|
|
||||||
"m.new_content": {
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "only the content can be edited",
|
|
||||||
"m.mentions": {}
|
|
||||||
},
|
|
||||||
"m.relates_to": {
|
|
||||||
rel_type: "m.replace",
|
|
||||||
event_id: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd111"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}])
|
|
||||||
t.deepEqual(promotions, [
|
|
||||||
{
|
|
||||||
column: "part",
|
|
||||||
eventID: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd111"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
column: "reaction_part",
|
|
||||||
eventID: "$f9cjKiacXI9qPF_nUAckzbiKnJEi0LM399kOkhdd111"
|
|
||||||
}
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("edit2changes: generated embed", async t => {
|
|
||||||
let called = 0
|
|
||||||
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_social_media_image, data.guild.general, {
|
|
||||||
async getEvent(roomID, eventID) {
|
|
||||||
called++
|
|
||||||
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
|
||||||
t.equal(eventID, "$mPSzglkCu-6cZHbYro0RW2u5mHvbH9aXDjO5FCzosc0")
|
|
||||||
return {sender: "@_ooye_cadence:cadence.moe"}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.deepEqual(eventsToRedact, [])
|
|
||||||
t.deepEqual(eventsToReplace, [])
|
|
||||||
t.deepEqual(eventsToSend, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.notice",
|
|
||||||
body: "| via hthrflwrs on cohost"
|
|
||||||
+ "\n| \n| ## This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO https://cohost.org/jkap/post/4794219-empty"
|
|
||||||
+ "\n| \n| 1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:"
|
|
||||||
+ "\n| \n| * Both players draw eight cards"
|
|
||||||
+ "\n| * Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand"
|
|
||||||
+ "\n| * Both players present their best five-or-less-card pok...",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: `<blockquote><p><sub>hthrflwrs on cohost</sub>`
|
|
||||||
+ `</p><p><strong><a href="https://cohost.org/jkap/post/4794219-empty">This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO</a></strong>`
|
|
||||||
+ `</p><p>1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:`
|
|
||||||
+ `<br><br><ul><li>Both players draw eight cards`
|
|
||||||
+ `</li><li>Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand`
|
|
||||||
+ `</li><li>Both players present their best five-or-less-card pok...</li></ul></p></blockquote>`,
|
|
||||||
"m.mentions": {}
|
|
||||||
}])
|
|
||||||
t.deepEqual(promotions, []) // TODO: it would be ideal to promote this to reaction_part = 0. this is OK to do because the main message won't have had any reactions yet.
|
|
||||||
t.equal(senderMxid, "@_ooye_cadence:cadence.moe")
|
|
||||||
t.equal(called, 1)
|
|
||||||
})
|
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -1,11 +1,25 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const stream = require("stream")
|
const Ty = require("../../types")
|
||||||
|
const assert = require("assert").strict
|
||||||
const {PNG} = require("pngjs")
|
const {PNG} = require("pngjs")
|
||||||
|
|
||||||
|
const passthrough = require("../../passthrough")
|
||||||
|
const {sync, db, discord, select} = passthrough
|
||||||
|
/** @type {import("../../matrix/file")} */
|
||||||
|
const file = sync.require("../../matrix/file")
|
||||||
|
//** @type {import("../../matrix/mreq")} */
|
||||||
|
const mreq = sync.require("../../matrix/mreq")
|
||||||
|
|
||||||
const SIZE = 160 // Discord's display size on 1x displays is 160
|
const SIZE = 160 // Discord's display size on 1x displays is 160
|
||||||
|
|
||||||
|
const INFO = {
|
||||||
|
mimetype: "image/png",
|
||||||
|
w: SIZE,
|
||||||
|
h: SIZE
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef RlottieWasm
|
* @typedef RlottieWasm
|
||||||
* @prop {(string) => boolean} load load lottie data from string of json
|
* @prop {(string) => boolean} load load lottie data from string of json
|
||||||
|
@ -20,15 +34,20 @@ const Rlottie = (async () => {
|
||||||
})()
|
})()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} text
|
* @param {DiscordTypes.APIStickerItem} stickerItem
|
||||||
* @returns {Promise<import("stream").Readable>}
|
* @returns {Promise<{mxc_url: string, info: typeof INFO}>}
|
||||||
*/
|
*/
|
||||||
async function convert(text) {
|
async function convert(stickerItem) {
|
||||||
|
const existingMxc = select("lottie", "mxc_url", {sticker_id: stickerItem.id}).pluck().get()
|
||||||
|
if (existingMxc) return {mxc_url: existingMxc, info: INFO}
|
||||||
const r = await Rlottie
|
const r = await Rlottie
|
||||||
|
const res = await fetch(file.DISCORD_IMAGES_BASE + file.sticker(stickerItem))
|
||||||
|
if (res.status !== 200) throw new Error("Sticker data file not found.")
|
||||||
|
const text = await res.text()
|
||||||
/** @type RlottieWasm */
|
/** @type RlottieWasm */
|
||||||
const rh = new r.RlottieWasm()
|
const rh = new r.RlottieWasm()
|
||||||
const status = rh.load(text)
|
const status = rh.load(text)
|
||||||
assert(status, `Rlottie unable to load ${text.length} byte data file.`)
|
if (!status) throw new Error(`Rlottie unable to load ${text.length} byte data file.`)
|
||||||
const rendered = rh.render(0, SIZE, SIZE)
|
const rendered = rh.render(0, SIZE, SIZE)
|
||||||
let png = new PNG({
|
let png = new PNG({
|
||||||
width: SIZE,
|
width: SIZE,
|
||||||
|
@ -39,10 +58,17 @@ async function convert(text) {
|
||||||
inputHasAlpha: true,
|
inputHasAlpha: true,
|
||||||
})
|
})
|
||||||
png.data = Buffer.from(rendered)
|
png.data = Buffer.from(rendered)
|
||||||
// png.pack() is a bad stream and will throw away any data it sends if it's not connected to a destination straight away.
|
// @ts-ignore wrong type from pngjs
|
||||||
// We use Duplex.from to convert it into a good stream.
|
const readablePng = png.pack()
|
||||||
return stream.Duplex.from(png.pack())
|
/** @type {Ty.R.FileUploaded} */
|
||||||
|
const root = await mreq.mreq("POST", "/media/v3/upload", readablePng, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": INFO.mimetype
|
||||||
|
}
|
||||||
|
})
|
||||||
|
assert(root.content_uri)
|
||||||
|
db.prepare("INSERT INTO lottie (sticker_id, mxc_url) VALUES (?, ?)").run(stickerItem.id, root.content_uri)
|
||||||
|
return {mxc_url: root.content_uri, info: INFO}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.convert = convert
|
module.exports.convert = convert
|
||||||
module.exports.SIZE = SIZE
|
|
||||||
|
|
|
@ -1,34 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const fs = require("fs")
|
|
||||||
const stream = require("stream")
|
|
||||||
const {test} = require("supertape")
|
|
||||||
const {convert} = require("./lottie")
|
|
||||||
|
|
||||||
const WRITE_PNG = false
|
|
||||||
|
|
||||||
test("lottie: can convert and save PNG", async t => {
|
|
||||||
const input = await fs.promises.readFile("test/res/lottie-bee.json", "utf8")
|
|
||||||
const resultStream = await convert(input)
|
|
||||||
/* c8 ignore next 3 */
|
|
||||||
if (WRITE_PNG) {
|
|
||||||
resultStream.pipe(fs.createWriteStream("test/res/lottie-bee.png"))
|
|
||||||
t.fail("PNG written to /test/res/lottie-bee.png, please manually check it")
|
|
||||||
} else {
|
|
||||||
const expected = await fs.promises.readFile("test/res/lottie-bee.png")
|
|
||||||
const actual = Buffer.alloc(expected.length)
|
|
||||||
let i = 0
|
|
||||||
await stream.promises.pipeline(
|
|
||||||
resultStream,
|
|
||||||
async function* (source) {
|
|
||||||
for await (const chunk of source) {
|
|
||||||
chunk.copy(actual, i)
|
|
||||||
i += chunk.length
|
|
||||||
}
|
|
||||||
},
|
|
||||||
new stream.PassThrough()
|
|
||||||
)
|
|
||||||
t.equal(i, actual.length, `allocated ${actual.length} bytes, but wrote ${i}`)
|
|
||||||
t.deepEqual(actual, expected)
|
|
||||||
}
|
|
||||||
})
|
|
|
@ -3,20 +3,46 @@ const {messageToEvent} = require("./message-to-event")
|
||||||
const data = require("../../test/data")
|
const data = require("../../test/data")
|
||||||
const Ty = require("../../types")
|
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 => {
|
test("message2event embeds: nothing but a field", async t => {
|
||||||
const events = await messageToEvent(data.message_with_embeds.nothing_but_a_field, data.guild.general, {})
|
const events = await messageToEvent(data.message_with_embeds.nothing_but_a_field, data.guild.general, {})
|
||||||
t.deepEqual(events, [{
|
t.deepEqual(events, [{
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
"m.mentions": {},
|
"m.mentions": {},
|
||||||
msgtype: "m.notice",
|
msgtype: "m.notice",
|
||||||
body: "| ### Amanda 🎵#2192 :online:"
|
body: "> **Amanda 🎵#2192 :online:"
|
||||||
+ "\n| willow tree, branch 0"
|
+ "\n> willow tree, branch 0**"
|
||||||
+ "\n| **❯ Uptime:**\n| 3m 55s\n| **❯ Memory:**\n| 64.45MB",
|
+ "\n> **❯ Uptime:**\n> 3m 55s\n> **❯ Memory:**\n> 64.45MB",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: '<blockquote><p><strong>Amanda 🎵#2192 <img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/LCEqjStXCxvRQccEkuslXEyZ\" title=\":online:\" alt=\":online:\">'
|
formatted_body: '<blockquote><strong>Amanda 🎵#2192 <img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/LCEqjStXCxvRQccEkuslXEyZ\" title=\":online:\" alt=\":online:\">'
|
||||||
+ '<br>willow tree, branch 0</strong>'
|
+ '<br>willow tree, branch 0</strong>'
|
||||||
+ '<br><strong>❯ Uptime:</strong><br>3m 55s'
|
+ '<br><strong>❯ Uptime:</strong><br>3m 55s'
|
||||||
+ '<br><strong>❯ Memory:</strong><br>64.45MB</p></blockquote>'
|
+ '<br><strong>❯ Memory:</strong><br>64.45MB</blockquote>'
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -26,17 +52,19 @@ test("message2event embeds: reply with just an embed", async t => {
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
msgtype: "m.notice",
|
msgtype: "m.notice",
|
||||||
"m.mentions": {},
|
"m.mentions": {},
|
||||||
body: "| ## ⏺️ dynastic (@dynastic) https://twitter.com/i/user/719631291747078145"
|
body: "> [**⏺️ dynastic (@dynastic)**](https://twitter.com/i/user/719631291747078145)"
|
||||||
+ "\n| \n| does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?"
|
+ "\n> \n> **https://twitter.com/i/status/1707484191963648161**"
|
||||||
+ "\n| \n| ### Retweets"
|
+ "\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| 119"
|
+ "\n> \n> **Retweets**"
|
||||||
+ "\n| \n| ### Likes"
|
+ "\n> 119"
|
||||||
+ "\n| 5581"
|
+ "\n> \n> **Likes**"
|
||||||
+ "\n| — Twitter",
|
+ "\n> 5581"
|
||||||
|
+ "\n> \n> — Twitter",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: '<blockquote><p><strong><a href="https://twitter.com/i/user/719631291747078145">⏺️ dynastic (@dynastic)</a></strong>'
|
formatted_body: '<blockquote><a href="https://twitter.com/i/user/719631291747078145"><strong>⏺️ dynastic (@dynastic)</strong></a>'
|
||||||
+ '</p><p>does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?'
|
+ '<br><br><strong><a href="https://twitter.com/i/status/1707484191963648161">https://twitter.com/i/status/1707484191963648161</a></strong>'
|
||||||
+ '</p><p><strong>Retweets</strong><br>119</p><p><strong>Likes</strong><br>5581</p>— Twitter</blockquote>'
|
+ '<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>'
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -71,248 +99,3 @@ test("message2event embeds: image embed and attachment", async t => {
|
||||||
"m.mentions": {}
|
"m.mentions": {}
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("message2event embeds: blockquote in embed", async t => {
|
|
||||||
let called = 0
|
|
||||||
const events = await messageToEvent(data.message_with_embeds.blockquote_in_embed, data.guild.general, {}, {
|
|
||||||
api: {
|
|
||||||
async getStateEvent(roomID, type, key) {
|
|
||||||
called++
|
|
||||||
t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe")
|
|
||||||
t.equal(type, "m.room.power_levels")
|
|
||||||
t.equal(key, "")
|
|
||||||
return {
|
|
||||||
users: {
|
|
||||||
"@_ooye_bot:cadence.moe": 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async getJoinedMembers(roomID) {
|
|
||||||
called++
|
|
||||||
t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe")
|
|
||||||
return {
|
|
||||||
joined: {
|
|
||||||
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
|
|
||||||
"@user:example.invalid": {display_name: null, avatar_url: null}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: ":emoji: **4 |** #wonderland",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: `<img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/mwZaCtRGAQQyOItagDeCocEO\" title=\":emoji:\" alt=\":emoji:\"> <strong>4 |</strong> <a href=\"https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe?via=cadence.moe&via=example.invalid\">#wonderland</a>`,
|
|
||||||
"m.mentions": {}
|
|
||||||
}, {
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.notice",
|
|
||||||
body: "| ## ⏺️ minimus https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo?via=cadence.moe&via=example.invalid\n| \n| reply draft\n| > The following is a message composed via consensus of the Stinker Council.\n| > \n| > For those who are not currently aware of our existence, we represent the organization known as Wonderland. Our previous mission centered around the assortment and study of puzzling objects, entities and other assorted phenomena. This mission was the focus of our organization for more than 28 years.\n| > \n| > Due to circumstances outside of our control, this directive has now changed. Our new mission will be the extermination of the stinker race.\n| > \n| > There will be no further communication.\n| \n| [Go to Message](https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo?via=cadence.moe&via=example.invalid)",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: "<blockquote><p><strong><a href=\"https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo?via=cadence.moe&via=example.invalid\">⏺️ minimus</a></strong></p><p>reply draft<br><blockquote>The following is a message composed via consensus of the Stinker Council.<br><br>For those who are not currently aware of our existence, we represent the organization known as Wonderland. Our previous mission centered around the assortment and study of puzzling objects, entities and other assorted phenomena. This mission was the focus of our organization for more than 28 years.<br><br>Due to circumstances outside of our control, this directive has now changed. Our new mission will be the extermination of the stinker race.<br><br>There will be no further communication.</blockquote></p><p><a href=\"https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo?via=cadence.moe&via=example.invalid\">Go to Message</a></p></blockquote>",
|
|
||||||
"m.mentions": {}
|
|
||||||
}])
|
|
||||||
t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event embeds: crazy html is all escaped", async t => {
|
|
||||||
const events = await messageToEvent(data.message_with_embeds.escaping_crazy_html_tags, data.guild.general)
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.notice",
|
|
||||||
body: "| ## ⏺️ <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&) https://a.co/&<script>"
|
|
||||||
+ "\n| \n| ## <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&) https://a.co/&<script>"
|
|
||||||
+ "\n| \n| <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&)"
|
|
||||||
+ "\n| \n| ### <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&)"
|
|
||||||
+ "\n| <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&)"
|
|
||||||
+ "\n| — <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&)",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: `<blockquote>`
|
|
||||||
+ `<p><strong><a href="https://a.co/&amp;<script>">⏺️ <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;)</a></strong></p>`
|
|
||||||
+ `<p><strong><a href=\"https://a.co/&amp;<script>"><strong>[<span data-mx-color='#123456'>Hey<script>](<a href="https://a.co/&amp">https://a.co/&amp</a>;)</a></strong></p>`
|
|
||||||
+ `<p><strong><a href="https://a.co/&amp;"><span data-mx-color='#123456'>Hey<script></a></p>`
|
|
||||||
+ `<p><strong><strong>[<span data-mx-color='#123456'>Hey<script>](<a href=\"https://a.co/&amp\">https://a.co/&amp</a>;)</strong>`
|
|
||||||
+ `<br><strong><a href="https://a.co/&amp;"><span data-mx-color='#123456'>Hey<script></a></p>`
|
|
||||||
+ `— <strong>[<span data-mx-color='#123456'>Hey<script>](https://a.co/&amp;)</blockquote>`,
|
|
||||||
"m.mentions": {}
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event embeds: title without url", async t => {
|
|
||||||
const events = await messageToEvent(data.message_with_embeds.title_without_url, data.guild.general)
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.notice",
|
|
||||||
body: "| ## Hi, I'm Amanda!\n| \n| I condone pirating music!",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: `<blockquote><p><strong>Hi, I'm Amanda!</strong></p><p>I condone pirating music!</p></blockquote>`,
|
|
||||||
"m.mentions": {}
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event embeds: url without title", async t => {
|
|
||||||
const events = await messageToEvent(data.message_with_embeds.url_without_title, data.guild.general)
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.notice",
|
|
||||||
body: "| I condone pirating music!",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: `<blockquote><p>I condone pirating music!</p></blockquote>`,
|
|
||||||
"m.mentions": {}
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event embeds: author without url", async t => {
|
|
||||||
const events = await messageToEvent(data.message_with_embeds.author_without_url, data.guild.general)
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.notice",
|
|
||||||
body: "| ## Amanda\n| \n| I condone pirating music!",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: `<blockquote><p><strong>Amanda</strong></p><p>I condone pirating music!</p></blockquote>`,
|
|
||||||
"m.mentions": {}
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event embeds: author url without name", async t => {
|
|
||||||
const events = await messageToEvent(data.message_with_embeds.author_url_without_name, data.guild.general)
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.notice",
|
|
||||||
body: "| I condone pirating music!",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: `<blockquote><p>I condone pirating music!</p></blockquote>`,
|
|
||||||
"m.mentions": {}
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event embeds: vx image", async t => {
|
|
||||||
const events = await messageToEvent(data.message_with_embeds.vx_image, data.guild.general)
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "https://vxtwitter.com/TomorrowCorp/status/1760330671074287875 we got a release date!!!",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: '<a href="https://vxtwitter.com/TomorrowCorp/status/1760330671074287875">https://vxtwitter.com/TomorrowCorp/status/1760330671074287875</a> we got a release date!!!',
|
|
||||||
"m.mentions": {}
|
|
||||||
}, {
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.notice",
|
|
||||||
body: "| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix"
|
|
||||||
+ "\n| "
|
|
||||||
+ "\n| ## Twitter https://twitter.com/tomorrowcorp/status/1760330671074287875"
|
|
||||||
+ "\n| "
|
|
||||||
+ "\n| ## Tomorrow Corporation (@TomorrowCorp) https://vxtwitter.com/TomorrowCorp/status/1760330671074287875"
|
|
||||||
+ "\n| "
|
|
||||||
+ "\n| Mark your calendar with a wet black stain! World of Goo 2 releases on May 23, 2024 on Nintendo Switch, Epic Games Store (Win/Mac), and http://WorldOfGoo2.com (Win/Mac/Linux)."
|
|
||||||
+ "\n| "
|
|
||||||
+ "\n| https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms"
|
|
||||||
+ "\n| "
|
|
||||||
+ "\n| 💖 123 🔁 36"
|
|
||||||
+ "\n| "
|
|
||||||
+ "\n| 📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: `<blockquote><p><sub><a href="https://github.com/dylanpdx/BetterTwitFix">vxTwitter / fixvx</a></sub>`
|
|
||||||
+ `</p><p><strong><a href="https://twitter.com/tomorrowcorp/status/1760330671074287875">Twitter</a></strong>`
|
|
||||||
+ `</p><p><strong><a href="https://vxtwitter.com/TomorrowCorp/status/1760330671074287875">Tomorrow Corporation (@TomorrowCorp)</a></strong>`
|
|
||||||
+ `</p><p>Mark your calendar with a wet black stain! World of Goo 2 releases on May 23, 2024 on Nintendo Switch, Epic Games Store (Win/Mac), and <a href="http://WorldOfGoo2.com">http://WorldOfGoo2.com</a> (Win/Mac/Linux).`
|
|
||||||
+ `<br><br><a href="https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms">https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms</a>`
|
|
||||||
+ `<br><br>💖 123 🔁 36`
|
|
||||||
+ `</p><p>📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg</p></blockquote>`,
|
|
||||||
"m.mentions": {}
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event embeds: vx video", async t => {
|
|
||||||
const events = await messageToEvent(data.message_with_embeds.vx_video, data.guild.general)
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "https://vxtwitter.com/McDonalds/status/1759971752254341417",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: '<a href="https://vxtwitter.com/McDonalds/status/1759971752254341417">https://vxtwitter.com/McDonalds/status/1759971752254341417</a>',
|
|
||||||
"m.mentions": {}
|
|
||||||
}, {
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.notice",
|
|
||||||
body: "| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix"
|
|
||||||
+ "\n| \n| ## McDonald’s🤝@studiopierrot"
|
|
||||||
+ "\n| \n| 💖 89 🔁 21 https://twitter.com/McDonalds/status/1759971752254341417"
|
|
||||||
+ "\n| \n| ## McDonald's (@McDonalds) https://vxtwitter.com/McDonalds/status/1759971752254341417"
|
|
||||||
+ "\n| \n| 🎞️ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: `<blockquote><p><sub><a href="https://github.com/dylanpdx/BetterTwitFix">vxTwitter / fixvx</a></sub>`
|
|
||||||
+ `</p><p><strong><a href="https://twitter.com/McDonalds/status/1759971752254341417">McDonald’s🤝@studiopierrot\n\n💖 89 🔁 21</a></strong>`
|
|
||||||
+ `</p><p><strong><a href="https://vxtwitter.com/McDonalds/status/1759971752254341417">McDonald's (@McDonalds)</a></strong>`
|
|
||||||
+ `</p><p>🎞️ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12</p></blockquote>`,
|
|
||||||
"m.mentions": {}
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event embeds: youtube video", async t => {
|
|
||||||
const events = await messageToEvent(data.message_with_embeds.youtube_video, data.guild.general)
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E\n\n\nJutomi I'm gonna make these sounds in your walls tonight",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: `<a href="https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E">https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E</a><br><br><br>Jutomi I'm gonna make these sounds in your walls tonight`,
|
|
||||||
"m.mentions": {}
|
|
||||||
}, {
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.notice",
|
|
||||||
body: "| via YouTube https://www.youtube.com"
|
|
||||||
+ "\n| \n| ## Happy O Funny https://www.youtube.com/channel/UCEpQ9aEb1NafpvWp5Aoizrg"
|
|
||||||
+ "\n| \n| ## Shoebill stork clattering sounds like machine guun~!! (Japan Matsue... https://www.youtube.com/watch?v=kDMHHw8JqLE"
|
|
||||||
+ "\n| \n| twitter"
|
|
||||||
+ "\n| https://twitter.com/matsuevogelpark"
|
|
||||||
+ "\n| \n| The shoebill (Balaeniceps rex) also known as whalehead, whale-headed stork, or shoe-billed stork, is a very large stork-like bird. It derives its name from its enormous shoe-shaped bill"
|
|
||||||
+ "\n| some people also called them the living dinosaur~~"
|
|
||||||
+ "\n| \n| #shoebill #livingdinosaur #happyofunny #weirdcreature #weirdsoun..."
|
|
||||||
+ "\n| \n| 🎞️ https://www.youtube.com/embed/kDMHHw8JqLE",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: `<blockquote><p><sub><a href="https://www.youtube.com">YouTube</a></sub></p>`
|
|
||||||
+ `<p><strong><a href="https://www.youtube.com/channel/UCEpQ9aEb1NafpvWp5Aoizrg">Happy O Funny</a></strong>`
|
|
||||||
+ `</p><p><strong><a href="https://www.youtube.com/watch?v=kDMHHw8JqLE">Shoebill stork clattering sounds like machine guun~!! (Japan Matsue...</a></strong>`
|
|
||||||
+ `</p><p>twitter<br><a href="https://twitter.com/matsuevogelpark">https://twitter.com/matsuevogelpark</a><br><br>The shoebill (Balaeniceps rex) also known as whalehead, whale-headed stork, or shoe-billed stork, is a very large stork-like bird. It derives its name from its enormous shoe-shaped bill<br>some people also called them the living dinosaur~~<br><br>#shoebill #livingdinosaur #happyofunny #weirdcreature #weirdsoun...`
|
|
||||||
+ `</p><p>🎞️ https://www.youtube.com/embed/kDMHHw8JqLE`
|
|
||||||
+ `</p></blockquote>`,
|
|
||||||
"m.mentions": {}
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event embeds: if discord creates an embed preview for a discord channel link, don't copy that embed", async t => {
|
|
||||||
const events = await messageToEvent(data.message_with_embeds.discord_server_included_punctuation_bad_discord, data.guild.general, {}, {
|
|
||||||
api: {
|
|
||||||
async getStateEvent(roomID, type, key) {
|
|
||||||
t.equal(roomID, "!TqlyQmifxGUggEmdBN:cadence.moe")
|
|
||||||
t.equal(type, "m.room.power_levels")
|
|
||||||
t.equal(key, "")
|
|
||||||
return {
|
|
||||||
users: {
|
|
||||||
"@_ooye_bot:cadence.moe": 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async getJoinedMembers(roomID) {
|
|
||||||
t.equal(roomID, "!TqlyQmifxGUggEmdBN:cadence.moe")
|
|
||||||
return {
|
|
||||||
joined: {
|
|
||||||
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
|
|
||||||
"@user:matrix.org": {display_name: null, avatar_url: null}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "(test https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU?via=cadence.moe&via=matrix.org)",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: `(test <a href="https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU?via=cadence.moe&via=matrix.org">https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU?via=cadence.moe&via=matrix.org</a>)`,
|
|
||||||
"m.mentions": {}
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
|
@ -4,7 +4,6 @@ const assert = require("assert").strict
|
||||||
const markdown = require("discord-markdown")
|
const markdown = require("discord-markdown")
|
||||||
const pb = require("prettier-bytes")
|
const pb = require("prettier-bytes")
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const {tag} = require("html-template-tag")
|
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {sync, db, discord, select, from} = passthrough
|
const {sync, db, discord, select, from} = passthrough
|
||||||
|
@ -12,22 +11,13 @@ const {sync, db, discord, select, from} = passthrough
|
||||||
const file = sync.require("../../matrix/file")
|
const file = sync.require("../../matrix/file")
|
||||||
/** @type {import("./emoji-to-key")} */
|
/** @type {import("./emoji-to-key")} */
|
||||||
const emojiToKey = sync.require("./emoji-to-key")
|
const emojiToKey = sync.require("./emoji-to-key")
|
||||||
/** @type {import("../actions/lottie")} */
|
/** @type {import("./lottie")} */
|
||||||
const lottie = sync.require("../actions/lottie")
|
const lottie = sync.require("./lottie")
|
||||||
/** @type {import("../../m2d/converters/utils")} */
|
|
||||||
const mxUtils = sync.require("../../m2d/converters/utils")
|
|
||||||
/** @type {import("../../discord/utils")} */
|
|
||||||
const dUtils = sync.require("../../discord/utils")
|
|
||||||
const reg = require("../../matrix/read-registration")
|
const reg = require("../../matrix/read-registration")
|
||||||
|
|
||||||
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
|
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
|
||||||
|
|
||||||
/**
|
function getDiscordParseCallbacks(message, useHTML) {
|
||||||
* @param {DiscordTypes.APIMessage} message
|
|
||||||
* @param {DiscordTypes.APIGuild} guild
|
|
||||||
* @param {boolean} useHTML
|
|
||||||
*/
|
|
||||||
function getDiscordParseCallbacks(message, guild, useHTML) {
|
|
||||||
return {
|
return {
|
||||||
/** @param {{id: string, type: "discordUser"}} node */
|
/** @param {{id: string, type: "discordUser"}} node */
|
||||||
user: node => {
|
user: node => {
|
||||||
|
@ -39,156 +29,36 @@ function getDiscordParseCallbacks(message, guild, useHTML) {
|
||||||
return `@${username}:`
|
return `@${username}:`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
/** @param {{id: string, type: "discordChannel", row: {room_id: string, name: string, nick: string?}?, via: string}} node */
|
/** @param {{id: string, type: "discordChannel"}} node */
|
||||||
channel: node => {
|
channel: node => {
|
||||||
if (!node.row) { // fallback for when this channel is not bridged
|
const row = select("channel_room", ["room_id", "name", "nick"], {channel_id: node.id}).get()
|
||||||
const channel = discord.channels.get(node.id)
|
if (!row) {
|
||||||
if (channel) {
|
return `<#${node.id}>` // fallback for when this channel is not bridged
|
||||||
return `#${channel.name} [channel not bridged]`
|
|
||||||
} else {
|
|
||||||
return `#unknown-channel [channel from an unbridged server]`
|
|
||||||
}
|
|
||||||
} else if (useHTML) {
|
} else if (useHTML) {
|
||||||
return `<a href="https://matrix.to/#/${node.row.room_id}?${node.via}">#${node.row.nick || node.row.name}</a>`
|
return `<a href="https://matrix.to/#/${row.room_id}">#${row.nick || row.name}</a>`
|
||||||
} else {
|
} else {
|
||||||
return `#${node.row.nick || node.row.name}`
|
return `#${row.nick || row.name}`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
/** @param {{animated: boolean, name: string, id: string, type: "discordEmoji"}} node */
|
/** @param {{animated: boolean, name: string, id: string, type: "discordEmoji"}} node */
|
||||||
emoji: node => {
|
emoji: node => {
|
||||||
if (useHTML) {
|
if (useHTML) {
|
||||||
const mxc = select("emoji", "mxc_url", {emoji_id: node.id}).pluck().get()
|
const mxc = select("emoji", "mxc_url", {emoji_id: node.id}).pluck().get()
|
||||||
assert(mxc) // All emojis should have been added ahead of time in the messageToEvent function.
|
if (mxc) {
|
||||||
return `<img data-mx-emoticon height="32" src="${mxc}" title=":${node.name}:" alt=":${node.name}:">`
|
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 {
|
} else {
|
||||||
return `:${node.name}:`
|
return `:${node.name}:`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
role: node => {
|
role: node =>
|
||||||
const role = guild.roles.find(r => r.id === node.id)
|
"@&" + node.id,
|
||||||
if (!role) {
|
everyone: node =>
|
||||||
// This fallback should only trigger if somebody manually writes a silly message, or if the cache breaks (hasn't happened yet).
|
"@room",
|
||||||
// If the cache breaks, fix discord-packets.js to store role info properly.
|
here: node =>
|
||||||
return "@&" + node.id
|
"@here"
|
||||||
} else if (useHTML && role.color) {
|
|
||||||
return `<font color="#${role.color.toString(16)}">@${role.name}</font>`
|
|
||||||
} else if (useHTML) {
|
|
||||||
return `<span data-mx-color="#ffffff" data-mx-bg-color="#414eef">@${role.name}</span>`
|
|
||||||
} else {
|
|
||||||
return `@${role.name}:`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
everyone: () => {
|
|
||||||
if (message.mention_everyone) return "@room"
|
|
||||||
return "@everyone"
|
|
||||||
},
|
|
||||||
here: () => {
|
|
||||||
if (message.mention_everyone) return "@room"
|
|
||||||
return "@here"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const embedTitleParser = markdown.markdownEngine.parserFor({
|
|
||||||
...markdown.rules,
|
|
||||||
autolink: undefined,
|
|
||||||
link: undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {{room?: boolean, user_ids?: string[]}} mentions
|
|
||||||
* @param {DiscordTypes.APIAttachment} attachment
|
|
||||||
*/
|
|
||||||
async function attachmentToEvent(mentions, attachment) {
|
|
||||||
const emoji =
|
|
||||||
attachment.content_type?.startsWith("image/jp") ? "📸"
|
|
||||||
: attachment.content_type?.startsWith("image/") ? "🖼️"
|
|
||||||
: attachment.content_type?.startsWith("video/") ? "🎞️"
|
|
||||||
: attachment.content_type?.startsWith("text/") ? "📝"
|
|
||||||
: attachment.content_type?.startsWith("audio/") ? "🎶"
|
|
||||||
: "📄"
|
|
||||||
// no native media spoilers in Element, so we'll post a link instead, forcing it to not preview using a blockquote
|
|
||||||
if (attachment.filename.startsWith("SPOILER_")) {
|
|
||||||
return {
|
|
||||||
$type: "m.room.message",
|
|
||||||
"m.mentions": mentions,
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: `${emoji} Uploaded SPOILER file: ${attachment.url} (${pb(attachment.size)})`,
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: `<blockquote>${emoji} Uploaded SPOILER file: <a href="${attachment.url}">${attachment.url}</a> (${pb(attachment.size)})</blockquote>`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// for large files, always link them instead of uploading so I don't use up all the space in the content repo
|
|
||||||
else if (attachment.size > reg.ooye.max_file_size) {
|
|
||||||
return {
|
|
||||||
$type: "m.room.message",
|
|
||||||
"m.mentions": mentions,
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: `${emoji} Uploaded file: ${attachment.url} (${pb(attachment.size)})`,
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: `${emoji} Uploaded file: <a href="${attachment.url}">${attachment.filename}</a> (${pb(attachment.size)})`
|
|
||||||
}
|
|
||||||
} else if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) {
|
|
||||||
return {
|
|
||||||
$type: "m.room.message",
|
|
||||||
"m.mentions": mentions,
|
|
||||||
msgtype: "m.image",
|
|
||||||
url: await file.uploadDiscordFileToMxc(attachment.url),
|
|
||||||
external_url: attachment.url,
|
|
||||||
body: attachment.description || attachment.filename,
|
|
||||||
filename: attachment.filename,
|
|
||||||
info: {
|
|
||||||
mimetype: attachment.content_type,
|
|
||||||
w: attachment.width,
|
|
||||||
h: attachment.height,
|
|
||||||
size: attachment.size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (attachment.content_type?.startsWith("video/") && attachment.width && attachment.height) {
|
|
||||||
return {
|
|
||||||
$type: "m.room.message",
|
|
||||||
"m.mentions": mentions,
|
|
||||||
msgtype: "m.video",
|
|
||||||
url: await file.uploadDiscordFileToMxc(attachment.url),
|
|
||||||
external_url: attachment.url,
|
|
||||||
body: attachment.description || attachment.filename,
|
|
||||||
filename: attachment.filename,
|
|
||||||
info: {
|
|
||||||
mimetype: attachment.content_type,
|
|
||||||
w: attachment.width,
|
|
||||||
h: attachment.height,
|
|
||||||
size: attachment.size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (attachment.content_type?.startsWith("audio/")) {
|
|
||||||
return {
|
|
||||||
$type: "m.room.message",
|
|
||||||
"m.mentions": mentions,
|
|
||||||
msgtype: "m.audio",
|
|
||||||
url: await file.uploadDiscordFileToMxc(attachment.url),
|
|
||||||
external_url: attachment.url,
|
|
||||||
body: attachment.description || attachment.filename,
|
|
||||||
filename: attachment.filename,
|
|
||||||
info: {
|
|
||||||
mimetype: attachment.content_type,
|
|
||||||
size: attachment.size,
|
|
||||||
duration: attachment.duration_secs ? attachment.duration_secs * 1000 : undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
$type: "m.room.message",
|
|
||||||
"m.mentions": mentions,
|
|
||||||
msgtype: "m.file",
|
|
||||||
url: await file.uploadDiscordFileToMxc(attachment.url),
|
|
||||||
external_url: attachment.url,
|
|
||||||
body: attachment.description || attachment.filename,
|
|
||||||
filename: attachment.filename,
|
|
||||||
info: {
|
|
||||||
mimetype: attachment.content_type,
|
|
||||||
size: attachment.size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -203,7 +73,6 @@ async function attachmentToEvent(mentions, attachment) {
|
||||||
async function messageToEvent(message, guild, options = {}, di) {
|
async function messageToEvent(message, guild, options = {}, di) {
|
||||||
const events = []
|
const events = []
|
||||||
|
|
||||||
/* c8 ignore next 7 */
|
|
||||||
if (message.type === DiscordTypes.MessageType.ThreadCreated) {
|
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.
|
// 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:
|
// It lacks the lines and the pill, so it looks kind of like a member join message, and it says:
|
||||||
|
@ -224,8 +93,7 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
const event = await di.api.getEvent(roomID, eventID)
|
const event = await di.api.getEvent(roomID, eventID)
|
||||||
return [{
|
return [{
|
||||||
...event.content,
|
...event.content,
|
||||||
$type: event.type,
|
$type: event.type
|
||||||
$sender: null
|
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -249,8 +117,6 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
let repliedToEventRow = null
|
let repliedToEventRow = null
|
||||||
let repliedToEventSenderMxid = null
|
let repliedToEventSenderMxid = null
|
||||||
|
|
||||||
if (message.mention_everyone) mentions.room = true
|
|
||||||
|
|
||||||
function addMention(mxid) {
|
function addMention(mxid) {
|
||||||
if (!mentions.user_ids) mentions.user_ids = []
|
if (!mentions.user_ids) mentions.user_ids = []
|
||||||
if (!mentions.user_ids.includes(mxid)) mentions.user_ids.push(mxid)
|
if (!mentions.user_ids.includes(mxid)) mentions.user_ids.push(mxid)
|
||||||
|
@ -263,37 +129,6 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
if (row) {
|
if (row) {
|
||||||
repliedToEventRow = row
|
repliedToEventRow = row
|
||||||
}
|
}
|
||||||
} else if (dUtils.isWebhookMessage(message) && message.embeds[0]?.author?.name?.endsWith("↩️")) {
|
|
||||||
// It could be a PluralKit emulated reply, let's see if it has a message link
|
|
||||||
const isEmulatedReplyToText = message.embeds[0].description?.startsWith("**[Reply to:]")
|
|
||||||
const isEmulatedReplyToAttachment = message.embeds[0].description?.startsWith("*[(click to see attachment")
|
|
||||||
if (isEmulatedReplyToText || isEmulatedReplyToAttachment) {
|
|
||||||
assert(message.embeds[0].description)
|
|
||||||
const match = message.embeds[0].description.match(/\/channels\/[0-9]*\/[0-9]*\/([0-9]{2,})/)
|
|
||||||
if (match) {
|
|
||||||
const row = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id").select("event_id", "room_id", "source").and("WHERE message_id = ? AND part = 0").get(match[1])
|
|
||||||
if (row) {
|
|
||||||
/*
|
|
||||||
we generate a partial referenced_message based on what PK provided. we don't need everything, since this will only be used for further message-to-event converting.
|
|
||||||
the following properties are necessary:
|
|
||||||
- content: used for generating the reply fallback
|
|
||||||
- author: used for the top of the reply fallback (only used for discord authors. for matrix authors, repliedToEventSenderMxid is set.)
|
|
||||||
*/
|
|
||||||
const emulatedMessageContent =
|
|
||||||
( isEmulatedReplyToAttachment ? "[Media]"
|
|
||||||
: message.embeds[0].description.replace(/^.*?\)\*\*\s*/, ""))
|
|
||||||
message.referenced_message = {
|
|
||||||
content: emulatedMessageContent,
|
|
||||||
// @ts-ignore
|
|
||||||
author: {
|
|
||||||
username: message.embeds[0].author.name.replace(/\s*↩️\s*$/, "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
message.embeds.shift()
|
|
||||||
repliedToEventRow = row
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (repliedToEventRow && repliedToEventRow.source === 0) { // reply was originally from Matrix
|
if (repliedToEventRow && repliedToEventRow.source === 0) { // reply was originally from Matrix
|
||||||
// Need to figure out who sent that event...
|
// Need to figure out who sent that event...
|
||||||
|
@ -303,63 +138,16 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
addMention(repliedToEventSenderMxid)
|
addMention(repliedToEventSenderMxid)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {Map<string, Promise<string>>} */
|
async function addTextEvent(content, msgtype, {scanMentions}) {
|
||||||
const viaMemo = new Map()
|
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()
|
||||||
* @param {string} roomID
|
|
||||||
* @returns {Promise<string>} string encoded URLSearchParams
|
|
||||||
*/
|
|
||||||
function getViaServersMemo(roomID) {
|
|
||||||
// @ts-ignore
|
|
||||||
if (viaMemo.has(roomID)) return viaMemo.get(roomID)
|
|
||||||
const promise = mxUtils.getViaServersQuery(roomID, di.api).then(p => p.toString())
|
|
||||||
viaMemo.set(roomID, promise)
|
|
||||||
return promise
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Translate Discord message links to Matrix event links.
|
|
||||||
* If OOYE has handled this message in the past, this is an instant database lookup.
|
|
||||||
* Otherwise, if OOYE knows the channel, this is a multi-second request to /timestamp_to_event to approximate.
|
|
||||||
* @param {string} content Partial or complete Discord message content
|
|
||||||
*/
|
|
||||||
async function transformContentMessageLinks(content) {
|
|
||||||
let offset = 0
|
|
||||||
for (const match of [...content.matchAll(/https:\/\/(?:ptb\.|canary\.|www\.)?discord(?:app)?\.com\/channels\/[0-9]+\/([0-9]+)\/([0-9]+)/g)]) {
|
|
||||||
assert(typeof match.index === "number")
|
|
||||||
const [_, channelID, messageID] = match
|
|
||||||
let result
|
|
||||||
|
|
||||||
const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
|
const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
|
||||||
if (roomID) {
|
if (eventID && roomID) {
|
||||||
const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get()
|
return `https://matrix.to/#/${roomID}/${eventID}`
|
||||||
const via = await getViaServersMemo(roomID)
|
|
||||||
if (eventID && roomID) {
|
|
||||||
result = `https://matrix.to/#/${roomID}/${eventID}?${via}`
|
|
||||||
} else {
|
|
||||||
const ts = dUtils.snowflakeToTimestampExact(messageID)
|
|
||||||
const {event_id} = await di.api.getEventForTimestamp(roomID, ts)
|
|
||||||
result = `https://matrix.to/#/${roomID}/${event_id}?${via}`
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
result = `${match[0]} [event is from another server]`
|
return `${whole} [event not found]`
|
||||||
}
|
}
|
||||||
|
})
|
||||||
content = content.slice(0, match.index + offset) + result + content.slice(match.index + match[0].length + offset)
|
|
||||||
offset += result.length - match[0].length
|
|
||||||
}
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Translate links and emojis and mentions and stuff. Give back the text and HTML so they can be combined into bigger events.
|
|
||||||
* @param {string} content Partial or complete Discord message content
|
|
||||||
* @param {any} customOptions
|
|
||||||
* @param {any} customParser
|
|
||||||
* @param {any} customHtmlOutput
|
|
||||||
*/
|
|
||||||
async function transformContent(content, customOptions = {}, customParser = null, customHtmlOutput = null) {
|
|
||||||
content = await transformContentMessageLinks(content)
|
|
||||||
|
|
||||||
// Handling emojis that we don't know about. The emoji has to be present in the DB for it to be picked up in the emoji markdown converter.
|
// 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.
|
// So we scan the message ahead of time for all its emojis and ensure they are in the DB.
|
||||||
|
@ -367,42 +155,39 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
await Promise.all(emojiMatches.map(match => {
|
await Promise.all(emojiMatches.map(match => {
|
||||||
const id = match[3]
|
const id = match[3]
|
||||||
const name = match[2]
|
const name = match[2]
|
||||||
const animated = !!match[1]
|
const animated = match[1]
|
||||||
return emojiToKey.emojiToKey({id, name, animated}) // Register the custom emoji if needed
|
return emojiToKey.emojiToKey({id, name, animated}) // Register the custom emoji if needed
|
||||||
}))
|
}))
|
||||||
|
|
||||||
async function transformParsedVia(parsed) {
|
let html = markdown.toHTML(content, {
|
||||||
for (const node of parsed) {
|
discordCallback: getDiscordParseCallbacks(message, true)
|
||||||
if (node.type === "discordChannel") {
|
|
||||||
node.row = select("channel_room", ["room_id", "name", "nick"], {channel_id: node.id}).get()
|
|
||||||
if (node.row?.room_id) {
|
|
||||||
node.via = await getViaServersMemo(node.row.room_id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Array.isArray(node.content)) {
|
|
||||||
await transformParsedVia(node.content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return parsed
|
|
||||||
}
|
|
||||||
|
|
||||||
let html = await markdown.toHtmlWithPostParser(content, transformParsedVia, {
|
|
||||||
discordCallback: getDiscordParseCallbacks(message, guild, true),
|
|
||||||
...customOptions
|
|
||||||
}, customParser, customHtmlOutput)
|
|
||||||
|
|
||||||
let body = await markdown.toHtmlWithPostParser(content, transformParsedVia, {
|
|
||||||
discordCallback: getDiscordParseCallbacks(message, guild, false),
|
|
||||||
discordOnly: true,
|
|
||||||
escapeHTML: false,
|
|
||||||
...customOptions
|
|
||||||
}, null, null)
|
}, null, null)
|
||||||
|
|
||||||
return {body, html}
|
let body = markdown.toHTML(content, {
|
||||||
}
|
discordCallback: getDiscordParseCallbacks(message, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// FIXME: What was the scanMentions parameter supposed to activate? It's unused.
|
|
||||||
async function addTextEvent(body, html, msgtype, {scanMentions}) {
|
|
||||||
// Star * prefix for fallback edits
|
// Star * prefix for fallback edits
|
||||||
if (options.includeEditFallbackStar) {
|
if (options.includeEditFallbackStar) {
|
||||||
body = "* " + body
|
body = "* " + body
|
||||||
|
@ -423,25 +208,20 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
if (repliedToEventRow?.source === 0 && repliedToEventSenderMxid) {
|
if (repliedToEventRow?.source === 0 && repliedToEventSenderMxid) {
|
||||||
const match = repliedToEventSenderMxid.match(/^@([^:]*)/)
|
const match = repliedToEventSenderMxid.match(/^@([^:]*)/)
|
||||||
assert(match)
|
assert(match)
|
||||||
repliedToDisplayName = message.referenced_message?.author.username || match[1] || "a Matrix user" // grab the localpart as the display name, whatever
|
repliedToDisplayName = match[1] || "a Matrix user" // grab the localpart as the display name, whatever
|
||||||
repliedToUserHtml = `<a href="https://matrix.to/#/${repliedToEventSenderMxid}">${repliedToDisplayName}</a>`
|
repliedToUserHtml = `<a href="https://matrix.to/#/${repliedToEventSenderMxid}">${repliedToDisplayName}</a>`
|
||||||
} else {
|
} else {
|
||||||
repliedToDisplayName = message.referenced_message?.author.global_name || message.referenced_message?.author.username || "a Discord user"
|
repliedToDisplayName = message.referenced_message?.author.global_name || message.referenced_message?.author.username || "a Discord user"
|
||||||
repliedToUserHtml = repliedToDisplayName
|
repliedToUserHtml = repliedToDisplayName
|
||||||
}
|
}
|
||||||
let repliedToContent = message.referenced_message?.content
|
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]"
|
if (repliedToContent == "") repliedToContent = "[Media]"
|
||||||
else if (!repliedToContent) repliedToContent = "[Replied-to message content wasn't provided by Discord]"
|
else if (!repliedToContent) repliedToContent = "[Replied-to message content wasn't provided by Discord]"
|
||||||
const repliedToHtml = markdown.toHTML(repliedToContent, {
|
const repliedToHtml = markdown.toHTML(repliedToContent, {
|
||||||
discordCallback: getDiscordParseCallbacks(message, guild, true)
|
discordCallback: getDiscordParseCallbacks(message, true)
|
||||||
}, null, null)
|
}, null, null)
|
||||||
const repliedToBody = markdown.toHTML(repliedToContent, {
|
const repliedToBody = markdown.toHTML(repliedToContent, {
|
||||||
discordCallback: getDiscordParseCallbacks(message, guild, false),
|
discordCallback: getDiscordParseCallbacks(message, false),
|
||||||
discordOnly: true,
|
discordOnly: true,
|
||||||
escapeHTML: false,
|
escapeHTML: false,
|
||||||
}, null, null)
|
}, null, null)
|
||||||
|
@ -480,127 +260,166 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
message.content = "changed the channel name to **" + message.content + "**"
|
message.content = "changed the channel name to **" + message.content + "**"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Text content appears first
|
||||||
if (message.content) {
|
if (message.content) {
|
||||||
// Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention.
|
await addTextEvent(message.content, msgtype, {scanMentions: true})
|
||||||
const matches = [...message.content.matchAll(/@ ?([a-z0-9._]+)\b/gi)]
|
|
||||||
if (matches.length && matches.some(m => m[1].match(/[a-z]/i) && m[1] !== "everyone" && m[1] !== "here")) {
|
|
||||||
const writtenMentionsText = matches.map(m => m[1].toLowerCase())
|
|
||||||
const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
|
|
||||||
assert(roomID)
|
|
||||||
const {joined} = await di.api.getJoinedMembers(roomID)
|
|
||||||
for (const [mxid, member] of Object.entries(joined)) {
|
|
||||||
if (!userRegex.some(rx => mxid.match(rx))) {
|
|
||||||
const localpart = mxid.match(/@([^:]*)/)
|
|
||||||
assert(localpart)
|
|
||||||
const displayName = member.display_name || localpart[1]
|
|
||||||
if (writtenMentionsText.includes(localpart[1].toLowerCase()) || writtenMentionsText.includes(displayName.toLowerCase())) addMention(mxid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Text content appears first
|
|
||||||
const {body, html} = await transformContent(message.content)
|
|
||||||
await addTextEvent(body, html, msgtype, {scanMentions: true})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then attachments
|
// Then attachments
|
||||||
if (message.attachments) {
|
const attachmentEvents = await Promise.all(message.attachments.map(async attachment => {
|
||||||
const attachmentEvents = await Promise.all(message.attachments.map(attachmentToEvent.bind(null, mentions)))
|
const emoji =
|
||||||
events.push(...attachmentEvents)
|
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
|
// Then embeds
|
||||||
for (const embed of message.embeds || []) {
|
for (const embed of message.embeds || []) {
|
||||||
if (embed.type === "image") {
|
if (embed.type === "image") {
|
||||||
continue // Matrix's own URL previews are fine for images.
|
continue // Matrix already does a fine enough job of providing image embeds.
|
||||||
}
|
|
||||||
|
|
||||||
if (embed.url?.startsWith("https://discord.com/")) {
|
|
||||||
continue // If discord creates an embed preview for a discord channel link, don't copy that embed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start building up a replica ("rep") of the embed in Discord-markdown format, which we will convert into both plaintext and formatted body at once
|
// Start building up a replica ("rep") of the embed in Discord-markdown format, which we will convert into both plaintext and formatted body at once
|
||||||
const rep = new mxUtils.MatrixStringBuilder()
|
let repParagraphs = []
|
||||||
|
const makeUrlTitle = (text, url) =>
|
||||||
|
( text && url ? `[**${text}**](${url})`
|
||||||
|
: text ? `**${text}**`
|
||||||
|
: url ? `**${url}**`
|
||||||
|
: "")
|
||||||
|
|
||||||
// Provider
|
|
||||||
if (embed.provider?.name) {
|
|
||||||
if (embed.provider.url) {
|
|
||||||
rep.addParagraph(`via ${embed.provider.name} ${embed.provider.url}`, tag`<sub><a href="${embed.provider.url}">${embed.provider.name}</a></sub>`)
|
|
||||||
} else {
|
|
||||||
rep.addParagraph(`via ${embed.provider.name}`, tag`<sub>${embed.provider.name}</sub>`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Author and URL into a paragraph
|
|
||||||
let authorNameText = embed.author?.name || ""
|
let authorNameText = embed.author?.name || ""
|
||||||
if (authorNameText && embed.author?.icon_url) authorNameText = `⏺️ ${authorNameText}` // using the emoji instead of an image
|
if (authorNameText && embed.author?.icon_url) authorNameText = `⏺️ ${authorNameText}` // not using the real image
|
||||||
if (authorNameText) {
|
let authorTitle = makeUrlTitle(authorNameText, embed.author?.url)
|
||||||
if (embed.author?.url) {
|
if (authorTitle) repParagraphs.push(authorTitle)
|
||||||
const authorURL = await transformContentMessageLinks(embed.author.url)
|
|
||||||
rep.addParagraph(`## ${authorNameText} ${authorURL}`, tag`<strong><a href="${authorURL}">${authorNameText}</a></strong>`)
|
|
||||||
} else {
|
|
||||||
rep.addParagraph(`## ${authorNameText}`, tag`<strong>${authorNameText}</strong>`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Title and URL into a paragraph
|
let title = makeUrlTitle(embed.title, embed.url)
|
||||||
if (embed.title) {
|
if (title) repParagraphs.push(title)
|
||||||
const {body, html} = await transformContent(embed.title, {}, embedTitleParser, markdown.htmlOutput)
|
|
||||||
if (embed.url) {
|
|
||||||
rep.addParagraph(`## ${body} ${embed.url}`, tag`<strong><a href="${embed.url}">$${html}</a></strong>`)
|
|
||||||
} else {
|
|
||||||
rep.addParagraph(`## ${body}`, `<strong>${html}</strong>`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let embedTypeShouldShowDescription = embed.type !== "video" // Discord doesn't display descriptions for videos
|
if (embed.image?.url) repParagraphs.push(`📸 ${embed.image.url}`)
|
||||||
if (embed.provider?.name === "YouTube") embedTypeShouldShowDescription = true // But I personally like showing the descriptions for YouTube videos specifically
|
if (embed.video?.url) repParagraphs.push(`🎞️ ${embed.video.url}`)
|
||||||
if (embed.description && embedTypeShouldShowDescription) {
|
|
||||||
const {body, html} = await transformContent(embed.description)
|
|
||||||
rep.addParagraph(body, html)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (embed.description) repParagraphs.push(embed.description)
|
||||||
for (const field of embed.fields || []) {
|
for (const field of embed.fields || []) {
|
||||||
const name = field.name.match(/^[\s]*$/) ? {body: "", html: ""} : await transformContent(field.name, {}, embedTitleParser, markdown.htmlOutput)
|
repParagraphs.push(`**${field.name}**\n${field.value}`)
|
||||||
const value = await transformContent(field.value)
|
|
||||||
const fieldRep = new mxUtils.MatrixStringBuilder()
|
|
||||||
.addLine(`### ${name.body}`, `<strong>${name.html}</strong>`, name.body)
|
|
||||||
.addLine(value.body, value.html, !!value.body)
|
|
||||||
rep.addParagraph(fieldRep.get().body, fieldRep.get().formatted_body)
|
|
||||||
}
|
}
|
||||||
|
if (embed.footer?.text) repParagraphs.push(`— ${embed.footer.text}`)
|
||||||
let chosenImage = embed.image?.url
|
const repContent = repParagraphs.join("\n\n")
|
||||||
// the thumbnail seems to be used for "article" type but displayed big at the bottom by discord
|
const repContentQuoted = repContent.split("\n").map(l => "> " + l).join("\n")
|
||||||
if (embed.type === "article" && embed.thumbnail?.url && !chosenImage) chosenImage = embed.thumbnail.url
|
|
||||||
if (chosenImage) rep.addParagraph(`📸 ${chosenImage}`)
|
|
||||||
|
|
||||||
if (embed.video?.url) rep.addParagraph(`🎞️ ${embed.video.url}`)
|
|
||||||
|
|
||||||
if (embed.footer?.text) rep.addLine(`— ${embed.footer.text}`, tag`— ${embed.footer.text}`)
|
|
||||||
let {body, formatted_body: html} = rep.get()
|
|
||||||
body = body.split("\n").map(l => "| " + l).join("\n")
|
|
||||||
html = `<blockquote>${html}</blockquote>`
|
|
||||||
|
|
||||||
// Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person
|
// Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person
|
||||||
await addTextEvent(body, html, "m.notice", {scanMentions: false})
|
await addTextEvent(repContentQuoted, "m.notice", {scanMentions: false})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then stickers
|
// Then stickers
|
||||||
if (message.sticker_items) {
|
if (message.sticker_items) {
|
||||||
const stickerEvents = await Promise.all(message.sticker_items.map(async stickerItem => {
|
const stickerEvents = await Promise.all(message.sticker_items.map(async stickerItem => {
|
||||||
const format = file.stickerFormat.get(stickerItem.format_type)
|
const format = file.stickerFormat.get(stickerItem.format_type)
|
||||||
assert(format?.mime)
|
|
||||||
if (format?.mime === "lottie") {
|
if (format?.mime === "lottie") {
|
||||||
const {mxc_url, info} = await lottie.convert(stickerItem)
|
try {
|
||||||
return {
|
const {mxc_url, info} = await lottie.convert(stickerItem)
|
||||||
$type: "m.sticker",
|
return {
|
||||||
"m.mentions": mentions,
|
$type: "m.sticker",
|
||||||
body: stickerItem.name,
|
"m.mentions": mentions,
|
||||||
info,
|
body: stickerItem.name,
|
||||||
url: mxc_url
|
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 {
|
} else if (format?.mime) {
|
||||||
let body = stickerItem.name
|
let body = stickerItem.name
|
||||||
const sticker = guild.stickers.find(sticker => sticker.id === stickerItem.id)
|
const sticker = guild.stickers.find(sticker => sticker.id === stickerItem.id)
|
||||||
if (sticker && sticker.description) body += ` - ${sticker.description}`
|
if (sticker && sticker.description) body += ` - ${sticker.description}`
|
||||||
|
@ -614,6 +433,12 @@ async function messageToEvent(message, guild, options = {}, di) {
|
||||||
url: await file.uploadDiscordFileToMxc(file.sticker(stickerItem))
|
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)
|
events.push(...stickerEvents)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,134 +0,0 @@
|
||||||
const {test} = require("supertape")
|
|
||||||
const {messageToEvent} = require("./message-to-event")
|
|
||||||
const data = require("../../test/data")
|
|
||||||
const Ty = require("../../types")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} roomID
|
|
||||||
* @param {string} eventID
|
|
||||||
* @returns {(roomID: string, eventID: string) => Promise<Ty.Event.Outer<Ty.Event.M_Room_Message>>}
|
|
||||||
*/
|
|
||||||
function mockGetEvent(t, roomID_in, eventID_in, outer) {
|
|
||||||
return async function(roomID, eventID) {
|
|
||||||
t.equal(roomID, roomID_in)
|
|
||||||
t.equal(eventID, eventID_in)
|
|
||||||
return new Promise(resolve => {
|
|
||||||
setTimeout(() => {
|
|
||||||
resolve({
|
|
||||||
event_id: eventID_in,
|
|
||||||
room_id: roomID_in,
|
|
||||||
origin_server_ts: 1680000000000,
|
|
||||||
unsigned: {
|
|
||||||
age: 2245,
|
|
||||||
transaction_id: "$local.whatever"
|
|
||||||
},
|
|
||||||
...outer
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("message2event: pk reply to matrix is converted to native matrix reply", async t => {
|
|
||||||
const events = await messageToEvent(data.pk_message.pk_reply_to_matrix, {}, {}, {
|
|
||||||
api: {
|
|
||||||
getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU", {
|
|
||||||
type: "m.room.message",
|
|
||||||
sender: "@cadence:cadence.moe",
|
|
||||||
content: {
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "now for my next experiment:"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
"m.mentions": {
|
|
||||||
user_ids: [
|
|
||||||
"@cadence:cadence.moe"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "> cadence [they]: now for my next experiment:\n\nthis is a reply",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: '<mx-reply><blockquote><a href="https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU">In reply to</a> <a href="https://matrix.to/#/@cadence:cadence.moe">cadence [they]</a><br>'
|
|
||||||
+ "now for my next experiment:</blockquote></mx-reply>"
|
|
||||||
+ "this is a reply",
|
|
||||||
"m.relates_to": {
|
|
||||||
"m.in_reply_to": {
|
|
||||||
event_id: "$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event: pk reply to discord is converted to native matrix reply", async t => {
|
|
||||||
const events = await messageToEvent(data.pk_message.pk_reply_to_discord, {}, {}, {
|
|
||||||
api: {
|
|
||||||
getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU", {
|
|
||||||
type: "m.room.message",
|
|
||||||
sender: "@_ooye_.wing.:cadence.moe",
|
|
||||||
content: {
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "some text"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.text",
|
|
||||||
"m.mentions": {},
|
|
||||||
body: "> wing: some text\n\nthis is a reply",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: '<mx-reply><blockquote><a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA">In reply to</a> wing<br>'
|
|
||||||
+ "some text</blockquote></mx-reply>"
|
|
||||||
+ "this is a reply",
|
|
||||||
"m.relates_to": {
|
|
||||||
"m.in_reply_to": {
|
|
||||||
event_id: "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event: pk reply to matrix attachment is converted to native matrix reply", async t => {
|
|
||||||
const events = await messageToEvent(data.pk_message.pk_reply_to_matrix_attachment, {}, {}, {
|
|
||||||
api: {
|
|
||||||
getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$OEEK-Wam2FTh6J-6kVnnJ6KnLA_lLRnLTHatKKL62-Y", {
|
|
||||||
sender: "@ampflower:matrix.org",
|
|
||||||
type: "m.room.message",
|
|
||||||
content: {
|
|
||||||
body: "catnod.gif",
|
|
||||||
filename: "catnod.gif",
|
|
||||||
info: {
|
|
||||||
h: 128,
|
|
||||||
mimetype: "image/gif",
|
|
||||||
size: 20816,
|
|
||||||
w: 128
|
|
||||||
},
|
|
||||||
msgtype: "m.image",
|
|
||||||
url: "mxc://matrix.org/jtzXIawXCkFIHSsMUNsKkUJX"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.text",
|
|
||||||
"m.mentions": {
|
|
||||||
user_ids: ["@ampflower:matrix.org"]
|
|
||||||
},
|
|
||||||
body: "> Ampflower 🌺: [Media]\n\nCat nod",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: '<mx-reply><blockquote><a href="https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$OEEK-Wam2FTh6J-6kVnnJ6KnLA_lLRnLTHatKKL62-Y">In reply to</a> <a href="https://matrix.to/#/@ampflower:matrix.org">Ampflower 🌺</a><br>'
|
|
||||||
+ "[Media]</blockquote></mx-reply>"
|
|
||||||
+ "Cat nod",
|
|
||||||
"m.relates_to": {
|
|
||||||
"m.in_reply_to": {
|
|
||||||
event_id: "$OEEK-Wam2FTh6J-6kVnnJ6KnLA_lLRnLTHatKKL62-Y"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}])
|
|
||||||
})
|
|
|
@ -62,183 +62,26 @@ test("message2event: simple user mention", async t => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test("message2event: simple room mention", async t => {
|
test("message2event: simple room mention", async t => {
|
||||||
let called = 0
|
const events = await messageToEvent(data.message.simple_room_mention, data.guild.general, {})
|
||||||
const events = await messageToEvent(data.message.simple_room_mention, data.guild.general, {}, {
|
|
||||||
api: {
|
|
||||||
async getStateEvent(roomID, type, key) {
|
|
||||||
called++
|
|
||||||
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
|
||||||
t.equal(type, "m.room.power_levels")
|
|
||||||
t.equal(key, "")
|
|
||||||
return {
|
|
||||||
users: {
|
|
||||||
"@_ooye_bot:cadence.moe": 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async getJoinedMembers(roomID) {
|
|
||||||
called++
|
|
||||||
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
|
||||||
return {
|
|
||||||
joined: {
|
|
||||||
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
|
|
||||||
"@user:matrix.org": {display_name: null, avatar_url: null}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.deepEqual(events, [{
|
t.deepEqual(events, [{
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
"m.mentions": {},
|
"m.mentions": {},
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: "#main",
|
body: "#main",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: '<a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&via=matrix.org">#main</a>'
|
formatted_body: '<a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe">#main</a>'
|
||||||
}])
|
|
||||||
t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event: unknown room mention", async t => {
|
|
||||||
const events = await messageToEvent(data.message.unknown_room_mention, data.guild.general, {})
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
"m.mentions": {},
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "#unknown-channel [channel from an unbridged server]"
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event: unbridged room mention", async t => {
|
|
||||||
const events = await messageToEvent(data.message.unbridged_room_mention, data.guild.general, {})
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
"m.mentions": {},
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "#bad-boots-prison [channel not bridged]"
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event: simple role mentions", async t => {
|
|
||||||
const events = await messageToEvent(data.message.simple_role_mentions, data.guild.general, {})
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
"m.mentions": {},
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "I'm just @!!DLCS!!: testing a few role pings @Master Wonder Mage: don't mind me",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: `I'm just <font color="#a901ff">@!!DLCS!!</font> testing a few role pings <span data-mx-color="#ffffff" data-mx-bg-color="#414eef">@Master Wonder Mage</span> don't mind me`
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event: manually constructed unknown roles should use fallback", async t => {
|
|
||||||
const events = await messageToEvent(data.message.unknown_role, data.guild.general, {})
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
"m.mentions": {},
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "I'm just @&4 testing a few role pings <@&B> don't mind me",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: "I'm just @&4 testing a few role pings <@&B> don't mind me"
|
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("message2event: simple message link", async t => {
|
test("message2event: simple message link", async t => {
|
||||||
let called = 0
|
const events = await messageToEvent(data.message.simple_message_link, data.guild.general, {})
|
||||||
const events = await messageToEvent(data.message.simple_message_link, data.guild.general, {}, {
|
|
||||||
api: {
|
|
||||||
async getStateEvent(roomID, type, key) {
|
|
||||||
called++
|
|
||||||
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
|
||||||
t.equal(type, "m.room.power_levels")
|
|
||||||
t.equal(key, "")
|
|
||||||
return {
|
|
||||||
users: {
|
|
||||||
"@_ooye_bot:cadence.moe": 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async getJoinedMembers(roomID) {
|
|
||||||
called++
|
|
||||||
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
|
||||||
return {
|
|
||||||
joined: {
|
|
||||||
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
|
|
||||||
"@user:super.invalid": {display_name: null, avatar_url: null}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.deepEqual(events, [{
|
t.deepEqual(events, [{
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
"m.mentions": {},
|
"m.mentions": {},
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: "https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg?via=cadence.moe&via=super.invalid",
|
body: "https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: '<a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg?via=cadence.moe&via=super.invalid">https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg?via=cadence.moe&via=super.invalid</a>'
|
formatted_body: '<a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg">https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg</a>'
|
||||||
}])
|
|
||||||
t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event: message link that OOYE doesn't know about", async t => {
|
|
||||||
let called = 0
|
|
||||||
const events = await messageToEvent(data.message.message_link_to_before_ooye, data.guild.general, {}, {
|
|
||||||
api: {
|
|
||||||
async getEventForTimestamp(roomID, ts) {
|
|
||||||
called++
|
|
||||||
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
|
||||||
return {
|
|
||||||
event_id: "$E8IQDGFqYzOU7BwY5Z74Bg-cwaU9OthXSroaWtgYc7U",
|
|
||||||
origin_server_ts: 1613287812754
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async getStateEvent(roomID, type, key) { // for ?via calculation
|
|
||||||
called++
|
|
||||||
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
|
||||||
t.equal(type, "m.room.power_levels")
|
|
||||||
t.equal(key, "")
|
|
||||||
return {
|
|
||||||
users: {
|
|
||||||
"@_ooye_bot:cadence.moe": 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async getJoinedMembers(roomID) { // for ?via calculation
|
|
||||||
called++
|
|
||||||
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
|
|
||||||
return {
|
|
||||||
joined: {
|
|
||||||
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
|
|
||||||
"@user:matrix.org": {display_name: null, avatar_url: null}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
"m.mentions": {},
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "Me: I'll scroll up to find a certain message I'll send\n_scrolls up and clicks message links for god knows how long_\n_completely forgets what they were looking for and simply begins scrolling up to find some fun moments_\n_stumbles upon:_ "
|
|
||||||
+ "https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$E8IQDGFqYzOU7BwY5Z74Bg-cwaU9OthXSroaWtgYc7U?via=cadence.moe&via=matrix.org",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: "Me: I'll scroll up to find a certain message I'll send<br><em>scrolls up and clicks message links for god knows how long</em><br><em>completely forgets what they were looking for and simply begins scrolling up to find some fun moments</em><br><em>stumbles upon:</em> "
|
|
||||||
+ '<a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$E8IQDGFqYzOU7BwY5Z74Bg-cwaU9OthXSroaWtgYc7U?via=cadence.moe&via=matrix.org">https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$E8IQDGFqYzOU7BwY5Z74Bg-cwaU9OthXSroaWtgYc7U?via=cadence.moe&via=matrix.org</a>'
|
|
||||||
}])
|
|
||||||
t.equal(called, 3, "getEventForTimestamp, getStateEvent, and getJoinedMembers should be called once each")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event: message link from another server", async t => {
|
|
||||||
const events = await messageToEvent(data.message.message_link_from_another_server, data.guild.general)
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
"m.mentions": {},
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "Neither of these servers are known to OOYE: https://discord.com/channels/111/222/333 [event is from another server] https://canary.discordapp.com/channels/444/555/666 [event is from another server]",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: 'Neither of these servers are known to OOYE: <a href="https://discord.com/channels/111/222/333">https://discord.com/channels/111/222/333</a> [event is from another server]'
|
|
||||||
+ ' <a href="https://canary.discordapp.com/channels/444/555/666">https://canary.discordapp.com/channels/444/555/666</a> [event is from another server]'
|
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -269,7 +112,7 @@ test("message2event: spoiler attachment", async t => {
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: "📄 Uploaded SPOILER file: https://cdn.discordapp.com/attachments/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)",
|
body: "📄 Uploaded SPOILER file: https://cdn.discordapp.com/attachments/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: "<blockquote>📄 Uploaded SPOILER file: <a href=\"https://cdn.discordapp.com/attachments/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci\">https://cdn.discordapp.com/attachments/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci</a> (74 KB)</blockquote>"
|
formatted_body: "<blockquote>📄 Uploaded SPOILER file: <span data-mx-spoiler><a href=\"https://cdn.discordapp.com/attachments/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci\">View</a></span> (74 KB)</blockquote>"
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -307,21 +150,6 @@ test("message2event: stickers", async t => {
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("message2event: lottie sticker", async t => {
|
|
||||||
const events = await messageToEvent(data.message.lottie_sticker, data.guild.general, {})
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.sticker",
|
|
||||||
"m.mentions": {},
|
|
||||||
body: "8",
|
|
||||||
info: {
|
|
||||||
mimetype: "image/png",
|
|
||||||
w: 160,
|
|
||||||
h: 160
|
|
||||||
},
|
|
||||||
url: "mxc://cadence.moe/ZtvvVbwMIdUZeovWVyGVFCeR"
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event: skull webp attachment with content", async t => {
|
test("message2event: skull webp attachment with content", async t => {
|
||||||
const events = await messageToEvent(data.message.skull_webp_attachment_with_content, data.guild.general, {})
|
const events = await messageToEvent(data.message.skull_webp_attachment_with_content, data.guild.general, {})
|
||||||
t.deepEqual(events, [{
|
t.deepEqual(events, [{
|
||||||
|
@ -445,120 +273,6 @@ test("message2event: simple reply to matrix user, reply fallbacks disabled", asy
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("message2event: reply with a video", async t => {
|
|
||||||
const events = await messageToEvent(data.message.reply_with_video, data.guild.general, {
|
|
||||||
api: {
|
|
||||||
getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$7tJoMw1h44n2gxgLUE1T_YinGrLbK0x-TDY1z6M7GBw", {
|
|
||||||
type: "m.room.message",
|
|
||||||
content: {
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: 'deadpicord "extremity you woke up at 4 am"'
|
|
||||||
},
|
|
||||||
sender: "@_ooye_extremity:cadence.moe"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.video",
|
|
||||||
body: "Ins_1960637570.mp4",
|
|
||||||
filename: "Ins_1960637570.mp4",
|
|
||||||
url: "mxc://cadence.moe/kMqLycqMURhVpwleWkmASpnU",
|
|
||||||
external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1197621094786531358/Ins_1960637570.mp4?ex=65bbee8f&is=65a9798f&hm=ae14f7824c3d526c5e11c162e012e1ee405fd5776e1e9302ed80ccd86503cfda&",
|
|
||||||
info: {
|
|
||||||
h: 854,
|
|
||||||
mimetype: "video/mp4",
|
|
||||||
size: 860559,
|
|
||||||
w: 480,
|
|
||||||
},
|
|
||||||
"m.mentions": {},
|
|
||||||
"m.relates_to": {
|
|
||||||
"m.in_reply_to": {
|
|
||||||
event_id: "$7tJoMw1h44n2gxgLUE1T_YinGrLbK0x-TDY1z6M7GBw"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event: voice message", async t => {
|
|
||||||
const events = await messageToEvent(data.message.voice_message)
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
body: "voice-message.ogg",
|
|
||||||
external_url: "https://cdn.discordapp.com/attachments/1099031887500034088/1112476845502365786/voice-message.ogg?ex=65c92d4c&is=65b6b84c&hm=0654bab5027474cbe23875954fa117cf44d8914c144cd151879590fa1baf8b1c&",
|
|
||||||
filename: "voice-message.ogg",
|
|
||||||
info: {
|
|
||||||
duration: 3960.0000381469727,
|
|
||||||
mimetype: "audio/ogg",
|
|
||||||
size: 10584,
|
|
||||||
},
|
|
||||||
"m.mentions": {},
|
|
||||||
msgtype: "m.audio",
|
|
||||||
url: "mxc://cadence.moe/MRRPDggXQMYkrUjTpxQbmcxB"
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event: misc file", async t => {
|
|
||||||
const events = await messageToEvent(data.message.misc_file)
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "final final final revised draft",
|
|
||||||
"m.mentions": {}
|
|
||||||
}, {
|
|
||||||
$type: "m.room.message",
|
|
||||||
body: "the.yml",
|
|
||||||
external_url: "https://cdn.discordapp.com/attachments/122155380120748034/1174514575220158545/the.yml?ex=65cd6270&is=65baed70&hm=8c5f1b571784e3c7f99628492298815884e351ae0dc7c2ae40dd22d97caf27d9&",
|
|
||||||
filename: "the.yml",
|
|
||||||
info: {
|
|
||||||
mimetype: "text/plain; charset=utf-8",
|
|
||||||
size: 2274
|
|
||||||
},
|
|
||||||
"m.mentions": {},
|
|
||||||
msgtype: "m.file",
|
|
||||||
url: "mxc://cadence.moe/HnQIYQmmlIKwOQsbFsIGpzPP"
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event: simple reply in thread to a matrix user's reply", async t => {
|
|
||||||
const events = await messageToEvent(data.message.simple_reply_to_reply_in_thread, data.guild.general, {}, {
|
|
||||||
api: {
|
|
||||||
getEvent: mockGetEvent(t, "!FuDZhlOAtqswlyxzeR:cadence.moe", "$nUM-ABBF8KdnvrhXwLlYAE9dgDl_tskOvvcNIBrtsVo", {
|
|
||||||
type: "m.room.message",
|
|
||||||
sender: "@cadence:cadence.moe",
|
|
||||||
content: {
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "> <@_ooye_cadence:cadence.moe> So what I'm wondering is about replies.\n\nWhat about them?",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: "<mx-reply><blockquote><a href=\"https://matrix.to/#/!FuDZhlOAtqswlyxzeR:cadence.moe/$fWQT8uOrzLzAXNVXz88VkGx7Oo724iS5uD8Qn5KUy9w?via=cadence.moe\">In reply to</a> <a href=\"https://matrix.to/#/@_ooye_cadence:cadence.moe\">@_ooye_cadence:cadence.moe</a><br>So what I'm wondering is about replies.</blockquote></mx-reply>What about them?",
|
|
||||||
"m.relates_to": {
|
|
||||||
"m.in_reply_to": {
|
|
||||||
event_id: "$fWQT8uOrzLzAXNVXz88VkGx7Oo724iS5uD8Qn5KUy9w"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
event_id: "$nUM-ABBF8KdnvrhXwLlYAE9dgDl_tskOvvcNIBrtsVo",
|
|
||||||
room_id: "!FuDZhlOAtqswlyxzeR:cadence.moe"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
"m.relates_to": {
|
|
||||||
"m.in_reply_to": {
|
|
||||||
event_id: "$nUM-ABBF8KdnvrhXwLlYAE9dgDl_tskOvvcNIBrtsVo"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"m.mentions": {
|
|
||||||
user_ids: ["@cadence:cadence.moe"]
|
|
||||||
},
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "> cadence [they]: What about them?\n\nWell, they don't seem to...",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: "<mx-reply><blockquote><a href=\"https://matrix.to/#/!FuDZhlOAtqswlyxzeR:cadence.moe/$nUM-ABBF8KdnvrhXwLlYAE9dgDl_tskOvvcNIBrtsVo\">In reply to</a> <a href=\"https://matrix.to/#/@cadence:cadence.moe\">cadence [they]</a><br>What about them?</blockquote></mx-reply>Well, they don't seem to...",
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event: simple written @mention for matrix user", async t => {
|
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, {}, {
|
const events = await messageToEvent(data.message.simple_written_at_mention_for_matrix, data.guild.general, {}, {
|
||||||
api: {
|
api: {
|
||||||
|
@ -720,7 +434,6 @@ test("message2event: thread start message reference", async t => {
|
||||||
})
|
})
|
||||||
t.deepEqual(events, [{
|
t.deepEqual(events, [{
|
||||||
$type: "m.room.message",
|
$type: "m.room.message",
|
||||||
$sender: null,
|
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: "layer 4",
|
body: "layer 4",
|
||||||
"m.mentions": {}
|
"m.mentions": {}
|
||||||
|
@ -789,63 +502,3 @@ test("message2event: crossposted announcements say where they are crossposted fr
|
||||||
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"
|
formatted_body: "🔀 <strong>Chewey Bot Official Server #announcements</strong><br>All text based commands are now inactive on Chewey Bot<br>To continue using commands you'll need to use them as slash commands"
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("message2event: @everyone", async t => {
|
|
||||||
const events = await messageToEvent(data.message_mention_everyone.at_everyone)
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "@room",
|
|
||||||
"m.mentions": {
|
|
||||||
room: true
|
|
||||||
}
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event: @here", async t => {
|
|
||||||
const events = await messageToEvent(data.message_mention_everyone.at_here)
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "@room",
|
|
||||||
"m.mentions": {
|
|
||||||
room: true
|
|
||||||
}
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event: @everyone without permission", async t => {
|
|
||||||
const events = await messageToEvent(data.message_mention_everyone.at_everyone_without_permission)
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "@everyone <-- this is testing that it DOESN'T mention. if this mentions everyone then my apologies.",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: "@everyone <-- this is testing that it DOESN'T mention. if this mentions everyone then my apologies.",
|
|
||||||
"m.mentions": {}
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event: @here without permission", async t => {
|
|
||||||
const events = await messageToEvent(data.message_mention_everyone.at_here_without_permission)
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "@here <-- this is testing that it DOESN'T mention. if this mentions people then my apologies.",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: "@here <-- this is testing that it DOESN'T mention. if this mentions people then my apologies.",
|
|
||||||
"m.mentions": {}
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("message2event: @everyone within a link", async t => {
|
|
||||||
const events = await messageToEvent(data.message_mention_everyone.at_everyone_within_link)
|
|
||||||
t.deepEqual(events, [{
|
|
||||||
$type: "m.room.message",
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "https://github.com/@everyone",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: `<a href="https://github.com/@everyone">https://github.com/@everyone</a>`,
|
|
||||||
"m.mentions": {}
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
|
@ -1,19 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const {select} = require("../../passthrough")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {import("discord-api-types/v10").RESTGetAPIChannelPinsResult} pins
|
|
||||||
*/
|
|
||||||
function pinsToList(pins) {
|
|
||||||
/** @type {string[]} */
|
|
||||||
const result = []
|
|
||||||
for (const message of pins) {
|
|
||||||
const eventID = select("event_message", "event_id", {message_id: message.id, part: 0}).pluck().get()
|
|
||||||
if (eventID) result.push(eventID)
|
|
||||||
}
|
|
||||||
result.reverse()
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.pinsToList = pinsToList
|
|
|
@ -1,12 +0,0 @@
|
||||||
const {test} = require("supertape")
|
|
||||||
const data = require("../../test/data")
|
|
||||||
const {pinsToList} = require("./pins-to-list")
|
|
||||||
|
|
||||||
test("pins2list: converts known IDs, ignores unknown IDs", t => {
|
|
||||||
const result = pinsToList(data.pins.faked)
|
|
||||||
t.deepEqual(result, [
|
|
||||||
"$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4",
|
|
||||||
"$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA",
|
|
||||||
"$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg"
|
|
||||||
])
|
|
||||||
})
|
|
|
@ -1,88 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const Ty = require("../../types")
|
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
|
||||||
const {discord, sync, select} = passthrough
|
|
||||||
/** @type {import("../../m2d/converters/utils")} */
|
|
||||||
const utils = sync.require("../../m2d/converters/utils")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef ReactionRemoveRequest
|
|
||||||
* @prop {string} eventID
|
|
||||||
* @prop {string | null} mxid
|
|
||||||
* @prop {bigint} [hash]
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {DiscordTypes.GatewayMessageReactionRemoveDispatchData} data
|
|
||||||
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>[]} reactions
|
|
||||||
* @param {string} key
|
|
||||||
*/
|
|
||||||
function removeReaction(data, reactions, key) {
|
|
||||||
/** @type {ReactionRemoveRequest[]} */
|
|
||||||
const removals = []
|
|
||||||
|
|
||||||
const wantToRemoveMatrixReaction = data.user_id === discord.application.id
|
|
||||||
for (const event of reactions) {
|
|
||||||
const eventID = event.event_id
|
|
||||||
if (event.content["m.relates_to"].key === key) {
|
|
||||||
const lookingAtMatrixReaction = !utils.eventSenderIsFromDiscord(event.sender)
|
|
||||||
if (lookingAtMatrixReaction && wantToRemoveMatrixReaction) {
|
|
||||||
// We are removing a Matrix user's reaction, so we need to redact from the correct user ID (not @_ooye_matrix_bridge).
|
|
||||||
// Even though the bridge bot only reacted once on Discord-side, multiple Matrix users may have
|
|
||||||
// reacted on Matrix-side. Semantically, we want to remove the reaction from EVERY Matrix user.
|
|
||||||
// Also need to clean up the database.
|
|
||||||
const hash = utils.getEventIDHash(event.event_id)
|
|
||||||
removals.push({eventID, mxid: null, hash})
|
|
||||||
}
|
|
||||||
if (!lookingAtMatrixReaction && !wantToRemoveMatrixReaction) {
|
|
||||||
// We are removing a Discord user's reaction, so we just make the sim user remove it.
|
|
||||||
const mxid = select("sim", "mxid", {user_id: data.user_id}).pluck().get()
|
|
||||||
if (mxid === event.sender) {
|
|
||||||
removals.push({eventID, mxid})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return removals
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {DiscordTypes.GatewayMessageReactionRemoveEmojiDispatchData} data
|
|
||||||
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>[]} relations
|
|
||||||
* @param {string} key
|
|
||||||
*/
|
|
||||||
function removeEmojiReaction(data, relations, key) {
|
|
||||||
/** @type {ReactionRemoveRequest[]} */
|
|
||||||
const removals = []
|
|
||||||
|
|
||||||
for (const event of relations) {
|
|
||||||
const eventID = event.event_id
|
|
||||||
if (event.content["m.relates_to"].key === key) {
|
|
||||||
const mxid = utils.eventSenderIsFromDiscord(event.sender) ? event.sender : null
|
|
||||||
removals.push({eventID, mxid})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return removals
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {DiscordTypes.GatewayMessageReactionRemoveAllDispatchData} data
|
|
||||||
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>[]} relations
|
|
||||||
* @returns {ReactionRemoveRequest[]}
|
|
||||||
*/
|
|
||||||
function removeAllReactions(data, relations) {
|
|
||||||
return relations.map(event => {
|
|
||||||
const eventID = event.event_id
|
|
||||||
const mxid = utils.eventSenderIsFromDiscord(event.sender) ? event.sender : null
|
|
||||||
return {eventID, mxid}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.removeReaction = removeReaction
|
|
||||||
module.exports.removeEmojiReaction = removeEmojiReaction
|
|
||||||
module.exports.removeAllReactions = removeAllReactions
|
|
|
@ -1,170 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const {test} = require("supertape")
|
|
||||||
const removeReaction = require("./remove-reaction")
|
|
||||||
|
|
||||||
const BRIDGE_ID = "684280192553844747"
|
|
||||||
|
|
||||||
function fakeSpecificReactionRemoval(userID, emoji, emojiID) {
|
|
||||||
return {
|
|
||||||
channel_id: "THE_CHANNEL",
|
|
||||||
message_id: "THE_MESSAGE",
|
|
||||||
user_id: userID,
|
|
||||||
emoji: {id: emojiID, name: emoji}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fakeEmojiReactionRemoval(emoji, emojiID) {
|
|
||||||
return {
|
|
||||||
channel_id: "THE_CHANNEL",
|
|
||||||
message_id: "THE_MESSAGE",
|
|
||||||
emoji: {id: emojiID, name: emoji}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fakeAllReactionRemoval() {
|
|
||||||
return {
|
|
||||||
channel_id: "THE_CHANNEL",
|
|
||||||
message_id: "THE_MESSAGE"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fakeReactions(reactions) {
|
|
||||||
return reactions.map(({sender, key}, i) => ({
|
|
||||||
content: {
|
|
||||||
"m.relates_to": {
|
|
||||||
rel_type: "m.annotation",
|
|
||||||
event_id: "$message",
|
|
||||||
key
|
|
||||||
}
|
|
||||||
},
|
|
||||||
event_id: `$reaction_${i}`,
|
|
||||||
sender,
|
|
||||||
type: "m.reaction",
|
|
||||||
origin_server_ts: 0,
|
|
||||||
room_id: "!THE_ROOM",
|
|
||||||
unsigned: null
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
test("remove reaction: a specific discord user's reaction is removed", t => {
|
|
||||||
const removals = removeReaction.removeReaction(
|
|
||||||
fakeSpecificReactionRemoval("820865262526005258", "🐈", null),
|
|
||||||
fakeReactions([{key: "🐈", sender: "@_ooye_crunch_god:cadence.moe"}]),
|
|
||||||
"🐈"
|
|
||||||
)
|
|
||||||
t.deepEqual(removals, [{
|
|
||||||
eventID: "$reaction_0",
|
|
||||||
mxid: "@_ooye_crunch_god:cadence.moe"
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("remove reaction: a specific matrix user's reaction is removed", t => {
|
|
||||||
const removals = removeReaction.removeReaction(
|
|
||||||
fakeSpecificReactionRemoval(BRIDGE_ID, "🐈", null),
|
|
||||||
fakeReactions([{key: "🐈", sender: "@cadence:cadence.moe"}]),
|
|
||||||
"🐈"
|
|
||||||
)
|
|
||||||
t.deepEqual(removals, [{
|
|
||||||
eventID: "$reaction_0",
|
|
||||||
mxid: null,
|
|
||||||
hash: 2842343637291700751n
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("remove reaction: a specific discord user's reaction is removed when there are multiple reactions", t => {
|
|
||||||
const removals = removeReaction.removeReaction(
|
|
||||||
fakeSpecificReactionRemoval("820865262526005258", "🐈", null),
|
|
||||||
fakeReactions([
|
|
||||||
{key: "🐈⬛", sender: "@_ooye_crunch_god:cadence.moe"},
|
|
||||||
{key: "🐈", sender: "@_ooye_crunch_god:cadence.moe"},
|
|
||||||
{key: "🐈", sender: "@_ooye_extremity:cadence.moe"},
|
|
||||||
{key: "🐈", sender: "@cadence:cadence.moe"},
|
|
||||||
{key: "🐈", sender: "@zoe:cadence.moe"}
|
|
||||||
]),
|
|
||||||
"🐈"
|
|
||||||
)
|
|
||||||
t.deepEqual(removals, [{
|
|
||||||
eventID: "$reaction_1",
|
|
||||||
mxid: "@_ooye_crunch_god:cadence.moe"
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("remove reaction: a specific reaction leads to all matrix users' reaction of the emoji being removed", t => {
|
|
||||||
const removals = removeReaction.removeReaction(
|
|
||||||
fakeSpecificReactionRemoval(BRIDGE_ID, "🐈", null),
|
|
||||||
fakeReactions([
|
|
||||||
{key: "🐈", sender: "@_ooye_crunch_god:cadence.moe"},
|
|
||||||
{key: "🐈", sender: "@cadence:cadence.moe"},
|
|
||||||
{key: "🐈⬛", sender: "@zoe:cadence.moe"},
|
|
||||||
{key: "🐈", sender: "@zoe:cadence.moe"},
|
|
||||||
{key: "🐈", sender: "@_ooye_extremity:cadence.moe"}
|
|
||||||
]),
|
|
||||||
"🐈"
|
|
||||||
)
|
|
||||||
t.deepEqual(removals, [{
|
|
||||||
eventID: "$reaction_1",
|
|
||||||
mxid: null,
|
|
||||||
hash: -8635141960139030904n
|
|
||||||
}, {
|
|
||||||
eventID: "$reaction_3",
|
|
||||||
mxid: null,
|
|
||||||
hash: 326222869084879263n
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("remove reaction: an emoji removes all instances of the emoij from both sides", t => {
|
|
||||||
const removals = removeReaction.removeEmojiReaction(
|
|
||||||
fakeEmojiReactionRemoval("🐈", null),
|
|
||||||
fakeReactions([
|
|
||||||
{key: "🐈", sender: "@_ooye_crunch_god:cadence.moe"},
|
|
||||||
{key: "🐈", sender: "@cadence:cadence.moe"},
|
|
||||||
{key: "🐈⬛", sender: "@zoe:cadence.moe"},
|
|
||||||
{key: "🐈", sender: "@zoe:cadence.moe"},
|
|
||||||
{key: "🐈", sender: "@_ooye_extremity:cadence.moe"}
|
|
||||||
]),
|
|
||||||
"🐈"
|
|
||||||
)
|
|
||||||
t.deepEqual(removals, [{
|
|
||||||
eventID: "$reaction_0",
|
|
||||||
mxid: "@_ooye_crunch_god:cadence.moe"
|
|
||||||
}, {
|
|
||||||
eventID: "$reaction_1",
|
|
||||||
mxid: null
|
|
||||||
}, {
|
|
||||||
eventID: "$reaction_3",
|
|
||||||
mxid: null
|
|
||||||
}, {
|
|
||||||
eventID: "$reaction_4",
|
|
||||||
mxid: "@_ooye_extremity:cadence.moe"
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("remove reaction: remove all removes all from both sides", t => {
|
|
||||||
const removals = removeReaction.removeAllReactions(
|
|
||||||
fakeAllReactionRemoval(),
|
|
||||||
fakeReactions([
|
|
||||||
{key: "🐈", sender: "@_ooye_crunch_god:cadence.moe"},
|
|
||||||
{key: "🐈", sender: "@cadence:cadence.moe"},
|
|
||||||
{key: "🐈⬛", sender: "@zoe:cadence.moe"},
|
|
||||||
{key: "🐈", sender: "@zoe:cadence.moe"},
|
|
||||||
{key: "🐈", sender: "@_ooye_extremity:cadence.moe"}
|
|
||||||
])
|
|
||||||
)
|
|
||||||
t.deepEqual(removals, [{
|
|
||||||
eventID: "$reaction_0",
|
|
||||||
mxid: "@_ooye_crunch_god:cadence.moe"
|
|
||||||
}, {
|
|
||||||
eventID: "$reaction_1",
|
|
||||||
mxid: null
|
|
||||||
}, {
|
|
||||||
eventID: "$reaction_2",
|
|
||||||
mxid: null
|
|
||||||
}, {
|
|
||||||
eventID: "$reaction_3",
|
|
||||||
mxid: null
|
|
||||||
}, {
|
|
||||||
eventID: "$reaction_4",
|
|
||||||
mxid: "@_ooye_extremity:cadence.moe"
|
|
||||||
}])
|
|
||||||
})
|
|
|
@ -6,8 +6,6 @@ const passthrough = require("../../passthrough")
|
||||||
const {discord, sync, db, select} = passthrough
|
const {discord, sync, db, select} = passthrough
|
||||||
/** @type {import("../../matrix/read-registration")} */
|
/** @type {import("../../matrix/read-registration")} */
|
||||||
const reg = sync.require("../../matrix/read-registration.js")
|
const reg = sync.require("../../matrix/read-registration.js")
|
||||||
/** @type {import("../../m2d/converters/utils")} */
|
|
||||||
const mxUtils = sync.require("../../m2d/converters/utils")
|
|
||||||
|
|
||||||
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
|
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
|
||||||
|
|
||||||
|
@ -31,9 +29,8 @@ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thr
|
||||||
|
|
||||||
const msgtype = creatorMxid ? "m.emote" : "m.text"
|
const 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,
|
||||||
|
|
|
@ -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"]
|
||||||
},
|
},
|
||||||
|
|
|
@ -5,10 +5,6 @@ const assert = require("assert")
|
||||||
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
|
||||||
|
@ -34,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
|
||||||
|
@ -54,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 => {
|
||||||
|
@ -18,7 +17,7 @@ test("user2name: works on emojis", t => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test("user2name: works on single emoji at the end", t => {
|
test("user2name: works on single emoji at the end", t => {
|
||||||
t.equal(userToSimName({username: "Melody 🎵", discriminator: "2192"}), "melody")
|
t.equal(userToSimName({username: "Amanda 🎵", discriminator: "2192"}), "amanda")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("user2name: works on crazy name", t => {
|
test("user2name: works on crazy name", t => {
|
||||||
|
@ -40,7 +39,3 @@ test("user2name: uses ID if name becomes too short", t => {
|
||||||
test("user2name: uses ID when name has only disallowed characters", t => {
|
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")
|
|
||||||
})
|
|
||||||
|
|
|
@ -47,16 +47,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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -42,8 +42,6 @@ const utils = {
|
||||||
client.channels.set(thread.id, thread)
|
client.channels.set(thread.id, thread)
|
||||||
}
|
}
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -95,13 +93,6 @@ const utils = {
|
||||||
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)
|
||||||
|
@ -142,9 +133,6 @@ const utils = {
|
||||||
} else if (message.t === "CHANNEL_UPDATE") {
|
} else if (message.t === "CHANNEL_UPDATE") {
|
||||||
await eventDispatcher.onChannelOrThreadUpdate(client, message.d, false)
|
await eventDispatcher.onChannelOrThreadUpdate(client, message.d, false)
|
||||||
|
|
||||||
} else if (message.t === "CHANNEL_PINS_UPDATE") {
|
|
||||||
await eventDispatcher.onChannelPinsUpdate(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)
|
||||||
|
@ -161,17 +149,20 @@ 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)
|
||||||
|
|
||||||
} else if (message.t === "MESSAGE_REACTION_ADD") {
|
} else if (message.t === "MESSAGE_REACTION_ADD") {
|
||||||
await eventDispatcher.onReactionAdd(client, message.d)
|
await eventDispatcher.onReactionAdd(client, message.d)
|
||||||
|
|
||||||
} else if (message.t === "MESSAGE_REACTION_REMOVE" || message.t === "MESSAGE_REACTION_REMOVE_EMOJI" || message.t === "MESSAGE_REACTION_REMOVE_ALL") {
|
} else if (message.t === "MESSAGE_REACTION_REMOVE") {
|
||||||
await eventDispatcher.onSomeReactionsRemoved(client, message.d)
|
await eventDispatcher.onReactionRemove(client, message.d)
|
||||||
|
|
||||||
|
} else if (message.t === "MESSAGE_REACTION_REMOVE_EMOJI") {
|
||||||
|
await eventDispatcher.onReactionEmojiRemove(client, message.d)
|
||||||
|
|
||||||
|
} else if (message.t === "MESSAGE_REACTION_REMOVE_ALL") {
|
||||||
|
await eventDispatcher.onRemoveAllReactions(client, message.d)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Let OOYE try to handle errors too
|
// Let OOYE try to handle errors too
|
||||||
|
|
|
@ -1,7 +1,4 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const assert = require("assert").strict
|
const assert = require("assert").strict
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
|
||||||
const util = require("util")
|
const util = require("util")
|
||||||
const {sync, db, select, from} = require("../passthrough")
|
const {sync, db, select, from} = require("../passthrough")
|
||||||
|
|
||||||
|
@ -21,22 +18,10 @@ const announceThread = sync.require("./actions/announce-thread")
|
||||||
const createRoom = sync.require("./actions/create-room")
|
const createRoom = sync.require("./actions/create-room")
|
||||||
/** @type {import("./actions/create-space")}) */
|
/** @type {import("./actions/create-space")}) */
|
||||||
const createSpace = sync.require("./actions/create-space")
|
const createSpace = sync.require("./actions/create-space")
|
||||||
/** @type {import("./actions/update-pins")}) */
|
|
||||||
const updatePins = sync.require("./actions/update-pins")
|
|
||||||
/** @type {import("../matrix/api")}) */
|
/** @type {import("../matrix/api")}) */
|
||||||
const api = sync.require("../matrix/api")
|
const api = sync.require("../matrix/api")
|
||||||
/** @type {import("../discord/utils")} */
|
|
||||||
const dUtils = sync.require("../discord/utils")
|
|
||||||
/** @type {import("../discord/discord-command-handler")}) */
|
/** @type {import("../discord/discord-command-handler")}) */
|
||||||
const discordCommandHandler = sync.require("../discord/discord-command-handler")
|
const discordCommandHandler = sync.require("../discord/discord-command-handler")
|
||||||
/** @type {import("../m2d/converters/utils")} */
|
|
||||||
const mxUtils = require("../m2d/converters/utils")
|
|
||||||
/** @type {import("./actions/speedbump")} */
|
|
||||||
const speedbump = sync.require("./actions/speedbump")
|
|
||||||
|
|
||||||
/** @type {any} */ // @ts-ignore bad types from semaphore
|
|
||||||
const Semaphore = require("@chriscdn/promise-semaphore")
|
|
||||||
const checkMissedPinsSema = new Semaphore()
|
|
||||||
|
|
||||||
let lastReportedEvent = 0
|
let lastReportedEvent = 0
|
||||||
|
|
||||||
|
@ -59,30 +44,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) {
|
let cloudstormLine = stackLines.findIndex(l => l.includes("/node_modules/cloudstorm/"))
|
||||||
stackLines = e.stack.split("\n")
|
if (cloudstormLine !== -1) {
|
||||||
let cloudstormLine = stackLines.findIndex(l => l.includes("/node_modules/cloudstorm/"))
|
stackLines = stackLines.slice(0, cloudstormLine - 2)
|
||||||
if (cloudstormLine !== -1) {
|
|
||||||
stackLines = stackLines.slice(0, cloudstormLine - 2)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const builder = new mxUtils.MatrixStringBuilder()
|
|
||||||
builder.addLine("\u26a0 Bridged event from Discord not delivered", "\u26a0 <strong>Bridged event from Discord not delivered</strong>")
|
|
||||||
builder.addLine(`Gateway event: ${gatewayMessage.t}`)
|
|
||||||
builder.addLine(e.toString())
|
|
||||||
if (stackLines) {
|
|
||||||
builder.addLine(`Error trace:\n${stackLines.join("\n")}`, `<details><summary>Error trace</summary><pre>${stackLines.join("\n")}</pre></details>`)
|
|
||||||
}
|
|
||||||
builder.addLine("", `<details><summary>Original payload</summary><pre>${util.inspect(gatewayMessage.d, false, 4, false)}</pre></details>`)
|
|
||||||
api.sendEvent(roomID, "m.room.message", {
|
api.sendEvent(roomID, "m.room.message", {
|
||||||
...builder.get(),
|
msgtype: "m.text",
|
||||||
|
body: "\u26a0 Bridged event from Discord not delivered. See formatted content for full details.",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: "\u26a0 <strong>Bridged event from Discord not delivered</strong>"
|
||||||
|
+ `<br>Gateway event: ${gatewayMessage.t}`
|
||||||
|
+ `<br>${e.toString()}`
|
||||||
|
+ `<br><details><summary>Error trace</summary>`
|
||||||
|
+ `<pre>${stackLines.join("\n")}</pre></details>`
|
||||||
|
+ `<details><summary>Original payload</summary>`
|
||||||
|
+ `<pre>${util.inspect(gatewayMessage.d, false, 4, false)}</pre></details>`,
|
||||||
"moe.cadence.ooye.error": {
|
"moe.cadence.ooye.error": {
|
||||||
source: "discord",
|
source: "discord",
|
||||||
payload: gatewayMessage
|
payload: gatewayMessage
|
||||||
|
@ -98,7 +80,7 @@ module.exports = {
|
||||||
* If more messages were missed, only the latest missed message will be posted. TODO: Consider bridging more, or post a warning when skipping history?
|
* If more messages were missed, only the latest missed message will be posted. TODO: Consider bridging more, or post a warning when skipping history?
|
||||||
* This can ONLY detect new messages, not any other kind of event. Any missed edits, deletes, reactions, etc will not be bridged.
|
* This can ONLY detect new messages, not any other kind of event. Any missed edits, deletes, reactions, etc will not be bridged.
|
||||||
* @param {import("./discord-client")} client
|
* @param {import("./discord-client")} client
|
||||||
* @param {DiscordTypes.GatewayGuildCreateDispatchData} guild
|
* @param {import("discord-api-types/v10").GatewayGuildCreateDispatchData} guild
|
||||||
*/
|
*/
|
||||||
async checkMissedMessages(client, guild) {
|
async checkMissedMessages(client, guild) {
|
||||||
if (guild.unavailable) return
|
if (guild.unavailable) return
|
||||||
|
@ -106,17 +88,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 +113,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,51 +122,14 @@ 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"
|
||||||
* @param {import("./discord-client")} client
|
* @param {import("./discord-client")} client
|
||||||
* @param {DiscordTypes.APIThreadChannel} thread
|
* @param {import("discord-api-types/v10").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
|
||||||
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)
|
||||||
|
@ -198,7 +137,7 @@ module.exports = {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("./discord-client")} client
|
* @param {import("./discord-client")} client
|
||||||
* @param {DiscordTypes.GatewayGuildUpdateDispatchData} guild
|
* @param {import("discord-api-types/v10").GatewayGuildUpdateDispatchData} guild
|
||||||
*/
|
*/
|
||||||
async onGuildUpdate(client, guild) {
|
async onGuildUpdate(client, guild) {
|
||||||
const spaceID = select("guild_space", "space_id", {guild_id: guild.id}).pluck().get()
|
const spaceID = select("guild_space", "space_id", {guild_id: guild.id}).pluck().get()
|
||||||
|
@ -208,7 +147,7 @@ module.exports = {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("./discord-client")} client
|
* @param {import("./discord-client")} client
|
||||||
* @param {DiscordTypes.GatewayChannelUpdateDispatchData} channelOrThread
|
* @param {import("discord-api-types/v10").GatewayChannelUpdateDispatchData} channelOrThread
|
||||||
* @param {boolean} isThread
|
* @param {boolean} isThread
|
||||||
*/
|
*/
|
||||||
async onChannelOrThreadUpdate(client, channelOrThread, isThread) {
|
async onChannelOrThreadUpdate(client, channelOrThread, isThread) {
|
||||||
|
@ -219,77 +158,54 @@ module.exports = {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("./discord-client")} client
|
* @param {import("./discord-client")} client
|
||||||
* @param {DiscordTypes.GatewayChannelPinsUpdateDispatchData} data
|
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
|
||||||
*/
|
|
||||||
async onChannelPinsUpdate(client, data) {
|
|
||||||
const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get()
|
|
||||||
if (!roomID) return // No target room to update pins in
|
|
||||||
const convertedTimestamp = updatePins.convertTimestamp(data.last_pin_timestamp)
|
|
||||||
await updatePins.updatePins(data.channel_id, roomID, convertedTimestamp)
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {import("./discord-client")} client
|
|
||||||
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message
|
|
||||||
*/
|
*/
|
||||||
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 {import("discord-api-types/v10").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),
|
||||||
|
|
||||||
const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id)
|
|
||||||
if (affected) return
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
await sendMessage.sendMessage(message, channel, guild, row),
|
|
||||||
await discordCommandHandler.execute(message, channel, guild)
|
await discordCommandHandler.execute(message, channel, guild)
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("./discord-client")} client
|
* @param {import("./discord-client")} client
|
||||||
* @param {DiscordTypes.GatewayMessageUpdateDispatchData} data
|
* @param {import("discord-api-types/v10").GatewayMessageUpdateDispatchData} data
|
||||||
*/
|
*/
|
||||||
async onMessageUpdate(client, data) {
|
async onMessageUpdate(client, data) {
|
||||||
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!
|
|
||||||
|
|
||||||
// Edits need to go through the speedbump as well. If the message is delayed but the edit isn't, we don't have anything to edit from.
|
|
||||||
const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id)
|
|
||||||
if (affected) return
|
|
||||||
|
|
||||||
// Based on looking at data they've sent me over the gateway, this is the best way to check for meaningful changes.
|
// Based on looking at data they've sent me over the gateway, this is the best way to check for meaningful changes.
|
||||||
// If the message content is a string then it includes all interesting fields and is meaningful.
|
// If 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") {
|
||||||
if (typeof data.content === "string" || "embeds" in data) {
|
/** @type {import("discord-api-types/v10").GatewayMessageCreateDispatchData} */
|
||||||
/** @type {DiscordTypes.GatewayMessageCreateDispatchData} */
|
|
||||||
// @ts-ignore
|
|
||||||
const message = data
|
const message = data
|
||||||
|
/** @type {import("discord-api-types/v10").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 editMessage.editMessage(message, guild, row)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("./discord-client")} client
|
* @param {import("./discord-client")} client
|
||||||
* @param {DiscordTypes.GatewayMessageReactionAddDispatchData} data
|
* @param {import("discord-api-types/v10").GatewayMessageReactionAddDispatchData} data
|
||||||
*/
|
*/
|
||||||
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.
|
||||||
|
@ -299,32 +215,39 @@ module.exports = {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("./discord-client")} client
|
* @param {import("./discord-client")} client
|
||||||
* @param {DiscordTypes.GatewayMessageReactionRemoveDispatchData | DiscordTypes.GatewayMessageReactionRemoveEmojiDispatchData | DiscordTypes.GatewayMessageReactionRemoveAllDispatchData} data
|
* @param {import("discord-api-types/v10").GatewayMessageReactionRemoveDispatchData} data
|
||||||
*/
|
*/
|
||||||
async onSomeReactionsRemoved(client, data) {
|
async onReactionRemove(client, data) {
|
||||||
await removeReaction.removeSomeReactions(data)
|
await removeReaction.removeReaction(data)
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("./discord-client")} client
|
* @param {import("./discord-client")} client
|
||||||
* @param {DiscordTypes.GatewayMessageDeleteDispatchData} data
|
* @param {import("discord-api-types/v10").GatewayMessageReactionRemoveEmojiDispatchData} data
|
||||||
|
*/
|
||||||
|
async onReactionEmojiRemove(client, data) {
|
||||||
|
await removeReaction.removeEmojiReaction(data)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import("./discord-client")} client
|
||||||
|
* @param {import("discord-api-types/v10").GatewayMessageReactionRemoveAllDispatchData} data
|
||||||
|
*/
|
||||||
|
async onRemoveAllReactions(client, data) {
|
||||||
|
await removeReaction.removeAllReactions(data)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import("./discord-client")} client
|
||||||
|
* @param {import("discord-api-types/v10").GatewayMessageDeleteDispatchData} data
|
||||||
*/
|
*/
|
||||||
async onMessageDelete(client, data) {
|
async onMessageDelete(client, data) {
|
||||||
speedbump.onMessageDelete(data.id)
|
|
||||||
await deleteMessage.deleteMessage(data)
|
await deleteMessage.deleteMessage(data)
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {import("./discord-client")} client
|
|
||||||
* @param {DiscordTypes.GatewayMessageDeleteBulkDispatchData} data
|
|
||||||
*/
|
|
||||||
async onMessageDeleteBulk(client, data) {
|
|
||||||
await deleteMessage.deleteMessageBulk(data)
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("./discord-client")} client
|
* @param {import("./discord-client")} client
|
||||||
* @param {DiscordTypes.GatewayTypingStartDispatchData} data
|
* @param {import("discord-api-types/v10").GatewayTypingStartDispatchData} data
|
||||||
*/
|
*/
|
||||||
async onTypingStart(client, data) {
|
async onTypingStart(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()
|
||||||
|
@ -339,9 +262,9 @@ module.exports = {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("./discord-client")} client
|
* @param {import("./discord-client")} client
|
||||||
* @param {DiscordTypes.GatewayGuildEmojisUpdateDispatchData | DiscordTypes.GatewayGuildStickersUpdateDispatchData} data
|
* @param {import("discord-api-types/v10").GatewayGuildEmojisUpdateDispatchData | import("discord-api-types/v10").GatewayGuildStickersUpdateDispatchData} data
|
||||||
*/
|
*/
|
||||||
async onExpressionsUpdate(client, data) {
|
async onExpressionsUpdate(client, data) {
|
||||||
await createSpace.syncSpaceExpressions(data, false)
|
await createSpace.syncSpaceExpressions(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
BEGIN TRANSACTION;
|
|
||||||
|
|
||||||
ALTER TABLE guild_space ADD COLUMN privacy_level INTEGER NOT NULL DEFAULT 0;
|
|
||||||
|
|
||||||
COMMIT;
|
|
|
@ -1,24 +0,0 @@
|
||||||
BEGIN TRANSACTION;
|
|
||||||
|
|
||||||
-- Add column reaction_part to event_message, copying the existing value from part
|
|
||||||
|
|
||||||
CREATE TABLE "new_event_message" (
|
|
||||||
"event_id" TEXT NOT NULL,
|
|
||||||
"event_type" TEXT,
|
|
||||||
"event_subtype" TEXT,
|
|
||||||
"message_id" TEXT NOT NULL,
|
|
||||||
"part" INTEGER NOT NULL,
|
|
||||||
"reaction_part" INTEGER NOT NULL,
|
|
||||||
"source" INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY("message_id","event_id")
|
|
||||||
) WITHOUT ROWID;
|
|
||||||
|
|
||||||
INSERT INTO new_event_message SELECT event_id, event_type, event_subtype, message_id, part, part, source FROM event_message;
|
|
||||||
|
|
||||||
DROP TABLE event_message;
|
|
||||||
|
|
||||||
ALTER TABLE new_event_message RENAME TO event_message;
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
||||||
VACUUM;
|
|
|
@ -1,5 +0,0 @@
|
||||||
BEGIN TRANSACTION;
|
|
||||||
|
|
||||||
ALTER TABLE channel_room ADD COLUMN last_bridged_pin_timestamp INTEGER;
|
|
||||||
|
|
||||||
COMMIT;
|
|
|
@ -1,7 +0,0 @@
|
||||||
BEGIN TRANSACTION;
|
|
||||||
|
|
||||||
ALTER TABLE channel_room ADD COLUMN speedbump_id TEXT;
|
|
||||||
ALTER TABLE channel_room ADD COLUMN speedbump_webhook_id TEXT;
|
|
||||||
ALTER TABLE channel_room ADD COLUMN speedbump_checked INTEGER;
|
|
||||||
|
|
||||||
COMMIT;
|
|
|
@ -1,6 +0,0 @@
|
||||||
CREATE TABLE IF NOT EXISTS sim_proxy (
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
proxy_owner_id TEXT NOT NULL,
|
|
||||||
displayname TEXT NOT NULL,
|
|
||||||
PRIMARY KEY(user_id)
|
|
||||||
) WITHOUT ROWID;
|
|
|
@ -1,16 +0,0 @@
|
||||||
/*
|
|
||||||
a. If the bridge bot sim already has the correct ID:
|
|
||||||
- No rows updated.
|
|
||||||
|
|
||||||
b. If the bridge bot sim has the wrong ID but there's no duplicate:
|
|
||||||
- One row updated.
|
|
||||||
|
|
||||||
c. If the bridge bot sim has the wrong ID and there's a duplicate:
|
|
||||||
- One row updated (replaces an existing row).
|
|
||||||
*/
|
|
||||||
|
|
||||||
module.exports = async function(db) {
|
|
||||||
const config = require("../../config")
|
|
||||||
const id = Buffer.from(config.discordToken.split(".")[0], "base64").toString()
|
|
||||||
db.prepare("UPDATE OR REPLACE sim SET user_id = ? WHERE user_id = '0'").run(id)
|
|
||||||
}
|
|
|
@ -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: {
|
||||||
|
@ -18,7 +14,6 @@ export type Models = {
|
||||||
event_type: string | null
|
event_type: string | null
|
||||||
event_subtype: string | null
|
event_subtype: string | null
|
||||||
part: number
|
part: number
|
||||||
reaction_part: number
|
|
||||||
source: number
|
source: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -30,7 +25,6 @@ export type Models = {
|
||||||
guild_space: {
|
guild_space: {
|
||||||
guild_id: string
|
guild_id: string
|
||||||
space_id: string
|
space_id: string
|
||||||
privacy_level: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lottie: {
|
lottie: {
|
||||||
|
@ -63,12 +57,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
|
||||||
|
@ -100,10 +88,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}
|
|
||||||
|
|
12
db/orm.js
12
db/orm.js
|
@ -38,8 +38,6 @@ 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 = []
|
||||||
|
@ -55,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
|
||||||
}
|
}
|
||||||
|
@ -86,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
|
||||||
|
@ -116,8 +113,7 @@ class From {
|
||||||
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>>} */
|
||||||
|
@ -137,7 +133,6 @@ class From {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* c8 ignore start - this code is only used for types and does not actually execute */
|
|
||||||
/**
|
/**
|
||||||
* @template {keyof U.Models} Table
|
* @template {keyof U.Models} Table
|
||||||
* @template {keyof U.Merge<U.Models[Table]>} Col
|
* @template {keyof U.Merge<U.Models[Table]>} Col
|
||||||
|
@ -161,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
|
||||||
|
|
|
@ -44,12 +44,3 @@ test("orm: from: where and pluck works", t => {
|
||||||
const subtypes = from("event_message").where({message_id: "1141501302736695316"}).pluck("event_subtype").all()
|
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)
|
|
||||||
})
|
|
||||||
|
|
|
@ -11,8 +11,6 @@ const {discord, sync, db, select} = require("../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("../d2m/actions/create-space")} */
|
|
||||||
const createSpace = sync.require("../d2m/actions/create-space")
|
|
||||||
/** @type {import("./utils")} */
|
/** @type {import("./utils")} */
|
||||||
const utils = sync.require("./utils")
|
const utils = sync.require("./utils")
|
||||||
|
|
||||||
|
@ -137,7 +135,7 @@ const commands = [{
|
||||||
// Check CREATE_INSTANT_INVITE permission
|
// Check CREATE_INSTANT_INVITE permission
|
||||||
assert(message.member)
|
assert(message.member)
|
||||||
const guildPermissions = utils.getPermissions(message.member.roles, guild.roles)
|
const guildPermissions = utils.getPermissions(message.member.roles, guild.roles)
|
||||||
if (!(guildPermissions & DiscordTypes.PermissionFlagsBits.CreateInstantInvite)) {
|
if (!(guildPermissions & BigInt(1))) {
|
||||||
return discord.snow.channel.createMessage(channel.id, {
|
return discord.snow.channel.createMessage(channel.id, {
|
||||||
...ctx,
|
...ctx,
|
||||||
content: "You don't have permission to invite people to this Discord server."
|
content: "You don't have permission to invite people to this Discord server."
|
||||||
|
@ -214,48 +212,6 @@ const commands = [{
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}, {
|
|
||||||
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} */
|
/** @type {CommandExecute} */
|
||||||
|
|
|
@ -1,9 +1,6 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const assert = require("assert").strict
|
|
||||||
|
|
||||||
const EPOCH = 1420070400000
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string[]} userRoles
|
* @param {string[]} userRoles
|
||||||
|
@ -26,7 +23,7 @@ function getPermissions(userRoles, guildRoles, userID, channelOverwrites) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (channelOverwrites) {
|
if (channelOverwrites) {
|
||||||
/** @type {((overwrite: Required<DiscordTypes.APIOverwrite>) => any)[]} */
|
/** @type {((overwrite: Required<DiscordTypes.APIGuildChannel>["permission_overwrites"][0]) => any)[]} */
|
||||||
const actions = [
|
const actions = [
|
||||||
// Channel @everyone deny
|
// Channel @everyone deny
|
||||||
overwrite => overwrite.id === everyoneID && (allowed &= ~BigInt(overwrite.deny)),
|
overwrite => overwrite.id === everyoneID && (allowed &= ~BigInt(overwrite.deny)),
|
||||||
|
@ -35,7 +32,7 @@ function getPermissions(userRoles, guildRoles, userID, channelOverwrites) {
|
||||||
// Role deny
|
// Role deny
|
||||||
overwrite => userRoles.includes(overwrite.id) && (allowed &= ~BigInt(overwrite.deny)),
|
overwrite => userRoles.includes(overwrite.id) && (allowed &= ~BigInt(overwrite.deny)),
|
||||||
// Role allow
|
// Role allow
|
||||||
overwrite => userRoles.includes(overwrite.id) && (allowed |= BigInt(overwrite.allow)),
|
overwrite => userRoles.includes(overwrite.id) && (allowed |= ~BigInt(overwrite.allow)),
|
||||||
// User deny
|
// User deny
|
||||||
overwrite => overwrite.id === userID && (allowed &= ~BigInt(overwrite.deny)),
|
overwrite => overwrite.id === userID && (allowed &= ~BigInt(overwrite.deny)),
|
||||||
// User allow
|
// User allow
|
||||||
|
@ -50,48 +47,6 @@ function getPermissions(userRoles, guildRoles, userID, channelOverwrites) {
|
||||||
return allowed
|
return allowed
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Note: You can only provide one permission bit to permissionToCheckFor. To check multiple permissions, call `hasAllPermissions` or `hasSomePermissions`.
|
|
||||||
* It is designed like this to avoid developer error with bit manipulations.
|
|
||||||
*
|
|
||||||
* @param {bigint} resolvedPermissions
|
|
||||||
* @param {bigint} permissionToCheckFor
|
|
||||||
* @returns {boolean} whether the user has the requested permission
|
|
||||||
* @example
|
|
||||||
* const permissions = getPermissions(userRoles, guildRoles, userID, channelOverwrites)
|
|
||||||
* hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel)
|
|
||||||
*/
|
|
||||||
function hasPermission(resolvedPermissions, permissionToCheckFor) {
|
|
||||||
// Make sure permissionToCheckFor has exactly one permission in it
|
|
||||||
assert.equal(permissionToCheckFor.toString(2).match(/1/g)?.length, 1)
|
|
||||||
// Do the actual calculation
|
|
||||||
return (resolvedPermissions & permissionToCheckFor) === permissionToCheckFor
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {bigint} resolvedPermissions
|
|
||||||
* @param {(keyof DiscordTypes.PermissionFlagsBits)[]} permissionsToCheckFor
|
|
||||||
* @returns {boolean} whether the user has any of the requested permissions
|
|
||||||
* @example
|
|
||||||
* const permissions = getPermissions(userRoles, guildRoles, userID, channelOverwrites)
|
|
||||||
* hasSomePermissions(permissions, ["ViewChannel", "ReadMessageHistory"])
|
|
||||||
*/
|
|
||||||
function hasSomePermissions(resolvedPermissions, permissionsToCheckFor) {
|
|
||||||
return permissionsToCheckFor.some(x => hasPermission(resolvedPermissions, DiscordTypes.PermissionFlagsBits[x]))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {bigint} resolvedPermissions
|
|
||||||
* @param {(keyof DiscordTypes.PermissionFlagsBits)[]} permissionsToCheckFor
|
|
||||||
* @returns {boolean} whether the user has all of the requested permissions
|
|
||||||
* @example
|
|
||||||
* const permissions = getPermissions(userRoles, guildRoles, userID, channelOverwrites)
|
|
||||||
* hasAllPermissions(permissions, ["ViewChannel", "ReadMessageHistory"])
|
|
||||||
*/
|
|
||||||
function hasAllPermissions(resolvedPermissions, permissionsToCheckFor) {
|
|
||||||
return permissionsToCheckFor.every(x => hasPermission(resolvedPermissions, DiscordTypes.PermissionFlagsBits[x]))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Command interaction responses have a webhook_id for some reason, but still have real author info of a real bot user in the server.
|
* 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
|
* @param {DiscordTypes.APIMessage} message
|
||||||
|
@ -101,29 +56,5 @@ function isWebhookMessage(message) {
|
||||||
return message.webhook_id && !isInteractionResponse
|
return message.webhook_id && !isInteractionResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Ephemeral messages can be generated if a slash command is attached to the same bot that OOYE is running on
|
|
||||||
* @param {DiscordTypes.APIMessage} message
|
|
||||||
*/
|
|
||||||
function isEphemeralMessage(message) {
|
|
||||||
return message.flags & (1 << 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param {string} snowflake */
|
|
||||||
function snowflakeToTimestampExact(snowflake) {
|
|
||||||
return Number(BigInt(snowflake) >> 22n) + EPOCH
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param {number} timestamp */
|
|
||||||
function timestampToSnowflakeInexact(timestamp) {
|
|
||||||
return String((timestamp - EPOCH) * 2**22)
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.getPermissions = getPermissions
|
module.exports.getPermissions = getPermissions
|
||||||
module.exports.hasPermission = hasPermission
|
|
||||||
module.exports.hasSomePermissions = hasSomePermissions
|
|
||||||
module.exports.hasAllPermissions = hasAllPermissions
|
|
||||||
module.exports.isWebhookMessage = isWebhookMessage
|
module.exports.isWebhookMessage = isWebhookMessage
|
||||||
module.exports.isEphemeralMessage = isEphemeralMessage
|
|
||||||
module.exports.snowflakeToTimestampExact = snowflakeToTimestampExact
|
|
||||||
module.exports.timestampToSnowflakeInexact = timestampToSnowflakeInexact
|
|
||||||
|
|
|
@ -1,109 +0,0 @@
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
|
||||||
const {test} = require("supertape")
|
|
||||||
const data = require("../test/data")
|
|
||||||
const utils = require("./utils")
|
|
||||||
|
|
||||||
test("is webhook message: identifies bot interaction response as not a message", t => {
|
|
||||||
t.equal(utils.isWebhookMessage(data.interaction_message.thinking_interaction), false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("is webhook message: identifies webhook interaction response as not a message", t => {
|
|
||||||
t.equal(utils.isWebhookMessage(data.interaction_message.thinking_interaction_without_bot_user), false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("is webhook message: identifies webhook message as a message", t => {
|
|
||||||
t.equal(utils.isWebhookMessage(data.special_message.bridge_echo_webhook), true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("discord utils: converts snowflake to timestamp", t => {
|
|
||||||
t.equal(utils.snowflakeToTimestampExact("86913608335773696"), 1440792219004)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("discord utils: converts timestamp to snowflake", t => {
|
|
||||||
t.match(utils.timestampToSnowflakeInexact(1440792219004), /^869136083357.....$/)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("getPermissions: channel overwrite to allow role works", t => {
|
|
||||||
const guildRoles = [
|
|
||||||
{
|
|
||||||
version: 1695412489043,
|
|
||||||
unicode_emoji: null,
|
|
||||||
tags: {},
|
|
||||||
position: 0,
|
|
||||||
permissions: "559623605571137",
|
|
||||||
name: "@everyone",
|
|
||||||
mentionable: false,
|
|
||||||
managed: false,
|
|
||||||
id: "1154868424724463687",
|
|
||||||
icon: null,
|
|
||||||
hoist: false,
|
|
||||||
flags: 0,
|
|
||||||
color: 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
version: 1695412604262,
|
|
||||||
unicode_emoji: null,
|
|
||||||
tags: { bot_id: "466378653216014359" },
|
|
||||||
position: 1,
|
|
||||||
permissions: "536995904",
|
|
||||||
name: "PluralKit",
|
|
||||||
mentionable: false,
|
|
||||||
managed: true,
|
|
||||||
id: "1154868908336099444",
|
|
||||||
icon: null,
|
|
||||||
hoist: false,
|
|
||||||
flags: 0,
|
|
||||||
color: 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
version: 1698778936921,
|
|
||||||
unicode_emoji: null,
|
|
||||||
tags: {},
|
|
||||||
position: 1,
|
|
||||||
permissions: "536870912",
|
|
||||||
name: "web hookers",
|
|
||||||
mentionable: false,
|
|
||||||
managed: false,
|
|
||||||
id: "1168988246680801360",
|
|
||||||
icon: null,
|
|
||||||
hoist: false,
|
|
||||||
flags: 0,
|
|
||||||
color: 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
const userRoles = [ "1168988246680801360" ]
|
|
||||||
const userID = "684280192553844747"
|
|
||||||
const overwrites = [
|
|
||||||
{ type: 0, id: "1154868908336099444", deny: "0", allow: "1024" },
|
|
||||||
{ type: 0, id: "1154868424724463687", deny: "1024", allow: "0" },
|
|
||||||
{ type: 0, id: "1168988246680801360", deny: "0", allow: "1024" },
|
|
||||||
{ type: 1, id: "353373325575323648", deny: "0", allow: "1024" }
|
|
||||||
]
|
|
||||||
const permissions = utils.getPermissions(userRoles, guildRoles, userID, overwrites)
|
|
||||||
const want = BigInt(1 << 10 | 1 << 16)
|
|
||||||
t.equal((permissions & want), want)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("hasSomePermissions: detects the permission", t => {
|
|
||||||
const userPermissions = DiscordTypes.PermissionFlagsBits.MentionEveryone | DiscordTypes.PermissionFlagsBits.BanMembers
|
|
||||||
const canRemoveMembers = utils.hasSomePermissions(userPermissions, ["KickMembers", "BanMembers"])
|
|
||||||
t.equal(canRemoveMembers, true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("hasSomePermissions: doesn't detect not the permission", t => {
|
|
||||||
const userPermissions = DiscordTypes.PermissionFlagsBits.MentionEveryone | DiscordTypes.PermissionFlagsBits.SendMessages
|
|
||||||
const canRemoveMembers = utils.hasSomePermissions(userPermissions, ["KickMembers", "BanMembers"])
|
|
||||||
t.equal(canRemoveMembers, false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("hasAllPermissions: detects the permissions", t => {
|
|
||||||
const userPermissions = DiscordTypes.PermissionFlagsBits.KickMembers | DiscordTypes.PermissionFlagsBits.BanMembers | DiscordTypes.PermissionFlagsBits.MentionEveryone
|
|
||||||
const canRemoveMembers = utils.hasAllPermissions(userPermissions, ["KickMembers", "BanMembers"])
|
|
||||||
t.equal(canRemoveMembers, true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("hasAllPermissions: doesn't detect not the permissions", t => {
|
|
||||||
const userPermissions = DiscordTypes.PermissionFlagsBits.MentionEveryone | DiscordTypes.PermissionFlagsBits.SendMessages | DiscordTypes.PermissionFlagsBits.KickMembers
|
|
||||||
const canRemoveMembers = utils.hasAllPermissions(userPermissions, ["KickMembers", "BanMembers"])
|
|
||||||
t.equal(canRemoveMembers, false)
|
|
||||||
})
|
|
|
@ -1,466 +0,0 @@
|
||||||
# How to add a new event type
|
|
||||||
|
|
||||||
It's much easier to understand code with examples. So let's go through it together and add support for **pinned events** to Out Of Your Element.
|
|
||||||
|
|
||||||
## Gathering intel
|
|
||||||
|
|
||||||
First, we need to know what pinned events are supposed to look like. The Matrix C-S spec gives this example:
|
|
||||||
|
|
||||||
> **pinned** ... [string] ... Required: An ordered list of event IDs to pin.
|
|
||||||
> ```json
|
|
||||||
> {
|
|
||||||
> "content": {
|
|
||||||
> "pinned": [
|
|
||||||
> "$someevent:example.org"
|
|
||||||
> ]
|
|
||||||
> },
|
|
||||||
> "event_id": "$143273582443PhrSn:example.org",
|
|
||||||
> "room_id": "!jEsUZKDJdhlrceRyVU:example.org",
|
|
||||||
> "sender": "@example:example.org",
|
|
||||||
> "state_key": "",
|
|
||||||
> "type": "m.room.pinned_events",
|
|
||||||
> }
|
|
||||||
> ```
|
|
||||||
|
|
||||||
This is part of the persistent room state. Simple enough. To update this, the state event and its list of pinned events is updated as a whole.
|
|
||||||
|
|
||||||
What does it look like on Discord-side?
|
|
||||||
|
|
||||||
> **Get Pinned Messages** \
|
|
||||||
> `GET` `/channels/{channel.id}/pins` \
|
|
||||||
> Returns all pinned messages in the channel as an array of message objects.
|
|
||||||
|
|
||||||
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?
|
|
||||||
|
|
||||||
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 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.
|
|
||||||
|
|
||||||
## Missing messages
|
|
||||||
|
|
||||||
> ...we will convert each message ID into the ID of an event ***we already have***
|
|
||||||
|
|
||||||
As a couple of examples, the message might not be bridged if it was sent before OOYE was set up, or if OOYE just had a bug trying to handle that message. If a particular Discord message wasn't bridged, and then it gets pinned, we're in a bit of a pickle. We can't pin the Matrix equivalent because it doesn't exist.
|
|
||||||
|
|
||||||
In this situation we need to stop and think about the possible paths forward we could take.
|
|
||||||
|
|
||||||
* We could ignore this message and just not pin it.
|
|
||||||
* We could convert and send this message now with its original timestamp from the past, then pin this representation.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
In this situation I will prefer to keep the pins list inconsistent between both sides and only bridge _changes_ to the list.
|
|
||||||
|
|
||||||
If you were implementing this for real, you might have made different decisions than me, and that's okay. It's a matter of taste. You just need to be aware of the consequences of what you choose.
|
|
||||||
|
|
||||||
## Test data for the d2m converter
|
|
||||||
|
|
||||||
Let's start writing the d2m converter. It's helpful to write automated tests for Out Of Your Element, since this lets you check if it worked without having to start up a local copy of the bridge or mess around with the interface.
|
|
||||||
|
|
||||||
To test the Discord-to-Matrix pin converter, we'll need some samples of Discord message objects. Then we can put these sample message objects through the converter and check what comes out the other side.
|
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```js
|
|
||||||
[
|
|
||||||
{id: "1126786462646550579"},
|
|
||||||
{id: "1141501302736695316"},
|
|
||||||
{id: "1106366167788044450"},
|
|
||||||
{id: "1115688611186193400"}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
"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 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.
|
|
||||||
|
|
||||||
Now that I've got my list, I will make my first change to the code. I will add these IDs to `test/data.js`:
|
|
||||||
|
|
||||||
```diff
|
|
||||||
diff --git a/test/data.js b/test/data.js
|
|
||||||
index c36f252..4919beb 100644
|
|
||||||
--- a/test/data.js
|
|
||||||
+++ b/test/data.js
|
|
||||||
@@ -221,6 +221,14 @@ module.exports = {
|
|
||||||
deaf: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
+ pins: {
|
|
||||||
+ faked: [
|
|
||||||
+ {id: "1126786462646550579"},
|
|
||||||
+ {id: "1141501302736695316"},
|
|
||||||
+ {id: "1106366167788044450"},
|
|
||||||
+ {id: "1115688611186193400"}
|
|
||||||
+ ]
|
|
||||||
+ },
|
|
||||||
message: {
|
|
||||||
// Display order is text content, attachments, then stickers
|
|
||||||
simple_plaintext: {
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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:
|
|
||||||
|
|
||||||
```diff
|
|
||||||
diff --git a/d2m/converters/pins-to-list.js b/d2m/converters/pins-to-list.js
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..e4107be
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/d2m/converters/pins-to-list.js
|
|
||||||
@@ -0,0 +1,18 @@
|
|
||||||
+// @ts-check
|
|
||||||
+
|
|
||||||
+const {select} = require("../../passthrough")
|
|
||||||
+
|
|
||||||
+/**
|
|
||||||
+ * @param {import("discord-api-types/v10").RESTGetAPIChannelPinsResult} pins
|
|
||||||
+ */
|
|
||||||
+function pinsToList(pins) {
|
|
||||||
+ /** @type {string[]} */
|
|
||||||
+ const result = []
|
|
||||||
+ for (const message of pins) {
|
|
||||||
+ const eventID = select("event_message", "event_id", {message_id: message.id}).pluck().get()
|
|
||||||
+ if (eventID) result.push(eventID)
|
|
||||||
+ }
|
|
||||||
+ return result
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
+module.exports.pinsToList = pinsToList
|
|
||||||
```
|
|
||||||
|
|
||||||
### Explaining the code
|
|
||||||
|
|
||||||
All converters have a `function` which does the work, and the function is added to `module.exports` so that other files can use it.
|
|
||||||
|
|
||||||
Importing `select` from `passthrough` lets us do database access. Calling the `select` function can select from OOYE's own SQLite database. If you want to see what's in the database, look at `ooye-test-data.sql` for test data, or open `ooye.db` for real data from your own bridge.
|
|
||||||
|
|
||||||
The comments `// @ts-check`, `/** @type ... */`, and `/** @param ... */` provide type-based autosuggestions when editing in Visual Studio Code.
|
|
||||||
|
|
||||||
Here's the code I haven't yet discussed:
|
|
||||||
|
|
||||||
```js
|
|
||||||
function pinsToList(pins) {
|
|
||||||
const result = []
|
|
||||||
for (const message of pins) {
|
|
||||||
const eventID = select("event_message", "event_id", {message_id: message.id}).pluck().get()
|
|
||||||
if (eventID) result.push(eventID)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
It will go through each `message` in `pins`. For each message, it will look up the corresponding Matrix event in the database, and if found, it will add it to `result`.
|
|
||||||
|
|
||||||
The `select` line will run this SQL: `SELECT event_id FROM event_message WHERE message_id = {the message ID}` and will return the event ID as a string or null.
|
|
||||||
|
|
||||||
For any database experts worried about an SQL query inside a loop, the N+1 problem does not apply to SQLite because the queries are executed in the same process rather than crossing a process (and network) boundary. https://www.sqlite.org/np1queryprob.html
|
|
||||||
|
|
||||||
## Test case for the d2m converter
|
|
||||||
|
|
||||||
There's not much room for bugs in this function. A single manual test that it works would be good enough for me. But since this is an example of how you can add your own, let's add a test case for this. The testing code will take the data we just prepared and process it through the `pinsToList` function we just wrote. Then, it will check the result is what we expected.
|
|
||||||
|
|
||||||
```diff
|
|
||||||
diff --git a/d2m/converters/pins-to-list.test.js b/d2m/converters/pins-to-list.test.js
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..c2e3774
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/d2m/converters/pins-to-list.test.js
|
|
||||||
@@ -0,0 +1,12 @@
|
|
||||||
+const {test} = require("supertape")
|
|
||||||
+const data = require("../../test/data")
|
|
||||||
+const {pinsToList} = require("./pins-to-list")
|
|
||||||
+
|
|
||||||
+test("pins2list: converts known IDs, ignores unknown IDs", t => {
|
|
||||||
+ const result = pinsToList(data.pins.faked)
|
|
||||||
+ t.deepEqual(result, [
|
|
||||||
+ "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg",
|
|
||||||
+ "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA",
|
|
||||||
+ "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4"
|
|
||||||
+ ])
|
|
||||||
+})
|
|
||||||
```
|
|
||||||
|
|
||||||
Don't forget to list your test in `test.js` so that it gets picked up:
|
|
||||||
|
|
||||||
```diff
|
|
||||||
diff --git a/test/test.js b/test/test.js
|
|
||||||
index 5cc851e..280503d 100644
|
|
||||||
--- a/test/test.js
|
|
||||||
+++ b/test/test.js
|
|
||||||
@@ -52,6 +52,7 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not
|
|
||||||
require("../d2m/converters/message-to-event.test")
|
|
||||||
require("../d2m/converters/message-to-event.embeds.test")
|
|
||||||
require("../d2m/converters/edit-to-changes.test")
|
|
||||||
+ require("../d2m/converters/pins-to-list.test")
|
|
||||||
require("../d2m/converters/remove-reaction.test")
|
|
||||||
require("../d2m/converters/thread-to-announcement.test")
|
|
||||||
require("../d2m/converters/user-to-mxid.test")
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
> out-of-your-element@1.1.0 test
|
|
||||||
> cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap test/test.js | tap-dot
|
|
||||||
|
|
||||||
pins2list: converts known IDs, ignores unknown IDs - should deep equal
|
|
||||||
operator: deepEqual
|
|
||||||
diff: |-
|
|
||||||
Array [
|
|
||||||
"$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg",
|
|
||||||
- "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA",
|
|
||||||
- "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4",
|
|
||||||
+ "$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCI",
|
|
||||||
+ "$Ijf1MFCD39ktrNHxrA-i2aKoRWNYdAV2ZXYQeiZIgEU",
|
|
||||||
]
|
|
||||||
at out-of-your-element/d2m/converters/pins-to-list.test.js:7:4
|
|
||||||
Error: should deep equal
|
|
||||||
at run (file:///out-of-your-element/node_modules/supertape/lib/operators.mjs:272:33)
|
|
||||||
at Object.deepEqual (file:///out-of-your-element/node_modules/supertape/lib/operators.mjs:198:9)
|
|
||||||
at out-of-your-element/d2m/converters/pins-to-list.test.js:7:4
|
|
||||||
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:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- snipped from ooye-test-data.sql
|
|
||||||
('$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA', 'm.room.message', 'm.text', '1141501302736695316', 0, 1),
|
|
||||||
('$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCI', 'm.room.message', 'm.image', '1141501302736695316', 1, 1),
|
|
||||||
```
|
|
||||||
|
|
||||||
Explanation: This Discord message `1141501302736695316` is actually part of 2 different Matrix events, `$mtR...` and `$51f...`. This often happens when a Discord user uploads an image with a caption. Matrix doesn't support combined image+text events, so the image and the text have to be bridged to separate events.
|
|
||||||
|
|
||||||
In the current code, `pinsToList` is picking ALL the associated event IDs, and then `.get` is forcing it to limit that list to 1. It doesn't care which, so it's essentially random which event it wants to pin.
|
|
||||||
|
|
||||||
We should make a decision on which event is more important. You can make whatever decision you want - you could even make it pin every event associated with a message - but I've decided that the text should be the primary part and be pinned, and the image should be considered a secondary part and left unpinned.
|
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```diff
|
|
||||||
diff --git a/d2m/converters/pins-to-list.js b/d2m/converters/pins-to-list.js
|
|
||||||
index e4107be..f401de2 100644
|
|
||||||
--- a/d2m/converters/pins-to-list.js
|
|
||||||
+++ b/d2m/converters/pins-to-list.js
|
|
||||||
@@ -9,7 +9,7 @@ function pinsToList(pins) {
|
|
||||||
/** @type {string[]} */
|
|
||||||
const result = []
|
|
||||||
for (const message of pins) {
|
|
||||||
- const eventID = select("event_message", "event_id", {message_id: message.id}).pluck().get()
|
|
||||||
+ const eventID = select("event_message", "event_id", {message_id: message.id, part: 0}).pluck().get()
|
|
||||||
if (eventID) result.push(eventID)
|
|
||||||
}
|
|
||||||
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
|
|
||||||
|
|
||||||
144 tests
|
|
||||||
232 passed
|
|
||||||
|
|
||||||
Pass!
|
|
||||||
```
|
|
||||||
|
|
||||||
## Wiring it up to an action
|
|
||||||
|
|
||||||
Actions call converters to do the thing, but actions have to receive their input event from somewhere. Let's wire it up so we receive a "pins changed" event from Discord and do the whole flow from there. Checking the documentation again, Discord will trigger this gateway event when the pins change:
|
|
||||||
|
|
||||||
> **Channel Pins Update** \
|
|
||||||
> Sent when a message is pinned or unpinned in a text channel. This is not sent when a pinned message is deleted. \
|
|
||||||
> **guild_id?** ... snowflake ... ID of the guild \
|
|
||||||
> **channel_id** ... snowflake ... ID of the channel \
|
|
||||||
> **last_pin_timestamp?** ... ?ISO8601 timestamp ... Time at which the most recent pinned message was pinned
|
|
||||||
|
|
||||||
Notably, the event doesn't deliver the actual list of pinned messages to us. We'll have to listen for this event, then trigger an API request to `GET` the pins list. Alright, enough preparation, time to code.
|
|
||||||
|
|
||||||
All packets are delivered to `discord-packets.js` which manages the internal state of the Discord object and then passes it on to a function in `event-dispatcher.js`:
|
|
||||||
|
|
||||||
```diff
|
|
||||||
diff --git a/d2m/discord-packets.js b/d2m/discord-packets.js
|
|
||||||
index 83c31cd..4de84d9 100644
|
|
||||||
--- a/d2m/discord-packets.js
|
|
||||||
+++ b/d2m/discord-packets.js
|
|
||||||
@@ -133,6 +133,9 @@ const utils = {
|
|
||||||
} else if (message.t === "CHANNEL_UPDATE") {
|
|
||||||
await eventDispatcher.onChannelOrThreadUpdate(client, message.d, false)
|
|
||||||
|
|
||||||
+ } else if (message.t === "CHANNEL_PINS_UPDATE") {
|
|
||||||
+ await eventDispatcher.onChannelPinsUpdate(client, message.d)
|
|
||||||
+
|
|
||||||
} else if (message.t === "THREAD_CREATE") {
|
|
||||||
// @ts-ignore
|
|
||||||
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:
|
|
||||||
|
|
||||||
```diff
|
|
||||||
diff --git a/d2m/event-dispatcher.js b/d2m/event-dispatcher.js
|
|
||||||
index 0f9f1e6..6e91e9e 100644
|
|
||||||
--- a/d2m/event-dispatcher.js
|
|
||||||
+++ b/d2m/event-dispatcher.js
|
|
||||||
@@ -19,6 +19,8 @@ const announceThread = sync.require("./actions/announce-thread")
|
|
||||||
const createRoom = sync.require("./actions/create-room")
|
|
||||||
/** @type {import("./actions/create-space")}) */
|
|
||||||
const createSpace = sync.require("./actions/create-space")
|
|
||||||
+/** @type {import("./actions/update-pins")}) */
|
|
||||||
+const updatePins = sync.require("./actions/update-pins")
|
|
||||||
/** @type {import("../matrix/api")}) */
|
|
||||||
const api = sync.require("../matrix/api")
|
|
||||||
/** @type {import("../discord/discord-command-handler")}) */
|
|
||||||
@@ -157,6 +159,16 @@ module.exports = {
|
|
||||||
await createRoom.syncRoom(channelOrThread.id)
|
|
||||||
},
|
|
||||||
|
|
||||||
+ /**
|
|
||||||
+ * @param {import("./discord-client")} client
|
|
||||||
+ * @param {DiscordTypes.GatewayChannelPinsUpdateDispatchData} data
|
|
||||||
+ */
|
|
||||||
+ async onChannelPinsUpdate(client, data) {
|
|
||||||
+ const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get()
|
|
||||||
+ if (!roomID) return // No target room to update pins in
|
|
||||||
+ await updatePins.updatePins(data.channel_id, roomID)
|
|
||||||
+ },
|
|
||||||
+
|
|
||||||
/**
|
|
||||||
* @param {import("./discord-client")} client
|
|
||||||
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message
|
|
||||||
```
|
|
||||||
|
|
||||||
And now I can write the `update-pins.js` action:
|
|
||||||
|
|
||||||
```diff
|
|
||||||
diff --git a/d2m/actions/update-pins.js b/d2m/actions/update-pins.js
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..40cc358
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/d2m/actions/update-pins.js
|
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
|
|
||||||
I try to keep as much logic as possible out of the actions and in the converters. This should mean I *never have to unit test the actions themselves.* The actions will be tested manually with the real bot.
|
|
||||||
|
|
||||||
## See if it works
|
|
||||||
|
|
||||||
Since the automated tests pass, let's start up the bridge and run our nice new code:
|
|
||||||
|
|
||||||
```
|
|
||||||
node start.js
|
|
||||||
```
|
|
||||||
|
|
||||||
We can try these things and see if they are bridged to Matrix:
|
|
||||||
|
|
||||||
- Pin a recent message on Discord-side
|
|
||||||
- Pin an old message on Discord-side
|
|
||||||
- Unpin a message on Discord-side
|
|
||||||
|
|
||||||
It works like I'd expect!
|
|
||||||
|
|
||||||
## Order of pinned messages
|
|
||||||
|
|
||||||
I expected that to be the end of the guide, but after some time, I noticed a new problem: The pins are in reverse order. How could this happen?
|
|
||||||
|
|
||||||
[After some investigation,](https://gitdab.com/cadence/out-of-your-element/issues/16) it turns out Discord puts the most recently pinned message at the start of the array and displays the array in forwards order, while Matrix puts the most recently pinned message at the end of the array and displays the array in reverse order.
|
|
||||||
|
|
||||||
We can fix this by reversing the order of the list of pins before we store it. The converter can do this:
|
|
||||||
|
|
||||||
```diff
|
|
||||||
diff --git a/d2m/converters/pins-to-list.js b/d2m/converters/pins-to-list.js
|
|
||||||
index f401de2..047bb9f 100644
|
|
||||||
--- a/d2m/converters/pins-to-list.js
|
|
||||||
+++ b/d2m/converters/pins-to-list.js
|
|
||||||
@@ -12,6 +12,7 @@ function pinsToList(pins) {
|
|
||||||
const eventID = select("event_message", "event_id", {message_id: message.id, part: 0}).pluck().get()
|
|
||||||
if (eventID) result.push(eventID)
|
|
||||||
}
|
|
||||||
+ result.reverse()
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Since the results have changed, I'll need to update the test so it expects the new result:
|
|
||||||
|
|
||||||
```diff
|
|
||||||
diff --git a/d2m/converters/pins-to-list.test.js b/d2m/converters/pins-to-list.test.js
|
|
||||||
index c2e3774..92e5678 100644
|
|
||||||
--- a/d2m/converters/pins-to-list.test.js
|
|
||||||
+++ b/d2m/converters/pins-to-list.test.js
|
|
||||||
@@ -5,8 +5,8 @@ const {pinsToList} = require("./pins-to-list")
|
|
||||||
test("pins2list: converts known IDs, ignores unknown IDs", t => {
|
|
||||||
const result = pinsToList(data.pins.faked)
|
|
||||||
t.deepEqual(result, [
|
|
||||||
- "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg",
|
|
||||||
- "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA",
|
|
||||||
- "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4"
|
|
||||||
+ "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4",
|
|
||||||
+ "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA",
|
|
||||||
+ "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg"
|
|
||||||
])
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
><> $ npm t
|
|
||||||
|
|
||||||
144 tests
|
|
||||||
232 passed
|
|
||||||
|
|
||||||
Pass!
|
|
||||||
```
|
|
||||||
|
|
||||||
Next time a message is pinned or unpinned on Discord, OOYE should update the order of all the pins on Matrix.
|
|
||||||
|
|
||||||
## Notes on missed events
|
|
||||||
|
|
||||||
Note that this will only sync pins _when the pins change._ Existing pins from Discord will not be backfilled to Matrix rooms. If I wanted, there's a couple of ways I could address this:
|
|
||||||
|
|
||||||
* I could create a one-shot script in `scripts/update-pins.js` which will sync pins for _all_ Discord channels right away. I can run this after finishing the feature, or if the bot has been offline for some time.
|
|
||||||
* I could create a database table that holds the timestamp of the most recently detected pin for each channel - the `last_pin_timestamp` field from the gateway. Every time the bot starts, it would automatically compare the database table against every channel, and if the pins have changed since it last looked, it could automatically update them.
|
|
||||||
|
|
||||||
I already have code to backfill missed messages when the bridge starts up. The second option above would add a similar feature for backfilling missed pins. It would be worth considering.
|
|
|
@ -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.)
|
|
|
@ -28,8 +28,6 @@ If a room is newly created, it will be added to the space, but it will not be an
|
||||||
|
|
||||||
If a thread is newly created, it will be added to the space, and an announcement will also be posted to the parent channel with a link to quickly join.
|
If a thread is newly created, it will be added to the space, and an announcement will also be posted to the parent channel with a link to quickly join.
|
||||||
|
|
||||||
Matrix users can create their own thread with `/thread <name>`. This will create a real thread channel on Discord-side and announce its creation on both sides in the usual way.
|
|
||||||
|
|
||||||
## Custom Room Icons
|
## Custom Room Icons
|
||||||
|
|
||||||
Normally on Matrix, the room icons will match the space icon. Since Matrix allows for room-specific icons, the bridge will keep track of any custom icon that was set on a room.
|
Normally on Matrix, the room icons will match the space icon. Since Matrix allows for room-specific icons, the bridge will keep track of any custom icon that was set on a room.
|
||||||
|
|
|
@ -16,7 +16,7 @@ const emoji = sync.require("../converters/emoji")
|
||||||
async function addReaction(event) {
|
async function addReaction(event) {
|
||||||
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
|
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
|
||||||
if (!channelID) return // We just assume the bridge has already been created
|
if (!channelID) return // We just assume the bridge has already been created
|
||||||
const messageID = select("event_message", "message_id", {event_id: event.content["m.relates_to"].event_id}, "ORDER BY reaction_part").pluck().get()
|
const messageID = select("event_message", "message_id", {event_id: event.content["m.relates_to"].event_id, part: 0}).pluck().get() // 0 = primary
|
||||||
if (!messageID) return // Nothing can be done if the parent message was never bridged.
|
if (!messageID) return // Nothing can be done if the parent message was never bridged.
|
||||||
|
|
||||||
const key = event.content["m.relates_to"].key
|
const key = event.content["m.relates_to"].key
|
||||||
|
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
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 {Readable} = require("stream")
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {discord, db, select} = passthrough
|
const {discord, db, select} = passthrough
|
||||||
|
|
||||||
|
@ -52,12 +51,12 @@ async function withWebhook(channelID, callback) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} channelID
|
* @param {string} channelID
|
||||||
* @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]}} data
|
* @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer}[]}} data
|
||||||
* @param {string} [threadID]
|
* @param {string} [threadID]
|
||||||
*/
|
*/
|
||||||
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
|
||||||
}
|
}
|
||||||
|
@ -65,7 +64,7 @@ async function sendMessageWithWebhook(channelID, data, threadID) {
|
||||||
/**
|
/**
|
||||||
* @param {string} channelID
|
* @param {string} channelID
|
||||||
* @param {string} messageID
|
* @param {string} messageID
|
||||||
* @param {DiscordTypes.RESTPatchAPIWebhookWithTokenMessageJSONBody & {files?: {name: string, file: Buffer | Readable}[]}} data
|
* @param {DiscordTypes.RESTPatchAPIWebhookWithTokenMessageJSONBody & {files?: {name: string, file: Buffer}[]}} data
|
||||||
* @param {string} [threadID]
|
* @param {string} [threadID]
|
||||||
*/
|
*/
|
||||||
async function editMessageWithWebhook(channelID, messageID, data, threadID) {
|
async function editMessageWithWebhook(channelID, messageID, data, threadID) {
|
||||||
|
|
|
@ -1,36 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
const assert = require("assert")
|
|
||||||
const fetch = require("node-fetch").default
|
|
||||||
|
|
||||||
const utils = require("../converters/utils")
|
|
||||||
const {sync} = require("../../passthrough")
|
|
||||||
|
|
||||||
/** @type {import("../converters/emoji-sheet")} */
|
|
||||||
const emojiSheetConverter = sync.require("../converters/emoji-sheet")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Downloads the emoji from the web and converts to uncompressed PNG data.
|
|
||||||
* @param {string} mxc a single mxc:// URL
|
|
||||||
* @returns {Promise<Buffer | undefined>} uncompressed PNG data, or undefined if the downloaded emoji is not valid
|
|
||||||
*/
|
|
||||||
async function getAndConvertEmoji(mxc) {
|
|
||||||
const abortController = new AbortController()
|
|
||||||
|
|
||||||
const url = utils.getPublicUrlForMxc(mxc)
|
|
||||||
assert(url)
|
|
||||||
|
|
||||||
/** @type {import("node-fetch").Response} */
|
|
||||||
// If it turns out to be a GIF, we want to abandon the connection without downloading the whole thing.
|
|
||||||
// If we were using connection pooling, we would be forced to download the entire GIF.
|
|
||||||
// So we set no agent to ensure we are not connection pooling.
|
|
||||||
// @ts-ignore the signal is slightly different from the type it wants (still works fine)
|
|
||||||
const res = await fetch(url, {agent: false, signal: abortController.signal})
|
|
||||||
return emojiSheetConverter.convertImageStream(res.body, () => {
|
|
||||||
abortController.abort()
|
|
||||||
res.body.pause()
|
|
||||||
res.body.emit("end")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.getAndConvertEmoji = getAndConvertEmoji
|
|
|
@ -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.event_id)
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const Ty = require("../../types")
|
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
|
||||||
const {Readable} = require("stream")
|
|
||||||
const assert = require("assert").strict
|
const assert = require("assert").strict
|
||||||
const crypto = require("crypto")
|
const crypto = require("crypto")
|
||||||
const fetch = require("node-fetch").default
|
const {pipeline} = require("stream")
|
||||||
|
const {promisify} = require("util")
|
||||||
|
const Ty = require("../../types")
|
||||||
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {sync, discord, db, select} = passthrough
|
const {sync, discord, db, select} = passthrough
|
||||||
|
|
||||||
|
@ -15,20 +15,15 @@ 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, url: string} | {name: string, url: string, key: string, iv: string} | {name: string, buffer: Buffer | Readable})[]}} message
|
* @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer}[], pendingFiles?: ({name: string, url: string} | {name: string, url: string, key: string, iv: string} | {name: string, buffer: Buffer})[]}} message
|
||||||
* @returns {Promise<DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]}>}
|
* @returns {Promise<DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer}[]}>}
|
||||||
*/
|
*/
|
||||||
async function resolvePendingFiles(message) {
|
async function resolvePendingFiles(message) {
|
||||||
if (!message.pendingFiles) return message
|
if (!message.pendingFiles) return message
|
||||||
const files = await Promise.all(message.pendingFiles.map(async p => {
|
const files = await Promise.all(message.pendingFiles.map(async p => {
|
||||||
|
let fileBuffer
|
||||||
if ("buffer" in p) {
|
if ("buffer" in p) {
|
||||||
return {
|
return {
|
||||||
name: p.name,
|
name: p.name,
|
||||||
|
@ -36,22 +31,21 @@ async function resolvePendingFiles(message) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ("key" in p) {
|
if ("key" in p) {
|
||||||
// Encrypted file
|
// Encrypted
|
||||||
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
|
fileBuffer = await fetch(p.url).then(res => res.arrayBuffer()).then(x => {
|
||||||
fetch(p.url).then(res => res.body.pipe(d))
|
return Buffer.concat([
|
||||||
return {
|
d.update(Buffer.from(x)),
|
||||||
name: p.name,
|
d.final()
|
||||||
file: d
|
])
|
||||||
}
|
})
|
||||||
} else {
|
} else {
|
||||||
// Unencrypted file
|
// Unencrypted
|
||||||
/** @type {Readable} */ // @ts-ignore
|
fileBuffer = await fetch(p.url).then(res => res.arrayBuffer()).then(x => Buffer.from(x))
|
||||||
const body = await fetch(p.url).then(res => res.body)
|
}
|
||||||
return {
|
return {
|
||||||
name: p.name,
|
name: p.name,
|
||||||
file: body
|
file: fileBuffer // TODO: Once SnowTransfer supports ReadableStreams for attachment uploads, pass in those instead of Buffers
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
const newMessage = {
|
const newMessage = {
|
||||||
|
@ -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, fetch, 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,46 +94,18 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const message of messagesToSend) {
|
for (const message of messagesToSend) {
|
||||||
const reactionPart = messagesToEdit.length === 0 && message === messagesToSend[messagesToSend.length - 1] ? 0 : 1
|
|
||||||
const messageResponse = await channelWebhook.sendMessageWithWebhook(channelID, message, threadID)
|
const messageResponse = await channelWebhook.sendMessageWithWebhook(channelID, message, threadID)
|
||||||
db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(messageResponse.id, threadID || channelID)
|
db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(messageResponse.id, channelID)
|
||||||
db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 0)").run(event.event_id, event.type, event.content["msgtype"] || null, messageResponse.id, eventPart, reactionPart) // source 0 = matrix
|
db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, source) VALUES (?, ?, ?, ?, ?, 0)").run(event.event_id, event.type, event.content["msgtype"] || null, messageResponse.id, eventPart) // source 0 = matrix
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,8 @@ const assert = require("assert").strict
|
||||||
const {pipeline} = require("stream").promises
|
const {pipeline} = require("stream").promises
|
||||||
const sharp = require("sharp")
|
const sharp = require("sharp")
|
||||||
const {GIFrame} = require("giframe")
|
const {GIFrame} = require("giframe")
|
||||||
const {PNG} = require("pngjs")
|
const utils = require("./utils")
|
||||||
|
const fetch = require("node-fetch").default
|
||||||
const streamMimeType = require("stream-mime-type")
|
const streamMimeType = require("stream-mime-type")
|
||||||
|
|
||||||
const SIZE = 48
|
const SIZE = 48
|
||||||
|
@ -14,11 +15,64 @@ const IMAGES_ACROSS = Math.floor(RESULT_WIDTH / SIZE)
|
||||||
/**
|
/**
|
||||||
* Composite a bunch of Matrix emojis into a kind of spritesheet image to upload to Discord.
|
* Composite a bunch of Matrix emojis into a kind of spritesheet image to upload to Discord.
|
||||||
* @param {string[]} mxcs mxc URLs, in order
|
* @param {string[]} mxcs mxc URLs, in order
|
||||||
* @param {(mxc: string) => Promise<Buffer | undefined>} mxcDownloader function that will download the mxc URLs and convert to uncompressed PNG data. use `getAndConvertEmoji` or a mock.
|
|
||||||
* @returns {Promise<Buffer>} PNG image
|
* @returns {Promise<Buffer>} PNG image
|
||||||
*/
|
*/
|
||||||
async function compositeMatrixEmojis(mxcs, mxcDownloader) {
|
async function compositeMatrixEmojis(mxcs) {
|
||||||
const buffers = await Promise.all(mxcs.map(mxcDownloader))
|
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
|
// Calculate the size of the final composited image
|
||||||
const totalWidth = Math.min(buffers.length, IMAGES_ACROSS) * SIZE
|
const totalWidth = Math.min(buffers.length, IMAGES_ACROSS) * SIZE
|
||||||
|
@ -47,67 +101,4 @@ async function compositeMatrixEmojis(mxcs, mxcDownloader) {
|
||||||
return output.data
|
return output.data
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {import("node-fetch").Response["body"]} streamIn
|
|
||||||
* @param {() => any} stopStream
|
|
||||||
* @returns {Promise<Buffer | undefined>} Uncompressed PNG image
|
|
||||||
*/
|
|
||||||
async function convertImageStream(streamIn, stopStream) {
|
|
||||||
const {stream, mime} = await streamMimeType.getMimeType(streamIn)
|
|
||||||
assert(["image/png", "image/jpeg", "image/webp", "image/gif", "image/apng"].includes(mime), `Mime type ${mime} is impossible for emojis`)
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (mime === "image/png" || mime === "image/jpeg" || mime === "image/webp") {
|
|
||||||
/** @type {{info: sharp.OutputInfo, buffer: Buffer}} */
|
|
||||||
const result = await new Promise((resolve, reject) => {
|
|
||||||
const transformer = sharp()
|
|
||||||
.resize(SIZE, SIZE, {fit: "contain", background: {r: 0, g: 0, b: 0, alpha: 0}})
|
|
||||||
.png({compressionLevel: 0})
|
|
||||||
.toBuffer((err, buffer, info) => {
|
|
||||||
/* c8 ignore next */
|
|
||||||
if (err) return reject(err)
|
|
||||||
resolve({info, buffer})
|
|
||||||
})
|
|
||||||
pipeline(
|
|
||||||
stream,
|
|
||||||
transformer
|
|
||||||
)
|
|
||||||
})
|
|
||||||
return result.buffer
|
|
||||||
|
|
||||||
} else if (mime === "image/gif") {
|
|
||||||
const giframe = new GIFrame(0)
|
|
||||||
stream.on("data", chunk => {
|
|
||||||
giframe.feed(chunk)
|
|
||||||
})
|
|
||||||
const frame = await giframe.getFrame()
|
|
||||||
stopStream()
|
|
||||||
|
|
||||||
const buffer = await sharp(frame.pixels, {raw: {width: frame.width, height: frame.height, channels: 4}})
|
|
||||||
.resize(SIZE, SIZE, {fit: "contain", background: {r: 0, g: 0, b: 0, alpha: 0}})
|
|
||||||
.png({compressionLevel: 0})
|
|
||||||
.toBuffer({resolveWithObject: true})
|
|
||||||
return buffer.data
|
|
||||||
|
|
||||||
} else if (mime === "image/apng") {
|
|
||||||
const png = new PNG({maxFrames: 1})
|
|
||||||
// @ts-ignore
|
|
||||||
stream.pipe(png)
|
|
||||||
/** @type {Buffer} */ // @ts-ignore
|
|
||||||
const frame = await new Promise(resolve => png.on("parsed", resolve))
|
|
||||||
stopStream()
|
|
||||||
|
|
||||||
const buffer = await sharp(frame, {raw: {width: png.width, height: png.height, channels: 4}})
|
|
||||||
.resize(SIZE, SIZE, {fit: "contain", background: {r: 0, g: 0, b: 0, alpha: 0}})
|
|
||||||
.png({compressionLevel: 0})
|
|
||||||
.toBuffer({resolveWithObject: true})
|
|
||||||
return buffer.data
|
|
||||||
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
stopStream()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.compositeMatrixEmojis = compositeMatrixEmojis
|
module.exports.compositeMatrixEmojis = compositeMatrixEmojis
|
||||||
module.exports.convertImageStream = convertImageStream
|
|
||||||
|
|
|
@ -1,58 +0,0 @@
|
||||||
const {test} = require("supertape")
|
|
||||||
const {convertImageStream} = require("./emoji-sheet")
|
|
||||||
const fs = require("fs")
|
|
||||||
const {Transform} = require("stream").Transform
|
|
||||||
|
|
||||||
/* c8 ignore next 7 */
|
|
||||||
function slow() {
|
|
||||||
if (process.argv.includes("--slow")) {
|
|
||||||
return test
|
|
||||||
} else {
|
|
||||||
return test.skip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Meter extends Transform {
|
|
||||||
bytes = 0
|
|
||||||
|
|
||||||
_transform(chunk, encoding, cb) {
|
|
||||||
this.bytes += chunk.length
|
|
||||||
this.push(chunk)
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {import("supertape").Test} t
|
|
||||||
* @param {string} path
|
|
||||||
* @param {number} totalSize
|
|
||||||
* @param {number => boolean} sizeCheck
|
|
||||||
*/
|
|
||||||
async function runSingleTest(t, path, totalSize, sizeCheck) {
|
|
||||||
const file = fs.createReadStream(path)
|
|
||||||
const meter = new Meter()
|
|
||||||
const p = file.pipe(meter)
|
|
||||||
const result = await convertImageStream(p, () => {
|
|
||||||
file.pause()
|
|
||||||
file.emit("end")
|
|
||||||
})
|
|
||||||
t.equal(result.subarray(1, 4).toString("ascii"), "PNG", `test that this is a PNG file: ${result.toString("base64").slice(0, 100)}`)
|
|
||||||
/* c8 ignore next 5 */
|
|
||||||
if (sizeCheck(meter.bytes)) {
|
|
||||||
t.pass("read the correct amount of the file")
|
|
||||||
} else {
|
|
||||||
t.fail(`read too much or too little of the file, read: ${meter.bytes}, total: ${totalSize}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
slow()("emoji-sheet: only partial file is read for APNG", async t => {
|
|
||||||
await runSingleTest(t, "test/res/butterfly.png", 2438998, n => n < 2438998 / 4) // should download less than 25% of the file
|
|
||||||
})
|
|
||||||
|
|
||||||
slow()("emoji-sheet: only partial file is read for GIF", async t => {
|
|
||||||
await runSingleTest(t, "test/res/butterfly.gif", 781223, n => n < 781223 / 4) // should download less than 25% of the file
|
|
||||||
})
|
|
||||||
|
|
||||||
slow()("emoji-sheet: entire file is read for static PNG", async t => {
|
|
||||||
await runSingleTest(t, "test/res/RLMgJGfgTPjIQtvvWZsYjhjy.png", 11301, n => n === 11301) // should download entire file
|
|
||||||
})
|
|
|
@ -36,12 +36,8 @@ function encodeEmoji(input, shortcode) {
|
||||||
|
|
||||||
const forceTrimmedList = [
|
const forceTrimmedList = [
|
||||||
"%F0%9F%91%8D", // 👍
|
"%F0%9F%91%8D", // 👍
|
||||||
"%F0%9F%91%8E", // 👎️
|
|
||||||
"%E2%AD%90", // ⭐
|
"%E2%AD%90", // ⭐
|
||||||
"%F0%9F%90%88", // 🐈
|
"%F0%9F%90%88", // 🐈
|
||||||
"%E2%9D%93", // ❓
|
|
||||||
"%F0%9F%8F%86", // 🏆️
|
|
||||||
"%F0%9F%93%9A", // 📚️
|
|
||||||
]
|
]
|
||||||
|
|
||||||
discordPreferredEncoding =
|
discordPreferredEncoding =
|
||||||
|
|
|
@ -2,19 +2,16 @@
|
||||||
|
|
||||||
const Ty = require("../../types")
|
const Ty = require("../../types")
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const {Readable} = require("stream")
|
|
||||||
const chunk = require("chunk-text")
|
const chunk = require("chunk-text")
|
||||||
const TurndownService = require("turndown")
|
const TurndownService = require("turndown")
|
||||||
const domino = require("domino")
|
|
||||||
const assert = require("assert").strict
|
const assert = require("assert").strict
|
||||||
const entities = require("entities")
|
|
||||||
|
|
||||||
const passthrough = require("../../passthrough")
|
const passthrough = require("../../passthrough")
|
||||||
const {sync, db, discord, select, from} = passthrough
|
const {sync, db, discord, select, from} = passthrough
|
||||||
|
/** @type {import("../../matrix/file")} */
|
||||||
|
const file = sync.require("../../matrix/file")
|
||||||
/** @type {import("../converters/utils")} */
|
/** @type {import("../converters/utils")} */
|
||||||
const mxUtils = sync.require("../converters/utils")
|
const utils = sync.require("../converters/utils")
|
||||||
/** @type {import("../../discord/utils")} */
|
|
||||||
const dUtils = sync.require("../../discord/utils")
|
|
||||||
/** @type {import("./emoji-sheet")} */
|
/** @type {import("./emoji-sheet")} */
|
||||||
const emojiSheet = sync.require("./emoji-sheet")
|
const emojiSheet = sync.require("./emoji-sheet")
|
||||||
|
|
||||||
|
@ -33,19 +30,13 @@ const markdownEscapes = [
|
||||||
[/^>/g, '\\>'],
|
[/^>/g, '\\>'],
|
||||||
[/_/g, '\\_'],
|
[/_/g, '\\_'],
|
||||||
[/^(\d+)\. /g, '$1\\. ']
|
[/^(\d+)\. /g, '$1\\. ']
|
||||||
/*
|
]
|
||||||
Strikethrough is deliberately not escaped. Usually when Matrix users type ~~ it's not because they wanted to send ~~,
|
|
||||||
it's because they wanted strikethrough and it didn't work because their client doesn't support it.
|
|
||||||
As bridge developers, we can choose between "messages should look as similar as possible" vs "it was most likely intended to be strikethrough".
|
|
||||||
I went with the latter. Even though the appearance doesn't match, I'd rather it displayed as originally intended for 80% of the readers than for 0%.
|
|
||||||
*/
|
|
||||||
]
|
|
||||||
|
|
||||||
const turndownService = new TurndownService({
|
const turndownService = new TurndownService({
|
||||||
hr: "----",
|
hr: "----",
|
||||||
headingStyle: "atx",
|
headingStyle: "atx",
|
||||||
preformattedCode: true,
|
preformattedCode: true,
|
||||||
codeBlockStyle: "fenced"
|
codeBlockStyle: "fenced",
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -54,17 +45,16 @@ const turndownService = new TurndownService({
|
||||||
*/
|
*/
|
||||||
// @ts-ignore bad type from turndown
|
// @ts-ignore bad type from turndown
|
||||||
turndownService.escape = function (string) {
|
turndownService.escape = function (string) {
|
||||||
return string.replace(/\s+|\S+/g, part => { // match chunks of spaces or non-spaces
|
const escapedWords = string.split(" ").map(word => {
|
||||||
if (part.match(/\s/)) return part // don't process spaces
|
if (word.match(/^https?:\/\//)) {
|
||||||
|
return word
|
||||||
if (part.match(/^https?:\/\//)) {
|
|
||||||
return part
|
|
||||||
} else {
|
} else {
|
||||||
return markdownEscapes.reduce(function (accumulator, escape) {
|
return markdownEscapes.reduce(function (accumulator, escape) {
|
||||||
return accumulator.replace(escape[0], escape[1])
|
return accumulator.replace(escape[0], escape[1])
|
||||||
}, part)
|
}, word)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
return escapedWords.join(" ")
|
||||||
}
|
}
|
||||||
|
|
||||||
turndownService.remove("mx-reply")
|
turndownService.remove("mx-reply")
|
||||||
|
@ -94,15 +84,11 @@ turndownService.addRule("blockquote", {
|
||||||
|
|
||||||
turndownService.addRule("spoiler", {
|
turndownService.addRule("spoiler", {
|
||||||
filter: function (node, options) {
|
filter: function (node, options) {
|
||||||
return node.tagName === "SPAN" && node.hasAttribute("data-mx-spoiler")
|
return node.hasAttribute("data-mx-spoiler")
|
||||||
},
|
},
|
||||||
|
|
||||||
replacement: function (content, node) {
|
replacement: function (content, node) {
|
||||||
if (node.getAttribute("data-mx-spoiler")) {
|
return "||" + content + "||"
|
||||||
// escape parentheses so it can't become a link
|
|
||||||
return `\\(${node.getAttribute("data-mx-spoiler")}\\) ||${content}||`
|
|
||||||
}
|
|
||||||
return `||${content}||`
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -115,40 +101,12 @@ turndownService.addRule("inlineLink", {
|
||||||
},
|
},
|
||||||
|
|
||||||
replacement: function (content, node) {
|
replacement: function (content, node) {
|
||||||
if (node.getAttribute("data-user-id")) {
|
if (node.getAttribute("data-user-id")) return `<@${node.getAttribute("data-user-id")}>`
|
||||||
const user_id = node.getAttribute("data-user-id")
|
|
||||||
const row = select("sim_proxy", ["displayname", "proxy_owner_id"], {user_id}).get()
|
|
||||||
if (row) {
|
|
||||||
return `**@${row.displayname}** (<@${row.proxy_owner_id}>)`
|
|
||||||
} else {
|
|
||||||
return `<@${user_id}>`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (node.getAttribute("data-message-id")) return `https://discord.com/channels/${node.getAttribute("data-guild-id")}/${node.getAttribute("data-channel-id")}/${node.getAttribute("data-message-id")}`
|
|
||||||
if (node.getAttribute("data-channel-id")) return `<#${node.getAttribute("data-channel-id")}>`
|
if (node.getAttribute("data-channel-id")) return `<#${node.getAttribute("data-channel-id")}>`
|
||||||
const href = node.getAttribute("href")
|
const href = node.getAttribute("href")
|
||||||
content = content.replace(/ @.*/, "")
|
let brackets = ["", ""]
|
||||||
if (href === content) return href
|
if (href.startsWith("https://matrix.to")) brackets = ["<", ">"]
|
||||||
if (href.startsWith("https://matrix.to/#/@") && content[0] !== "@") content = "@" + content
|
return "[" + content + "](" + brackets[0] + href + brackets[1] + ")"
|
||||||
return "[" + content + "](" + href + ")"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
turndownService.addRule("listItem", {
|
|
||||||
filter: "li",
|
|
||||||
replacement: function (content, node, options) {
|
|
||||||
content = content
|
|
||||||
.replace(/^\n+/, "") // remove leading newlines
|
|
||||||
.replace(/\n+$/, "\n") // replace trailing newlines with just a single one
|
|
||||||
.replace(/\n/gm, "\n ") // indent
|
|
||||||
var prefix = options.bulletListMarker + " "
|
|
||||||
var parent = node.parentNode
|
|
||||||
if (parent.nodeName === "OL") {
|
|
||||||
var start = parent.getAttribute("start")
|
|
||||||
var index = Array.prototype.indexOf.call(parent.children, node)
|
|
||||||
prefix = (start ? Number(start) + index : index + 1) + ". "
|
|
||||||
}
|
|
||||||
return prefix + content + (node.nextSibling && !/\n$/.test(content) ? "\n" : "")
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -162,8 +120,32 @@ turndownService.addRule("emoji", {
|
||||||
|
|
||||||
replacement: function (content, node) {
|
replacement: function (content, node) {
|
||||||
const mxcUrl = node.getAttribute("src")
|
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, "")
|
const guessedName = node.getAttribute("title").replace(/^:|:$/g, "")
|
||||||
return convertEmoji(mxcUrl, guessedName, true, true)
|
for (const guild of discord.guilds.values()) {
|
||||||
|
/** @type {{name: string, id: string, animated: number}[]} */
|
||||||
|
// @ts-ignore
|
||||||
|
const emojis = guild.emojis
|
||||||
|
const match = emojis.find(e => e.id === row?.emoji_id) || emojis.find(e => e.name === guessedName) || emojis.find(e => e.name?.toLowerCase() === guessedName.toLowerCase())
|
||||||
|
if (match) {
|
||||||
|
guess = match
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (guess) {
|
||||||
|
// We know an emoji, and we can use it
|
||||||
|
const animatedChar = guess.animated ? "a" : ""
|
||||||
|
return `<${animatedChar}:${guess.name}:${guess.id}>`
|
||||||
|
} else if (endOfMessageEmojis.includes(mxcUrl)) {
|
||||||
|
// We can't locate or use a suitable emoji. After control returns, it will rewind over this, delete this section, and upload the emojis as a sprite sheet.
|
||||||
|
return `<::>`
|
||||||
|
} else {
|
||||||
|
// We prefer not to upload this as a sprite sheet because the emoji is not at the end of the message, it is in the middle.
|
||||||
|
return `[${node.getAttribute("title")}](${utils.getPublicUrlForMxc(mxcUrl)})`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -180,7 +162,7 @@ turndownService.addRule("fencedCodeBlock", {
|
||||||
const className = node.firstChild.getAttribute("class") || ""
|
const className = node.firstChild.getAttribute("class") || ""
|
||||||
const language = (className.match(/language-(\S+)/) || [null, ""])[1]
|
const language = (className.match(/language-(\S+)/) || [null, ""])[1]
|
||||||
const code = node.firstChild
|
const code = node.firstChild
|
||||||
const visibleCode = getCodeContent(code)
|
const visibleCode = code.childNodes.map(c => c.nodeName === "BR" ? "\n" : c.textContent).join("").replace(/\n*$/g, "")
|
||||||
|
|
||||||
var fence = "```"
|
var fence = "```"
|
||||||
|
|
||||||
|
@ -192,62 +174,6 @@ turndownService.addRule("fencedCodeBlock", {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/** @param {{ childNodes: Node[]; }} preCode the <code> directly inside the <pre> */
|
|
||||||
function getCodeContent(preCode) {
|
|
||||||
return preCode.childNodes.map(c => c.nodeName === "BR" ? "\n" : c.textContent).join("").replace(/\n*$/g, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string | null} mxcUrl
|
|
||||||
* @param {string | null} nameForGuess without colons
|
|
||||||
* @param {boolean} allowSpriteSheetIndicator
|
|
||||||
* @param {boolean} allowLink
|
|
||||||
* @returns {string} discord markdown that represents the custom emoji in some form
|
|
||||||
*/
|
|
||||||
function convertEmoji(mxcUrl, nameForGuess, allowSpriteSheetIndicator, allowLink) {
|
|
||||||
// Get the known emoji from the database.
|
|
||||||
let row
|
|
||||||
if (mxcUrl) row = select("emoji", ["emoji_id", "name", "animated"], {mxc_url: mxcUrl}).get()
|
|
||||||
// Now we have to search all servers to see if we're able to send this emoji.
|
|
||||||
if (row) {
|
|
||||||
const found = [...discord.guilds.values()].find(g => g.emojis.find(e => e.id === row.id))
|
|
||||||
if (!found) row = null
|
|
||||||
}
|
|
||||||
// Or, if we don't have an emoji right now, we search for the name instead.
|
|
||||||
if (!row && nameForGuess) {
|
|
||||||
const nameForGuessLower = nameForGuess.toLowerCase()
|
|
||||||
for (const guild of discord.guilds.values()) {
|
|
||||||
/** @type {{name: string, id: string, animated: number}[]} */
|
|
||||||
// @ts-ignore
|
|
||||||
const emojis = guild.emojis
|
|
||||||
const found = emojis.find(e => e.id === row?.id || e.name?.toLowerCase() === nameForGuessLower)
|
|
||||||
if (found) {
|
|
||||||
row = {
|
|
||||||
animated: found.animated,
|
|
||||||
emoji_id: found.id,
|
|
||||||
name: found.name
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (row) {
|
|
||||||
// We know an emoji, and we can use it
|
|
||||||
const animatedChar = row.animated ? "a" : ""
|
|
||||||
return `<${animatedChar}:${row.name}:${row.emoji_id}>`
|
|
||||||
} else if (allowSpriteSheetIndicator && mxcUrl && endOfMessageEmojis.includes(mxcUrl)) {
|
|
||||||
// We can't locate or use a suitable emoji. After control returns, it will rewind over this, delete this section, and upload the emojis as a sprite sheet.
|
|
||||||
return `<::>`
|
|
||||||
} else if (allowLink && mxcUrl && nameForGuess) {
|
|
||||||
// We prefer not to upload this as a sprite sheet because the emoji is not at the end of the message, it is in the middle.
|
|
||||||
return `[:${nameForGuess}:](${mxUtils.getPublicUrlForMxc(mxcUrl)})`
|
|
||||||
} else if (nameForGuess) {
|
|
||||||
return `:${nameForGuess}:`
|
|
||||||
} else {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} roomID
|
* @param {string} roomID
|
||||||
* @param {string} mxid
|
* @param {string} mxid
|
||||||
|
@ -265,8 +191,8 @@ async function getMemberFromCacheOrHomeserver(roomID, mxid, api) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Splits a display name into one chunk containing <=80 characters (80 being how many characters Discord allows for the name of a webhook),
|
* Splits a display name into one chunk containing <=80 characters, and another chunk containing the rest of the characters. Splits on
|
||||||
* and another chunk containing the rest of the characters. Splits on whitespace if possible.
|
* whitespace if possible.
|
||||||
* These chunks, respectively, go in the display name, and at the top of the message.
|
* 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
|
* 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.
|
* can be prepended to the message content as-is.
|
||||||
|
@ -289,25 +215,14 @@ function splitDisplayName(displayName) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a Matrix user ID into a Discord user ID for mentioning, where if the user is a PK proxy, it will mention the proxy owner.
|
|
||||||
* @param {string} mxid
|
|
||||||
*/
|
|
||||||
function getUserOrProxyOwnerID(mxid) {
|
|
||||||
const row = from("sim").join("sim_proxy", "user_id", "left").select("user_id", "proxy_owner_id").where({mxid}).get()
|
|
||||||
if (!row) return null
|
|
||||||
return row.proxy_owner_id || row.user_id
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* At the time of this executing, we know what the end of message emojis are, and we know that at least one of them is unknown.
|
* 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.
|
* This function will strip them from the content and generate the correct pending file of the sprite sheet.
|
||||||
* @param {string} content
|
* @param {string} content
|
||||||
* @param {{id: string, name: string}[]} attachments
|
* @param {{id: string, name: string}[]} attachments
|
||||||
* @param {({name: string, url: string} | {name: string, url: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} pendingFiles
|
* @param {({name: string, url: string} | {name: string, url: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} pendingFiles
|
||||||
* @param {(mxc: string) => Promise<Buffer | undefined>} mxcDownloader function that will download the mxc URLs and convert to uncompressed PNG data. use `getAndConvertEmoji` or a mock.
|
|
||||||
*/
|
*/
|
||||||
async function uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles, mxcDownloader) {
|
async function uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles) {
|
||||||
if (!content.includes("<::>")) return content // No unknown emojis, nothing to do
|
if (!content.includes("<::>")) return content // No unknown emojis, nothing to do
|
||||||
// Remove known and unknown emojis from the end of the message
|
// Remove known and unknown emojis from the end of the message
|
||||||
const r = /<a?:[a-zA-Z0-9_]*:[0-9]*>\s*$/
|
const r = /<a?:[a-zA-Z0-9_]*:[0-9]*>\s*$/
|
||||||
|
@ -315,136 +230,25 @@ async function uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles,
|
||||||
content = content.replace(r, "")
|
content = content.replace(r, "")
|
||||||
}
|
}
|
||||||
// Create a sprite sheet of known and unknown emojis from the end of the message
|
// Create a sprite sheet of known and unknown emojis from the end of the message
|
||||||
const buffer = await emojiSheet.compositeMatrixEmojis(endOfMessageEmojis, mxcDownloader)
|
const buffer = await emojiSheet.compositeMatrixEmojis(endOfMessageEmojis)
|
||||||
// Attach it
|
// Attach it
|
||||||
const name = "emojis.png"
|
const name = "emojis.png"
|
||||||
attachments.push({id: String(attachments.length), name})
|
attachments.push({id: "0", name})
|
||||||
pendingFiles.push({name, buffer})
|
pendingFiles.push({name, buffer})
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} input
|
|
||||||
* @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API
|
|
||||||
*/
|
|
||||||
async function handleRoomOrMessageLinks(input, di) {
|
|
||||||
let offset = 0
|
|
||||||
for (const match of [...input.matchAll(/("?https:\/\/matrix.to\/#\/(![^"/, ?)]+)(?:\/(\$[^"/ ?)]+))?(?:\?[^",:!? )]*)?)(">|[, )]|$)/g)]) {
|
|
||||||
assert(typeof match.index === "number")
|
|
||||||
const [_, attributeValue, roomID, eventID, endMarker] = match
|
|
||||||
let result
|
|
||||||
|
|
||||||
const resultType = endMarker === '">' ? "html" : "plain"
|
|
||||||
const MAKE_RESULT = {
|
|
||||||
ROOM_LINK: {
|
|
||||||
html: channelID => `${attributeValue}" data-channel-id="${channelID}">`,
|
|
||||||
plain: channelID => `<#${channelID}>${endMarker}`
|
|
||||||
},
|
|
||||||
MESSAGE_LINK: {
|
|
||||||
html: (guildID, channelID, messageID) => `${attributeValue}" data-channel-id="${channelID}" data-guild-id="${guildID}" data-message-id="${messageID}">`,
|
|
||||||
plain: (guildID, channelID, messageID) => `https://discord.com/channels/${guildID}/${channelID}/${messageID}${endMarker}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't process links that are part of the reply fallback, they'll be removed entirely by turndown
|
|
||||||
if (input.slice(match.index + match[0].length + offset).startsWith("In reply to")) continue
|
|
||||||
|
|
||||||
const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get()
|
|
||||||
if (!channelID) continue
|
|
||||||
if (!eventID) {
|
|
||||||
// 1: It's a room link, so <#link> to the channel
|
|
||||||
result = MAKE_RESULT.ROOM_LINK[resultType](channelID)
|
|
||||||
} else {
|
|
||||||
// Linking to a particular event with a discord.com/channels/guildID/channelID/messageID link
|
|
||||||
// Need to know the guildID and messageID
|
|
||||||
const guildID = discord.channels.get(channelID)?.["guild_id"]
|
|
||||||
if (!guildID) continue
|
|
||||||
const messageID = select("event_message", "message_id", {event_id: eventID}).pluck().get()
|
|
||||||
if (messageID) {
|
|
||||||
// 2: Linking to a known event
|
|
||||||
result = MAKE_RESULT.MESSAGE_LINK[resultType](guildID, channelID, messageID)
|
|
||||||
} else {
|
|
||||||
// 3: Linking to an unknown event that OOYE didn't originally bridge - we can guess messageID from the timestamp
|
|
||||||
let originalEvent
|
|
||||||
try {
|
|
||||||
originalEvent = await di.api.getEvent(roomID, eventID)
|
|
||||||
} catch (e) {
|
|
||||||
continue // Our homeserver doesn't know about the event, so can't resolve it to a Discord link
|
|
||||||
}
|
|
||||||
const guessedMessageID = dUtils.timestampToSnowflakeInexact(originalEvent.origin_server_ts)
|
|
||||||
result = MAKE_RESULT.MESSAGE_LINK[resultType](guildID, channelID, guessedMessageID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input = input.slice(0, match.index + offset) + result + input.slice(match.index + match[0].length + offset)
|
|
||||||
offset += result.length - match[0].length
|
|
||||||
}
|
|
||||||
|
|
||||||
return input
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} content
|
|
||||||
* @param {string} senderMxid
|
|
||||||
* @param {string} roomID
|
|
||||||
* @param {DiscordTypes.APIGuild} guild
|
|
||||||
* @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, fetch: import("node-fetch")["default"]}} di
|
|
||||||
*/
|
|
||||||
async function checkWrittenMentions(content, senderMxid, roomID, guild, di) {
|
|
||||||
let writtenMentionMatch = content.match(/(?:^|[^"[<>/A-Za-z0-9])@([A-Za-z][A-Za-z0-9._\[\]\(\)-]+):?/d) // /d flag for indices requires node.js 16+
|
|
||||||
if (writtenMentionMatch) {
|
|
||||||
if (writtenMentionMatch[1] === "room") { // convert @room to @everyone
|
|
||||||
const powerLevels = await di.api.getStateEvent(roomID, "m.room.power_levels", "")
|
|
||||||
const userPower = powerLevels.users?.[senderMxid] || 0
|
|
||||||
if (userPower >= powerLevels.notifications?.room) {
|
|
||||||
return {
|
|
||||||
// @ts-ignore - typescript doesn't know about indices yet
|
|
||||||
content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `@everyone` + content.slice(writtenMentionMatch.indices[1][1]),
|
|
||||||
ensureJoined: [],
|
|
||||||
allowedMentionsParse: ["everyone"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const results = await di.snow.guild.searchGuildMembers(guild.id, {query: writtenMentionMatch[1]})
|
|
||||||
if (results[0]) {
|
|
||||||
assert(results[0].user)
|
|
||||||
return {
|
|
||||||
// @ts-ignore - typescript doesn't know about indices yet
|
|
||||||
content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `<@${results[0].user.id}>` + content.slice(writtenMentionMatch.indices[1][1]),
|
|
||||||
ensureJoined: [results[0].user],
|
|
||||||
allowedMentionsParse: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Element} node
|
|
||||||
* @param {string[]} tagNames allcaps tag names
|
|
||||||
* @returns {any | undefined} the node you were checking for, or undefined
|
|
||||||
*/
|
|
||||||
function nodeIsChildOf(node, tagNames) {
|
|
||||||
// @ts-ignore
|
|
||||||
for (; node; node = node.parentNode) if (tagNames.includes(node.tagName)) return node
|
|
||||||
}
|
|
||||||
|
|
||||||
const attachmentEmojis = new Map([
|
|
||||||
["m.image", "🖼️"],
|
|
||||||
["m.video", "🎞️"],
|
|
||||||
["m.audio", "🎶"],
|
|
||||||
["m.file", "📄"]
|
|
||||||
])
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker | Ty.Event.Outer_M_Room_Message_Encrypted_File} event
|
* @param {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 {import("discord-api-types/v10").APIGuild} guild
|
||||||
* @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, fetch: import("node-fetch")["default"], mxcDownloader: (mxc: string) => Promise<Buffer | undefined>}} di simple-as-nails dependency injection for the matrix API
|
* @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API
|
||||||
*/
|
*/
|
||||||
async function eventToMessage(event, guild, di) {
|
async function eventToMessage(event, guild, di) {
|
||||||
|
/** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer}[]})[]} */
|
||||||
|
let messages = []
|
||||||
|
|
||||||
let displayName = event.sender
|
let displayName = event.sender
|
||||||
let avatarURL = undefined
|
let avatarURL = undefined
|
||||||
const allowedMentionsParse = ["users", "roles"]
|
|
||||||
/** @type {string[]} */
|
/** @type {string[]} */
|
||||||
let messageIDsToEdit = []
|
let messageIDsToEdit = []
|
||||||
let replyLine = ""
|
let replyLine = ""
|
||||||
|
@ -454,7 +258,7 @@ async function eventToMessage(event, guild, di) {
|
||||||
// Try to extract an accurate display name and avatar URL from the member event
|
// 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)
|
const member = await getMemberFromCacheOrHomeserver(event.room_id, event.sender, di?.api)
|
||||||
if (member.displayname) displayName = member.displayname
|
if (member.displayname) displayName = member.displayname
|
||||||
if (member.avatar_url) avatarURL = mxUtils.getPublicUrlForMxc(member.avatar_url) || undefined
|
if (member.avatar_url) avatarURL = utils.getPublicUrlForMxc(member.avatar_url) || undefined
|
||||||
// If the display name is too long to be put into the webhook (80 characters is the maximum),
|
// 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
|
// put the excess characters into displayNameRunoff, later to be put at the top of the message
|
||||||
let [displayNameShortened, displayNameRunoff] = splitDisplayName(displayName)
|
let [displayNameShortened, displayNameRunoff] = splitDisplayName(displayName)
|
||||||
|
@ -467,18 +271,18 @@ async function eventToMessage(event, guild, di) {
|
||||||
const attachments = []
|
const attachments = []
|
||||||
/** @type {({name: string, url: string} | {name: string, url: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} */
|
/** @type {({name: string, url: string} | {name: string, url: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} */
|
||||||
const pendingFiles = []
|
const pendingFiles = []
|
||||||
/** @type {DiscordTypes.APIUser[]} */
|
|
||||||
const ensureJoined = []
|
|
||||||
|
|
||||||
// Convert content depending on what the message is
|
// Convert content depending on what the message is
|
||||||
if (event.type === "m.room.message" && (event.content.msgtype === "m.text" || event.content.msgtype === "m.emote")) {
|
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.
|
// 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
|
// this event ---is an edit of--> original event ---is a reply to--> past event
|
||||||
await (async () => {
|
await (async () => {
|
||||||
// Check if there is an edit
|
if (!event.content["m.new_content"]) return
|
||||||
const relatesTo = event.content["m.relates_to"]
|
const relatesTo = event.content["m.relates_to"]
|
||||||
if (!event.content["m.new_content"] || !relatesTo || relatesTo.rel_type !== "m.replace") return
|
if (!relatesTo) return
|
||||||
// Check if we have a pointer to what was edited
|
// 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
|
const originalEventId = relatesTo.event_id
|
||||||
if (!originalEventId) return
|
if (!originalEventId) return
|
||||||
messageIDsToEdit = select("event_message", "message_id", {event_id: originalEventId}, "ORDER BY part").pluck().all()
|
messageIDsToEdit = select("event_message", "message_id", {event_id: originalEventId}, "ORDER BY part").pluck().all()
|
||||||
|
@ -510,43 +314,8 @@ async function eventToMessage(event, guild, di) {
|
||||||
await (async () => {
|
await (async () => {
|
||||||
const repliedToEventId = event.content["m.relates_to"]?.["m.in_reply_to"]?.event_id
|
const repliedToEventId = event.content["m.relates_to"]?.["m.in_reply_to"]?.event_id
|
||||||
if (!repliedToEventId) return
|
if (!repliedToEventId) return
|
||||||
let repliedToEvent
|
let repliedToEvent = await di.api.getEvent(event.room_id, repliedToEventId)
|
||||||
try {
|
if (!repliedToEvent) return
|
||||||
repliedToEvent = await di.api.getEvent(event.room_id, repliedToEventId)
|
|
||||||
} catch (e) {
|
|
||||||
// Original event isn't on our homeserver, so we'll *partially* trust the client's reply fallback.
|
|
||||||
// We'll trust the fallback's quoted content and put it in the reply preview, but we won't trust the authorship info on it.
|
|
||||||
|
|
||||||
// (But if the fallback's quoted content doesn't exist, we give up. There's nothing for us to quote.)
|
|
||||||
if (event.content["format"] !== "org.matrix.custom.html" || typeof event.content["formatted_body"] !== "string") {
|
|
||||||
const lines = event.content.body.split("\n")
|
|
||||||
let stage = 0
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
if (stage >= 0 && lines[i][0] === ">") stage = 1
|
|
||||||
if (stage >= 1 && lines[i].trim() === "") stage = 2
|
|
||||||
if (stage === 2 && lines[i].trim() !== "") {
|
|
||||||
event.content.body = lines.slice(i).join("\n")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const mxReply = event.content["formatted_body"]
|
|
||||||
const quoted = mxReply.match(/^<mx-reply><blockquote>.*?In reply to.*?<br>(.*)<\/blockquote><\/mx-reply>/)?.[1]
|
|
||||||
if (!quoted) return
|
|
||||||
const contentPreviewChunks = chunk(
|
|
||||||
entities.decodeHTML5Strict( // Remove entities like & "
|
|
||||||
quoted.replace(/^\s*<blockquote>.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards
|
|
||||||
.replace(/(?:\n|<br>)+/g, " ") // Should all be on one line
|
|
||||||
.replace(/<span [^>]*data-mx-spoiler\b[^>]*>.*?<\/span>/g, "[spoiler]") // Good enough method of removing spoiler content. (I don't want to break out the HTML parser unless I have to.)
|
|
||||||
.replace(/<[^>]+>/g, "") // Completely strip all HTML tags and formatting.
|
|
||||||
), 50)
|
|
||||||
replyLine = "> " + contentPreviewChunks[0]
|
|
||||||
if (contentPreviewChunks.length > 1) replyLine = replyLine.replace(/[,.']$/, "") + "..."
|
|
||||||
replyLine += "\n"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const autoEmoji = new Map(select("auto_emoji", ["name", "emoji_id"], {}, "WHERE name = 'L1' OR name = 'L2'").raw().all())
|
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")}>`
|
replyLine = `<:L1:${autoEmoji.get("L1")}><:L2:${autoEmoji.get("L2")}>`
|
||||||
|
@ -555,16 +324,11 @@ async function eventToMessage(event, guild, di) {
|
||||||
replyLine += `https://discord.com/channels/${guild.id}/${row.channel_id}/${row.message_id} `
|
replyLine += `https://discord.com/channels/${guild.id}/${row.channel_id}/${row.message_id} `
|
||||||
}
|
}
|
||||||
const sender = repliedToEvent.sender
|
const sender = repliedToEvent.sender
|
||||||
const authorID = getUserOrProxyOwnerID(sender)
|
const senderName = sender.match(/@([^:]*)/)?.[1] || sender
|
||||||
|
const authorID = select("sim", "user_id", {mxid: repliedToEvent.sender}).pluck().get()
|
||||||
if (authorID) {
|
if (authorID) {
|
||||||
replyLine += `<@${authorID}>`
|
replyLine += `<@${authorID}>`
|
||||||
} else {
|
} else {
|
||||||
let senderName = select("member_cache", "displayname", {mxid: sender}).pluck().get()
|
|
||||||
if (!senderName) {
|
|
||||||
const match = sender.match(/@([^:]*)/)
|
|
||||||
assert(match)
|
|
||||||
senderName = match[1]
|
|
||||||
}
|
|
||||||
replyLine += `Ⓜ️**${senderName}**`
|
replyLine += `Ⓜ️**${senderName}**`
|
||||||
}
|
}
|
||||||
// If the event has been edited, the homeserver will include the relation in `unsigned`.
|
// If the event has been edited, the homeserver will include the relation in `unsigned`.
|
||||||
|
@ -573,34 +337,24 @@ async function eventToMessage(event, guild, di) {
|
||||||
repliedToEvent.content = repliedToEvent.content["m.new_content"]
|
repliedToEvent.content = repliedToEvent.content["m.new_content"]
|
||||||
}
|
}
|
||||||
let contentPreview
|
let contentPreview
|
||||||
const fileReplyContentAlternative = attachmentEmojis.get(repliedToEvent.content.msgtype)
|
const fileReplyContentAlternative =
|
||||||
|
( repliedToEvent.content.msgtype === "m.image" ? "🖼️"
|
||||||
|
: repliedToEvent.content.msgtype === "m.video" ? "🎞️"
|
||||||
|
: repliedToEvent.content.msgtype === "m.audio" ? "🎶"
|
||||||
|
: repliedToEvent.content.msgtype === "m.file" ? "📄"
|
||||||
|
: null)
|
||||||
if (fileReplyContentAlternative) {
|
if (fileReplyContentAlternative) {
|
||||||
contentPreview = " " + fileReplyContentAlternative
|
contentPreview = " " + fileReplyContentAlternative
|
||||||
} else if (repliedToEvent.unsigned?.redacted_because) {
|
|
||||||
contentPreview = " (in reply to a deleted message)"
|
|
||||||
} else {
|
} else {
|
||||||
// Generate a reply preview for a standard message
|
const repliedToContent = repliedToEvent.content.formatted_body || repliedToEvent.content.body
|
||||||
/** @type {string} */
|
const contentPreviewChunks = chunk(
|
||||||
let repliedToContent = repliedToEvent.content.formatted_body || repliedToEvent.content.body
|
repliedToContent.replace(/.*<\/mx-reply>/, "") // Remove everything before replies, so just use the actual message body
|
||||||
repliedToContent = repliedToContent.replace(/.*<\/mx-reply>/s, "") // Remove everything before replies, so just use the actual message body
|
.replace(/<blockquote>.*?<\/blockquote>/, "") // If the message starts with a blockquote, don't count it and use the message body afterwards
|
||||||
repliedToContent = repliedToContent.replace(/^\s*<blockquote>.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards
|
.replace(/(?:\n|<br>)+/g, " ") // Should all be on one line
|
||||||
repliedToContent = repliedToContent.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.)
|
||||||
repliedToContent = repliedToContent.replace(/<span [^>]*data-mx-spoiler\b[^>]*>.*?<\/span>/g, "[spoiler]") // Good enough method of removing spoiler content. (I don't want to break out the HTML parser unless I have to.)
|
.replace(/<[^>]+>/g, ""), 50) // Completely strip all other formatting.
|
||||||
repliedToContent = repliedToContent.replace(/<img([^>]*)>/g, (_, att) => { // Convert Matrix emoji images into Discord emoji markdown
|
contentPreview = ":\n> "
|
||||||
const mxcUrlMatch = att.match(/\bsrc="(mxc:\/\/[^"]+)"/)
|
contentPreview += contentPreviewChunks.length > 1 ? contentPreviewChunks[0] + "..." : contentPreviewChunks[0]
|
||||||
const titleTextMatch = att.match(/\btitle=":?([^:"]+)/)
|
|
||||||
return convertEmoji(mxcUrlMatch?.[1], titleTextMatch?.[1], false, false)
|
|
||||||
})
|
|
||||||
repliedToContent = repliedToContent.replace(/<[^:>][^>]*>/g, "") // Completely strip all HTML tags and formatting.
|
|
||||||
repliedToContent = entities.decodeHTML5Strict(repliedToContent) // Remove entities like & "
|
|
||||||
const contentPreviewChunks = chunk(repliedToContent, 50)
|
|
||||||
if (contentPreviewChunks.length) {
|
|
||||||
contentPreview = ":\n> " + contentPreviewChunks[0]
|
|
||||||
if (contentPreviewChunks.length > 1) contentPreview = contentPreview.replace(/[,.']$/, "") + "..."
|
|
||||||
} else {
|
|
||||||
console.log("Unable to generate reply preview for this replied-to event because we stripped all of it:", repliedToEvent)
|
|
||||||
contentPreview = ""
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
replyLine = `> ${replyLine}${contentPreview}\n`
|
replyLine = `> ${replyLine}${contentPreview}\n`
|
||||||
})()
|
})()
|
||||||
|
@ -612,29 +366,19 @@ async function eventToMessage(event, guild, di) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handling mentions of Discord users
|
// Handling mentions of Discord users
|
||||||
input = input.replace(/("https:\/\/matrix.to\/#\/((?:@|%40)[^"]+)")>/g, (whole, attributeValue, mxid) => {
|
input = input.replace(/("https:\/\/matrix.to\/#\/(@[^"]+)")>/g, (whole, attributeValue, mxid) => {
|
||||||
mxid = decodeURIComponent(mxid)
|
if (!utils.eventSenderIsFromDiscord(mxid)) return whole
|
||||||
if (mxUtils.eventSenderIsFromDiscord(mxid)) {
|
const userID = select("sim", "user_id", {mxid: mxid}).pluck().get()
|
||||||
// Handle mention of an OOYE sim user by their mxid
|
if (!userID) return whole
|
||||||
const id = select("sim", "user_id", {mxid}).pluck().get()
|
return `${attributeValue} data-user-id="${userID}">`
|
||||||
if (!id) return whole
|
|
||||||
return `${attributeValue} data-user-id="${id}">`
|
|
||||||
} else {
|
|
||||||
// Handle mention of a Matrix user by their mxid
|
|
||||||
// Check if this Matrix user is actually the sim user from another old bridge in the room?
|
|
||||||
const match = mxid.match(/[^:]*discord[^:]*_([0-9]{6,}):/) // try to match @_discord_123456, @_discordpuppet_123456, etc.
|
|
||||||
if (match) return `${attributeValue} data-user-id="${match[1]}">`
|
|
||||||
// Nope, just a real Matrix user.
|
|
||||||
return whole
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handling mentions of rooms and room-messages
|
// Handling mentions of Discord rooms
|
||||||
input = await handleRoomOrMessageLinks(input, di)
|
input = input.replace(/("https:\/\/matrix.to\/#\/(![^"]+)")>/g, (whole, attributeValue, roomID) => {
|
||||||
|
const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get()
|
||||||
// Stripping colons after mentions
|
if (!channelID) return whole
|
||||||
input = input.replace(/( data-user-id.*?<\/a>):?/g, "$1")
|
return `${attributeValue} data-channel-id="${channelID}">`
|
||||||
input = input.replace(/("https:\/\/matrix.to.*?<\/a>):?/g, "$1")
|
})
|
||||||
|
|
||||||
// Element adds a bunch of <br> before </blockquote> but doesn't render them. I can't figure out how this even works in the browser, so let's just delete those.
|
// 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>")
|
input = input.replace(/(?:\n|<br ?\/?>\s*)*<\/blockquote>/g, "</blockquote>")
|
||||||
|
@ -650,7 +394,7 @@ async function eventToMessage(event, guild, di) {
|
||||||
beforeTag = beforeTag || ""
|
beforeTag = beforeTag || ""
|
||||||
afterContext = afterContext || ""
|
afterContext = afterContext || ""
|
||||||
afterTag = afterTag || ""
|
afterTag = afterTag || ""
|
||||||
if (!mxUtils.BLOCK_ELEMENTS.includes(beforeTag.toUpperCase()) && !mxUtils.BLOCK_ELEMENTS.includes(afterTag.toUpperCase())) {
|
if (!utils.BLOCK_ELEMENTS.includes(beforeTag.toUpperCase()) && !utils.BLOCK_ELEMENTS.includes(afterTag.toUpperCase())) {
|
||||||
return beforeContext + "<br>" + afterContext
|
return beforeContext + "<br>" + afterContext
|
||||||
} else {
|
} else {
|
||||||
return whole
|
return whole
|
||||||
|
@ -662,52 +406,6 @@ async function eventToMessage(event, guild, di) {
|
||||||
// input = input.replace(/ /g, " ")
|
// input = input.replace(/ /g, " ")
|
||||||
// There is also a corresponding test to uncomment, named "event2message: whitespace is retained"
|
// There is also a corresponding test to uncomment, named "event2message: whitespace is retained"
|
||||||
|
|
||||||
// Handling written @mentions: we need to look for candidate Discord members to join to the room
|
|
||||||
// This shouldn't apply to code blocks, links, or inside attributes. So editing the HTML tree instead of regular expressions is a sensible choice here.
|
|
||||||
// We're using the domino parser because Turndown uses the same and can reuse this tree.
|
|
||||||
const doc = domino.createDocument(
|
|
||||||
// DOM parsers arrange elements in the <head> and <body>. Wrapping in a custom element ensures elements are reliably arranged in a single element.
|
|
||||||
'<x-turndown id="turndown-root">' + input + '</x-turndown>'
|
|
||||||
);
|
|
||||||
const root = doc.getElementById("turndown-root");
|
|
||||||
async function forEachNode(node) {
|
|
||||||
for (; node; node = node.nextSibling) {
|
|
||||||
// Check written mentions
|
|
||||||
if (node.nodeType === 3 && node.nodeValue.includes("@") && !nodeIsChildOf(node, ["A", "CODE", "PRE"])) {
|
|
||||||
const result = await checkWrittenMentions(node.nodeValue, event.sender, event.room_id, guild, di)
|
|
||||||
if (result) {
|
|
||||||
node.nodeValue = result.content
|
|
||||||
ensureJoined.push(...result.ensureJoined)
|
|
||||||
allowedMentionsParse.push(...result.allowedMentionsParse)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Check for incompatible backticks in code blocks
|
|
||||||
let preNode
|
|
||||||
if (node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"]))) {
|
|
||||||
if (preNode.firstChild?.nodeName === "CODE") {
|
|
||||||
const ext = (preNode.firstChild.className.match(/language-(\S+)/) || [null, "txt"])[1]
|
|
||||||
const filename = `inline_code.${ext}`
|
|
||||||
// Build the replacement <code> node
|
|
||||||
const replacementCode = doc.createElement("code")
|
|
||||||
replacementCode.textContent = `[${filename}]`
|
|
||||||
// Build its containing <span> node
|
|
||||||
const replacement = doc.createElement("span")
|
|
||||||
replacement.appendChild(doc.createTextNode(" "))
|
|
||||||
replacement.appendChild(replacementCode)
|
|
||||||
replacement.appendChild(doc.createTextNode(" "))
|
|
||||||
// Replace the code block with the <span>
|
|
||||||
preNode.replaceWith(replacement)
|
|
||||||
// Upload the code as an attachment
|
|
||||||
const content = getCodeContent(preNode.firstChild)
|
|
||||||
attachments.push({id: String(attachments.length), filename})
|
|
||||||
pendingFiles.push({name: filename, buffer: Buffer.from(content, "utf8")})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await forEachNode(node.firstChild)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await forEachNode(root)
|
|
||||||
|
|
||||||
// SPRITE SHEET EMOJIS FEATURE: Emojis at the end of the message that we don't know about will be reuploaded as a sprite sheet.
|
// 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.
|
// First we need to determine which emojis are at the end.
|
||||||
endOfMessageEmojis = []
|
endOfMessageEmojis = []
|
||||||
|
@ -717,24 +415,18 @@ async function eventToMessage(event, guild, di) {
|
||||||
if (!match[0].includes("data-mx-emoticon")) break
|
if (!match[0].includes("data-mx-emoticon")) break
|
||||||
const mxcUrl = match[0].match(/\bsrc="(mxc:\/\/[^"]+)"/)
|
const mxcUrl = match[0].match(/\bsrc="(mxc:\/\/[^"]+)"/)
|
||||||
if (mxcUrl) endOfMessageEmojis.unshift(mxcUrl[1])
|
if (mxcUrl) endOfMessageEmojis.unshift(mxcUrl[1])
|
||||||
assert(typeof match.index === "number", "Your JavaScript implementation does not comply with TC39: https://tc39.es/ecma262/multipage/text-processing.html#sec-regexpbuiltinexec")
|
if (typeof match.index !== "number") break
|
||||||
last = match.index
|
last = match.index
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ts-ignore bad type from turndown
|
// @ts-ignore bad type from turndown
|
||||||
content = turndownService.turndown(root)
|
content = turndownService.turndown(input)
|
||||||
|
|
||||||
// Put < > around any surviving matrix.to links to hide the URL previews
|
|
||||||
content = content.replace(/\bhttps?:\/\/matrix\.to\/[^ )]*/g, "<$&>")
|
|
||||||
|
|
||||||
// It's designed for commonmark, we need to replace the space-space-newline with just newline
|
// It's designed for commonmark, we need to replace the space-space-newline with just newline
|
||||||
content = content.replace(/ \n/g, "\n")
|
content = content.replace(/ \n/g, "\n")
|
||||||
|
|
||||||
// If there's a blockquote at the start of the message body and this message is a reply, they should be visually separated
|
|
||||||
if (replyLine && content.startsWith("> ")) content = "\n" + content
|
|
||||||
|
|
||||||
// SPRITE SHEET EMOJIS FEATURE:
|
// SPRITE SHEET EMOJIS FEATURE:
|
||||||
content = await uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles, di?.mxcDownloader)
|
content = await uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles)
|
||||||
} else {
|
} else {
|
||||||
// Looks like we're using the plaintext body!
|
// Looks like we're using the plaintext body!
|
||||||
content = event.content.body
|
content = event.content.body
|
||||||
|
@ -743,43 +435,30 @@ async function eventToMessage(event, guild, di) {
|
||||||
content = `* ${displayName} ${content}`
|
content = `* ${displayName} ${content}`
|
||||||
}
|
}
|
||||||
|
|
||||||
content = await handleRoomOrMessageLinks(content, di) // Replace matrix.to links with discord.com equivalents where possible
|
|
||||||
content = content.replace(/\bhttps?:\/\/matrix\.to\/[^ )]*/, "<$&>") // Put < > around any surviving matrix.to links to hide the URL previews
|
|
||||||
|
|
||||||
const result = await checkWrittenMentions(content, event.sender, event.room_id, guild, di)
|
|
||||||
if (result) {
|
|
||||||
content = result.content
|
|
||||||
ensureJoined.push(...result.ensureJoined)
|
|
||||||
allowedMentionsParse.push(...result.allowedMentionsParse)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Markdown needs to be escaped, though take care not to escape the middle of links
|
// Markdown needs to be escaped, though take care not to escape the middle of links
|
||||||
// @ts-ignore bad type from turndown
|
// @ts-ignore bad type from turndown
|
||||||
content = turndownService.escape(content)
|
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")) {
|
} 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 = ""
|
content = ""
|
||||||
const filename = event.content.filename || event.content.body
|
const filename = event.content.body
|
||||||
// A written `event.content.body` will be bridged to Discord's image `description` which is like alt text.
|
|
||||||
// Bridging as description rather than message content in order to match Matrix clients (Element, Neochat) which treat this as alt text or title text.
|
|
||||||
const description = (event.content.body !== event.content.filename && event.content.filename && event.content.body) || undefined
|
|
||||||
if ("url" in event.content) {
|
if ("url" in event.content) {
|
||||||
// Unencrypted
|
// Unencrypted
|
||||||
const url = mxUtils.getPublicUrlForMxc(event.content.url)
|
const url = utils.getPublicUrlForMxc(event.content.url)
|
||||||
assert(url)
|
assert(url)
|
||||||
attachments.push({id: "0", description, filename})
|
attachments.push({id: "0", filename})
|
||||||
pendingFiles.push({name: filename, url})
|
pendingFiles.push({name: filename, url})
|
||||||
} else {
|
} else {
|
||||||
// Encrypted
|
// Encrypted
|
||||||
const url = mxUtils.getPublicUrlForMxc(event.content.file.url)
|
const url = utils.getPublicUrlForMxc(event.content.file.url)
|
||||||
assert(url)
|
assert(url)
|
||||||
assert.equal(event.content.file.key.alg, "A256CTR")
|
assert.equal(event.content.file.key.alg, "A256CTR")
|
||||||
attachments.push({id: "0", description, filename})
|
attachments.push({id: "0", filename})
|
||||||
pendingFiles.push({name: filename, url, key: event.content.file.key.k, iv: event.content.file.iv})
|
pendingFiles.push({name: filename, url, key: event.content.file.key.k, iv: event.content.file.iv})
|
||||||
}
|
}
|
||||||
} else if (event.type === "m.sticker") {
|
} else if (event.type === "m.sticker") {
|
||||||
content = ""
|
content = ""
|
||||||
const url = mxUtils.getPublicUrlForMxc(event.content.url)
|
const url = utils.getPublicUrlForMxc(event.content.url)
|
||||||
assert(url)
|
assert(url)
|
||||||
let filename = event.content.body
|
let filename = event.content.body
|
||||||
if (event.type === "m.sticker") {
|
if (event.type === "m.sticker") {
|
||||||
|
@ -787,11 +466,8 @@ async function eventToMessage(event, guild, di) {
|
||||||
if (event.content.info?.mimetype?.includes("/")) {
|
if (event.content.info?.mimetype?.includes("/")) {
|
||||||
mimetype = event.content.info.mimetype
|
mimetype = event.content.info.mimetype
|
||||||
} else {
|
} else {
|
||||||
const res = await di.fetch(url, {method: "HEAD"})
|
const res = await fetch(url, {method: "HEAD"})
|
||||||
if (res.status === 200) {
|
mimetype = res.headers.get("content-type") || "image/webp"
|
||||||
mimetype = res.headers.get("content-type")
|
|
||||||
}
|
|
||||||
if (!mimetype) throw new Error(`Server error ${res.status} or missing content-type while detecting sticker mimetype`)
|
|
||||||
}
|
}
|
||||||
filename += "." + mimetype.split("/")[1]
|
filename += "." + mimetype.split("/")[1]
|
||||||
}
|
}
|
||||||
|
@ -803,15 +479,11 @@ async function eventToMessage(event, guild, di) {
|
||||||
|
|
||||||
// Split into 2000 character chunks
|
// Split into 2000 character chunks
|
||||||
const chunks = chunk(content, 2000)
|
const chunks = chunk(content, 2000)
|
||||||
/** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]})[]} */
|
messages = messages.concat(chunks.map(content => ({
|
||||||
const messages = chunks.map(content => ({
|
|
||||||
content,
|
content,
|
||||||
allowed_mentions: {
|
|
||||||
parse: allowedMentionsParse
|
|
||||||
},
|
|
||||||
username: displayNameShortened,
|
username: displayNameShortened,
|
||||||
avatar_url: avatarURL
|
avatar_url: avatarURL
|
||||||
}))
|
})))
|
||||||
|
|
||||||
if (attachments.length) {
|
if (attachments.length) {
|
||||||
// If content is empty (should be the case when uploading a file) then chunk-text will create 0 messages.
|
// If content is empty (should be the case when uploading a file) then chunk-text will create 0 messages.
|
||||||
|
@ -846,8 +518,7 @@ async function eventToMessage(event, guild, di) {
|
||||||
return {
|
return {
|
||||||
messagesToEdit,
|
messagesToEdit,
|
||||||
messagesToSend,
|
messagesToSend,
|
||||||
messagesToDelete: messageIDsToEdit,
|
messagesToDelete: messageIDsToEdit
|
||||||
ensureJoined
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -16,7 +16,6 @@ const BLOCK_ELEMENTS = [
|
||||||
"NOSCRIPT", "OL", "OUTPUT", "P", "PRE", "SECTION", "SUMMARY", "TABLE", "TBODY", "TD",
|
"NOSCRIPT", "OL", "OUTPUT", "P", "PRE", "SECTION", "SUMMARY", "TABLE", "TBODY", "TD",
|
||||||
"TFOOT", "TH", "THEAD", "TR", "UL"
|
"TFOOT", "TH", "THEAD", "TR", "UL"
|
||||||
]
|
]
|
||||||
const NEWLINE_ELEMENTS = BLOCK_ELEMENTS.concat(["BR"])
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine whether an event is the bridged representation of a discord message.
|
* Determine whether an event is the bridged representation of a discord message.
|
||||||
|
@ -64,159 +63,7 @@ function getEventIDHash(eventID) {
|
||||||
return signedHash
|
return signedHash
|
||||||
}
|
}
|
||||||
|
|
||||||
class MatrixStringBuilder {
|
|
||||||
constructor() {
|
|
||||||
this.body = ""
|
|
||||||
this.formattedBody = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} body
|
|
||||||
* @param {string} [formattedBody]
|
|
||||||
* @param {any} [condition]
|
|
||||||
*/
|
|
||||||
add(body, formattedBody, condition = true) {
|
|
||||||
if (condition) {
|
|
||||||
if (formattedBody == undefined) formattedBody = body
|
|
||||||
this.body += body
|
|
||||||
this.formattedBody += formattedBody
|
|
||||||
}
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} body
|
|
||||||
* @param {string} [formattedBody]
|
|
||||||
* @param {any} [condition]
|
|
||||||
*/
|
|
||||||
addLine(body, formattedBody, condition = true) {
|
|
||||||
if (condition) {
|
|
||||||
if (formattedBody == undefined) formattedBody = body
|
|
||||||
if (this.body.length && this.body.slice(-1) !== "\n") this.body += "\n"
|
|
||||||
this.body += body
|
|
||||||
const match = this.formattedBody.match(/<\/?([a-zA-Z]+[a-zA-Z0-9]*)[^>]*>\s*$/)
|
|
||||||
if (this.formattedBody.length && (!match || !NEWLINE_ELEMENTS.includes(match[1].toUpperCase()))) this.formattedBody += "<br>"
|
|
||||||
this.formattedBody += formattedBody
|
|
||||||
}
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} body
|
|
||||||
* @param {string} [formattedBody]
|
|
||||||
* @param {any} [condition]
|
|
||||||
*/
|
|
||||||
addParagraph(body, formattedBody, condition = true) {
|
|
||||||
if (condition) {
|
|
||||||
if (formattedBody == undefined) formattedBody = body
|
|
||||||
if (this.body.length && this.body.slice(-1) !== "\n") this.body += "\n\n"
|
|
||||||
this.body += body
|
|
||||||
formattedBody = `<p>${formattedBody}</p>`
|
|
||||||
this.formattedBody += formattedBody
|
|
||||||
}
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
get() {
|
|
||||||
return {
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: this.body,
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: this.formattedBody
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Context: Room IDs are not routable on their own. Room permalinks need a list of servers to try. The client is responsible for coming up with a list of servers.
|
|
||||||
* ASSUMPTION 1: The bridge bot is a member of the target room and can therefore access its power levels and member list for calculation.
|
|
||||||
* ASSUMPTION 2: Because the bridge bot is a member of the target room, the target room is bridged.
|
|
||||||
* https://spec.matrix.org/v1.9/appendices/#routing
|
|
||||||
* https://gitdab.com/cadence/out-of-your-element/issues/11
|
|
||||||
* @param {string} roomID
|
|
||||||
* @param {{[K in "getStateEvent" | "getJoinedMembers"]: import("../../matrix/api")[K]}} api
|
|
||||||
*/
|
|
||||||
async function getViaServers(roomID, api) {
|
|
||||||
const candidates = []
|
|
||||||
const {joined} = await api.getJoinedMembers(roomID)
|
|
||||||
// Candidate 0: The bot's own server name
|
|
||||||
candidates.push(reg.ooye.server_name)
|
|
||||||
// Candidate 1: Highest joined non-sim non-bot power level user in the room
|
|
||||||
// https://github.com/matrix-org/matrix-react-sdk/blob/552c65db98b59406fb49562e537a2721c8505517/src/utils/permalinks/Permalinks.ts#L172
|
|
||||||
try {
|
|
||||||
/** @type {{users?: {[mxid: string]: number}}} */
|
|
||||||
const powerLevels = await api.getStateEvent(roomID, "m.room.power_levels", "")
|
|
||||||
if (powerLevels.users) {
|
|
||||||
const sorted = Object.entries(powerLevels.users).sort((a, b) => b[1] - a[1]) // Highest...
|
|
||||||
for (const power of sorted) {
|
|
||||||
const mxid = power[0]
|
|
||||||
if (!(mxid in joined)) continue // joined...
|
|
||||||
if (userRegex.some(r => mxid.match(r))) continue // non-sim non-bot...
|
|
||||||
const match = mxid.match(/:(.*)/)
|
|
||||||
assert(match)
|
|
||||||
if (!candidates.includes(match[1])) {
|
|
||||||
candidates.push(match[1])
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// power levels event not found
|
|
||||||
}
|
|
||||||
// Candidates 2-3: Most popular servers in the room
|
|
||||||
/** @type {Map<string, number>} */
|
|
||||||
const servers = new Map()
|
|
||||||
// We can get the most popular servers if we know the members, so let's process those...
|
|
||||||
Object.keys(joined)
|
|
||||||
.filter(mxid => !mxid.startsWith("@_")) // Quick check
|
|
||||||
.filter(mxid => !userRegex.some(r => mxid.match(r))) // Full check
|
|
||||||
.slice(0, 1000) // Just sample the first thousand real members
|
|
||||||
.map(mxid => {
|
|
||||||
const match = mxid.match(/:(.*)/)
|
|
||||||
assert(match)
|
|
||||||
return match[1]
|
|
||||||
})
|
|
||||||
.filter(server => !server.match(/([a-f0-9:]+:+)+[a-f0-9]+/)) // No IPv6 servers
|
|
||||||
.filter(server => !server.match(/[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/)) // No IPv4 servers
|
|
||||||
// I don't care enough to check ACLs
|
|
||||||
.forEach(server => {
|
|
||||||
const existing = servers.get(server)
|
|
||||||
if (!existing) servers.set(server, 1)
|
|
||||||
else servers.set(server, existing + 1)
|
|
||||||
})
|
|
||||||
const serverList = [...servers.entries()].sort((a, b) => b[1] - a[1])
|
|
||||||
for (const server of serverList) {
|
|
||||||
if (!candidates.includes(server[0])) {
|
|
||||||
candidates.push(server[0])
|
|
||||||
if (candidates.length >= 4) break // Can have at most 4 candidate via servers
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return candidates
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Context: Room IDs are not routable on their own. Room permalinks need a list of servers to try. The client is responsible for coming up with a list of servers.
|
|
||||||
* ASSUMPTION 1: The bridge bot is a member of the target room and can therefore access its power levels and member list for calculation.
|
|
||||||
* ASSUMPTION 2: Because the bridge bot is a member of the target room, the target room is bridged.
|
|
||||||
* https://spec.matrix.org/v1.9/appendices/#routing
|
|
||||||
* https://gitdab.com/cadence/out-of-your-element/issues/11
|
|
||||||
* @param {string} roomID
|
|
||||||
* @param {{[K in "getStateEvent" | "getJoinedMembers"]: import("../../matrix/api")[K]}} api
|
|
||||||
* @returns {Promise<URLSearchParams>}
|
|
||||||
*/
|
|
||||||
async function getViaServersQuery(roomID, api) {
|
|
||||||
const list = await getViaServers(roomID, api)
|
|
||||||
const qs = new URLSearchParams()
|
|
||||||
for (const server of list) {
|
|
||||||
qs.append("via", server)
|
|
||||||
}
|
|
||||||
return qs
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.BLOCK_ELEMENTS = BLOCK_ELEMENTS
|
module.exports.BLOCK_ELEMENTS = BLOCK_ELEMENTS
|
||||||
module.exports.eventSenderIsFromDiscord = eventSenderIsFromDiscord
|
module.exports.eventSenderIsFromDiscord = eventSenderIsFromDiscord
|
||||||
module.exports.getPublicUrlForMxc = getPublicUrlForMxc
|
module.exports.getPublicUrlForMxc = getPublicUrlForMxc
|
||||||
module.exports.getEventIDHash = getEventIDHash
|
module.exports.getEventIDHash = getEventIDHash
|
||||||
module.exports.MatrixStringBuilder = MatrixStringBuilder
|
|
||||||
module.exports.getViaServers = getViaServers
|
|
||||||
module.exports.getViaServersQuery = getViaServersQuery
|
|
||||||
|
|
|
@ -1,23 +1,7 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const e = new Error("Custom error")
|
|
||||||
|
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
const {eventSenderIsFromDiscord, getEventIDHash, MatrixStringBuilder, getViaServers} = require("./utils")
|
const {eventSenderIsFromDiscord, getEventIDHash} = require("./utils")
|
||||||
const util = require("util")
|
|
||||||
|
|
||||||
/** @param {string[]} mxids */
|
|
||||||
function joinedList(mxids) {
|
|
||||||
/** @type {{[mxid: string]: {display_name: null, avatar_url: null}}} */
|
|
||||||
const joined = {}
|
|
||||||
for (const mxid of mxids) {
|
|
||||||
joined[mxid] = {
|
|
||||||
display_name: null,
|
|
||||||
avatar_url: null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {joined}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("sender type: matrix user", t => {
|
test("sender type: matrix user", t => {
|
||||||
t.notOk(eventSenderIsFromDiscord("@cadence:cadence.moe"))
|
t.notOk(eventSenderIsFromDiscord("@cadence:cadence.moe"))
|
||||||
|
@ -39,140 +23,3 @@ test("event hash: hash is the same each time", t => {
|
||||||
test("event hash: hash is different for different inputs", t => {
|
test("event hash: hash is different for different inputs", t => {
|
||||||
t.notEqual(getEventIDHash("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe1"), getEventIDHash("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe2"))
|
t.notEqual(getEventIDHash("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe1"), getEventIDHash("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe2"))
|
||||||
})
|
})
|
||||||
|
|
||||||
test("MatrixStringBuilder: add, addLine, add same text", t => {
|
|
||||||
const gatewayMessage = {t: "MY_MESSAGE", d: {display: "Custom message data"}}
|
|
||||||
let stackLines = e.stack?.split("\n")
|
|
||||||
|
|
||||||
const builder = new MatrixStringBuilder()
|
|
||||||
builder.addLine("\u26a0 Bridged event from Discord not delivered", "\u26a0 <strong>Bridged event from Discord not delivered</strong>")
|
|
||||||
builder.addLine(`Gateway event: ${gatewayMessage.t}`)
|
|
||||||
builder.addLine(e.toString())
|
|
||||||
if (stackLines) {
|
|
||||||
stackLines = stackLines.slice(0, 2)
|
|
||||||
stackLines[1] = stackLines[1].replace(/\\/g, "/").replace(/(\s*at ).*(\/m2d\/)/, "$1.$2")
|
|
||||||
builder.addLine(`Error trace:`, `<details><summary>Error trace</summary>`)
|
|
||||||
builder.add(`\n${stackLines.join("\n")}`, `<pre>${stackLines.join("\n")}</pre></details>`)
|
|
||||||
}
|
|
||||||
builder.addLine("", `<details><summary>Original payload</summary><pre>${util.inspect(gatewayMessage.d, false, 4, false)}</pre></details>`)
|
|
||||||
|
|
||||||
t.deepEqual(builder.get(), {
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "\u26a0 Bridged event from Discord not delivered"
|
|
||||||
+ "\nGateway event: MY_MESSAGE"
|
|
||||||
+ "\nError: Custom error"
|
|
||||||
+ "\nError trace:"
|
|
||||||
+ "\nError: Custom error"
|
|
||||||
+ "\n at ./m2d/converters/utils.test.js:3:11)\n",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: "\u26a0 <strong>Bridged event from Discord not delivered</strong>"
|
|
||||||
+ "<br>Gateway event: MY_MESSAGE"
|
|
||||||
+ "<br>Error: Custom error"
|
|
||||||
+ "<br><details><summary>Error trace</summary><pre>Error: Custom error\n at ./m2d/converters/utils.test.js:3:11)</pre></details>"
|
|
||||||
+ `<details><summary>Original payload</summary><pre>{ display: 'Custom message data' }</pre></details>`
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("MatrixStringBuilder: complete code coverage", t => {
|
|
||||||
const builder = new MatrixStringBuilder()
|
|
||||||
builder.add("Line 1")
|
|
||||||
builder.addParagraph("Line 2")
|
|
||||||
builder.add("Line 3")
|
|
||||||
builder.addParagraph("Line 4")
|
|
||||||
|
|
||||||
t.deepEqual(builder.get(), {
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "Line 1\n\nLine 2Line 3\n\nLine 4",
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: "Line 1<p>Line 2</p>Line 3<p>Line 4</p>"
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("getViaServers: returns the server name if the room only has sim users", async t => {
|
|
||||||
const result = await getViaServers("!baby", {
|
|
||||||
getStateEvent: async () => ({}),
|
|
||||||
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe"])
|
|
||||||
})
|
|
||||||
t.deepEqual(result, ["cadence.moe"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("getViaServers: also returns the most popular servers in order", async t => {
|
|
||||||
const result = await getViaServers("!baby", {
|
|
||||||
getStateEvent: async () => ({}),
|
|
||||||
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid"])
|
|
||||||
})
|
|
||||||
t.deepEqual(result, ["cadence.moe", "thecollective.invalid", "selfhosted.invalid"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("getViaServers: does not return IP address servers", async t => {
|
|
||||||
const result = await getViaServers("!baby", {
|
|
||||||
getStateEvent: async () => ({}),
|
|
||||||
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:45.77.232.172:8443", "@cadence:[::1]:8443", "@cadence:123example.456example.invalid"])
|
|
||||||
})
|
|
||||||
t.deepEqual(result, ["cadence.moe", "123example.456example.invalid"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("getViaServers: also returns the highest power level user (100)", async t => {
|
|
||||||
const result = await getViaServers("!baby", {
|
|
||||||
getStateEvent: async () => ({
|
|
||||||
users: {
|
|
||||||
"@moderator:tractor.invalid": 50,
|
|
||||||
"@singleuser:selfhosted.invalid": 100,
|
|
||||||
"@_ooye_bot:cadence.moe": 100
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid", "@moderator:tractor.invalid"])
|
|
||||||
})
|
|
||||||
t.deepEqual(result, ["cadence.moe", "selfhosted.invalid", "thecollective.invalid", "tractor.invalid"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("getViaServers: also returns the highest power level user (50)", async t => {
|
|
||||||
const result = await getViaServers("!baby", {
|
|
||||||
getStateEvent: async () => ({
|
|
||||||
users: {
|
|
||||||
"@moderator:tractor.invalid": 50,
|
|
||||||
"@_ooye_bot:cadence.moe": 100
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid", "@singleuser:selfhosted.invalid"])
|
|
||||||
})
|
|
||||||
t.deepEqual(result, ["cadence.moe", "tractor.invalid", "thecollective.invalid", "selfhosted.invalid"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("getViaServers: returns at most 4 results", async t => {
|
|
||||||
const result = await getViaServers("!baby", {
|
|
||||||
getStateEvent: async () => ({
|
|
||||||
users: {
|
|
||||||
"@moderator:tractor.invalid": 50,
|
|
||||||
"@singleuser:selfhosted.invalid": 100,
|
|
||||||
"@_ooye_bot:cadence.moe": 100
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@cadence:123example.456example.invalid"])
|
|
||||||
})
|
|
||||||
t.deepEqual(result.length, 4)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("getViaServers: returns results even when power levels can't be fetched", async t => {
|
|
||||||
const result = await getViaServers("!baby", {
|
|
||||||
getStateEvent: async () => {
|
|
||||||
throw new Error("event not found or something")
|
|
||||||
},
|
|
||||||
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@cadence:123example.456example.invalid"])
|
|
||||||
})
|
|
||||||
t.deepEqual(result.length, 4)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("getViaServers: only considers power levels of currently joined members", async t => {
|
|
||||||
const result = await getViaServers("!baby", {
|
|
||||||
getStateEvent: async () => ({
|
|
||||||
users: {
|
|
||||||
"@moderator:tractor.invalid": 50,
|
|
||||||
"@former_moderator:missing.invalid": 100,
|
|
||||||
"@_ooye_bot:cadence.moe": 100
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid", "@singleuser:selfhosted.invalid"])
|
|
||||||
})
|
|
||||||
t.deepEqual(result, ["cadence.moe", "tractor.invalid", "thecollective.invalid", "selfhosted.invalid"])
|
|
||||||
})
|
|
||||||
|
|
|
@ -19,17 +19,15 @@ const makeTxnId = sync.require("./txnid")
|
||||||
* @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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -84,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[]>}
|
||||||
|
@ -115,38 +103,21 @@ 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`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} roomID
|
* @param {string} roomID
|
||||||
* @param {string} eventID
|
* @param {string} eventID
|
||||||
* @param {{from?: string, limit?: any}} pagination
|
|
||||||
* @param {string?} [relType]
|
* @param {string?} [relType]
|
||||||
* @returns {Promise<Ty.Pagination<Ty.Event.Outer<any>>>}
|
* @returns {Promise<Ty.Pagination<Ty.Event.Outer<any>>>}
|
||||||
*/
|
*/
|
||||||
function getRelations(roomID, eventID, pagination, relType) {
|
function getRelations(roomID, eventID, relType) {
|
||||||
let path = `/client/v1/rooms/${roomID}/relations/${eventID}`
|
let path = `/client/v1/rooms/${roomID}/relations/${eventID}`
|
||||||
if (relType) path += `/${relType}`
|
if (relType) path += `/${relType}`
|
||||||
if (!pagination.from) delete pagination.from
|
|
||||||
if (!pagination.limit) pagination.limit = 50 // get a little more consistency between homeservers
|
|
||||||
path += `?${new URLSearchParams(pagination)}`
|
|
||||||
return mreq.mreq("GET", path)
|
return mreq.mreq("GET", path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -248,11 +219,9 @@ 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.getHierarchy = getHierarchy
|
|
||||||
module.exports.getRelations = getRelations
|
module.exports.getRelations = getRelations
|
||||||
module.exports.sendState = sendState
|
module.exports.sendState = sendState
|
||||||
module.exports.sendEvent = sendEvent
|
module.exports.sendEvent = sendEvent
|
||||||
|
|
|
@ -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")
|
|
||||||
})
|
|
||||||
|
|
|
@ -2,14 +2,8 @@
|
||||||
|
|
||||||
const assert = require("assert").strict
|
const assert = require("assert").strict
|
||||||
const mixin = require("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()
|
|
||||||
})
|
|
||||||
|
|
|
@ -73,7 +73,6 @@ function onReactionAdd(event) {
|
||||||
* @callback CommandExecute
|
* @callback CommandExecute
|
||||||
* @param {Ty.Event.Outer_M_Room_Message} event
|
* @param {Ty.Event.Outer_M_Room_Message} event
|
||||||
* @param {string} realBody
|
* @param {string} realBody
|
||||||
* @param {string[]} words
|
|
||||||
* @param {any} [ctx]
|
* @param {any} [ctx]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@ -86,13 +85,62 @@ function onReactionAdd(event) {
|
||||||
/** @param {CommandExecute} execute */
|
/** @param {CommandExecute} execute */
|
||||||
function replyctx(execute) {
|
function replyctx(execute) {
|
||||||
/** @type {CommandExecute} */
|
/** @type {CommandExecute} */
|
||||||
return function(event, realBody, words, ctx = {}) {
|
return function(event, realBody, ctx = {}) {
|
||||||
ctx["m.relates_to"] = {
|
ctx["m.relates_to"] = {
|
||||||
"m.in_reply_to": {
|
"m.in_reply_to": {
|
||||||
event_id: event.event_id
|
event_id: event.event_id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return execute(event, realBody, words, ctx)
|
return execute(event, realBody, ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -100,7 +148,7 @@ function replyctx(execute) {
|
||||||
const commands = [{
|
const commands = [{
|
||||||
aliases: ["emoji"],
|
aliases: ["emoji"],
|
||||||
execute: replyctx(
|
execute: replyctx(
|
||||||
async (event, realBody, words, ctx) => {
|
async (event, realBody, ctx) => {
|
||||||
// Guard
|
// Guard
|
||||||
/** @type {string} */ // @ts-ignore
|
/** @type {string} */ // @ts-ignore
|
||||||
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
|
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
|
||||||
|
@ -117,7 +165,7 @@ const commands = [{
|
||||||
const permissions = dUtils.getPermissions([], guild.roles)
|
const permissions = dUtils.getPermissions([], guild.roles)
|
||||||
if (guild.emojis.length >= slots) {
|
if (guild.emojis.length >= slots) {
|
||||||
matrixOnlyReason = "CAPACITY"
|
matrixOnlyReason = "CAPACITY"
|
||||||
} else if (!(permissions & 0x40000000n)) { // MANAGE_GUILD_EXPRESSIONS (apparently CREATE_GUILD_EXPRESSIONS isn't good enough...)
|
} else if (!(permissions | 0x40000000n)) { // MANAGE_GUILD_EXPRESSIONS (apparently CREATE_GUILD_EXPRESSIONS isn't good enough...)
|
||||||
matrixOnlyReason = "USER_PERMISSIONS"
|
matrixOnlyReason = "USER_PERMISSIONS"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -170,7 +218,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 +249,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] = {
|
||||||
|
@ -236,36 +284,6 @@ const commands = [{
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}, {
|
|
||||||
aliases: ["thread"],
|
|
||||||
execute: replyctx(
|
|
||||||
async (event, realBody, words, ctx) => {
|
|
||||||
// Guard
|
|
||||||
/** @type {string} */ // @ts-ignore
|
|
||||||
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
|
|
||||||
const guildID = discord.channels.get(channelID)?.["guild_id"]
|
|
||||||
if (!guildID) {
|
|
||||||
return api.sendEvent(event.room_id, "m.room.message", {
|
|
||||||
...ctx,
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "This room isn't bridged to the other side."
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const guild = discord.guilds.get(guildID)
|
|
||||||
assert(guild)
|
|
||||||
const permissions = dUtils.getPermissions([], guild.roles)
|
|
||||||
if (!(permissions & 0x800000000n)) { // CREATE_PUBLIC_THREADS
|
|
||||||
return api.sendEvent(event.room_id, "m.room.message", {
|
|
||||||
...ctx,
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: "This command creates a thread on Discord. But you aren't allowed to do this, because if you were a Discord user, you wouldn't have the Create Public Threads permission."
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await discord.snow.channel.createThreadWithoutMessage(channelID, {type: 11, name: words.slice(1).join(" ")})
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}]
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
@ -290,7 +308,7 @@ async function execute(event) {
|
||||||
const command = commands.find(c => c.aliases.includes(commandName))
|
const command = commands.find(c => c.aliases.includes(commandName))
|
||||||
if (!command) return
|
if (!command) return
|
||||||
|
|
||||||
await command.execute(event, realBody, words)
|
await command.execute(event, realBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.execute = execute
|
module.exports.execute = execute
|
||||||
|
|
|
@ -2,8 +2,6 @@
|
||||||
|
|
||||||
const fetch = require("node-fetch").default
|
const fetch = require("node-fetch").default
|
||||||
const mixin = require("mixin-deep")
|
const mixin = require("mixin-deep")
|
||||||
const stream = require("stream")
|
|
||||||
const getStream = require("get-stream")
|
|
||||||
|
|
||||||
const passthrough = require("../passthrough")
|
const passthrough = require("../passthrough")
|
||||||
const { sync } = passthrough
|
const { sync } = passthrough
|
||||||
|
@ -29,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}`
|
||||||
}
|
}
|
||||||
|
@ -47,18 +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")) {
|
|
||||||
console.error(`OOYE cannot stream uploads to Synapse. Please choose one of these workarounds:`
|
|
||||||
+ `\n * Run an nginx reverse proxy to Synapse, and point registration.yaml's`
|
|
||||||
+ `\n \`server_origin\` to nginx`
|
|
||||||
+ `\n * Set \`content_length_workaround: true\` in registration.yaml (this will`
|
|
||||||
+ `\n halve the speed of bridging d->m files)`)
|
|
||||||
throw new Error("Synapse is not accepting stream uploads, see the message above.")
|
|
||||||
}
|
|
||||||
delete opts.headers.Authorization
|
|
||||||
throw new MatrixServerError(root, {baseUrl, url, ...opts})
|
|
||||||
}
|
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
|
@ -16,24 +16,21 @@
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chriscdn/promise-semaphore": "^2.0.1",
|
"@chriscdn/promise-semaphore": "^2.0.1",
|
||||||
"better-sqlite3": "^9.0.0",
|
"better-sqlite3": "^8.3.0",
|
||||||
"chunk-text": "^2.0.1",
|
"chunk-text": "^2.0.1",
|
||||||
"cloudstorm": "^0.10.8",
|
"cloudstorm": "^0.8.0",
|
||||||
"discord-markdown": "git+https://git.sr.ht/~cadence/nodejs-discord-markdown#2881b447954fcea10510f212fa4c1dbbdc0a57a3",
|
"discord-markdown": "git+https://git.sr.ht/~cadence/nodejs-discord-markdown#abc56d544072a1dc5624adfea455b0e902adf7b3",
|
||||||
"entities": "^4.5.0",
|
"giframe": "github:cloudrac3r/giframe#v0.4.1",
|
||||||
"get-stream": "^6.0.1",
|
"heatsync": "^2.4.1",
|
||||||
"giframe": "github:cloudrac3r/giframe#v0.4.2",
|
|
||||||
"heatsync": "^2.5.3",
|
|
||||||
"html-template-tag": "github:cloudrac3r/html-template-tag#v5.0",
|
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"matrix-appservice": "^2.0.0",
|
"matrix-appservice": "^2.0.0",
|
||||||
"minimist": "^1.2.8",
|
"minimist": "^1.2.8",
|
||||||
"mixin-deep": "github:cloudrac3r/mixin-deep#v3.0.0",
|
"mixin-deep": "github:cloudrac3r/mixin-deep#v3.0.0",
|
||||||
"node-fetch": "^2.6.7",
|
"node-fetch": "^2.6.7",
|
||||||
"pngjs": "github:cloudrac3r/pngjs#v7.0.2",
|
"pngjs": "^7.0.0",
|
||||||
"prettier-bytes": "^1.0.4",
|
"prettier-bytes": "^1.0.4",
|
||||||
"sharp": "^0.32.6",
|
"sharp": "^0.32.6",
|
||||||
"snowtransfer": "^0.10.5",
|
"snowtransfer": "^0.8.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",
|
||||||
"turndown": "^7.1.2",
|
"turndown": "^7.1.2",
|
||||||
|
@ -43,16 +40,15 @@
|
||||||
"@types/node": "^18.16.0",
|
"@types/node": "^18.16.0",
|
||||||
"@types/node-fetch": "^2.6.3",
|
"@types/node-fetch": "^2.6.3",
|
||||||
"c8": "^8.0.1",
|
"c8": "^8.0.1",
|
||||||
"colorette": "^1.4.0",
|
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"discord-api-types": "^0.37.60",
|
"discord-api-types": "^0.37.53",
|
||||||
"supertape": "^10.4.0",
|
"supertape": "^8.3.0",
|
||||||
"tap-dot": "github:cloudrac3r/tap-dot#9dd7750ececeae3a96afba91905be812b6b2cc2d"
|
"tap-dot": "github:cloudrac3r/tap-dot#9dd7750ececeae3a96afba91905be812b6b2cc2d"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"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 --skip-full -x db/migrations -x matrix/file.js -x matrix/api.js -x matrix/mreq.js -x d2m/converters/rlottie-wasm.js -r html -r text supertape --no-check-assertions-count --format fail --no-worker test/test.js -- --slow"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
35
readme.md
35
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.
|
||||||
|
@ -42,18 +42,17 @@ Most features you'd expect in both directions, plus a little extra spice:
|
||||||
* 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
|
* 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 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.
|
The bridge uses a small SQLite database to store relationships like which Discord messages correspond to which Matrix messages. This is so the bridge knows what to edit when some message is edited on Discord. Using `without rowid` on the database tables stores the index and the data in the same B-tree. Since Matrix and Discord's internal IDs are quite long, this vastly reduces storage space because those IDs do not have to be stored twice separately. Some event IDs are actually stored as xxhash integers to reduce storage requirements even more. On my personal instance of OOYE, every 100,000 messages require 16.1 MB of storage space in the SQLite database.
|
||||||
|
|
||||||
|
@ -61,11 +60,11 @@ Only necessary data and columns are queried from the database. We only contact t
|
||||||
|
|
||||||
File uploads (like avatars from bridged members) are checked locally and deduplicated. Only brand new files are uploaded to the homeserver. This saves loads of space in the homeserver's media repo, especially for Synapse.
|
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,10 +73,9 @@ You'll need:
|
||||||
|
|
||||||
Follow these steps:
|
Follow these steps:
|
||||||
|
|
||||||
1. [Get Node.js version 18 or later](https://nodejs.org/en/download/releases) (the version is required by the better-sqlite3 and matrix-appservice dependencies)
|
1. [Get Node.js version 18 or later](https://nodejs.org/en/download/releases) (the version is required by the matrix-appservice dependency)
|
||||||
|
|
||||||
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 --save-dev` (omit --save-dev if you will not run the automated tests)
|
1. Install dependencies: `npm install --save-dev` (omit --save-dev if you will not run the automated tests)
|
||||||
|
|
||||||
|
@ -97,10 +95,6 @@ Follow these steps:
|
||||||
* $ `npm run addbot`
|
* $ `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, either add yourself to `invite` in `registration.yaml`, or use the `//invite [your mxid here]` command on Discord.
|
|
||||||
|
|
||||||
# Development setup
|
# Development setup
|
||||||
|
|
||||||
* Be sure to install dependencies with `--save-dev` so you can run the tests.
|
* Be sure to install dependencies with `--save-dev` so you can run the tests.
|
||||||
|
@ -167,16 +161,13 @@ To get into the rooms on your Matrix account, either add yourself to `invite` in
|
||||||
* (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.
|
||||||
* (8) snowtransfer: Discord API library with bring-your-own-caching that I trust.
|
* (8) snowtransfer: Discord API library with bring-your-own-caching that I trust.
|
||||||
* (1) discord-markdown: This is my fork!
|
* (1) discord-markdown: This is my fork! I make sure it does what I want.
|
||||||
* (0) get-stream: Only needed if content_length_workaround is true.
|
* (0) giframe: This is my fork! It should do what I want.
|
||||||
* (0) giframe: This is my fork!
|
|
||||||
* (1) heatsync: Module hot-reloader that I trust.
|
* (1) heatsync: Module hot-reloader that I trust.
|
||||||
* (0) entities: Looks fine. No dependencies.
|
|
||||||
* (1) html-template-tag: This is my fork!
|
|
||||||
* (1) js-yaml: It seems to do what I want, and it's already pulled in by matrix-appservice.
|
* (1) js-yaml: It seems to do what I want, and it's already pulled in by matrix-appservice.
|
||||||
* (70) matrix-appservice: I wish it didn't pull in express :(
|
* (70) matrix-appservice: I wish it didn't pull in express :(
|
||||||
* (0) minimist: It's already pulled in by better-sqlite3->prebuild-install.
|
* (0) minimist: It's already pulled in by better-sqlite3->prebuild-install
|
||||||
* (0) mixin-deep: This is my fork! (It fixes a bug in regular mixin-deep.)
|
* (0) 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.
|
* (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) 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.
|
||||||
|
|
|
@ -18,7 +18,5 @@ ooye:
|
||||||
max_file_size: 5000000
|
max_file_size: 5000000
|
||||||
server_name: [the part after the colon in your matrix id, like cadence.moe]
|
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]
|
server_origin: [the full protocol and domain of your actual matrix server's location, with no trailing slash, like https://matrix.cadence.moe]
|
||||||
content_length_workaround: false
|
|
||||||
invite:
|
invite:
|
||||||
# uncomment this to auto-invite the named user to newly created spaces and mark them as admin (PL 100) everywhere
|
# - @cadence:cadence.moe # uncomment this to auto-invite the named user to newly created spaces
|
||||||
# - '@cadence:cadence.moe'
|
|
||||||
|
|
|
@ -37,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)
|
||||||
|
|
||||||
|
|
|
@ -1,15 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
// Trigger the database migration flow and exit after committing.
|
|
||||||
// You can use this to run migrations locally and check the result using sqlitebrowser.
|
|
||||||
|
|
||||||
const sqlite = require("better-sqlite3")
|
|
||||||
|
|
||||||
const config = require("../config")
|
|
||||||
const passthrough = require("../passthrough")
|
|
||||||
const db = new sqlite("db/ooye.db")
|
|
||||||
const migrate = require("../db/migrate")
|
|
||||||
|
|
||||||
Object.assign(passthrough, {config, db })
|
|
||||||
|
|
||||||
migrate.migrate(db)
|
|
|
@ -53,38 +53,20 @@ async function uploadAutoEmoji(guild, name, filename) {
|
||||||
const mxid = `@${reg.sender_localpart}:${reg.ooye.server_name}`
|
const mxid = `@${reg.sender_localpart}:${reg.ooye.server_name}`
|
||||||
|
|
||||||
// ensure registration is correctly set...
|
// 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(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(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(reg.ooye.server_origin.match(/^https?:\/\//)) // must start with http or https
|
||||||
assert.notEqual(reg.ooye.server_origin.slice(-1), "/", "server origin must not end in slash")
|
assert.notEqual(reg.ooye.server_origin.slice(-1), "/") // must not end in slash
|
||||||
const botID = Buffer.from(config.discordToken.split(".")[0], "base64").toString()
|
|
||||||
assert(botID.match(/^[0-9]{10,}$/), "discord token must follow the correct format")
|
|
||||||
console.log("✅ Configuration looks good...")
|
console.log("✅ Configuration looks good...")
|
||||||
|
|
||||||
// database ddl...
|
// database ddl...
|
||||||
await migrate.migrate(db)
|
await migrate.migrate(db)
|
||||||
|
|
||||||
// add initial rows to database, like adding the bot to sim...
|
// 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)
|
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...")
|
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
|
// 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()
|
const emojis = db.prepare("SELECT name FROM auto_emoji WHERE name = 'L1' OR name = 'L2'").pluck().all()
|
||||||
if (emojis.length !== 2) {
|
if (emojis.length !== 2) {
|
||||||
|
@ -122,6 +104,11 @@ async function uploadAutoEmoji(guild, name, filename) {
|
||||||
}
|
}
|
||||||
console.log("✅ Emojis are ready...")
|
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...
|
// set profile data on discord...
|
||||||
const avatarImageBuffer = await fetch("https://cadence.moe/friends/out_of_your_element.png").then(res => res.arrayBuffer())
|
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.user.updateSelf({avatar: "data:image/png;base64," + Buffer.from(avatarImageBuffer).toString("base64")})
|
||||||
|
|
24
stdin.js
24
stdin.js
|
@ -16,26 +16,20 @@ const api = sync.require("./matrix/api")
|
||||||
const file = sync.require("./matrix/file")
|
const file = sync.require("./matrix/file")
|
||||||
const sendEvent = sync.require("./m2d/actions/send-event")
|
const sendEvent = sync.require("./m2d/actions/send-event")
|
||||||
const eventDispatcher = sync.require("./d2m/event-dispatcher")
|
const eventDispatcher = sync.require("./d2m/event-dispatcher")
|
||||||
const updatePins = sync.require("./d2m/actions/update-pins")
|
|
||||||
const speedbump = sync.require("./d2m/actions/speedbump")
|
|
||||||
const ks = sync.require("./matrix/kstate")
|
const ks = sync.require("./matrix/kstate")
|
||||||
const guildID = "112760669178241024"
|
const guildID = "112760669178241024"
|
||||||
|
|
||||||
const extraContext = {}
|
const extraContext = {}
|
||||||
|
|
||||||
if (process.stdin.isTTY) {
|
setImmediate(() => { // assign after since old extraContext data will get removed
|
||||||
setImmediate(() => { // assign after since old extraContext data will get removed
|
if (!passthrough.repl) {
|
||||||
if (!passthrough.repl) {
|
const cli = repl.start({ prompt: "", eval: customEval, writer: s => s })
|
||||||
const cli = repl.start({ prompt: "", eval: customEval, writer: s => s })
|
Object.assign(cli.context, extraContext, passthrough)
|
||||||
Object.assign(cli.context, extraContext, passthrough)
|
passthrough.repl = cli
|
||||||
passthrough.repl = cli
|
} else Object.assign(passthrough.repl.context, extraContext)
|
||||||
} else {
|
// @ts-expect-error Says exit isn't assignable to a string
|
||||||
Object.assign(passthrough.repl.context, extraContext)
|
sync.addTemporaryListener(passthrough.repl, "exit", () => process.exit())
|
||||||
}
|
})
|
||||||
// @ts-expect-error Says exit isn't assignable to a string
|
|
||||||
sync.addTemporaryListener(passthrough.repl, "exit", () => process.exit())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} input
|
* @param {string} input
|
||||||
|
|
1623
test/data.js
1623
test/data.js
File diff suppressed because it is too large
Load Diff
|
@ -1,19 +1,15 @@
|
||||||
BEGIN TRANSACTION;
|
BEGIN TRANSACTION;
|
||||||
|
|
||||||
INSERT INTO guild_space (guild_id, space_id, privacy_level) VALUES
|
INSERT INTO guild_space (guild_id, space_id) VALUES
|
||||||
('112760669178241024', '!jjWAGMeQdNrVZSSfvz:cadence.moe', 0);
|
('112760669178241024', '!jjWAGMeQdNrVZSSfvz:cadence.moe');
|
||||||
|
|
||||||
INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar) VALUES
|
INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar) VALUES
|
||||||
('112760669178241024', '!kLRqKKUQXcibIMtOpl:cadence.moe', 'heave', 'main', NULL, NULL),
|
('112760669178241024', '!kLRqKKUQXcibIMtOpl:cadence.moe', 'heave', 'main', NULL, NULL),
|
||||||
('497161350934560778', '!CzvdIdUQXgUjDVKxeU:cadence.moe', 'amanda-spam', NULL, NULL, NULL),
|
('497161350934560778', '!CzvdIdUQXgUjDVKxeU:cadence.moe', 'amanda-spam', NULL, NULL, NULL),
|
||||||
('160197704226439168', '!hYnGGlPHlbujVVfktC:cadence.moe', 'the-stanley-parable-channel', 'bots', NULL, NULL),
|
('160197704226439168', '!hYnGGlPHlbujVVfktC:cadence.moe', 'the-stanley-parable-channel', 'bots', NULL, NULL),
|
||||||
('1100319550446252084', '!BnKuBPCvyfOkhcUjEu:cadence.moe', 'worm-farm', NULL, NULL, NULL),
|
('1100319550446252084', '!BnKuBPCvyfOkhcUjEu:cadence.moe', 'worm-farm', NULL, NULL, NULL),
|
||||||
('1162005314908999790', '!FuDZhlOAtqswlyxzeR:cadence.moe', 'Hey.', NULL, '1100319550446252084', NULL),
|
|
||||||
('297272183716052993', '!rEOspnYqdOalaIFniV:cadence.moe', 'general', NULL, NULL, NULL),
|
('297272183716052993', '!rEOspnYqdOalaIFniV:cadence.moe', 'general', NULL, NULL, NULL),
|
||||||
('122155380120748034', '!cqeGDbPiMFAhLsqqqq:cadence.moe', 'cadences-mind', 'coding', NULL, NULL),
|
('122155380120748034', '!cqeGDbPiMFAhLsqqqq:cadence.moe', 'cadences-mind', 'coding', NULL, NULL);
|
||||||
('176333891320283136', '!qzDBLKlildpzrrOnFZ:cadence.moe', '🌈丨davids-horse_she-took-the-kids', 'wonderland', NULL, 'mxc://cadence.moe/EVvrSkKIRONHjtRJsMLmHWLS'),
|
|
||||||
('489237891895768942', '!tnedrGVYKFNUdnegvf:tchncs.de', 'ex-room-doesnt-exist-any-more', NULL, NULL, NULL),
|
|
||||||
('1160894080998461480', '!TqlyQmifxGUggEmdBN:cadence.moe', 'ooyexperiment', NULL, NULL, NULL);
|
|
||||||
|
|
||||||
INSERT INTO sim (user_id, sim_name, localpart, mxid) VALUES
|
INSERT INTO sim (user_id, sim_name, localpart, mxid) VALUES
|
||||||
('0', 'bot', '_ooye_bot', '@_ooye_bot:cadence.moe'),
|
('0', 'bot', '_ooye_bot', '@_ooye_bot:cadence.moe'),
|
||||||
|
@ -21,20 +17,13 @@ INSERT INTO sim (user_id, sim_name, localpart, mxid) VALUES
|
||||||
('771520384671416320', 'bojack_horseman', '_ooye_bojack_horseman', '@_ooye_bojack_horseman:cadence.moe'),
|
('771520384671416320', 'bojack_horseman', '_ooye_bojack_horseman', '@_ooye_bojack_horseman:cadence.moe'),
|
||||||
('112890272819507200', '.wing.', '_ooye_.wing.', '@_ooye_.wing.:cadence.moe'),
|
('112890272819507200', '.wing.', '_ooye_.wing.', '@_ooye_.wing.:cadence.moe'),
|
||||||
('114147806469554185', 'extremity', '_ooye_extremity', '@_ooye_extremity:cadence.moe'),
|
('114147806469554185', 'extremity', '_ooye_extremity', '@_ooye_extremity:cadence.moe'),
|
||||||
('111604486476181504', 'kyuugryphon', '_ooye_kyuugryphon', '@_ooye_kyuugryphon:cadence.moe'),
|
('111604486476181504', 'kyuugryphon', '_ooye_kyuugryphon', '@_ooye_kyuugryphon:cadence.moe');;
|
||||||
('1109360903096369153', 'amanda', '_ooye_amanda', '@_ooye_amanda:cadence.moe'),
|
|
||||||
('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '_pk_zoego', '_ooye__pk_zoego', '@_ooye__pk_zoego:cadence.moe');
|
|
||||||
|
|
||||||
INSERT INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES
|
|
||||||
('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '196188877885538304', 'Azalea &flwr; 🌺');
|
|
||||||
|
|
||||||
INSERT INTO sim_member (mxid, room_id, hashed_profile_content) VALUES
|
INSERT INTO sim_member (mxid, room_id, hashed_profile_content) VALUES
|
||||||
('@_ooye_bojack_horseman:cadence.moe', '!hYnGGlPHlbujVVfktC:cadence.moe', NULL);
|
('@_ooye_bojack_horseman:cadence.moe', '!hYnGGlPHlbujVVfktC:cadence.moe', NULL);
|
||||||
|
|
||||||
INSERT INTO message_channel (message_id, channel_id) VALUES
|
INSERT INTO message_channel (message_id, channel_id) VALUES
|
||||||
('1106366167788044450', '122155380120748034'),
|
('1106366167788044450', '122155380120748034'),
|
||||||
('1106366167788044451', '122155380120748034'),
|
|
||||||
('1106366167788044452', '122155380120748034'),
|
|
||||||
('1126786462646550579', '112760669178241024'),
|
('1126786462646550579', '112760669178241024'),
|
||||||
('1128084748338741392', '112760669178241024'),
|
('1128084748338741392', '112760669178241024'),
|
||||||
('1128084851279536279', '112760669178241024'),
|
('1128084851279536279', '112760669178241024'),
|
||||||
|
@ -47,49 +36,28 @@ INSERT INTO message_channel (message_id, channel_id) VALUES
|
||||||
('1144865310588014633', '687028734322147344'),
|
('1144865310588014633', '687028734322147344'),
|
||||||
('1145688633186193479', '1100319550446252084'),
|
('1145688633186193479', '1100319550446252084'),
|
||||||
('1145688633186193480', '1100319550446252084'),
|
('1145688633186193480', '1100319550446252084'),
|
||||||
('1145688633186193481', '1100319550446252084'),
|
('1145688633186193481', '1100319550446252084');
|
||||||
('1162005526675193909', '1162005314908999790'),
|
|
||||||
('1162625810109317170', '497161350934560778'),
|
|
||||||
('1158842413025071135', '176333891320283136'),
|
|
||||||
('1197612733600895076', '112760669178241024'),
|
|
||||||
('1202543413652881428', '1160894080998461480'),
|
|
||||||
('1207486471489986620', '1160894080998461480'),
|
|
||||||
('1210387798297682020', '112760669178241024');
|
|
||||||
|
|
||||||
INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES
|
INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, source) VALUES
|
||||||
('$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg', 'm.room.message', 'm.text', '1126786462646550579', 0, 0, 1),
|
('$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg', 'm.room.message', 'm.text', '1126786462646550579', 0, 1),
|
||||||
('$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4', 'm.room.message', 'm.text', '1128118177155526666', 0, 0, 0),
|
('$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4', 'm.room.message', 'm.text', '1128118177155526666', 0, 0),
|
||||||
('$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10', 'm.room.message', 'm.text', '1141619794500649020', 0, 0, 1),
|
('$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10', 'm.room.message', 'm.text', '1141619794500649020', 0, 1),
|
||||||
('$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY', 'm.room.message', 'm.text', '1141206225632112650', 0, 0, 1),
|
('$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY', 'm.room.message', 'm.text', '1141206225632112650', 0, 1),
|
||||||
('$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA', 'm.room.message', 'm.text', '1141501302736695316', 0, 1, 1),
|
('$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA', 'm.room.message', 'm.text', '1141501302736695316', 0, 1),
|
||||||
('$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCI', 'm.room.message', 'm.image', '1141501302736695316', 1, 0, 1),
|
('$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCI', 'm.room.message', 'm.image', '1141501302736695316', 1, 1),
|
||||||
('$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCJ', 'm.room.message', 'm.image', '1141501302736695317', 0, 0, 1),
|
('$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCJ', 'm.room.message', 'm.image', '1141501302736695317', 0, 1),
|
||||||
('$vgTKOR5ZTYNMKaS7XvgEIDaOWZtVCEyzLLi5Pc5Gz4M', 'm.room.message', 'm.text', '1128084851279536279', 0, 1, 1),
|
('$vgTKOR5ZTYNMKaS7XvgEIDaOWZtVCEyzLLi5Pc5Gz4M', 'm.room.message', 'm.text', '1128084851279536279', 0, 1),
|
||||||
('$YUJFa5j0ZJe7PUvD2DykRt9g51RoadUEYmuJLdSEbJ0', 'm.room.message', 'm.image', '1128084851279536279', 1, 0, 1),
|
('$YUJFa5j0ZJe7PUvD2DykRt9g51RoadUEYmuJLdSEbJ0', 'm.room.message', 'm.image', '1128084851279536279', 1, 1),
|
||||||
('$oLyUTyZ_7e_SUzGNWZKz880ll9amLZvXGbArJCKai2Q', 'm.room.message', 'm.text', '1128084748338741392', 0, 0, 1),
|
('$oLyUTyZ_7e_SUzGNWZKz880ll9amLZvXGbArJCKai2Q', 'm.room.message', 'm.text', '1128084748338741392', 0, 1),
|
||||||
('$FchUVylsOfmmbj-VwEs5Z9kY49_dt2zd0vWfylzy5Yo', 'm.room.message', 'm.text', '1143121514925928541', 0, 0, 1),
|
('$FchUVylsOfmmbj-VwEs5Z9kY49_dt2zd0vWfylzy5Yo', 'm.room.message', 'm.text', '1143121514925928541', 0, 1),
|
||||||
('$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4', 'm.room.message', 'm.text', '1106366167788044450', 0, 1, 1),
|
('$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4', 'm.room.message', 'm.text', '1106366167788044450', 0, 1),
|
||||||
('$Ijf1MFCD39ktrNHxrA-i2aKoRWNYdAV2ZXYQeiZIgEU', 'm.room.message', 'm.image', '1106366167788044450', 1, 1, 0),
|
('$Ijf1MFCD39ktrNHxrA-i2aKoRWNYdAV2ZXYQeiZIgEU', 'm.room.message', 'm.image', '1106366167788044450', 0, 0),
|
||||||
('$f9cjKiacXI9qPF_nUAckzbiKnJEi0LM399kOkhdd8f8', 'm.sticker', NULL, '1106366167788044450', 1, 0, 0),
|
('$f9cjKiacXI9qPF_nUAckzbiKnJEi0LM399kOkhdd8f8', 'm.sticker', NULL, '1106366167788044450', 0, 0),
|
||||||
('$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd999', 'm.room.message', 'm.text', '1106366167788044451', 0, 0, 1),
|
('$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04', 'm.room.message', 'm.text', '1144865310588014633', 0, 1),
|
||||||
('$Ijf1MFCD39ktrNHxrA-i2aKoRWNYdAV2ZXYQeiZI999', 'm.room.message', 'm.image', '1106366167788044451', 0, 0, 1),
|
('$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8', 'm.room.message', 'm.text', '1144874214311067708', 0, 0),
|
||||||
('$f9cjKiacXI9qPF_nUAckzbiKnJEi0LM399kOkhdd999', 'm.sticker', NULL, '1106366167788044451', 0, 0, 1),
|
('$7LIdiJCEqjcWUrpzWzS8TELOlFfBEe4ytgS7zn2lbSs', 'm.room.message', 'm.text', '1145688633186193479', 0, 0),
|
||||||
('$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd111', 'm.room.message', 'm.text', '1106366167788044452', 1, 1, 1),
|
('$7LIdiJCEqjcWUrpzWzS8TELOlFfBEe4ytgS7zn2lbSt', 'm.room.message', 'm.text', '1145688633186193480', 0, 0),
|
||||||
('$Ijf1MFCD39ktrNHxrA-i2aKoRWNYdAV2ZXYQeiZI111', 'm.room.message', 'm.image', '1106366167788044452', 1, 1, 1),
|
('$7LIdiJCEqjcWUrpzWzS8TELOlFfBEe4ytgS7zn2lbSt', 'm.room.message', 'm.text', '1145688633186193481', 1, 0);
|
||||||
('$f9cjKiacXI9qPF_nUAckzbiKnJEi0LM399kOkhdd111', 'm.sticker', NULL, '1106366167788044452', 1, 1, 1),
|
|
||||||
('$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04', 'm.room.message', 'm.text', '1144865310588014633', 0, 0, 1),
|
|
||||||
('$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8', 'm.room.message', 'm.text', '1144874214311067708', 0, 0, 0),
|
|
||||||
('$7LIdiJCEqjcWUrpzWzS8TELOlFfBEe4ytgS7zn2lbSs', 'm.room.message', 'm.text', '1145688633186193479', 0, 0, 0),
|
|
||||||
('$7LIdiJCEqjcWUrpzWzS8TELOlFfBEe4ytgS7zn2lbSt', 'm.room.message', 'm.text', '1145688633186193480', 0, 0, 0),
|
|
||||||
('$7LIdiJCEqjcWUrpzWzS8TELOlFfBEe4ytgS7zn2lbSt', 'm.room.message', 'm.text', '1145688633186193481', 1, 0, 0),
|
|
||||||
('$nUM-ABBF8KdnvrhXwLlYAE9dgDl_tskOvvcNIBrtsVo', 'm.room.message', 'm.text', '1162005526675193909', 0, 0, 0),
|
|
||||||
('$0wEdIP8fhTq-P68xwo_gyUw-Zv0KA2aS2tfhdFSrLZc', 'm.room.message', 'm.text', '1162625810109317170', 1, 1, 1),
|
|
||||||
('$zJFjTvNn1w_YqpR4o4ISKUFisNRgZcu1KSMI_LADPVQ', 'm.room.message', 'm.notice', '1162625810109317170', 1, 0, 1),
|
|
||||||
('$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo', 'm.room.message', 'm.text', '1158842413025071135', 0, 0, 1),
|
|
||||||
('$7tJoMw1h44n2gxgLUE1T_YinGrLbK0x-TDY1z6M7GBw', 'm.room.message', 'm.text', '1197612733600895076', 0, 0, 1),
|
|
||||||
('$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU', 'm.room.message', 'm.text', '1202543413652881428', 0, 0, 0),
|
|
||||||
('$OEEK-Wam2FTh6J-6kVnnJ6KnLA_lLRnLTHatKKL62-Y', 'm.room.message', 'm.image', '1207486471489986620', 0, 0, 0),
|
|
||||||
('$mPSzglkCu-6cZHbYro0RW2u5mHvbH9aXDjO5FCzosc0', 'm.room.message', 'm.text', '1210387798297682020', 0, 0, 1);
|
|
||||||
|
|
||||||
INSERT INTO file (discord_url, mxc_url) VALUES
|
INSERT INTO file (discord_url, mxc_url) VALUES
|
||||||
('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'),
|
('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'),
|
||||||
|
@ -104,37 +72,22 @@ INSERT INTO file (discord_url, mxc_url) VALUES
|
||||||
('https://cdn.discordapp.com/emojis/230201364309868544.png', 'mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC'),
|
('https://cdn.discordapp.com/emojis/230201364309868544.png', 'mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC'),
|
||||||
('https://cdn.discordapp.com/emojis/393635038903926784.gif', 'mxc://cadence.moe/WbYqNlACRuicynBfdnPYtmvc'),
|
('https://cdn.discordapp.com/emojis/393635038903926784.gif', 'mxc://cadence.moe/WbYqNlACRuicynBfdnPYtmvc'),
|
||||||
('https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg', 'mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR'),
|
('https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg', 'mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR'),
|
||||||
('https://cdn.discordapp.com/emojis/1125827250609201255.png', 'mxc://cadence.moe/pgdGTxAyEltccRgZKxdqzHHP'),
|
('https://cdn.discordapp.com/emojis/1125827250609201255.png', 'mxc://cadence.moe/pgdGTxAyEltccRgZKxdqzHHP');
|
||||||
('https://cdn.discordapp.com/avatars/320067006521147393/5fc4ad85c1ea876709e9a7d3374a78a1.png?size=1024', 'mxc://cadence.moe/JPzSmALLirnIprlSMKohSSoX'),
|
|
||||||
('https://cdn.discordapp.com/emojis/288858540888686602.png', 'mxc://cadence.moe/mwZaCtRGAQQyOItagDeCocEO'),
|
|
||||||
('https://cdn.discordapp.com/attachments/112760669178241024/1197621094786531358/Ins_1960637570.mp4', 'mxc://cadence.moe/kMqLycqMURhVpwleWkmASpnU'),
|
|
||||||
('https://cdn.discordapp.com/attachments/1099031887500034088/1112476845502365786/voice-message.ogg', 'mxc://cadence.moe/MRRPDggXQMYkrUjTpxQbmcxB'),
|
|
||||||
('https://cdn.discordapp.com/attachments/122155380120748034/1174514575220158545/the.yml', 'mxc://cadence.moe/HnQIYQmmlIKwOQsbFsIGpzPP');
|
|
||||||
|
|
||||||
INSERT INTO emoji (emoji_id, name, animated, mxc_url) VALUES
|
INSERT INTO emoji (emoji_id, name, animated, mxc_url) VALUES
|
||||||
('230201364309868544', 'hippo', 0, 'mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC'),
|
('230201364309868544', 'hippo', 0, 'mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC'),
|
||||||
('393635038903926784', 'hipposcope', 1, 'mxc://cadence.moe/WbYqNlACRuicynBfdnPYtmvc'),
|
('393635038903926784', 'hipposcope', 1, 'mxc://cadence.moe/WbYqNlACRuicynBfdnPYtmvc'),
|
||||||
('457898385297815911', 'emoji_from_unreachable_server', 0, 'mxc://cadence.moe/bZFuuUSEebJYXUMSxuuSuLTa'),
|
|
||||||
('362741439211503616', 'bn_re', 0, 'mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT'),
|
('362741439211503616', 'bn_re', 0, 'mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT'),
|
||||||
('551636841284108289', 'ae_botrac4r', 0, 'mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs'),
|
('551636841284108289', 'ae_botrac4r', 0, 'mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs'),
|
||||||
('975572106295259148', 'brillillillilliant_move', 0, 'mxc://cadence.moe/scfRIDOGKWFDEBjVXocWYQHik'),
|
('975572106295259148', 'brillillillilliant_move', 0, 'mxc://cadence.moe/scfRIDOGKWFDEBjVXocWYQHik'),
|
||||||
('606664341298872324', 'online', 0, 'mxc://cadence.moe/LCEqjStXCxvRQccEkuslXEyZ'),
|
('606664341298872324', 'online', 0, 'mxc://cadence.moe/LCEqjStXCxvRQccEkuslXEyZ');
|
||||||
('288858540888686602', 'upstinky', 0, 'mxc://cadence.moe/mwZaCtRGAQQyOItagDeCocEO');
|
|
||||||
|
|
||||||
INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES
|
INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES
|
||||||
('!kLRqKKUQXcibIMtOpl:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL),
|
('!kLRqKKUQXcibIMtOpl:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL),
|
||||||
('!BpMdOUkWWhFxmTrENV:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'malformed mxc'),
|
('!BpMdOUkWWhFxmTrENV:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'malformed mxc'),
|
||||||
('!fGgIymcYWOqjbSRUdV:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU'),
|
('!fGgIymcYWOqjbSRUdV:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU'),
|
||||||
('!BnKuBPCvyfOkhcUjEu:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU'),
|
('!BnKuBPCvyfOkhcUjEu:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU'),
|
||||||
('!maggESguZBqGBZtSnr:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU'),
|
('!maggESguZBqGBZtSnr:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU');
|
||||||
('!CzvdIdUQXgUjDVKxeU:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU'),
|
|
||||||
('!cBxtVRxDlZvSVhJXVK:cadence.moe', '@Milan:tchncs.de', 'Milan', NULL),
|
|
||||||
('!TqlyQmifxGUggEmdBN:cadence.moe', '@ampflower:matrix.org', 'Ampflower 🌺', 'mxc://cadence.moe/PRfhXYBTOalvgQYtmCLeUXko'),
|
|
||||||
('!TqlyQmifxGUggEmdBN:cadence.moe', '@aflower:syndicated.gay', 'Rose', 'mxc://syndicated.gay/ZkBUPXCiXTjdJvONpLJmcbKP'),
|
|
||||||
('!TqlyQmifxGUggEmdBN:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL);
|
|
||||||
|
|
||||||
INSERT INTO lottie (sticker_id, mxc_url) VALUES
|
|
||||||
('860171525772279849', 'mxc://cadence.moe/ZtvvVbwMIdUZeovWVyGVFCeR');
|
|
||||||
|
|
||||||
INSERT INTO "auto_emoji" ("name","emoji_id","guild_id") VALUES
|
INSERT INTO "auto_emoji" ("name","emoji_id","guild_id") VALUES
|
||||||
('L1','1144820033948762203','529176156398682115'),
|
('L1','1144820033948762203','529176156398682115'),
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -1,16 +0,0 @@
|
||||||
# Source
|
|
||||||
|
|
||||||
Flying Bee by Afif Ridwan
|
|
||||||
https://lottiefiles.com/animations/flying-bee-WkXvUiWkZ1
|
|
||||||
|
|
||||||
# License
|
|
||||||
|
|
||||||
Lottie Simple License (FL 9.13.21) https://lottiefiles.com/page/license
|
|
||||||
|
|
||||||
Copyright © 2021 Design Barn Inc.
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the public animation files available for download at the LottieFiles site (“Files”) to download, reproduce, modify, publish, distribute, publicly display, and publicly digitally perform such Files, including for commercial purposes, provided that any display, publication, performance, or distribution of Files must contain (and be subject to) the same terms and conditions of this license. Modifications to Files are deemed derivative works and must also be expressly distributed under the same terms and conditions of this license. You may not purport to impose any additional or different terms or conditions on, or apply any technical measures that restrict exercise of, the rights granted under this license. This license does not include the right to collect or compile Files from LottieFiles to replicate or develop a similar or competing service.
|
|
||||||
|
|
||||||
Use of Files without attributing the creator(s) of the Files is permitted under this license, though attribution is strongly encouraged. If attributions are included, such attributions should be visible to the end user.
|
|
||||||
|
|
||||||
FILES ARE PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE LAW, IN NO EVENT WILL THE CREATOR(S) OF FILES OR DESIGN BARN, INC. BE LIABLE ON ANY LEGAL THEORY FOR ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE, OR EXEMPLARY DAMAGES ARISING OUT OF THIS LICENSE OR THE USE OF SUCH FILES.
|
|
Binary file not shown.
Before Width: | Height: | Size: 5.4 KiB |
87
test/test.js
87
test/test.js
|
@ -2,16 +2,11 @@
|
||||||
|
|
||||||
const fs = require("fs")
|
const fs = require("fs")
|
||||||
const {join} = require("path")
|
const {join} = require("path")
|
||||||
const stp = require("stream").promises
|
|
||||||
const sqlite = require("better-sqlite3")
|
const sqlite = require("better-sqlite3")
|
||||||
const migrate = require("../db/migrate")
|
const migrate = require("../db/migrate")
|
||||||
const HeatSync = require("heatsync")
|
const HeatSync = require("heatsync")
|
||||||
const {test} = require("supertape")
|
const {test} = require("supertape")
|
||||||
const data = require("./data")
|
const data = require("./data")
|
||||||
/** @type {import("node-fetch").default} */
|
|
||||||
// @ts-ignore
|
|
||||||
const fetch = require("node-fetch")
|
|
||||||
const {green} = require("colorette")
|
|
||||||
|
|
||||||
const config = require("../config")
|
const config = require("../config")
|
||||||
const passthrough = require("../passthrough")
|
const passthrough = require("../passthrough")
|
||||||
|
@ -19,29 +14,12 @@ const db = new sqlite(":memory:")
|
||||||
|
|
||||||
const reg = require("../matrix/read-registration")
|
const reg = require("../matrix/read-registration")
|
||||||
reg.ooye.server_origin = "https://matrix.cadence.moe" // so that tests will pass even when hard-coded
|
reg.ooye.server_origin = "https://matrix.cadence.moe" // so that tests will pass even when hard-coded
|
||||||
reg.ooye.server_name = "cadence.moe"
|
|
||||||
reg.ooye.invite = ["@test_auto_invite:example.org"]
|
|
||||||
reg.id = "baby" // don't actually take authenticated actions on the server
|
|
||||||
reg.as_token = "baby"
|
|
||||||
reg.hs_token = "baby"
|
|
||||||
|
|
||||||
const sync = new HeatSync({watchFS: false})
|
const sync = new HeatSync({watchFS: false})
|
||||||
|
|
||||||
const discord = {
|
const discord = {
|
||||||
guilds: new Map([
|
guilds: new Map([
|
||||||
[data.guild.general.id, data.guild.general]
|
[data.guild.general.id, data.guild.general]
|
||||||
]),
|
|
||||||
application: {
|
|
||||||
id: "684280192553844747"
|
|
||||||
},
|
|
||||||
channels: new Map([
|
|
||||||
["497161350934560778", {
|
|
||||||
guild_id: "497159726455455754"
|
|
||||||
}],
|
|
||||||
["498323546729086986", {
|
|
||||||
guild_id: "497159726455455754",
|
|
||||||
name: "bad-boots-prison"
|
|
||||||
}]
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -52,87 +30,30 @@ passthrough.from = orm.from
|
||||||
passthrough.select = orm.select
|
passthrough.select = orm.select
|
||||||
|
|
||||||
const file = sync.require("../matrix/file")
|
const file = sync.require("../matrix/file")
|
||||||
/* c8 ignore next */
|
|
||||||
file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not allowed to upload files during testing.\nURL: ${url}`) }
|
file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not allowed to upload files during testing.\nURL: ${url}`) }
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
/* c8 ignore start - maybe download some more test files in slow mode */
|
|
||||||
if (process.argv.includes("--slow")) {
|
|
||||||
test("test files: download", async t => {
|
|
||||||
/** @param {{url: string, to: string}[]} files */
|
|
||||||
async function allReporter(files) {
|
|
||||||
return new Promise(resolve => {
|
|
||||||
let resolved = 0
|
|
||||||
const report = files.map(file => file.to.split("/").slice(-1)[0][0])
|
|
||||||
files.map(download).forEach((p, i) => {
|
|
||||||
p.then(() => {
|
|
||||||
report[i] = green(".")
|
|
||||||
process.stderr.write("\r" + report.join(""))
|
|
||||||
if (++resolved === files.length) resolve(null)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
async function download({url, to}) {
|
|
||||||
if (await fs.existsSync(to)) return
|
|
||||||
const res = await fetch(url)
|
|
||||||
await stp.pipeline(res.body, fs.createWriteStream(to, {encoding: "binary"}))
|
|
||||||
}
|
|
||||||
await allReporter([
|
|
||||||
{url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/RLMgJGfgTPjIQtvvWZsYjhjy", to: "test/res/RLMgJGfgTPjIQtvvWZsYjhjy.png"},
|
|
||||||
{url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/bZFuuUSEebJYXUMSxuuSuLTa", to: "test/res/bZFuuUSEebJYXUMSxuuSuLTa.png"},
|
|
||||||
{url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/qWmbXeRspZRLPcjseyLmeyXC", to: "test/res/qWmbXeRspZRLPcjseyLmeyXC.png"},
|
|
||||||
{url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/wcouHVjbKJJYajkhJLsyeJAA", to: "test/res/wcouHVjbKJJYajkhJLsyeJAA.png"},
|
|
||||||
{url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/WbYqNlACRuicynBfdnPYtmvc", to: "test/res/WbYqNlACRuicynBfdnPYtmvc.gif"},
|
|
||||||
{url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/HYcztccFIPgevDvoaWNsEtGJ", to: "test/res/HYcztccFIPgevDvoaWNsEtGJ.png"},
|
|
||||||
{url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/lHfmJpzgoNyNtYHdAmBHxXix", to: "test/res/lHfmJpzgoNyNtYHdAmBHxXix.png"},
|
|
||||||
{url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/MtRdXixoKjKKOyHJGWLsWLNU", to: "test/res/MtRdXixoKjKKOyHJGWLsWLNU.png"},
|
|
||||||
{url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/HXfFuougamkURPPMflTJRxGc", to: "test/res/HXfFuougamkURPPMflTJRxGc.png"},
|
|
||||||
{url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/ikYKbkhGhMERAuPPbsnQzZiX", to: "test/res/ikYKbkhGhMERAuPPbsnQzZiX.png"},
|
|
||||||
{url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/AYPpqXzVJvZdzMQJGjioIQBZ", to: "test/res/AYPpqXzVJvZdzMQJGjioIQBZ.png"},
|
|
||||||
{url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/UVuzvpVUhqjiueMxYXJiFEAj", to: "test/res/UVuzvpVUhqjiueMxYXJiFEAj.png"},
|
|
||||||
{url: "https://ezgif.com/images/format-demo/butterfly.gif", to: "test/res/butterfly.gif"},
|
|
||||||
{url: "https://ezgif.com/images/format-demo/butterfly.png", to: "test/res/butterfly.png"},
|
|
||||||
])
|
|
||||||
}, {timeout: 60000})
|
|
||||||
}
|
|
||||||
/* c8 ignore end */
|
|
||||||
|
|
||||||
const p = migrate.migrate(db)
|
const p = migrate.migrate(db)
|
||||||
test("migrate: migration works", async t => {
|
test("migrate: migration works", async t => {
|
||||||
await p
|
await p
|
||||||
t.pass("it did not throw an error")
|
t.pass("it did not throw an error")
|
||||||
})
|
})
|
||||||
await p
|
await p
|
||||||
|
|
||||||
test("migrate: migration works the second time", async t => {
|
|
||||||
await migrate.migrate(db)
|
|
||||||
t.pass("it did not throw an error")
|
|
||||||
})
|
|
||||||
|
|
||||||
db.exec(fs.readFileSync(join(__dirname, "ooye-test-data.sql"), "utf8"))
|
db.exec(fs.readFileSync(join(__dirname, "ooye-test-data.sql"), "utf8"))
|
||||||
|
|
||||||
require("../db/orm.test")
|
require("../db/orm.test")
|
||||||
require("../discord/utils.test")
|
|
||||||
require("../matrix/kstate.test")
|
require("../matrix/kstate.test")
|
||||||
require("../matrix/api.test")
|
require("../matrix/api.test")
|
||||||
require("../matrix/file.test")
|
require("../matrix/file.test")
|
||||||
require("../matrix/read-registration.test")
|
require("../matrix/read-registration.test")
|
||||||
require("../matrix/txnid.test")
|
require("../matrix/txnid.test")
|
||||||
require("../d2m/actions/create-room.test")
|
|
||||||
require("../d2m/actions/create-space.test")
|
|
||||||
require("../d2m/actions/register-user.test")
|
|
||||||
require("../d2m/converters/edit-to-changes.test")
|
|
||||||
require("../d2m/converters/emoji-to-key.test")
|
|
||||||
require("../d2m/converters/lottie.test")
|
|
||||||
require("../d2m/converters/message-to-event.test")
|
require("../d2m/converters/message-to-event.test")
|
||||||
require("../d2m/converters/message-to-event.embeds.test")
|
require("../d2m/converters/message-to-event.embeds.test")
|
||||||
require("../d2m/converters/message-to-event.pk.test")
|
require("../d2m/converters/edit-to-changes.test")
|
||||||
require("../d2m/converters/pins-to-list.test")
|
|
||||||
require("../d2m/converters/remove-reaction.test")
|
|
||||||
require("../d2m/converters/thread-to-announcement.test")
|
require("../d2m/converters/thread-to-announcement.test")
|
||||||
require("../d2m/converters/user-to-mxid.test")
|
require("../d2m/converters/user-to-mxid.test")
|
||||||
|
require("../d2m/converters/emoji-to-key.test")
|
||||||
|
require("../d2m/actions/create-room.test")
|
||||||
|
require("../d2m/actions/register-user.test")
|
||||||
require("../m2d/converters/event-to-message.test")
|
require("../m2d/converters/event-to-message.test")
|
||||||
require("../m2d/converters/utils.test")
|
require("../m2d/converters/utils.test")
|
||||||
require("../m2d/converters/emoji-sheet.test")
|
|
||||||
})()
|
})()
|
||||||
|
|
|
@ -21,7 +21,6 @@ export type AppServiceRegistrationConfig = {
|
||||||
max_file_size: number
|
max_file_size: number
|
||||||
server_name: string
|
server_name: string
|
||||||
server_origin: string
|
server_origin: string
|
||||||
content_length_workaround: boolean
|
|
||||||
invite: string[]
|
invite: string[]
|
||||||
}
|
}
|
||||||
old_bridge?: {
|
old_bridge?: {
|
||||||
|
@ -35,45 +34,6 @@ export type WebhookCreds = {
|
||||||
token: string
|
token: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PkSystem = {
|
|
||||||
id: string
|
|
||||||
uuid: string
|
|
||||||
name: string | null
|
|
||||||
description: string | null
|
|
||||||
tag: string | null
|
|
||||||
pronouns: string | null
|
|
||||||
avatar_url: string | null
|
|
||||||
banner: string | null
|
|
||||||
color: string | null
|
|
||||||
created: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PkMember = {
|
|
||||||
id: string
|
|
||||||
uuid: string
|
|
||||||
name: string
|
|
||||||
display_name: string | null
|
|
||||||
color: string | null
|
|
||||||
birthday: string | null
|
|
||||||
pronouns: string | null
|
|
||||||
avatar_url: string | null
|
|
||||||
webhook_avatar_url: string | null
|
|
||||||
banner: string | null
|
|
||||||
description: string | null
|
|
||||||
created: string | null
|
|
||||||
keep_proxy: boolean
|
|
||||||
tts: boolean
|
|
||||||
autoproxy_enabled: boolean | null
|
|
||||||
message_count: number | null
|
|
||||||
last_message_timestamp: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PkMessage = {
|
|
||||||
system: PkSystem
|
|
||||||
member: PkMember
|
|
||||||
sender: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export namespace Event {
|
export namespace Event {
|
||||||
export type Outer<T> = {
|
export type Outer<T> = {
|
||||||
type: string
|
type: string
|
||||||
|
@ -131,7 +91,6 @@ export namespace Event {
|
||||||
export type M_Room_Message_File = {
|
export type M_Room_Message_File = {
|
||||||
msgtype: "m.file" | "m.image" | "m.video" | "m.audio"
|
msgtype: "m.file" | "m.image" | "m.video" | "m.audio"
|
||||||
body: string
|
body: string
|
||||||
filename?: string
|
|
||||||
url: string
|
url: string
|
||||||
info?: any
|
info?: any
|
||||||
"m.relates_to"?: {
|
"m.relates_to"?: {
|
||||||
|
@ -148,7 +107,6 @@ export namespace Event {
|
||||||
export type M_Room_Message_Encrypted_File = {
|
export type M_Room_Message_Encrypted_File = {
|
||||||
msgtype: "m.file" | "m.image" | "m.video" | "m.audio"
|
msgtype: "m.file" | "m.image" | "m.video" | "m.audio"
|
||||||
body: string
|
body: string
|
||||||
filename?: string
|
|
||||||
file: {
|
file: {
|
||||||
url: string
|
url: string
|
||||||
iv: string
|
iv: string
|
||||||
|
@ -257,18 +215,6 @@ export namespace R {
|
||||||
export type EventRedacted = {
|
export type EventRedacted = {
|
||||||
event_id: string
|
event_id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Hierarchy = {
|
|
||||||
avatar_url?: string
|
|
||||||
canonical_alias?: string
|
|
||||||
children_state: {}
|
|
||||||
guest_can_join: boolean
|
|
||||||
join_rule?: string
|
|
||||||
name?: string
|
|
||||||
num_joined_members: number
|
|
||||||
room_id: string
|
|
||||||
room_type?: string
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Pagination<T> = {
|
export type Pagination<T> = {
|
||||||
|
@ -276,8 +222,3 @@ export type Pagination<T> = {
|
||||||
next_batch?: string
|
next_batch?: string
|
||||||
prev_match?: string
|
prev_match?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HierarchyPagination<T> = {
|
|
||||||
rooms: T[]
|
|
||||||
next_batch?: string
|
|
||||||
}
|
|
||||||
|
|
Loading…
Reference in New Issue