2023-04-30 12:57:30 +00:00
// @ts-check
2023-05-04 20:25:00 +00:00
const assert = require ( "assert" ) . strict
const DiscordTypes = require ( "discord-api-types/v10" )
2023-08-23 00:45:19 +00:00
const reg = require ( "../../matrix/read-registration" )
2023-05-04 20:25:00 +00:00
const passthrough = require ( "../../passthrough" )
2023-09-18 10:51:59 +00:00
const { discord , sync , db , select } = passthrough
2023-05-04 20:25:00 +00:00
/** @type {import("../../matrix/file")} */
const file = sync . require ( "../../matrix/file" )
2023-05-08 11:37:51 +00:00
/** @type {import("../../matrix/api")} */
const api = sync . require ( "../../matrix/api" )
2023-05-10 05:40:31 +00:00
/** @type {import("../../matrix/kstate")} */
const ks = sync . require ( "../../matrix/kstate" )
2023-09-12 11:15:55 +00:00
/** @type {import("./create-space")}) */
const createSpace = sync . require ( "./create-space" ) // watch out for the require loop
2023-05-05 13:25:15 +00:00
2023-10-12 07:30:41 +00:00
/ * *
* 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
2023-08-21 05:25:51 +00:00
/** @type {Map<string, Promise<string>>} channel ID -> Promise<room ID> */
const inflightRoomCreate = new Map ( )
2023-05-05 13:25:15 +00:00
/ * *
2023-08-25 05:23:51 +00:00
* Async because it gets all room state from the homeserver .
2023-05-05 13:25:15 +00:00
* @ param { string } roomID
* /
async function roomToKState ( roomID ) {
2023-05-08 11:37:51 +00:00
const root = await api . getAllState ( roomID )
2023-05-10 05:40:31 +00:00
return ks . stateToKState ( root )
2023-05-05 13:25:15 +00:00
}
/ * *
2023-08-19 06:39:23 +00:00
* @ param { string } roomID
* @ param { any } kstate
2023-05-05 13:25:15 +00:00
* /
function applyKStateDiffToRoom ( roomID , kstate ) {
2023-05-10 05:40:31 +00:00
const events = ks . kstateToState ( kstate )
2023-05-05 13:25:15 +00:00
return Promise . all ( events . map ( ( { type , state _key , content } ) =>
2023-05-08 11:37:51 +00:00
api . sendState ( roomID , type , state _key , content )
2023-05-05 13:25:15 +00:00
) )
}
2023-05-05 05:29:08 +00:00
2023-05-04 20:25:00 +00:00
/ * *
2023-08-25 00:05:16 +00:00
* @ param { { id : string , name : string , topic ? : string ? , type : number } } channel
2023-07-04 05:35:29 +00:00
* @ param { { id : string } } guild
2023-09-18 10:51:59 +00:00
* @ param { string | null | undefined } customName
2023-05-04 20:25:00 +00:00
* /
2023-07-04 05:35:29 +00:00
function convertNameAndTopic ( channel , guild , customName ) {
2023-08-25 00:05:16 +00:00
let channelPrefix =
( channel . type === DiscordTypes . ChannelType . PublicThread ? "[⛓️] "
: channel . type === DiscordTypes . ChannelType . PrivateThread ? "[🔒⛓️] "
: channel . type === DiscordTypes . ChannelType . GuildVoice ? "[🔊] "
: "" )
const chosenName = customName || ( channelPrefix + channel . name ) ;
2023-07-05 00:04:28 +00:00
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 } ` ;
2023-06-28 12:06:56 +00:00
2023-07-05 00:04:28 +00:00
const convertedTopic = customName
? ` # ${ channel . name } ${ maybeTopicWithPipe } \n \n ${ channelIDPart } \n ${ guildIDPart } `
: ` ${ maybeTopicWithNewlines } ${ channelIDPart } \n ${ guildIDPart } ` ;
2023-08-25 00:05:16 +00:00
return [ chosenName , convertedTopic ] ;
2023-07-04 05:35:29 +00:00
}
/ * *
2023-09-12 11:15:55 +00:00
* Async because it may create the guild and / or upload the guild icon to mxc .
2023-08-19 06:39:23 +00:00
* @ param { DiscordTypes . APIGuildTextChannel | DiscordTypes . APIThreadChannel } channel
2023-07-04 05:35:29 +00:00
* @ param { DiscordTypes . APIGuild } guild
* /
async function channelToKState ( channel , guild ) {
2023-09-20 04:37:24 +00:00
const spaceID = await createSpace . ensureSpace ( guild )
2023-10-12 07:30:41 +00:00
assert ( typeof spaceID === "string" )
const privacyLevel = select ( "guild_space" , "privacy_level" , { space _id : spaceID } ) . pluck ( ) . get ( )
2023-10-12 09:32:28 +00:00
assert ( typeof privacyLevel === "number" )
2023-07-04 05:35:29 +00:00
2023-10-05 23:31:10 +00:00
const row = select ( "channel_room" , [ "nick" , "custom_avatar" ] , { channel _id : channel . id } ) . get ( )
2023-08-23 12:27:51 +00:00
const customName = row ? . nick
const customAvatar = row ? . custom _avatar
2023-07-04 05:35:29 +00:00
const [ convertedName , convertedTopic ] = convertNameAndTopic ( channel , guild , customName )
const avatarEventContent = { }
2023-08-23 00:39:37 +00:00
if ( customAvatar ) {
avatarEventContent . url = customAvatar
} else if ( guild . icon ) {
2023-07-04 05:35:29 +00:00
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
}
2023-10-12 07:30:41 +00:00
let history _visibility = PRIVACY _ENUMS . ROOM _HISTORY _VISIBILITY [ privacyLevel ]
2023-08-21 11:31:40 +00:00
if ( channel [ "thread_metadata" ] ) history _visibility = "world_readable"
2023-10-12 07:30:41 +00:00
/** @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 ] }
}
2023-05-05 13:25:15 +00:00
const channelKState = {
2023-06-28 12:06:56 +00:00
"m.room.name/" : { name : convertedName } ,
"m.room.topic/" : { topic : convertedTopic } ,
2023-05-05 05:29:08 +00:00
"m.room.avatar/" : avatarEventContent ,
2023-10-12 07:30:41 +00:00
"m.room.guest_access/" : { guest _access : PRIVACY _ENUMS . GUEST _ACCESS [ privacyLevel ] } ,
2023-08-21 11:31:40 +00:00
"m.room.history_visibility/" : { history _visibility } ,
2023-05-05 13:25:15 +00:00
[ ` m.space.parent/ ${ spaceID } ` ] : {
2023-08-23 00:45:19 +00:00
via : [ reg . ooye . server _name ] ,
2023-05-05 05:29:08 +00:00
canonical : true
} ,
2023-09-03 13:36:58 +00:00
/** @type {{join_rule: string, [x: string]: any}} */
2023-10-12 07:30:41 +00:00
"m.room.join_rules/" : join _rules ,
2023-08-23 05:08:20 +00:00
"m.room.power_levels/" : {
events : {
"m.room.avatar" : 0
2023-10-12 09:55:52 +00:00
} ,
users : reg . ooye . invite . reduce ( ( a , c ) => ( a [ c ] = 100 , a ) , { } )
2023-09-03 05:13:04 +00:00
} ,
"chat.schildi.hide_ui/read_receipts" : {
hidden : true
2023-09-07 00:07:56 +00:00
} ,
[ ` 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 ,
2023-09-23 12:13:38 +00:00
avatar _url : await file . uploadDiscordFileToMxc ( file . guildIcon ( guild ) )
2023-09-07 00:07:56 +00:00
} ,
channel : {
id : channel . id ,
displayname : channel . name ,
external _url : ` https://discord.com/channels/ ${ guild . id } / ${ channel . id } `
}
2023-05-05 05:29:08 +00:00
}
2023-04-30 12:57:30 +00:00
}
2023-05-04 20:25:00 +00:00
2023-10-12 07:30:41 +00:00
return { spaceID , privacyLevel , channelKState }
2023-05-05 05:29:08 +00:00
}
/ * *
2023-05-08 20:03:57 +00:00
* Create a bridge room , store the relationship in the database , and add it to the guild ' s space .
2023-06-28 12:06:56 +00:00
* @ param { DiscordTypes . APIGuildTextChannel } channel
2023-05-05 05:29:08 +00:00
* @ param guild
* @ param { string } spaceID
* @ param { any } kstate
2023-10-12 07:30:41 +00:00
* @ param { number } privacyLevel
2023-05-08 20:03:57 +00:00
* @ returns { Promise < string > } room ID
2023-05-05 05:29:08 +00:00
* /
2023-10-12 07:30:41 +00:00
async function createRoom ( channel , guild , spaceID , kstate , privacyLevel ) {
2023-08-21 05:25:51 +00:00
let threadParent = null
if ( channel . type === DiscordTypes . ChannelType . PublicThread ) threadParent = channel . parent _id
2023-08-24 00:42:12 +00:00
// 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 )
2023-08-23 05:08:20 +00:00
const roomID = await postApplyPowerLevels ( kstate , async kstate => {
const roomID = await api . createRoom ( {
2023-08-24 00:42:12 +00:00
name ,
topic ,
2023-10-12 09:34:23 +00:00
preset : PRIVACY _ENUMS . PRESET [ privacyLevel ] , // This is closest to what we want, but properties from kstate override it anyway
2023-10-12 07:30:41 +00:00
visibility : PRIVACY _ENUMS . VISIBILITY [ privacyLevel ] ,
2023-09-04 21:59:34 +00:00
invite : [ ] ,
2023-08-23 05:08:20 +00:00
initial _state : ks . kstateToState ( kstate )
} )
2023-05-04 20:25:00 +00:00
2023-08-23 05:08:20 +00:00
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
} )
2023-05-04 20:25:00 +00:00
2023-08-23 05:08:20 +00:00
// Put the newly created child into the space, no need to await this
2023-08-19 06:39:23 +00:00
_syncSpaceMember ( channel , spaceID , roomID )
2023-05-08 20:03:57 +00:00
return roomID
2023-05-04 20:25:00 +00:00
}
2023-08-23 05:08:20 +00:00
/ * *
* Handling power levels separately . The spec doesn ' t specify what happens , Dendrite differs ,
* and Synapse does an absolutely insane * shallow merge * of what I provide on top of what it creates .
* We don ' t want the ` events ` key to be overridden completely .
* https : //github.com/matrix-org/synapse/blob/develop/synapse/handlers/room.py#L1170-L1210
* https : //github.com/matrix-org/matrix-spec/issues/492
* @ param { any } kstate
* @ param { ( _ : any ) => Promise < string > } callback must return room ID
* @ returns { Promise < string > } room ID
* /
async function postApplyPowerLevels ( kstate , callback ) {
const powerLevelContent = kstate [ "m.room.power_levels/" ]
const kstateWithoutPowerLevels = { ... kstate }
delete kstateWithoutPowerLevels [ "m.room.power_levels/" ]
/** @type {string} */
const roomID = await callback ( kstateWithoutPowerLevels )
// Now *really* apply the power level overrides on top of what Synapse *really* set
if ( powerLevelContent ) {
const newRoomKState = await roomToKState ( roomID )
const newRoomPowerLevelsDiff = ks . diffKState ( newRoomKState , { "m.room.power_levels/" : powerLevelContent } )
await applyKStateDiffToRoom ( roomID , newRoomPowerLevelsDiff )
}
return roomID
}
2023-05-05 05:29:08 +00:00
/ * *
2023-06-28 12:06:56 +00:00
* @ param { DiscordTypes . APIGuildChannel } channel
2023-05-05 05:29:08 +00:00
* /
2023-05-05 13:25:15 +00:00
function channelToGuild ( channel ) {
2023-05-05 05:29:08 +00:00
const guildID = channel . guild _id
assert ( guildID )
const guild = discord . guilds . get ( guildID )
assert ( guild )
2023-05-05 13:25:15 +00:00
return guild
}
2023-05-08 20:03:57 +00:00
/ *
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
2023-08-25 05:23:51 +00:00
Ensure + sync flow :
2023-05-08 20:03:57 +00:00
1. Get IDs
2. Does room exist ?
2023-08-25 05:23:51 +00:00
2.5 : If room does exist AND wasn ' t asked to sync : return here
2023-05-08 20:03:57 +00:00
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
* /
2023-05-05 13:25:15 +00:00
/ * *
* @ param { string } channelID
2023-05-08 20:03:57 +00:00
* @ param { boolean } shouldActuallySync false if just need to ensure room exists ( which is a quick database check ) , true if also want to sync room data when it does exist ( slow )
* @ returns { Promise < string > } room ID
2023-05-05 13:25:15 +00:00
* /
2023-05-08 20:03:57 +00:00
async function _syncRoom ( channelID , shouldActuallySync ) {
2023-06-28 12:06:56 +00:00
/** @ts-ignore @type {DiscordTypes.APIGuildChannel} */
2023-05-05 13:25:15 +00:00
const channel = discord . channels . get ( channelID )
assert . ok ( channel )
const guild = channelToGuild ( channel )
2023-05-05 05:29:08 +00:00
2023-08-21 05:25:51 +00:00
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
}
2023-10-05 23:31:10 +00:00
const existing = select ( "channel_room" , [ "room_id" , "thread_parent" ] , { channel _id : channelID } ) . get ( )
2023-08-19 06:39:23 +00:00
2023-05-05 05:29:08 +00:00
if ( ! existing ) {
2023-08-21 05:25:51 +00:00
const creation = ( async ( ) => {
2023-10-12 07:30:41 +00:00
const { spaceID , privacyLevel , channelKState } = await channelToKState ( channel , guild )
const roomID = await createRoom ( channel , guild , spaceID , channelKState , privacyLevel )
2023-08-21 05:25:51 +00:00
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.
}
2023-05-08 20:03:57 +00:00
2023-08-23 00:39:37 +00:00
const roomID = existing . room _id
2023-08-21 05:25:51 +00:00
if ( ! shouldActuallySync ) {
return existing . room _id // only need to ensure room exists, and it does. return the room ID
}
2023-05-10 10:15:20 +00:00
2023-08-21 05:25:51 +00:00
console . log ( ` [room sync] to matrix: ${ channel . name } ` )
2023-05-08 20:03:57 +00:00
2023-08-25 05:23:51 +00:00
const { spaceID , channelKState } = await channelToKState ( channel , guild ) // calling this in both branches because we don't want to calculate this if not syncing
2023-05-05 13:25:15 +00:00
2023-08-21 05:25:51 +00:00
// sync channel state to room
2023-08-23 00:39:37 +00:00
const roomKState = await roomToKState ( roomID )
2023-09-03 13:36:58 +00:00
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" }
}
2023-08-21 05:25:51 +00:00
const roomDiff = ks . diffKState ( roomKState , channelKState )
2023-08-23 00:39:37 +00:00
const roomApply = applyKStateDiffToRoom ( roomID , roomDiff )
db . prepare ( "UPDATE channel_room SET name = ? WHERE room_id = ?" ) . run ( channel . name , roomID )
2023-05-08 20:03:57 +00:00
2023-08-21 05:25:51 +00:00
// sync room as space member
2023-08-23 00:39:37 +00:00
const spaceApply = _syncSpaceMember ( channel , spaceID , roomID )
2023-08-21 05:25:51 +00:00
await Promise . all ( [ roomApply , spaceApply ] )
2023-08-23 00:39:37 +00:00
return roomID
2023-05-05 05:29:08 +00:00
}
2023-08-25 05:23:51 +00:00
/** 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 )
}
2023-08-19 10:54:23 +00:00
async function _unbridgeRoom ( channelID ) {
/** @ts-ignore @type {DiscordTypes.APIGuildChannel} */
const channel = discord . channels . get ( channelID )
assert . ok ( channel )
2023-09-07 11:20:48 +00:00
return unbridgeDeletedChannel ( channel . id , channel . guild _id )
}
async function unbridgeDeletedChannel ( channelID , guildID ) {
2023-10-05 23:31:10 +00:00
const roomID = select ( "channel_room" , "room_id" , { channel _id : channelID } ) . pluck ( ) . get ( )
2023-08-19 10:54:23 +00:00
assert . ok ( roomID )
2023-10-05 23:31:10 +00:00
const spaceID = select ( "guild_space" , "space_id" , { guild _id : guildID } ) . pluck ( ) . get ( )
2023-08-19 10:54:23 +00:00
assert . ok ( spaceID )
// remove room from being a space member
2023-08-25 11:27:44 +00:00
await api . sendState ( roomID , "m.space.parent" , spaceID , { } )
2023-08-19 10:54:23 +00:00
await api . sendState ( spaceID , "m.space.child" , roomID , { } )
2023-09-07 00:07:56 +00:00
// remove declaration that the room is bridged
2023-09-07 11:20:48 +00:00
await api . sendState ( roomID , "uk.half-shot.bridge" , ` moe.cadence.ooye://discord/ ${ guildID } / ${ channelID } ` , { } )
2023-09-07 00:07:56 +00:00
2023-08-19 10:54:23 +00:00
// 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 )
}
2023-08-19 06:39:23 +00:00
/ * *
2023-08-25 05:23:51 +00:00
* Async because it gets all space state from the homeserver , then if necessary sends one state event back .
2023-08-19 06:39:23 +00:00
* @ param { DiscordTypes . APIGuildTextChannel } channel
* @ param { string } spaceID
* @ param { string } roomID
* @ returns { Promise < string [ ] > }
* /
async function _syncSpaceMember ( channel , spaceID , roomID ) {
const spaceKState = await roomToKState ( spaceID )
let spaceEventContent = { }
if (
channel . type !== DiscordTypes . ChannelType . PrivateThread // private threads do not belong in the space (don't offer people something they can't join)
2023-08-21 11:31:40 +00:00
&& ! channel [ "thread_metadata" ] ? . archived // archived threads do not belong in the space (don't offer people conversations that are no longer relevant)
2023-08-19 06:39:23 +00:00
) {
spaceEventContent = {
2023-08-23 00:45:19 +00:00
via : [ reg . ooye . server _name ]
2023-08-19 06:39:23 +00:00
}
}
const spaceDiff = ks . diffKState ( spaceKState , {
[ ` m.space.child/ ${ roomID } ` ] : spaceEventContent
} )
return applyKStateDiffToRoom ( spaceID , spaceDiff )
}
2023-05-04 20:25:00 +00:00
async function createAllForGuild ( guildID ) {
const channelIDs = discord . guildChannelMap . get ( guildID )
assert . ok ( channelIDs )
for ( const channelID of channelIDs ) {
2023-08-19 11:12:36 +00:00
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 } ` )
2023-06-28 11:38:58 +00:00
}
2023-05-04 20:25:00 +00:00
}
}
2023-10-12 07:30:41 +00:00
module . exports . DEFAULT _PRIVACY _LEVEL = DEFAULT _PRIVACY _LEVEL
module . exports . PRIVACY _ENUMS = PRIVACY _ENUMS
2023-05-04 20:25:00 +00:00
module . exports . createRoom = createRoom
2023-05-08 20:03:57 +00:00
module . exports . ensureRoom = ensureRoom
2023-05-05 13:25:15 +00:00
module . exports . syncRoom = syncRoom
2023-05-04 20:25:00 +00:00
module . exports . createAllForGuild = createAllForGuild
2023-05-05 13:25:15 +00:00
module . exports . channelToKState = channelToKState
2023-08-23 00:39:37 +00:00
module . exports . roomToKState = roomToKState
module . exports . applyKStateDiffToRoom = applyKStateDiffToRoom
2023-08-23 05:08:20 +00:00
module . exports . postApplyPowerLevels = postApplyPowerLevels
2023-07-04 05:35:29 +00:00
module . exports . _convertNameAndTopic = convertNameAndTopic
2023-08-19 10:54:23 +00:00
module . exports . _unbridgeRoom = _unbridgeRoom
2023-09-07 11:20:48 +00:00
module . exports . unbridgeDeletedChannel = unbridgeDeletedChannel