diff --git a/.gitignore b/.gitignore index 9c175d8f..e533dce5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,16 @@ -node_modules +# Secrets config.js registration.yaml +ooye.db* +events.db* + +# Automatically generated +node_modules coverage -db/ooye.db* test/res/* !test/res/lottie* +icon.svg +*~ +.#* +\#*# +launch.json diff --git a/addbot.js b/addbot.js old mode 100644 new mode 100755 index 667fbabc..ef1cc63e --- a/addbot.js +++ b/addbot.js @@ -1,15 +1,18 @@ +#!/usr/bin/env node // @ts-check -const config = require("./config") +const {reg} = require("./src/matrix/read-registration") +const token = reg.ooye.discord_token +const id = Buffer.from(token.split(".")[0], "base64").toString() function addbot() { - const token = config.discordToken - const id = Buffer.from(token.split(".")[0], "base64") return `Open this link to add the bot to a Discord server:\nhttps://discord.com/oauth2/authorize?client_id=${id}&scope=bot&permissions=1610883072 ` } +/* c8 ignore next 3 */ if (process.argv.find(a => a.endsWith("addbot") || a.endsWith("addbot.js"))) { console.log(addbot()) } +module.exports.id = id module.exports.addbot = addbot diff --git a/addbot.sh b/addbot.sh index 6c3ff4b7..d40d0daf 100755 --- a/addbot.sh +++ b/addbot.sh @@ -1,3 +1,3 @@ #!/usr/bin/env sh echo "Open this link to add the bot to a Discord server:" -echo "https://discord.com/oauth2/authorize?client_id=$(grep discordToken config.js | sed -E 's!.*: ["'\'']([A-Za-z0-9+=/_-]*).*!\1!g' | base64 -d)&scope=bot&permissions=1610883072" +echo "https://discord.com/oauth2/authorize?client_id=$(grep discord_token registration.yaml | sed -E 's!.*: ["'\'']([A-Za-z0-9+=/_-]*).*!\1!g' | base64 -d)&scope=bot&permissions=1610883072" diff --git a/config.example.js b/config.example.js deleted file mode 100644 index 0d1a29ef..00000000 --- a/config.example.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - discordToken: "yes" -} diff --git a/d2m/actions/create-room.js b/d2m/actions/create-room.js deleted file mode 100644 index 6994b57d..00000000 --- a/d2m/actions/create-room.js +++ /dev/null @@ -1,414 +0,0 @@ -// @ts-check - -const assert = require("assert").strict -const DiscordTypes = require("discord-api-types/v10") -const reg = require("../../matrix/read-registration") - -const passthrough = require("../../passthrough") -const {discord, sync, db, select} = passthrough -/** @type {import("../../matrix/file")} */ -const file = sync.require("../../matrix/file") -/** @type {import("../../matrix/api")} */ -const api = sync.require("../../matrix/api") -/** @type {import("../../matrix/kstate")} */ -const ks = sync.require("../../matrix/kstate") -/** @type {import("./create-space")}) */ -const createSpace = sync.require("./create-space") // watch out for the require loop - -/** - * There are 3 levels of room privacy: - * 0: Room is invite-only. - * 1: Anybody can use a link to join. - * 2: Room is published in room directory. - */ -const PRIVACY_ENUMS = { - PRESET: ["private_chat", "public_chat", "public_chat"], - VISIBILITY: ["private", "private", "public"], - SPACE_HISTORY_VISIBILITY: ["invited", "world_readable", "world_readable"], // copying from element client - ROOM_HISTORY_VISIBILITY: ["shared", "shared", "world_readable"], // any events sent after 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>} channel ID -> Promise */ -const inflightRoomCreate = new Map() - -/** - * Async because it gets all room state from the homeserver. - * @param {string} roomID - */ -async function roomToKState(roomID) { - const root = await api.getAllState(roomID) - return ks.stateToKState(root) -} - -/** - * @param {string} roomID - * @param {any} kstate - */ -function applyKStateDiffToRoom(roomID, kstate) { - const events = ks.kstateToState(kstate) - return Promise.all(events.map(({type, state_key, content}) => - api.sendState(roomID, type, state_key, content) - )) -} - -/** - * @param {{id: string, name: string, topic?: string?, type: number}} channel - * @param {{id: string}} guild - * @param {string | null | undefined} customName - */ -function convertNameAndTopic(channel, guild, customName) { - let channelPrefix = - ( channel.type === DiscordTypes.ChannelType.PublicThread ? "[โ›“๏ธ] " - : channel.type === DiscordTypes.ChannelType.PrivateThread ? "[๐Ÿ”’โ›“๏ธ] " - : channel.type === DiscordTypes.ChannelType.GuildVoice ? "[๐Ÿ”Š] " - : "") - const chosenName = customName || (channelPrefix + channel.name); - const maybeTopicWithPipe = channel.topic ? ` | ${channel.topic}` : ''; - const maybeTopicWithNewlines = channel.topic ? `${channel.topic}\n\n` : ''; - const channelIDPart = `Channel ID: ${channel.id}`; - const guildIDPart = `Guild ID: ${guild.id}`; - - const convertedTopic = customName - ? `#${channel.name}${maybeTopicWithPipe}\n\n${channelIDPart}\n${guildIDPart}` - : `${maybeTopicWithNewlines}${channelIDPart}\n${guildIDPart}`; - - return [chosenName, convertedTopic]; -} - -/** - * Async because it may create the guild and/or upload the guild icon to mxc. - * @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel - * @param {DiscordTypes.APIGuild} guild - */ -async function channelToKState(channel, guild) { - const spaceID = await createSpace.ensureSpace(guild) - assert(typeof spaceID === "string") - const privacyLevel = select("guild_space", "privacy_level", {space_id: spaceID}).pluck().get() - assert(typeof privacyLevel === "number") - - const row = select("channel_room", ["nick", "custom_avatar"], {channel_id: channel.id}).get() - const customName = row?.nick - const customAvatar = row?.custom_avatar - const [convertedName, convertedTopic] = convertNameAndTopic(channel, guild, customName) - - const avatarEventContent = {} - if (customAvatar) { - avatarEventContent.url = customAvatar - } else if (guild.icon) { - avatarEventContent.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 = PRIVACY_ENUMS.ROOM_HISTORY_VISIBILITY[privacyLevel] - if (channel["thread_metadata"]) history_visibility = "world_readable" - - /** @type {{join_rule: string, allow?: any}} */ - let join_rules = { - join_rule: "restricted", - allow: [{ - type: "m.room_membership", - room_id: spaceID - }] - } - if (PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel] !== "restricted") { - join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]} - } - - const channelKState = { - "m.room.name/": {name: convertedName}, - "m.room.topic/": {topic: convertedTopic}, - "m.room.avatar/": avatarEventContent, - "m.room.guest_access/": {guest_access: PRIVACY_ENUMS.GUEST_ACCESS[privacyLevel]}, - "m.room.history_visibility/": {history_visibility}, - [`m.space.parent/${spaceID}`]: { - via: [reg.ooye.server_name], - canonical: true - }, - /** @type {{join_rule: string, [x: string]: any}} */ - "m.room.join_rules/": join_rules, - "m.room.power_levels/": { - events: { - "m.room.avatar": 0 - }, - users: reg.ooye.invite.reduce((a, c) => (a[c] = 100, a), {}) - }, - "chat.schildi.hide_ui/read_receipts": { - hidden: true - }, - [`uk.half-shot.bridge/moe.cadence.ooye://discord/${guild.id}/${channel.id}`]: { - bridgebot: `@${reg.sender_localpart}:${reg.ooye.server_name}`, - protocol: { - id: "discord", - displayname: "Discord" - }, - network: { - id: guild.id, - displayname: guild.name, - avatar_url: await file.uploadDiscordFileToMxc(file.guildIcon(guild)) - }, - channel: { - id: channel.id, - displayname: channel.name, - external_url: `https://discord.com/channels/${guild.id}/${channel.id}` - } - } - } - - return {spaceID, privacyLevel, channelKState} -} - -/** - * Create a bridge room, store the relationship in the database, and add it to the guild's space. - * @param {DiscordTypes.APIGuildTextChannel} channel - * @param guild - * @param {string} spaceID - * @param {any} kstate - * @param {number} privacyLevel - * @returns {Promise} room ID - */ -async function createRoom(channel, guild, spaceID, kstate, privacyLevel) { - let threadParent = null - if (channel.type === DiscordTypes.ChannelType.PublicThread) threadParent = channel.parent_id - - // Name and topic can be done earlier in room creation rather than in initial_state - // https://spec.matrix.org/latest/client-server-api/#creation - const name = kstate["m.room.name/"].name - delete kstate["m.room.name/"] - assert(name) - const topic = kstate["m.room.topic/"].topic - delete kstate["m.room.topic/"] - assert(topic) - - const roomID = await postApplyPowerLevels(kstate, async kstate => { - const roomID = await api.createRoom({ - name, - topic, - preset: PRIVACY_ENUMS.PRESET[privacyLevel], // This is closest to what we want, but properties from kstate override it anyway - visibility: PRIVACY_ENUMS.VISIBILITY[privacyLevel], - invite: [], - initial_state: ks.kstateToState(kstate) - }) - - db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent) VALUES (?, ?, ?, NULL, ?)").run(channel.id, roomID, channel.name, threadParent) - - return roomID - }) - - // Put the newly created child into the space, no need to await this - _syncSpaceMember(channel, spaceID, roomID) - - return roomID -} - -/** - * Handling power levels separately. The spec doesn't specify what happens, Dendrite differs, - * and Synapse does an absolutely insane *shallow merge* of what I provide on top of what it creates. - * We don't want the `events` key to be overridden completely. - * https://github.com/matrix-org/synapse/blob/develop/synapse/handlers/room.py#L1170-L1210 - * https://github.com/matrix-org/matrix-spec/issues/492 - * @param {any} kstate - * @param {(_: any) => Promise} callback must return room ID - * @returns {Promise} room ID - */ -async function postApplyPowerLevels(kstate, callback) { - const powerLevelContent = kstate["m.room.power_levels/"] - const kstateWithoutPowerLevels = {...kstate} - delete kstateWithoutPowerLevels["m.room.power_levels/"] - - /** @type {string} */ - const roomID = await callback(kstateWithoutPowerLevels) - - // Now *really* apply the power level overrides on top of what Synapse *really* set - if (powerLevelContent) { - const newRoomKState = await roomToKState(roomID) - const newRoomPowerLevelsDiff = ks.diffKState(newRoomKState, {"m.room.power_levels/": powerLevelContent}) - await applyKStateDiffToRoom(roomID, newRoomPowerLevelsDiff) - } - - return roomID -} - -/** - * @param {DiscordTypes.APIGuildChannel} channel - */ -function channelToGuild(channel) { - const guildID = channel.guild_id - assert(guildID) - const guild = discord.guilds.get(guildID) - assert(guild) - return guild -} - -/* - Ensure flow: - 1. Get IDs - 2. Does room exist? If so great! - (it doesn't, so it needs to be created) - 3. Get kstate for channel - 4. Create room, return new ID - - Ensure + sync flow: - 1. Get IDs - 2. Does room exist? - 2.5: If room does exist AND wasn't asked to sync: return here - 3. Get kstate for channel - 4. Create room with kstate if room doesn't exist - 5. Get and update room state with kstate if room does exist -*/ - -/** - * @param {string} channelID - * @param {boolean} shouldActuallySync false if just need to ensure room exists (which is a quick database check), true if also want to sync room data when it does exist (slow) - * @returns {Promise} room ID - */ -async function _syncRoom(channelID, shouldActuallySync) { - /** @ts-ignore @type {DiscordTypes.APIGuildChannel} */ - const channel = discord.channels.get(channelID) - assert.ok(channel) - const guild = channelToGuild(channel) - - if (inflightRoomCreate.has(channelID)) { - await inflightRoomCreate.get(channelID) // just waiting, and then doing a new db query afterwards, is the simplest way of doing it - } - - const existing = select("channel_room", ["room_id", "thread_parent"], {channel_id: channelID}).get() - - if (!existing) { - const creation = (async () => { - const {spaceID, privacyLevel, channelKState} = await channelToKState(channel, guild) - const roomID = await createRoom(channel, guild, spaceID, channelKState, privacyLevel) - inflightRoomCreate.delete(channelID) // OK to release inflight waiters now. they will read the correct `existing` row - return roomID - })() - inflightRoomCreate.set(channelID, creation) - return creation // Naturally, the newly created room is already up to date, so we can always skip syncing here. - } - - const roomID = existing.room_id - - if (!shouldActuallySync) { - return existing.room_id // only need to ensure room exists, and it does. return the room ID - } - - console.log(`[room sync] to matrix: ${channel.name}`) - - const {spaceID, channelKState} = await channelToKState(channel, guild) // calling this in both branches because we don't want to calculate this if not syncing - - // sync channel state to room - const roomKState = await roomToKState(roomID) - if (+roomKState["m.room.create/"].room_version <= 8) { - // join_rule `restricted` is not available in room version < 8 and not working properly in version == 8 - // read more: https://spec.matrix.org/v1.8/rooms/v9/ - // we have to use `public` instead, otherwise the room will be unjoinable. - channelKState["m.room.join_rules/"] = {join_rule: "public"} - } - const roomDiff = ks.diffKState(roomKState, channelKState) - const roomApply = applyKStateDiffToRoom(roomID, roomDiff) - db.prepare("UPDATE channel_room SET name = ? WHERE room_id = ?").run(channel.name, roomID) - - // sync room as space member - const spaceApply = _syncSpaceMember(channel, spaceID, roomID) - await Promise.all([roomApply, spaceApply]) - - return roomID -} - -/** Ensures the room exists. If it doesn't, creates the room with an accurate initial state. */ -function ensureRoom(channelID) { - return _syncRoom(channelID, false) -} - -/** Actually syncs. Gets all room state from the homeserver in order to diff, and uploads the icon to mxc if it has changed. */ -function syncRoom(channelID) { - return _syncRoom(channelID, true) -} - -async function _unbridgeRoom(channelID) { - /** @ts-ignore @type {DiscordTypes.APIGuildChannel} */ - const channel = discord.channels.get(channelID) - assert.ok(channel) - return unbridgeDeletedChannel(channel.id, channel.guild_id) -} - -async function unbridgeDeletedChannel(channelID, guildID) { - const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get() - assert.ok(roomID) - const spaceID = select("guild_space", "space_id", {guild_id: guildID}).pluck().get() - assert.ok(spaceID) - - // remove room from being a space member - await api.sendState(roomID, "m.space.parent", spaceID, {}) - await api.sendState(spaceID, "m.space.child", roomID, {}) - - // remove declaration that the room is bridged - await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channelID}`, {}) - - // send a notification in the room - await api.sendEvent(roomID, "m.room.message", { - msgtype: "m.notice", - body: "โš ๏ธ This room was removed from the bridge." - }) - - // leave room - await api.leaveRoom(roomID) - - // delete room from database - const {changes} = db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channelID) - assert.equal(changes, 1) -} - -/** - * Async because it gets all space state from the homeserver, then if necessary sends one state event back. - * @param {DiscordTypes.APIGuildTextChannel} channel - * @param {string} spaceID - * @param {string} roomID - * @returns {Promise} - */ -async function _syncSpaceMember(channel, spaceID, roomID) { - const spaceKState = await roomToKState(spaceID) - let spaceEventContent = {} - if ( - channel.type !== DiscordTypes.ChannelType.PrivateThread // private threads do not belong in the space (don't offer people something they can't join) - && !channel["thread_metadata"]?.archived // archived threads do not belong in the space (don't offer people conversations that are no longer relevant) - ) { - spaceEventContent = { - via: [reg.ooye.server_name] - } - } - const spaceDiff = ks.diffKState(spaceKState, { - [`m.space.child/${roomID}`]: spaceEventContent - }) - return applyKStateDiffToRoom(spaceID, spaceDiff) -} - -async function createAllForGuild(guildID) { - const channelIDs = discord.guildChannelMap.get(guildID) - assert.ok(channelIDs) - for (const channelID of channelIDs) { - const allowedTypes = [DiscordTypes.ChannelType.GuildText, DiscordTypes.ChannelType.PublicThread] - // @ts-ignore - if (allowedTypes.includes(discord.channels.get(channelID)?.type)) { - const roomID = await syncRoom(channelID) - console.log(`synced ${channelID} <-> ${roomID}`) - } - } -} - -module.exports.DEFAULT_PRIVACY_LEVEL = DEFAULT_PRIVACY_LEVEL -module.exports.PRIVACY_ENUMS = PRIVACY_ENUMS -module.exports.createRoom = createRoom -module.exports.ensureRoom = ensureRoom -module.exports.syncRoom = syncRoom -module.exports.createAllForGuild = createAllForGuild -module.exports.channelToKState = channelToKState -module.exports.roomToKState = roomToKState -module.exports.applyKStateDiffToRoom = applyKStateDiffToRoom -module.exports.postApplyPowerLevels = postApplyPowerLevels -module.exports._convertNameAndTopic = convertNameAndTopic -module.exports._unbridgeRoom = _unbridgeRoom -module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel diff --git a/d2m/actions/create-room.test.js b/d2m/actions/create-room.test.js deleted file mode 100644 index 93f9203b..00000000 --- a/d2m/actions/create-room.test.js +++ /dev/null @@ -1,89 +0,0 @@ -// @ts-check - -const {channelToKState, _convertNameAndTopic} = require("./create-room") -const {kstateStripConditionals} = require("../../matrix/kstate") -const {test} = require("supertape") -const testData = require("../../test/data") - -const passthrough = require("../../passthrough") -const {db} = passthrough - -test("channel2room: discoverable privacy room", async t => { - 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( - kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general).then(x => x.channelKState)), - testData.room.general - ) -}) - -test("convertNameAndTopic: custom name and topic", t => { - t.deepEqual( - _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, "hauntings"), - ["hauntings", "#the-twilight-zone | Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"] - ) -}) - -test("convertNameAndTopic: custom name, no topic", t => { - t.deepEqual( - _convertNameAndTopic({id: "123", name: "the-twilight-zone", type: 0}, {id: "456"}, "hauntings"), - ["hauntings", "#the-twilight-zone\n\nChannel ID: 123\nGuild ID: 456"] - ) -}) - -test("convertNameAndTopic: original name and topic", t => { - t.deepEqual( - _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, null), - ["the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"] - ) -}) - -test("convertNameAndTopic: original name, no topic", t => { - t.deepEqual( - _convertNameAndTopic({id: "123", name: "the-twilight-zone", type: 0}, {id: "456"}, null), - ["the-twilight-zone", "Channel ID: 123\nGuild ID: 456"] - ) -}) - -test("convertNameAndTopic: public thread icon", t => { - t.deepEqual( - _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 11}, {id: "456"}, null), - ["[โ›“๏ธ] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"] - ) -}) - -test("convertNameAndTopic: private thread icon", t => { - t.deepEqual( - _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 12}, {id: "456"}, null), - ["[๐Ÿ”’โ›“๏ธ] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"] - ) -}) - -test("convertNameAndTopic: voice channel icon", t => { - t.deepEqual( - _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 2}, {id: "456"}, null), - ["[๐Ÿ”Š] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"] - ) -}) diff --git a/d2m/actions/register-user.test.js b/d2m/actions/register-user.test.js deleted file mode 100644 index 96c73aa6..00000000 --- a/d2m/actions/register-user.test.js +++ /dev/null @@ -1,63 +0,0 @@ -const {_memberToStateContent} = require("./register-user") -const {test} = require("supertape") -const testData = require("../../test/data") - -test("member2state: without member nick or avatar", async t => { - t.deepEqual( - await _memberToStateContent(testData.member.kumaccino.user, testData.member.kumaccino, testData.guild.general.id), - { - avatar_url: "mxc://cadence.moe/UpAeIqeclhKfeiZNdIWNcXXL", - displayname: "kumaccino", - membership: "join", - "moe.cadence.ooye.member": { - avatar: "/avatars/113340068197859328/b48302623a12bc7c59a71328f72ccb39.png?size=1024" - }, - "uk.half-shot.discord.member": { - bot: false, - displayColor: 10206929, - id: "113340068197859328", - username: "@kumaccino" - } - } - ) -}) - -test("member2state: with global name, without member nick or avatar", async t => { - t.deepEqual( - await _memberToStateContent(testData.member.papiophidian.user, testData.member.papiophidian, testData.guild.general.id), - { - avatar_url: "mxc://cadence.moe/JPzSmALLirnIprlSMKohSSoX", - displayname: "PapiOphidian", - membership: "join", - "moe.cadence.ooye.member": { - avatar: "/avatars/320067006521147393/5fc4ad85c1ea876709e9a7d3374a78a1.png?size=1024" - }, - "uk.half-shot.discord.member": { - bot: false, - displayColor: 1579292, - id: "320067006521147393", - username: "@papiophidian" - } - } - ) -}) - -test("member2state: with member nick and avatar", async t => { - t.deepEqual( - await _memberToStateContent(testData.member.sheep.user, testData.member.sheep, testData.guild.general.id), - { - avatar_url: "mxc://cadence.moe/rfemHmAtcprjLEiPiEuzPhpl", - displayname: "The Expert's Submarine", - membership: "join", - "moe.cadence.ooye.member": { - avatar: "/guilds/112760669178241024/users/134826546694193153/avatars/38dd359aa12bcd52dd3164126c587f8c.png?size=1024" - }, - "uk.half-shot.discord.member": { - bot: false, - displayColor: null, - id: "134826546694193153", - username: "@aprilsong" - } - } - ) -}) diff --git a/d2m/converters/edit-to-changes.js b/d2m/converters/edit-to-changes.js deleted file mode 100644 index 08f787cf..00000000 --- a/d2m/converters/edit-to-changes.js +++ /dev/null @@ -1,157 +0,0 @@ -// @ts-check - -const assert = require("assert").strict - -const passthrough = require("../../passthrough") -const {discord, sync, db, select, from} = passthrough -/** @type {import("./message-to-event")} */ -const messageToEvent = sync.require("../converters/message-to-event") -/** @type {import("../actions/register-user")} */ -const registerUser = sync.require("../actions/register-user") -/** @type {import("../actions/create-room")} */ -const createRoom = sync.require("../actions/create-room") - -/** - * @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message - * IMPORTANT: This may not have all the normal fields! The API documentation doesn't provide possible types, just says it's all optional! - * Since I don't have a spec, I will have to capture some real traffic and add it as test cases... I hope they don't change anything later... - * @param {import("discord-api-types/v10").APIGuild} guild - * @param {import("../../matrix/api")} api simple-as-nails dependency injection for the matrix API - */ -async function editToChanges(message, guild, api) { - // Figure out what events we will be replacing - - const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get() - 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. */ - const senderMxid = from("sim").join("sim_member", "mxid").where({user_id: message.author.id, room_id: roomID}).pluck("mxid").get() || null - - const oldEventRows = select("event_message", ["event_id", "event_type", "event_subtype", "part", "reaction_part"], {message_id: message.id}).all() - - // Figure out what we will be replacing them with - - const newFallbackContent = await messageToEvent.messageToEvent(message, guild, {includeEditFallbackStar: true}, {api}) - const newInnerContent = await messageToEvent.messageToEvent(message, guild, {includeReplyFallback: false}, {api}) - assert.ok(newFallbackContent.length === newInnerContent.length) - - // Match the new events to the old events - - /* - Rules: - + The events must have the same type. - + The events must have the same subtype. - Events will therefore be divided into four categories: - */ - /** 1. Events that are matched, and should be edited by sending another m.replace event */ - let eventsToReplace = [] - /** 2. Events that are present in the old version only, and should be blanked or redacted */ - let eventsToRedact = [] - /** 3. Events that are present in the new version only, and should be sent as new, with references back to the context */ - let eventsToSend = [] - // 4. Events that are matched and have definitely not changed, so they don't need to be edited or replaced at all. This is represented as nothing. - - function shift() { - newFallbackContent.shift() - newInnerContent.shift() - } - - // For each old event... - outer: while (newFallbackContent.length) { - const newe = newFallbackContent[0] - // Find a new event to pair it with... - for (let i = 0; i < oldEventRows.length; i++) { - const olde = oldEventRows[i] - if (olde.event_type === newe.$type && olde.event_subtype === (newe.msgtype || null)) { // The spec does allow subtypes to change, so I can change this condition later if I want to - // Found one! - // Set up the pairing - eventsToReplace.push({ - old: olde, - newFallbackContent: newFallbackContent[0], - newInnerContent: newInnerContent[0] - }) - // These events have been handled now, so remove them from the source arrays - shift() - oldEventRows.splice(i, 1) - // Go all the way back to the start of the next iteration of the outer loop - continue outer - } - } - // If we got this far, we could not pair it to an existing event, so it'll have to be a new one - eventsToSend.push(newInnerContent[0]) - shift() - } - // Anything remaining in oldEventRows is present in the old version only and should be redacted. - eventsToRedact = oldEventRows - - // We want to maintain exactly one part = 0 and one reaction_part = 0 database row at all times. - /** @type {({column: string, eventID: string} | {column: string, nextEvent: true})[]} */ - const promotions = [] - for (const column of ["part", "reaction_part"]) { - // If no events with part = 0 exist (or will exist), we need to do some management. - if (!eventsToReplace.some(e => e.old[column] === 0)) { - 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)) - if (column === "part") { - promotions.push({column, eventID: eventsToReplace[0].old.event_id}) // part should be the first one - } else { - promotions.push({column, eventID: eventsToReplace[eventsToReplace.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}) - } - } - } - - // Now, everything in eventsToSend and eventsToRedact is a real change, but everything in eventsToReplace might not have actually changed! - // (Example: a MESSAGE_UPDATE for a text+image message - Discord does not allow the image to be changed, but the text might have been.) - // So we'll remove entries from eventsToReplace that *definitely* cannot have changed. (This is category 4 mentioned above.) Everything remaining *may* have changed. - eventsToReplace = eventsToReplace.filter(ev => { - // Discord does not allow files, images, attachments, or videos to be edited. - if (ev.old.event_type === "m.room.message" && ev.old.event_subtype !== "m.text" && ev.old.event_subtype !== "m.emote" && ev.old.event_subtype !== "m.notice") { - return false - } - // Discord does not allow stickers to be edited. - if (ev.old.event_type === "m.sticker") { - return false - } - // Anything else is fair game. - return true - }) - - // Removing unnecessary properties before returning - 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)})) - - return {roomID, eventsToReplace, eventsToRedact, eventsToSend, senderMxid, promotions} -} - -/** - * @template T - * @param {string} oldID - * @param {T} newFallbackContent - * @param {T} newInnerContent - * @returns {import("../../types").Event.ReplacementContent} content - */ -function makeReplacementEventContent(oldID, newFallbackContent, newInnerContent) { - const content = { - ...newFallbackContent, - "m.mentions": {}, - "m.new_content": { - ...newInnerContent - }, - "m.relates_to": { - rel_type: "m.replace", - event_id: oldID - } - } - delete content["m.new_content"]["$type"] - // Client-Server API spec 11.37.3: Any m.relates_to property within m.new_content is ignored. - delete content["m.new_content"]["m.relates_to"] - return content -} - -module.exports.editToChanges = editToChanges -module.exports.makeReplacementEventContent = makeReplacementEventContent diff --git a/d2m/converters/message-to-event.embeds.test.js b/d2m/converters/message-to-event.embeds.test.js deleted file mode 100644 index dfc5679c..00000000 --- a/d2m/converters/message-to-event.embeds.test.js +++ /dev/null @@ -1,143 +0,0 @@ -const {test} = require("supertape") -const {messageToEvent} = require("./message-to-event") -const data = require("../../test/data") -const Ty = require("../../types") - -test("message2event embeds: nothing but a field", async t => { - const events = await messageToEvent(data.message_with_embeds.nothing_but_a_field, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.notice", - body: "| ### Amanda ๐ŸŽต#2192 :online:" - + "\n| willow tree, branch 0" - + "\n| **โฏ Uptime:**\n| 3m 55s\n| **โฏ Memory:**\n| 64.45MB", - format: "org.matrix.custom.html", - formatted_body: '

