2023-09-26 19:20:18 +00:00
// @ts-check
const assert = require ( "assert" ) . strict
const Ty = require ( "../types" )
const { pipeline } = require ( "stream" ) . promises
const sharp = require ( "sharp" )
const { discord , sync , db , select } = require ( "../passthrough" )
/** @type {import("./api")}) */
const api = sync . require ( "./api" )
/** @type {import("../m2d/converters/utils")} */
const mxUtils = sync . require ( "../m2d/converters/utils" )
/** @type {import("../discord/utils")} */
const dUtils = sync . require ( "../discord/utils" )
2023-09-27 11:10:24 +00:00
/** @type {import("./kstate")} */
const ks = sync . require ( "./kstate" )
2024-09-05 03:36:43 +00:00
const { reg } = require ( "./read-registration" )
2023-09-26 19:20:18 +00:00
const PREFIXES = [ "//" , "/" ]
const EMOJI _SIZE = 128
/** This many normal emojis + this many animated emojis. The total number is doubled. */
const TIER _EMOJI _SLOTS = new Map ( [
[ 1 , 100 ] ,
[ 2 , 150 ] ,
[ 3 , 250 ]
] )
/** @param {number} tier */
function getSlotCount ( tier ) {
return TIER _EMOJI _SLOTS . get ( tier ) || 50
}
let buttons = [ ]
/ * *
* @ param { string } roomID where to add the button
* @ param { string } eventID where to add the button
* @ param { string } key emoji to add as a button
* @ param { string } mxid only listen for responses from this user
* @ returns { Promise < import ( "discord-api-types/v10" ) . GatewayMessageReactionAddDispatchData > }
* /
async function addButton ( roomID , eventID , key , mxid ) {
await api . sendEvent ( roomID , "m.reaction" , {
"m.relates_to" : {
rel _type : "m.annotation" ,
event _id : eventID ,
key
}
} )
return new Promise ( resolve => {
buttons . push ( { roomID , eventID , mxid , key , resolve , created : Date . now ( ) } )
} )
}
// Clear out old buttons every so often to free memory
setInterval ( ( ) => {
const now = Date . now ( )
buttons = buttons . filter ( b => now - b . created < 2 * 60 * 60 * 1000 )
} , 10 * 60 * 1000 )
/** @param {Ty.Event.Outer<Ty.Event.M_Reaction>} event */
function onReactionAdd ( event ) {
const button = buttons . find ( b => b . roomID === event . room _id && b . mxid === event . sender && b . eventID === event . content [ "m.relates_to" ] ? . event _id && b . key === event . content [ "m.relates_to" ] ? . key )
if ( button ) {
buttons = buttons . filter ( b => b !== button ) // remove button data so it can't be clicked again
button . resolve ( event )
}
}
/ * *
* @ callback CommandExecute
* @ param { Ty . Event . Outer _M _Room _Message } event
2023-09-27 11:10:24 +00:00
* @ param { string } realBody
2023-10-12 13:06:06 +00:00
* @ param { string [ ] } words
2023-09-26 19:20:18 +00:00
* @ param { any } [ ctx ]
* /
/ * *
* @ typedef Command
* @ property { string [ ] } aliases
* @ property { CommandExecute } execute
* /
/** @param {CommandExecute} execute */
function replyctx ( execute ) {
/** @type {CommandExecute} */
2023-10-12 13:06:06 +00:00
return function ( event , realBody , words , ctx = { } ) {
2023-09-26 19:20:18 +00:00
ctx [ "m.relates_to" ] = {
"m.in_reply_to" : {
event _id : event . event _id
}
}
2023-10-12 13:06:06 +00:00
return execute ( event , realBody , words , ctx )
2023-09-26 19:20:18 +00:00
}
}
/** @type {Command[]} */
const commands = [ {
aliases : [ "emoji" ] ,
execute : replyctx (
2023-10-12 13:06:06 +00:00
async ( event , realBody , words , ctx ) => {
2023-09-26 19:20:18 +00:00
// Guard
/** @type {string} */ // @ts-ignore
2023-10-05 23:31:10 +00:00
const channelID = select ( "channel_room" , "channel_id" , { room _id : event . room _id } ) . pluck ( ) . get ( )
2023-09-26 19:20:18 +00:00
const guildID = discord . channels . get ( channelID ) ? . [ "guild_id" ]
let matrixOnlyReason = null
const matrixOnlyConclusion = "So the emoji will be uploaded on Matrix-side only. It will still be usable over the bridge, but may have degraded functionality."
2023-09-27 11:10:24 +00:00
// Check if we can/should upload to Discord, for various causes
2023-09-26 19:20:18 +00:00
if ( ! guildID ) {
matrixOnlyReason = "NOT_BRIDGED"
} else {
const guild = discord . guilds . get ( guildID )
assert ( guild )
const slots = getSlotCount ( guild . premium _tier )
const permissions = dUtils . getPermissions ( [ ] , guild . roles )
if ( guild . emojis . length >= slots ) {
matrixOnlyReason = "CAPACITY"
2023-10-12 13:06:06 +00:00
} else if ( ! ( permissions & 0x40000000 n ) ) { // MANAGE_GUILD_EXPRESSIONS (apparently CREATE_GUILD_EXPRESSIONS isn't good enough...)
2023-09-26 19:20:18 +00:00
matrixOnlyReason = "USER_PERMISSIONS"
}
}
2023-09-27 11:10:24 +00:00
if ( matrixOnlyReason ) {
// If uploading to Matrix, check if we have permission
const state = await api . getAllState ( event . room _id )
const kstate = ks . stateToKState ( state )
const powerLevels = kstate [ "m.room.power_levels/" ]
const required = powerLevels . events [ "im.ponies.room_emotes" ] ? ? powerLevels . state _default ? ? 50
const have = powerLevels . users [ ` @ ${ reg . sender _localpart } : ${ reg . ooye . server _name } ` ] ? ? powerLevels . users _default ? ? 0
if ( have < required ) {
return api . sendEvent ( event . room _id , "m.room.message" , {
... ctx ,
msgtype : "m.text" ,
body : "I don't have sufficient permissions in this Matrix room to edit emojis."
} )
}
2023-09-26 19:20:18 +00:00
}
2023-09-27 11:10:24 +00:00
/** @type {{url: string, name: string}[]} */
const toUpload = [ ]
const nameMatch = realBody . match ( /:([a-zA-Z0-9_]{2,}):/ )
const mxcMatch = realBody . match ( /(mxc:\/\/.*?)\b/ )
if ( event . content [ "m.relates_to" ] ? . [ "m.in_reply_to" ] ? . event _id ) {
2023-09-26 19:20:18 +00:00
const repliedToEventID = event . content [ "m.relates_to" ] [ "m.in_reply_to" ] . event _id
const repliedToEvent = await api . getEvent ( event . room _id , repliedToEventID )
2023-09-27 11:10:24 +00:00
if ( nameMatch && repliedToEvent . type === "m.room.message" && repliedToEvent . content . msgtype === "m.image" && repliedToEvent . content . url ) {
toUpload . push ( { url : repliedToEvent . content . url , name : nameMatch [ 1 ] } )
} else if ( repliedToEvent . type === "m.room.message" && repliedToEvent . content . msgtype === "m.text" && "formatted_body" in repliedToEvent . content ) {
const namePrefixMatch = realBody . match ( /:([a-zA-Z0-9_]{2,})(?:\b|:)/ )
const imgMatches = [ ... repliedToEvent . content . formatted _body . matchAll ( /<img [^>]*>/g ) ]
for ( const match of imgMatches ) {
const e = match [ 0 ]
const url = e . match ( /src="([^"]*)"/ ) ? . [ 1 ]
let name = e . match ( /title=":?([^":]*):?"/ ) ? . [ 1 ]
if ( ! url || ! name ) continue
if ( namePrefixMatch ) name = namePrefixMatch [ 1 ] + name
toUpload . push ( { url , name } )
}
2023-09-26 19:20:18 +00:00
}
}
2023-09-27 11:10:24 +00:00
if ( ! toUpload . length && mxcMatch && nameMatch ) {
toUpload . push ( { url : mxcMatch [ 1 ] , name : nameMatch [ 1 ] } )
}
if ( ! toUpload . length ) {
2023-09-26 19:20:18 +00:00
return api . sendEvent ( event . room _id , "m.room.message" , {
... ctx ,
msgtype : "m.text" ,
2023-09-27 11:10:24 +00:00
body : "Not sure what image you wanted to add. Try replying to an uploaded image when you use the command, or write an mxc:// URL in your message. You should specify the new name :like_this:."
2023-09-26 19:20:18 +00:00
} )
}
2023-10-27 11:24:42 +00:00
const b = new mxUtils . MatrixStringBuilder ( )
2023-09-27 11:10:24 +00:00
. addLine ( "## Emoji preview" , "<h2>Emoji preview</h2>" )
. addLine ( ` Ⓜ️ This room isn't bridged to Discord. ${ matrixOnlyConclusion } ` , ` Ⓜ️ <em>This room isn't bridged to Discord. ${ matrixOnlyConclusion } </em> ` , matrixOnlyReason === "NOT_BRIDGED" )
. addLine ( ` Ⓜ️ *Discord ran out of space for emojis. ${ matrixOnlyConclusion } ` , ` Ⓜ️ <em>Discord ran out of space for emojis. ${ matrixOnlyConclusion } </em> ` , matrixOnlyReason === "CAPACITY" )
. addLine ( ` Ⓜ️ *If you were a Discord user, you wouldn't have permission to create emojis. ${ matrixOnlyConclusion } ` , ` Ⓜ️ <em>If you were a Discord user, you wouldn't have permission to create emojis. ${ matrixOnlyConclusion } </em> ` , matrixOnlyReason === "CAPACITY" )
. addLine ( "[Preview not available in plain text.]" , "Preview:" )
for ( const e of toUpload ) {
b . add ( "" , ` <img data-mx-emoticon height="48" src=" ${ e . url } " title=": ${ e . name } :" alt=": ${ e . name } :"> ` )
}
b . addLine ( "Hit ✅ to add it." )
2023-09-26 19:20:18 +00:00
const sent = await api . sendEvent ( event . room _id , "m.room.message" , {
... ctx ,
2023-09-27 11:10:24 +00:00
... b . get ( )
2023-09-26 19:20:18 +00:00
} )
addButton ( event . room _id , sent , "✅" , event . sender ) . then ( async ( ) => {
if ( matrixOnlyReason ) {
2023-09-27 04:06:02 +00:00
// Edit some state
const type = "im.ponies.room_emotes"
const key = "moe.cadence.ooye.pack.matrix"
let pack
try {
pack = await api . getStateEvent ( event . room _id , type , key )
} catch ( e ) {
pack = {
pack : {
display _name : "Non-Discord Emojis" ,
usage : [ "emoticon" , "sticker" ]
}
}
}
if ( ! ( "images" in pack ) ) pack . images = { }
2023-10-27 11:24:42 +00:00
const b = new mxUtils . MatrixStringBuilder ( )
2023-09-27 11:10:24 +00:00
. addLine ( ` Created ${ toUpload . length } emojis ` , "" )
for ( const e of toUpload ) {
pack . images [ e . name ] = {
url : e . url // Directly use the same file that the Matrix user uploaded. Don't need to worry about dimensions/filesize because clients already request their preferred resized version from the homeserver.
}
b . add ( "" , ` <img data-mx-emoticon height="48" src=" ${ e . url } " title=": ${ e . name } :" alt=": ${ e . name } :"> ` )
2023-09-27 04:06:02 +00:00
}
2023-09-27 11:10:24 +00:00
await api . sendState ( event . room _id , type , key , pack )
2023-09-26 19:20:18 +00:00
api . sendEvent ( event . room _id , "m.room.message" , {
... ctx ,
2023-09-27 11:10:24 +00:00
... b . get ( )
2023-09-26 19:20:18 +00:00
} )
} else {
// Upload it to Discord and have the bridge sync it back to Matrix again
2023-09-27 11:10:24 +00:00
for ( const e of toUpload ) {
const publicUrl = mxUtils . getPublicUrlForMxc ( e . url )
// @ts-ignore
const resizeInput = await fetch ( publicUrl , { agent : false } ) . then ( res => res . arrayBuffer ( ) )
const resizeOutput = await sharp ( resizeInput )
. resize ( EMOJI _SIZE , EMOJI _SIZE , { fit : "inside" , withoutEnlargement : true , background : { r : 0 , g : 0 , b : 0 , alpha : 0 } } )
. png ( )
. toBuffer ( { resolveWithObject : true } )
console . log ( ` uploading emoji ${ resizeOutput . data . length } bytes to : ${ e . name } : ` )
const emoji = await discord . snow . guildAssets . createEmoji ( guildID , { name : e . name , image : "data:image/png;base64," + resizeOutput . data . toString ( "base64" ) } )
}
2023-09-26 19:20:18 +00:00
api . sendEvent ( event . room _id , "m.room.message" , {
... ctx ,
msgtype : "m.text" ,
2023-09-27 11:10:24 +00:00
body : ` Created ${ toUpload . length } emojis `
2023-09-26 19:20:18 +00:00
} )
}
} )
}
)
2023-10-12 13:06:06 +00:00
} , {
aliases : [ "thread" ] ,
execute : replyctx (
async ( event , realBody , words , ctx ) => {
// Guard
/** @type {string} */ // @ts-ignore
const channelID = select ( "channel_room" , "channel_id" , { room _id : event . room _id } ) . pluck ( ) . get ( )
const guildID = discord . channels . get ( channelID ) ? . [ "guild_id" ]
if ( ! guildID ) {
return api . sendEvent ( event . room _id , "m.room.message" , {
... ctx ,
msgtype : "m.text" ,
body : "This room isn't bridged to the other side."
} )
}
const guild = discord . guilds . get ( guildID )
assert ( guild )
const permissions = dUtils . getPermissions ( [ ] , guild . roles )
if ( ! ( permissions & 0x800000000 n ) ) { // CREATE_PUBLIC_THREADS
return api . sendEvent ( event . room _id , "m.room.message" , {
... ctx ,
msgtype : "m.text" ,
body : "This command creates a thread on Discord. But you aren't allowed to do this, because if you were a Discord user, you wouldn't have the Create Public Threads permission."
} )
}
await discord . snow . channel . createThreadWithoutMessage ( channelID , { type : 11 , name : words . slice ( 1 ) . join ( " " ) } )
}
)
2023-09-26 19:20:18 +00:00
} ]
/** @type {CommandExecute} */
async function execute ( event ) {
let realBody = event . content . body
while ( realBody . startsWith ( "> " ) ) {
const i = realBody . indexOf ( "\n" )
if ( i === - 1 ) return
realBody = realBody . slice ( i + 1 )
}
realBody = realBody . replace ( /^\s*/ , "" )
let words
for ( const prefix of PREFIXES ) {
if ( realBody . startsWith ( prefix ) ) {
words = realBody . slice ( prefix . length ) . split ( " " )
break
}
}
if ( ! words ) return
const commandName = words [ 0 ]
const command = commands . find ( c => c . aliases . includes ( commandName ) )
if ( ! command ) return
2023-10-12 13:06:06 +00:00
await command . execute ( event , realBody , words )
2023-09-26 19:20:18 +00:00
}
module . exports . execute = execute
module . exports . onReactionAdd = onReactionAdd