2023-05-04 20:25:00 +00:00
// @ts-check
2023-08-25 05:23:51 +00:00
const assert = require ( "assert" ) . strict
2024-03-06 23:19:07 +00:00
const { isDeepStrictEqual } = require ( "util" )
2023-08-22 05:11:07 +00:00
const DiscordTypes = require ( "discord-api-types/v10" )
2024-03-25 12:10:29 +00:00
const Ty = require ( "../../types" )
2023-09-12 11:08:33 +00:00
const reg = require ( "../../matrix/read-registration" )
2023-08-22 05:11:07 +00:00
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-08 11:37:51 +00:00
/** @type {import("../../matrix/api")} */
const api = sync . require ( "../../matrix/api" )
2023-08-22 05:11:07 +00:00
/** @type {import("../../matrix/file")} */
const file = sync . require ( "../../matrix/file" )
/** @type {import("./create-room")} */
const createRoom = sync . require ( "./create-room" )
2024-01-19 03:43:12 +00:00
/** @type {import("./expression")} */
const expression = sync . require ( "./expression" )
2023-08-23 00:31:31 +00:00
/** @type {import("../../matrix/kstate")} */
const ks = sync . require ( "../../matrix/kstate" )
2023-05-04 20:25:00 +00:00
2023-09-12 11:15:55 +00:00
/** @type {Map<string, Promise<string>>} guild ID -> Promise<space ID> */
const inflightSpaceCreate = new Map ( )
2023-05-04 20:25:00 +00:00
/ * *
2024-01-16 03:00:33 +00:00
* @ param { DiscordTypes . RESTGetAPIGuildResult } guild
2023-08-22 05:11:07 +00:00
* @ param { any } kstate
2023-05-04 20:25:00 +00:00
* /
2023-08-22 05:11:07 +00:00
async function createSpace ( guild , kstate ) {
const name = kstate [ "m.room.name/" ] . name
const topic = kstate [ "m.room.topic/" ] ? . topic || undefined
assert ( name )
2023-08-23 05:08:20 +00:00
const roomID = await createRoom . postApplyPowerLevels ( kstate , async kstate => {
return api . createRoom ( {
name ,
2023-10-12 07:30:41 +00:00
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 ] ,
2023-08-23 05:08:20 +00:00
power _level _content _override : {
events _default : 100 , // space can only be managed by bridge
invite : 0 // any existing member can invite others
} ,
2023-09-12 11:08:33 +00:00
invite : reg . ooye . invite ,
2023-08-23 05:08:20 +00:00
topic ,
creation _content : {
type : "m.space"
} ,
initial _state : ks . kstateToState ( kstate )
} )
2023-05-04 20:25:00 +00:00
} )
2023-05-08 12:58:46 +00:00
db . prepare ( "INSERT INTO guild_space (guild_id, space_id) VALUES (?, ?)" ) . run ( guild . id , roomID )
return roomID
2023-05-04 20:25:00 +00:00
}
2023-08-22 05:11:07 +00:00
/ * *
2023-10-12 07:30:41 +00:00
* @ param { DiscordTypes . APIGuild } guild
* @ param { number } privacyLevel
2023-08-22 05:11:07 +00:00
* /
2023-10-12 07:30:41 +00:00
async function guildToKState ( guild , privacyLevel ) {
2023-08-22 05:11:07 +00:00
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
}
const guildKState = {
"m.room.name/" : { name : guild . name } ,
"m.room.avatar/" : avatarEventContent ,
2023-10-12 07:30:41 +00:00
"m.room.guest_access/" : { guest _access : createRoom . PRIVACY _ENUMS . GUEST _ACCESS [ privacyLevel ] } ,
2023-10-12 09:45:27 +00:00
"m.room.history_visibility/" : { history _visibility : createRoom . PRIVACY _ENUMS . SPACE _HISTORY _VISIBILITY [ privacyLevel ] } ,
2023-10-12 09:55:52 +00:00
"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 ) , { } ) }
2023-08-22 05:11:07 +00:00
}
return guildKState
}
2023-09-12 11:15:55 +00:00
/ * *
2023-09-20 04:37:24 +00:00
* @ param { DiscordTypes . APIGuild } guild
2023-09-12 11:15:55 +00:00
* @ param { boolean } shouldActuallySync false if just need to ensure nspace exists ( which is a quick database check ) ,
* true if also want to efficiently sync space name , space avatar , and child room avatars
* @ returns { Promise < string > } room ID
* /
2023-09-20 04:37:24 +00:00
async function _syncSpace ( guild , shouldActuallySync ) {
2023-08-22 05:11:07 +00:00
assert . ok ( guild )
2023-09-20 04:37:24 +00:00
if ( inflightSpaceCreate . has ( guild . id ) ) {
await inflightSpaceCreate . get ( guild . id ) // just waiting, and then doing a new db query afterwards, is the simplest way of doing it
2023-09-12 11:15:55 +00:00
}
2023-10-12 07:30:41 +00:00
const row = select ( "guild_space" , [ "space_id" , "privacy_level" ] , { guild _id : guild . id } ) . get ( )
2023-08-22 05:11:07 +00:00
2023-10-12 07:30:41 +00:00
if ( ! row ) {
2023-09-12 11:15:55 +00:00
const creation = ( async ( ) => {
2023-10-12 07:30:41 +00:00
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
2023-09-12 11:15:55 +00:00
const spaceID = await createSpace ( guild , guildKState )
2023-09-20 04:37:24 +00:00
inflightSpaceCreate . delete ( guild . id )
2023-09-12 11:15:55 +00:00
return spaceID
} ) ( )
2023-09-20 04:37:24 +00:00
inflightSpaceCreate . set ( guild . id , creation )
2023-09-12 11:15:55 +00:00
return creation // Naturally, the newly created space is already up to date, so we can always skip syncing here.
}
2023-10-12 07:30:41 +00:00
const { space _id : spaceID , privacy _level } = row
2023-09-12 11:15:55 +00:00
if ( ! shouldActuallySync ) {
return spaceID // only need to ensure space exists, and it does. return the space ID
2023-09-03 13:38:30 +00:00
}
2023-08-22 05:11:07 +00:00
2023-08-23 00:31:31 +00:00
console . log ( ` [space sync] to matrix: ${ guild . name } ` )
2023-08-22 05:11:07 +00:00
2023-10-12 07:30:41 +00:00
const guildKState = await guildToKState ( guild , privacy _level ) // calling this in both branches because we don't want to calculate this if not syncing
2023-09-12 11:15:55 +00:00
2023-08-25 05:23:51 +00:00
// sync guild state to space
2023-08-23 00:31:31 +00:00
const spaceKState = await createRoom . roomToKState ( spaceID )
const spaceDiff = ks . diffKState ( spaceKState , guildKState )
await createRoom . applyKStateDiffToRoom ( spaceID , spaceDiff )
2023-08-22 05:11:07 +00:00
2023-08-25 05:23:51 +00:00
// guild icon was changed, so room avatars need to be updated as well as the space ones
// doing it this way rather than calling syncRoom for great efficiency gains
const newAvatarState = spaceDiff [ "m.room.avatar/" ]
if ( guild . icon && newAvatarState ? . url ) {
// don't try to update rooms with custom avatars though
2023-10-05 23:31:10 +00:00
const roomsWithCustomAvatars = select ( "channel_room" , "room_id" , { } , "WHERE custom_avatar IS NOT NULL" ) . pluck ( ) . all ( )
2023-08-25 05:23:51 +00:00
const childRooms = ks . kstateToState ( spaceKState ) . filter ( ( { type , state _key , content } ) => {
2023-08-25 05:35:34 +00:00
return type === "m.space.child" && "via" in content && ! roomsWithCustomAvatars . includes ( state _key )
2023-08-25 05:23:51 +00:00
} ) . map ( ( { state _key } ) => state _key )
for ( const roomID of childRooms ) {
const avatarEventContent = await api . getStateEvent ( roomID , "m.room.avatar" , "" )
if ( avatarEventContent . url !== newAvatarState . url ) {
await api . sendState ( roomID , "m.room.avatar" , "" , newAvatarState )
}
}
}
2023-08-23 00:31:31 +00:00
return spaceID
}
2023-08-22 05:11:07 +00:00
2023-09-20 04:37:24 +00:00
/ * *
* Ensures the space exists . If it doesn ' t , creates the space with an accurate initial state .
* @ param { DiscordTypes . APIGuild } guild
* /
function ensureSpace ( guild ) {
return _syncSpace ( guild , false )
2023-09-12 11:15:55 +00:00
}
2023-09-20 04:37:24 +00:00
/ * *
* Actually syncs . Efficiently updates the space name , space avatar , and child room avatars .
* @ param { DiscordTypes . APIGuild } guild
* /
function syncSpace ( guild ) {
return _syncSpace ( guild , true )
2023-09-12 11:15:55 +00:00
}
2023-09-03 05:13:04 +00:00
/ * *
* Inefficiently force the space and its existing child rooms to be fully updated .
2023-10-12 11:37:18 +00:00
* Prefer not to call this as part of the bridge ' s normal operation .
2023-09-03 05:13:04 +00:00
* /
async function syncSpaceFully ( guildID ) {
/** @ts-ignore @type {DiscordTypes.APIGuild} */
const guild = discord . guilds . get ( guildID )
assert . ok ( guild )
2023-10-12 07:30:41 +00:00
const row = select ( "guild_space" , [ "space_id" , "privacy_level" ] , { guild _id : guildID } ) . get ( )
2023-09-03 05:13:04 +00:00
2023-10-12 07:30:41 +00:00
if ( ! row ) {
const guildKState = await guildToKState ( guild , createRoom . DEFAULT _PRIVACY _LEVEL )
2023-09-03 13:38:30 +00:00
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.
}
2023-09-03 05:13:04 +00:00
2023-10-12 07:30:41 +00:00
const { space _id : spaceID , privacy _level } = row
2023-09-03 05:13:04 +00:00
console . log ( ` [space sync] to matrix: ${ guild . name } ` )
2023-10-12 07:30:41 +00:00
const guildKState = await guildToKState ( guild , privacy _level )
2023-09-03 05:13:04 +00:00
// sync guild state to space
const spaceKState = await createRoom . roomToKState ( spaceID )
const spaceDiff = ks . diffKState ( spaceKState , guildKState )
await createRoom . applyKStateDiffToRoom ( spaceID , spaceDiff )
2024-03-25 12:10:29 +00:00
/** @type {string[]} room IDs */
let childRooms = [ ]
/** @type {string | undefined} */
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 )
2023-09-03 05:13:04 +00:00
for ( const roomID of childRooms ) {
2023-10-05 23:31:10 +00:00
const channelID = select ( "channel_room" , "channel_id" , { room _id : roomID } ) . pluck ( ) . get ( )
2023-09-03 05:13:04 +00:00
if ( ! channelID ) continue
2023-09-07 11:20:48 +00:00
if ( discord . channels . has ( channelID ) ) {
await createRoom . syncRoom ( channelID )
} else {
await createRoom . unbridgeDeletedChannel ( channelID , guildID )
}
2023-09-03 05:13:04 +00:00
}
return spaceID
}
2023-09-18 13:45:40 +00:00
/ * *
2024-01-16 03:00:33 +00:00
* @ param { DiscordTypes . GatewayGuildEmojisUpdateDispatchData | DiscordTypes . GatewayGuildStickersUpdateDispatchData } data
2024-01-10 10:56:10 +00:00
* @ param { boolean } checkBeforeSync false to always send new state , true to check the current state and only apply if state would change
2023-09-18 13:45:40 +00:00
* /
2024-01-10 10:56:10 +00:00
async function syncSpaceExpressions ( data , checkBeforeSync ) {
2023-09-18 13:45:40 +00:00
// 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.
2023-10-05 23:31:10 +00:00
const spaceID = select ( "guild_space" , "space_id" , { guild _id : data . guild _id } ) . pluck ( ) . get ( )
2023-09-18 13:45:40 +00:00
if ( ! spaceID ) return
2024-01-10 10:56:10 +00:00
/ * *
2024-01-16 03:00:33 +00:00
* @ typedef { DiscordTypes . GatewayGuildEmojisUpdateDispatchData & DiscordTypes . GatewayGuildStickersUpdateDispatchData } Expressions
2024-01-10 10:56:10 +00:00
* @ param { string } spaceID
* @ param { Expressions extends any ? keyof Expressions : never } key
* @ param { string } eventKey
2024-01-16 03:00:33 +00:00
* @ param { typeof expression [ "emojisToState" ] | typeof expression [ "stickersToState" ] } fn
2024-01-10 10:56:10 +00:00
* /
async function update ( spaceID , key , eventKey , fn ) {
if ( ! ( key in data ) || ! data [ key ] . length ) return
const content = await fn ( data [ key ] )
if ( checkBeforeSync ) {
2024-01-12 01:33:23 +00:00
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 ( [ ] )
}
2024-03-06 23:19:07 +00:00
if ( isDeepStrictEqual ( existing , content ) ) return
2024-01-10 10:56:10 +00:00
}
api . sendState ( spaceID , "im.ponies.room_emotes" , eventKey , content )
2023-09-18 13:45:40 +00:00
}
2024-01-10 10:56:10 +00:00
update ( spaceID , "emojis" , "moe.cadence.ooye.pack.emojis" , expression . emojisToState )
update ( spaceID , "stickers" , "moe.cadence.ooye.pack.stickers" , expression . stickersToState )
2023-09-18 13:45:40 +00:00
}
2023-05-04 20:25:00 +00:00
module . exports . createSpace = createSpace
2023-09-12 11:15:55 +00:00
module . exports . ensureSpace = ensureSpace
2023-08-23 00:31:31 +00:00
module . exports . syncSpace = syncSpace
2023-09-03 05:13:04 +00:00
module . exports . syncSpaceFully = syncSpaceFully
2023-08-23 00:31:31 +00:00
module . exports . guildToKState = guildToKState
2023-09-18 13:45:40 +00:00
module . exports . syncSpaceExpressions = syncSpaceExpressions