Amanda ๐ŸŽต#2192 \":online:\"' - + '
willow tree, branch 0
' - + '
โฏ Uptime:
3m 55s' - + '
โฏ Memory:
64.45MB

' - }]) -}) - -test("message2event embeds: reply with just an embed", async t => { - const events = await messageToEvent(data.message_with_embeds.reply_with_only_embed, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - msgtype: "m.notice", - "m.mentions": {}, - body: "| ## โบ๏ธ dynastic (@dynastic) https://twitter.com/i/user/719631291747078145" - + "\n| \n| ## https://twitter.com/i/status/1707484191963648161" - + "\n| \n| does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?" - + "\n| \n| ### Retweets" - + "\n| 119" - + "\n| \n| ### Likes" - + "\n| 5581" - + "\n| โ€” Twitter", - format: "org.matrix.custom.html", - formatted_body: '

โบ๏ธ dynastic (@dynastic)

' - + '

https://twitter.com/i/status/1707484191963648161' - + '

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?' - + '

Retweets
119

Likes
5581

โ€” Twitter
' - }]) -}) - -test("message2event embeds: image embed and attachment", async t => { - const events = await messageToEvent(data.message_with_embeds.image_embed_and_attachment, data.guild.general, {}, { - api: { - async getJoinedMembers(roomID) { - return {joined: []} - } - } - }) - t.deepEqual(events, [{ - $type: "m.room.message", - msgtype: "m.text", - body: "https://tootsuite.net/Warp-Gate2.gif\ntanget: @ monster spawner", - format: "org.matrix.custom.html", - formatted_body: 'https://tootsuite.net/Warp-Gate2.gif
tanget: @ monster spawner', - "m.mentions": {} - }, { - $type: "m.room.message", - msgtype: "m.image", - url: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR", - body: "Screenshot_20231001_034036.jpg", - external_url: "https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg?ex=651a1faa&is=6518ce2a&hm=eb5ca80a3fa7add8765bf404aea2028a28a2341e4a62435986bcdcf058da82f3&", - filename: "Screenshot_20231001_034036.jpg", - info: { - h: 1170, - w: 1080, - size: 51981, - mimetype: "image/jpeg" - }, - "m.mentions": {} - }]) -}) - -test("message2event embeds: blockquote in embed", async t => { - let called = 0 - const events = await messageToEvent(data.message_with_embeds.blockquote_in_embed, data.guild.general, {}, { - api: { - async getStateEvent(roomID, type, key) { - called++ - t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") - t.equal(type, "m.room.power_levels") - t.equal(key, "") - return { - users: { - "@_ooye_bot:cadence.moe": 100 - } - } - }, - async getJoinedMembers(roomID) { - called++ - t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") - return { - joined: { - "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, - "@user:example.invalid": {display_name: null, avatar_url: null} - } - } - } - } - }) - t.deepEqual(events, [{ - $type: "m.room.message", - msgtype: "m.text", - body: ":emoji: **4 |** #wonderland", - format: "org.matrix.custom.html", - formatted_body: `\":emoji:\" 4 | #wonderland`, - "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: "

โบ๏ธ minimus

reply draft

The following is a message composed via consensus of the Stinker Council.

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.

Due to circumstances outside of our control, this directive has now changed. Our new mission will be the extermination of the stinker race.

There will be no further communication.

Go to Message

", - "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: "| ## โบ๏ธ [Hey