From 613a1dc0866067f597b691b71d07bb72e70dfda5 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 12 Oct 2023 20:30:41 +1300 Subject: [PATCH] Add private/linkable/public privacy rules --- d2m/actions/create-room.js | 61 +++++++++++++++------ d2m/actions/create-space.js | 37 +++++++------ d2m/actions/send-message.js | 4 +- db/migrations/0006-add-privacy-to-space.sql | 5 ++ db/orm-defs.d.ts | 1 + test/ooye-test-data.sql | 4 +- 6 files changed, 76 insertions(+), 36 deletions(-) create mode 100644 db/migrations/0006-add-privacy-to-space.sql diff --git a/d2m/actions/create-room.js b/d2m/actions/create-room.js index 51926ee..bee45e2 100644 --- a/d2m/actions/create-room.js +++ b/d2m/actions/create-room.js @@ -15,6 +15,24 @@ 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() @@ -69,7 +87,9 @@ function convertNameAndTopic(channel, guild, customName) { */ async function channelToKState(channel, guild) { const spaceID = await createSpace.ensureSpace(guild) - assert.ok(typeof spaceID === "string") + assert(typeof spaceID === "string") + const privacyLevel = select("guild_space", "privacy_level", {space_id: spaceID}).pluck().get() + assert(privacyLevel) const row = select("channel_room", ["nick", "custom_avatar"], {channel_id: channel.id}).get() const customName = row?.nick @@ -84,27 +104,33 @@ 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 } - let history_visibility = "shared" + 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: "can_join"}, + "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_rule: "restricted", - allow: [{ - type: "m.room_membership", - room_id: spaceID - }] - }, + "m.room.join_rules/": join_rules, "m.room.power_levels/": { events: { "m.room.avatar": 0 @@ -132,7 +158,7 @@ async function channelToKState(channel, guild) { } } - return {spaceID, channelKState} + return {spaceID, privacyLevel, channelKState} } /** @@ -141,9 +167,10 @@ async function channelToKState(channel, guild) { * @param guild * @param {string} spaceID * @param {any} kstate + * @param {number} privacyLevel * @returns {Promise} room ID */ -async function createRoom(channel, guild, spaceID, kstate) { +async function createRoom(channel, guild, spaceID, kstate, privacyLevel) { let threadParent = null if (channel.type === DiscordTypes.ChannelType.PublicThread) threadParent = channel.parent_id @@ -160,8 +187,8 @@ async function createRoom(channel, guild, spaceID, kstate) { const roomID = await api.createRoom({ name, topic, - preset: "private_chat", // This is closest to what we want, but properties from kstate override it anyway - visibility: "private", // Not shown in the room directory + preset: PRIVACY_ENUMS.ROOM_HISTORY_VISIBILITY[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) }) @@ -252,8 +279,8 @@ async function _syncRoom(channelID, shouldActuallySync) { if (!existing) { const creation = (async () => { - const {spaceID, channelKState} = await channelToKState(channel, guild) - const roomID = await createRoom(channel, guild, spaceID, channelKState) + 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 })() @@ -371,6 +398,8 @@ async function createAllForGuild(guildID) { } } +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 diff --git a/d2m/actions/create-space.js b/d2m/actions/create-space.js index da877e0..1c1c357 100644 --- a/d2m/actions/create-space.js +++ b/d2m/actions/create-space.js @@ -32,8 +32,8 @@ async function createSpace(guild, kstate) { const roomID = await createRoom.postApplyPowerLevels(kstate, async kstate => { return api.createRoom({ name, - preset: "private_chat", // cannot join space unless invited - visibility: "private", + preset: createRoom.PRIVACY_ENUMS.PRESET[createRoom.DEFAULT_PRIVACY_LEVEL], // New spaces will have to use the default privacy level; we obviously can't look up the existing entry + visibility: createRoom.PRIVACY_ENUMS.VISIBILITY[createRoom.DEFAULT_PRIVACY_LEVEL], power_level_content_override: { events_default: 100, // space can only be managed by bridge invite: 0 // any existing member can invite others @@ -51,23 +51,21 @@ async function createSpace(guild, kstate) { } /** - * @param {DiscordTypes.APIGuild} guild] + * @param {DiscordTypes.APIGuild} guild + * @param {number} privacyLevel */ -async function guildToKState(guild) { +async function guildToKState(guild, privacyLevel) { 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 = { "m.room.name/": {name: guild.name}, "m.room.avatar/": avatarEventContent, - "m.room.guest_access/": {guest_access: "can_join"}, // guests can join space if other conditions are met - "m.room.history_visibility/": {history_visibility: "invited"} // any events sent after user was invited are visible + "m.room.guest_access/": {guest_access: createRoom.PRIVACY_ENUMS.GUEST_ACCESS[privacyLevel]}, + "m.room.history_visibility/": {history_visibility: createRoom.PRIVACY_ENUMS.GUEST_ACCESS[privacyLevel]} } return guildKState @@ -86,11 +84,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 } - const spaceID = select("guild_space", "space_id", {guild_id: guild.id}).pluck().get() + const row = select("guild_space", ["space_id", "privacy_level"], {guild_id: guild.id}).get() - if (!spaceID) { + if (!row) { const creation = (async () => { - const guildKState = await guildToKState(guild) + const guildKState = await guildToKState(guild, createRoom.DEFAULT_PRIVACY_LEVEL) // New spaces will have to use the default privacy level; we obviously can't look up the existing entry const spaceID = await createSpace(guild, guildKState) inflightSpaceCreate.delete(guild.id) return spaceID @@ -99,13 +97,15 @@ async function _syncSpace(guild, shouldActuallySync) { return creation // Naturally, the newly created space is already up to date, so we can always skip syncing here. } + const {space_id: spaceID, privacy_level} = row + if (!shouldActuallySync) { return spaceID // only need to ensure space exists, and it does. return the space ID } console.log(`[space sync] to matrix: ${guild.name}`) - const guildKState = await guildToKState(guild) // calling this in both branches because we don't want to calculate this if not syncing + const guildKState = await guildToKState(guild, privacy_level) // calling this in both branches because we don't want to calculate this if not syncing // sync guild state to space const spaceKState = await createRoom.roomToKState(spaceID) @@ -159,17 +159,20 @@ async function syncSpaceFully(guildID) { const guild = discord.guilds.get(guildID) assert.ok(guild) - const spaceID = select("guild_space", "space_id", {guild_id: guildID}).pluck().get() + const row = select("guild_space", ["space_id", "privacy_level"], {guild_id: guildID}).get() - const guildKState = await guildToKState(guild) - - if (!spaceID) { + if (!row) { + const guildKState = await guildToKState(guild, createRoom.DEFAULT_PRIVACY_LEVEL) const spaceID = await createSpace(guild, guildKState) return spaceID // Naturally, the newly created space is already up to date, so we can always skip syncing here. } + const {space_id: spaceID, privacy_level} = row + console.log(`[space sync] to matrix: ${guild.name}`) + const guildKState = await guildToKState(guild, privacy_level) + // sync guild state to space const spaceKState = await createRoom.roomToKState(spaceID) const spaceDiff = ks.diffKState(spaceKState, guildKState) diff --git a/d2m/actions/send-message.js b/d2m/actions/send-message.js index 843ee20..a0027b0 100644 --- a/d2m/actions/send-message.js +++ b/d2m/actions/send-message.js @@ -40,9 +40,11 @@ async function sendMessage(message, guild) { } for (const event of events) { const eventType = event.$type - /** @type {Pick> & { $type?: string }} */ + if (event.$sender) senderMxid = event.$sender + /** @type {Pick> & { $type?: string, $sender?: string }} */ const eventWithoutType = {...event} delete eventWithoutType.$type + delete eventWithoutType.$sender const useTimestamp = message["backfill"] ? new Date(message.timestamp).getTime() : undefined const eventID = await api.sendEvent(roomID, eventType, eventWithoutType, senderMxid, useTimestamp) diff --git a/db/migrations/0006-add-privacy-to-space.sql b/db/migrations/0006-add-privacy-to-space.sql new file mode 100644 index 0000000..a5a69e2 --- /dev/null +++ b/db/migrations/0006-add-privacy-to-space.sql @@ -0,0 +1,5 @@ +BEGIN TRANSACTION; + +ALTER TABLE guild_space ADD COLUMN privacy_level TEXT NOT NULL DEFAULT 0; + +COMMIT; diff --git a/db/orm-defs.d.ts b/db/orm-defs.d.ts index 292a445..0714e0b 100644 --- a/db/orm-defs.d.ts +++ b/db/orm-defs.d.ts @@ -25,6 +25,7 @@ export type Models = { guild_space: { guild_id: string space_id: string + privacy_level: number } lottie: { diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql index 1ce0467..2070b66 100644 --- a/test/ooye-test-data.sql +++ b/test/ooye-test-data.sql @@ -1,7 +1,7 @@ BEGIN TRANSACTION; -INSERT INTO guild_space (guild_id, space_id) VALUES -('112760669178241024', '!jjWAGMeQdNrVZSSfvz:cadence.moe'); +INSERT INTO guild_space (guild_id, space_id, privacy_level) VALUES +('112760669178241024', '!jjWAGMeQdNrVZSSfvz:cadence.moe', 0); INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar) VALUES ('112760669178241024', '!kLRqKKUQXcibIMtOpl:cadence.moe', 'heave', 'main', NULL, NULL),