2023-04-30 12:57:30 +00:00
// @ts-check
2024-03-06 20:13:25 +00:00
const assert = require ( "assert" ) . strict
2023-05-08 12:58:46 +00:00
const reg = require ( "../../matrix/read-registration" )
2024-03-06 04:40:06 +00:00
const DiscordTypes = require ( "discord-api-types/v10" )
const mixin = require ( "mixin-deep" )
2023-04-30 12:57:30 +00:00
2023-05-07 20:27:42 +00:00
const passthrough = require ( "../../passthrough" )
2023-09-18 10:51:59 +00:00
const { discord , sync , db , select } = passthrough
2023-05-08 05:22:20 +00:00
/** @type {import("../../matrix/api")} */
const api = sync . require ( "../../matrix/api" )
2023-05-07 20:27:42 +00:00
/** @type {import("../../matrix/file")} */
const file = sync . require ( "../../matrix/file" )
2024-03-06 04:40:06 +00:00
/** @type {import("../../discord/utils")} */
const utils = sync . require ( "../../discord/utils" )
2023-05-08 11:37:51 +00:00
/** @type {import("../converters/user-to-mxid")} */
const userToMxid = sync . require ( "../converters/user-to-mxid" )
2023-09-30 12:24:05 +00:00
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
let hasher = null
// @ts-ignore
require ( "xxhash-wasm" ) ( ) . then ( h => hasher = h )
2023-05-07 20:27:42 +00:00
/ * *
* A sim is an account that is being simulated by the bridge to copy events from the other side .
2024-03-06 04:40:06 +00:00
* @ param { DiscordTypes . APIUser } user
2023-05-08 12:58:46 +00:00
* @ returns mxid
2023-05-07 20:27:42 +00:00
* /
async function createSim ( user ) {
2023-05-08 12:58:46 +00:00
// Choose sim name
2023-05-08 11:37:51 +00:00
const simName = userToMxid . userToSimName ( user )
2023-07-13 05:11:24 +00:00
const localpart = reg . ooye . namespace _prefix + simName
2023-08-23 00:45:19 +00:00
const mxid = ` @ ${ localpart } : ${ reg . ooye . server _name } `
2023-05-08 12:58:46 +00:00
// Save chosen name in the database forever
2023-05-09 03:29:46 +00:00
// Making this database change right away so that in a concurrent registration, the 2nd registration will already have generated a different localpart because it can see this row when it generates
2023-10-04 23:32:05 +00:00
db . prepare ( "INSERT INTO sim (user_id, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)" ) . run ( user . id , simName , localpart , mxid )
2023-05-08 12:58:46 +00:00
// Register matrix user with that name
2023-05-09 03:29:46 +00:00
try {
await api . register ( localpart )
} catch ( e ) {
// If user creation fails, manually undo the database change. Still isn't perfect, but should help.
2023-10-16 03:47:42 +00:00
// (I would prefer a transaction, but it's not safe to leave transactions open across event loop ticks.)
2023-10-04 23:32:05 +00:00
db . prepare ( "DELETE FROM sim WHERE user_id = ?" ) . run ( user . id )
2023-05-09 03:29:46 +00:00
throw e
}
2023-05-08 12:58:46 +00:00
return mxid
2023-05-08 05:22:20 +00:00
}
2023-05-08 12:58:46 +00:00
/ * *
* Ensure a sim is registered for the user .
* If there is already a sim , use that one . If there isn ' t one yet , register a new sim .
2024-03-06 04:40:06 +00:00
* @ param { DiscordTypes . APIUser } user
2023-08-17 04:41:28 +00:00
* @ returns { Promise < string > } mxid
2023-05-08 12:58:46 +00:00
* /
async function ensureSim ( user ) {
let mxid = null
2023-10-05 23:31:10 +00:00
const existing = select ( "sim" , "mxid" , { user _id : user . id } ) . pluck ( ) . get ( )
2023-05-08 12:58:46 +00:00
if ( existing ) {
mxid = existing
} else {
mxid = await createSim ( user )
}
return mxid
}
/ * *
* Ensure a sim is registered for the user and is joined to the room .
2024-03-06 04:40:06 +00:00
* @ param { DiscordTypes . APIUser } user
2023-08-16 05:03:05 +00:00
* @ param { string } roomID
2023-08-17 04:41:28 +00:00
* @ returns { Promise < string > } mxid
2023-05-08 12:58:46 +00:00
* /
async function ensureSimJoined ( user , roomID ) {
// Ensure room ID is really an ID, not an alias
assert . ok ( roomID [ 0 ] === "!" )
// Ensure user
const mxid = await ensureSim ( user )
// Ensure joined
2023-10-05 23:31:10 +00:00
const existing = select ( "sim_member" , "mxid" , { room _id : roomID , mxid } ) . pluck ( ) . get ( )
2023-05-08 12:58:46 +00:00
if ( ! existing ) {
2023-09-02 22:47:01 +00:00
try {
await api . inviteToRoom ( roomID , mxid )
await api . joinRoom ( roomID , mxid )
} catch ( e ) {
if ( e . message . includes ( "is already in the room." ) ) {
// Sweet!
} else {
throw e
}
}
2023-09-17 08:15:20 +00:00
db . prepare ( "INSERT OR IGNORE INTO sim_member (room_id, mxid) VALUES (?, ?)" ) . run ( roomID , mxid )
2023-05-08 12:58:46 +00:00
}
return mxid
}
2023-05-10 05:40:31 +00:00
/ * *
2024-03-06 04:40:06 +00:00
* @ param { DiscordTypes . APIUser } user
* @ param { Omit < DiscordTypes . APIGuildMember , "user" > } member
2023-05-10 05:40:31 +00:00
* /
2023-05-10 10:15:20 +00:00
async function memberToStateContent ( user , member , guildID ) {
let displayname = user . username
2023-10-13 12:00:28 +00:00
if ( user . global _name ) displayname = user . global _name
2023-08-19 06:40:37 +00:00
if ( member . nick ) displayname = member . nick
2023-05-10 10:15:20 +00:00
const content = {
displayname ,
membership : "join" ,
"moe.cadence.ooye.member" : {
} ,
"uk.half-shot.discord.member" : {
bot : ! ! user . bot ,
displayColor : user . accent _color ,
id : user . id ,
username : user . discriminator . length === 4 ? ` ${ user . username } # ${ user . discriminator } ` : ` @ ${ user . username } `
}
}
if ( member . avatar || user . avatar ) {
// const avatarPath = file.userAvatar(user) // the user avatar only
const avatarPath = file . memberAvatar ( guildID , user , member ) // the member avatar or the user avatar
content [ "moe.cadence.ooye.member" ] . avatar = avatarPath
content . avatar _url = await file . uploadDiscordFileToMxc ( avatarPath )
}
return content
}
2024-03-06 04:40:06 +00:00
/ * *
* https : //gitdab.com/cadence/out-of-your-element/issues/9
* @ param { DiscordTypes . APIUser } user
* @ param { Omit < DiscordTypes . APIGuildMember , "user" > } member
* @ param { DiscordTypes . APIGuild } guild
* @ param { DiscordTypes . APIGuildChannel } channel
* @ returns { number } 0 to 100
* /
function memberToPowerLevel ( user , member , guild , channel ) {
const permissions = utils . getPermissions ( member . roles , guild . roles , user . id , channel . permission _overwrites )
/ *
* PL 100 = Administrator = People who can brick the room . RATIONALE :
* - Administrator .
* - Manage Webhooks : People who remove the webhook can break the room .
* - Manage Guild : People who can manage guild can add bots .
* - Manage Channels : People who can manage the channel can delete it .
* ( Setting sim users to PL 100 is safe because even though we can ' t demote the sims we can use code to make the sims demote themselves . )
* /
if ( guild . owner _id === user . id || utils . hasSomePermissions ( permissions , [ "Administrator" , "ManageWebhooks" , "ManageGuild" , "ManageChannels" ] ) ) return 100
/ *
* PL 50 = Moderator = People who can manage people and messages in many ways . RATIONALE :
* - Manage Messages : Can moderate by pinning or deleting the conversation .
* - Manage Nicknames : Can moderate by removing inappropriate nicknames .
* - Manage Threads : Can moderate by deleting conversations .
* - Kick Members & Ban Members : Can moderate by removing disruptive people .
* - Mute Members & Deafen Members : Can moderate by silencing disruptive people in ways they can ' t undo .
* - Moderate Members .
* /
if ( utils . hasSomePermissions ( permissions , [ "ManageMessages" , "ManageNicknames" , "ManageThreads" , "KickMembers" , "BanMembers" , "MuteMembers" , "DeafenMembers" , "ModerateMembers" ] ) ) return 50
/* PL 20 = Mention Everyone for technical reasons. */
if ( utils . hasSomePermissions ( permissions , [ "MentionEveryone" ] ) ) return 20
return 0
}
/ * *
* @ param { any } content
* @ param { number } powerLevel
* /
function _hashProfileContent ( content , powerLevel ) {
const unsignedHash = hasher . h64 ( ` ${ content . displayname } \u 0000 ${ content . avatar _url } \u 0000 ${ powerLevel } ` )
2023-09-30 12:24:05 +00:00
const signedHash = unsignedHash - 0x8000000000000000 n // shifting down to signed 64-bit range
return signedHash
2023-05-10 10:15:20 +00:00
}
/ * *
2023-08-18 04:58:46 +00:00
* Sync profile data for a sim user . This function follows the following process :
* 1. Join the sim to the room if needed
* 2. Make an object of what the new room member state content would be , including uploading the profile picture if it hasn ' t been done before
2024-03-06 04:40:06 +00:00
* 3. Calculate the power level the user should get based on their Discord permissions
* 4. Compare against the previously known state content , which is helpfully stored in the database
* 5. If the state content or power level have changed , send them to Matrix and update them in the database for next time
* @ param { DiscordTypes . APIUser } user
* @ param { Omit < DiscordTypes . APIGuildMember , "user" > } member
* @ param { DiscordTypes . APIGuildChannel } channel
2024-03-06 20:13:25 +00:00
* @ param { DiscordTypes . APIGuild } guild
* @ param { string } roomID
2023-08-18 04:58:46 +00:00
* @ returns { Promise < string > } mxid of the updated sim
2023-05-10 10:15:20 +00:00
* /
2024-03-06 20:13:25 +00:00
async function syncUser ( user , member , channel , guild , roomID ) {
2023-05-10 10:15:20 +00:00
const mxid = await ensureSimJoined ( user , roomID )
2024-03-06 04:40:06 +00:00
const content = await memberToStateContent ( user , member , guild . id )
const powerLevel = memberToPowerLevel ( user , member , guild , channel )
const currentHash = _hashProfileContent ( content , powerLevel )
2023-10-05 23:31:10 +00:00
const existingHash = select ( "sim_member" , "hashed_profile_content" , { room _id : roomID , mxid } ) . safeIntegers ( ) . pluck ( ) . get ( )
2023-05-10 10:15:20 +00:00
// only do the actual sync if the hash has changed since we last looked
2023-09-20 04:12:19 +00:00
if ( existingHash !== currentHash ) {
2024-03-06 04:40:06 +00:00
// Update room member state
2023-05-10 10:15:20 +00:00
await api . sendState ( roomID , "m.room.member" , mxid , content , mxid )
2024-03-06 04:40:06 +00:00
// Update power levels
const powerLevelsStateContent = await api . getStateEvent ( roomID , "m.room.power_levels" , "" )
mixin ( powerLevelsStateContent , { users : { [ mxid ] : powerLevel } } )
api . sendState ( roomID , "m.room.power_levels" , "" , powerLevelsStateContent )
// Update cached hash
2023-09-30 12:24:05 +00:00
db . prepare ( "UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?" ) . run ( currentHash , roomID , mxid )
2023-05-10 10:15:20 +00:00
}
2023-08-18 04:58:46 +00:00
return mxid
2023-05-10 10:15:20 +00:00
}
2024-03-06 20:13:25 +00:00
/ * *
* @ param { string } roomID
* /
2023-05-10 10:15:20 +00:00
async function syncAllUsersInRoom ( roomID ) {
2023-10-05 23:31:10 +00:00
const mxids = select ( "sim_member" , "mxid" , { room _id : roomID } ) . pluck ( ) . all ( )
2023-05-10 10:15:20 +00:00
2023-10-05 23:31:10 +00:00
const channelID = select ( "channel_room" , "channel_id" , { room _id : roomID } ) . pluck ( ) . get ( )
2023-05-10 10:15:20 +00:00
assert . ok ( typeof channelID === "string" )
2023-09-18 10:51:59 +00:00
2024-03-06 04:40:06 +00:00
/** @ts-ignore @type {DiscordTypes.APIGuildChannel} */
2023-05-10 10:15:20 +00:00
const channel = discord . channels . get ( channelID )
const guildID = channel . guild _id
assert . ok ( typeof guildID === "string" )
2024-03-06 04:40:06 +00:00
/** @ts-ignore @type {DiscordTypes.APIGuild} */
const guild = discord . guilds . get ( guildID )
2023-05-10 10:15:20 +00:00
for ( const mxid of mxids ) {
2023-10-05 23:31:10 +00:00
const userID = select ( "sim" , "user_id" , { mxid } ) . pluck ( ) . get ( )
2023-05-10 10:15:20 +00:00
assert . ok ( typeof userID === "string" )
2024-03-06 04:40:06 +00:00
/** @ts-ignore @type {Required<DiscordTypes.APIGuildMember>} */
2023-05-10 10:15:20 +00:00
const member = await discord . snow . guild . getGuildMember ( guildID , userID )
2024-03-06 04:40:06 +00:00
/** @ts-ignore @type {Required<DiscordTypes.APIUser>} user */
2023-05-10 10:15:20 +00:00
const user = member . user
assert . ok ( user )
console . log ( ` [user sync] to matrix: ${ user . username } in ${ channel . name } ` )
2024-03-06 20:13:25 +00:00
await syncUser ( user , member , channel , guild , roomID )
2023-05-10 05:40:31 +00:00
}
}
2023-06-30 03:15:23 +00:00
module . exports . _memberToStateContent = memberToStateContent
2024-01-20 11:54:18 +00:00
module . exports . _hashProfileContent = _hashProfileContent
2023-05-08 12:58:46 +00:00
module . exports . ensureSim = ensureSim
module . exports . ensureSimJoined = ensureSimJoined
2023-05-10 10:15:20 +00:00
module . exports . syncUser = syncUser
module . exports . syncAllUsersInRoom = syncAllUsersInRoom