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
} ,
2024-03-06 04:40:06 +00:00
notifications : {
room : 20 // TODO: Matrix users should have the same abilities as unprivileged Discord members. So make this automatically configured based on the guild or channel's default mention everyone permissions. That way if unprivileged Discord members can mention everyone, Matrix users can too.
} ,
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