diff --git a/.gitignore b/.gitignore index 9c175d8..e533dce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,16 @@ -node_modules +# Secrets config.js registration.yaml +ooye.db* +events.db* + +# Automatically generated +node_modules coverage -db/ooye.db* test/res/* !test/res/lottie* +icon.svg +*~ +.#* +\#*# +launch.json diff --git a/addbot.js b/addbot.js old mode 100644 new mode 100755 index 667fbab..ef1cc63 --- a/addbot.js +++ b/addbot.js @@ -1,15 +1,18 @@ +#!/usr/bin/env node // @ts-check -const config = require("./config") +const {reg} = require("./src/matrix/read-registration") +const token = reg.ooye.discord_token +const id = Buffer.from(token.split(".")[0], "base64").toString() function addbot() { - const token = config.discordToken - const id = Buffer.from(token.split(".")[0], "base64") return `Open this link to add the bot to a Discord server:\nhttps://discord.com/oauth2/authorize?client_id=${id}&scope=bot&permissions=1610883072 ` } +/* c8 ignore next 3 */ if (process.argv.find(a => a.endsWith("addbot") || a.endsWith("addbot.js"))) { console.log(addbot()) } +module.exports.id = id module.exports.addbot = addbot diff --git a/addbot.sh b/addbot.sh index 6c3ff4b..d40d0da 100755 --- a/addbot.sh +++ b/addbot.sh @@ -1,3 +1,3 @@ #!/usr/bin/env sh echo "Open this link to add the bot to a Discord server:" -echo "https://discord.com/oauth2/authorize?client_id=$(grep discordToken config.js | sed -E 's!.*: ["'\'']([A-Za-z0-9+=/_-]*).*!\1!g' | base64 -d)&scope=bot&permissions=1610883072" +echo "https://discord.com/oauth2/authorize?client_id=$(grep discord_token registration.yaml | sed -E 's!.*: ["'\'']([A-Za-z0-9+=/_-]*).*!\1!g' | base64 -d)&scope=bot&permissions=1610883072" diff --git a/config.example.js b/config.example.js deleted file mode 100644 index 0d1a29e..0000000 --- a/config.example.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - discordToken: "yes" -} diff --git a/d2m/actions/create-room.test.js b/d2m/actions/create-room.test.js deleted file mode 100644 index 2a76e2e..0000000 --- a/d2m/actions/create-room.test.js +++ /dev/null @@ -1,100 +0,0 @@ -// @ts-check - -const mixin = require("@cloudrac3r/mixin-deep") -const {channelToKState, _convertNameAndTopic} = require("./create-room") -const {kstateStripConditionals} = require("../../matrix/kstate") -const {test} = require("supertape") -const testData = require("../../test/data") - -const passthrough = require("../../passthrough") -const {db} = passthrough - -test("channel2room: discoverable privacy room", async t => { - db.prepare("UPDATE guild_space SET privacy_level = 2").run() - t.deepEqual( - kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general).then(x => x.channelKState)), - Object.assign({}, testData.room.general, { - "m.room.guest_access/": {guest_access: "forbidden"}, - "m.room.join_rules/": {join_rule: "public"}, - "m.room.history_visibility/": {history_visibility: "world_readable"} - }) - ) -}) - -test("channel2room: linkable privacy room", async t => { - db.prepare("UPDATE guild_space SET privacy_level = 1").run() - t.deepEqual( - kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general).then(x => x.channelKState)), - Object.assign({}, testData.room.general, { - "m.room.guest_access/": {guest_access: "forbidden"}, - "m.room.join_rules/": {join_rule: "public"} - }) - ) -}) - -test("channel2room: invite-only privacy room", async t => { - db.prepare("UPDATE guild_space SET privacy_level = 0").run() - t.deepEqual( - kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general).then(x => x.channelKState)), - testData.room.general - ) -}) - -test("channel2room: room where limited people can mention everyone", async t => { - const limitedGuild = mixin({}, testData.guild.general) - limitedGuild.roles[0].permissions = (BigInt(limitedGuild.roles[0].permissions) - 131072n).toString() - const limitedRoom = mixin({}, testData.room.general, {"m.room.power_levels/": {notifications: {room: 20}}}) - t.deepEqual( - kstateStripConditionals(await channelToKState(testData.channel.general, limitedGuild).then(x => x.channelKState)), - limitedRoom - ) -}) - -test("convertNameAndTopic: custom name and topic", t => { - t.deepEqual( - _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, "hauntings"), - ["hauntings", "#the-twilight-zone | Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"] - ) -}) - -test("convertNameAndTopic: custom name, no topic", t => { - t.deepEqual( - _convertNameAndTopic({id: "123", name: "the-twilight-zone", type: 0}, {id: "456"}, "hauntings"), - ["hauntings", "#the-twilight-zone\n\nChannel ID: 123\nGuild ID: 456"] - ) -}) - -test("convertNameAndTopic: original name and topic", t => { - t.deepEqual( - _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, null), - ["the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"] - ) -}) - -test("convertNameAndTopic: original name, no topic", t => { - t.deepEqual( - _convertNameAndTopic({id: "123", name: "the-twilight-zone", type: 0}, {id: "456"}, null), - ["the-twilight-zone", "Channel ID: 123\nGuild ID: 456"] - ) -}) - -test("convertNameAndTopic: public thread icon", t => { - t.deepEqual( - _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 11}, {id: "456"}, null), - ["[⛓️] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"] - ) -}) - -test("convertNameAndTopic: private thread icon", t => { - t.deepEqual( - _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 12}, {id: "456"}, null), - ["[🔒⛓️] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"] - ) -}) - -test("convertNameAndTopic: voice channel icon", t => { - t.deepEqual( - _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 2}, {id: "456"}, null), - ["[🔊] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"] - ) -}) diff --git a/d2m/actions/register-user.test.js b/d2m/actions/register-user.test.js deleted file mode 100644 index 96c73aa..0000000 --- a/d2m/actions/register-user.test.js +++ /dev/null @@ -1,63 +0,0 @@ -const {_memberToStateContent} = require("./register-user") -const {test} = require("supertape") -const testData = require("../../test/data") - -test("member2state: without member nick or avatar", async t => { - t.deepEqual( - await _memberToStateContent(testData.member.kumaccino.user, testData.member.kumaccino, testData.guild.general.id), - { - avatar_url: "mxc://cadence.moe/UpAeIqeclhKfeiZNdIWNcXXL", - displayname: "kumaccino", - membership: "join", - "moe.cadence.ooye.member": { - avatar: "/avatars/113340068197859328/b48302623a12bc7c59a71328f72ccb39.png?size=1024" - }, - "uk.half-shot.discord.member": { - bot: false, - displayColor: 10206929, - id: "113340068197859328", - username: "@kumaccino" - } - } - ) -}) - -test("member2state: with global name, without member nick or avatar", async t => { - t.deepEqual( - await _memberToStateContent(testData.member.papiophidian.user, testData.member.papiophidian, testData.guild.general.id), - { - avatar_url: "mxc://cadence.moe/JPzSmALLirnIprlSMKohSSoX", - displayname: "PapiOphidian", - membership: "join", - "moe.cadence.ooye.member": { - avatar: "/avatars/320067006521147393/5fc4ad85c1ea876709e9a7d3374a78a1.png?size=1024" - }, - "uk.half-shot.discord.member": { - bot: false, - displayColor: 1579292, - id: "320067006521147393", - username: "@papiophidian" - } - } - ) -}) - -test("member2state: with member nick and avatar", async t => { - t.deepEqual( - await _memberToStateContent(testData.member.sheep.user, testData.member.sheep, testData.guild.general.id), - { - avatar_url: "mxc://cadence.moe/rfemHmAtcprjLEiPiEuzPhpl", - displayname: "The Expert's Submarine", - membership: "join", - "moe.cadence.ooye.member": { - avatar: "/guilds/112760669178241024/users/134826546694193153/avatars/38dd359aa12bcd52dd3164126c587f8c.png?size=1024" - }, - "uk.half-shot.discord.member": { - bot: false, - displayColor: null, - id: "134826546694193153", - username: "@aprilsong" - } - } - ) -}) diff --git a/d2m/converters/pins-to-list.js b/d2m/converters/pins-to-list.js deleted file mode 100644 index 047bb9f..0000000 --- a/d2m/converters/pins-to-list.js +++ /dev/null @@ -1,19 +0,0 @@ -// @ts-check - -const {select} = require("../../passthrough") - -/** - * @param {import("discord-api-types/v10").RESTGetAPIChannelPinsResult} pins - */ -function pinsToList(pins) { - /** @type {string[]} */ - const result = [] - for (const message of pins) { - const eventID = select("event_message", "event_id", {message_id: message.id, part: 0}).pluck().get() - if (eventID) result.push(eventID) - } - result.reverse() - return result -} - -module.exports.pinsToList = pinsToList diff --git a/d2m/converters/pins-to-list.test.js b/d2m/converters/pins-to-list.test.js deleted file mode 100644 index 8a6daea..0000000 --- a/d2m/converters/pins-to-list.test.js +++ /dev/null @@ -1,12 +0,0 @@ -const {test} = require("supertape") -const data = require("../../test/data") -const {pinsToList} = require("./pins-to-list") - -test("pins2list: converts known IDs, ignores unknown IDs", t => { - const result = pinsToList(data.pins.faked) - t.deepEqual(result, [ - "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4", - "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", - "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" - ]) -}) diff --git a/d2m/converters/rlottie-wasm.js b/d2m/converters/rlottie-wasm.js deleted file mode 100644 index 4abf45d..0000000 --- a/d2m/converters/rlottie-wasm.js +++ /dev/null @@ -1 +0,0 @@ -var Module=typeof Module!="undefined"?Module:{};var moduleOverrides=Object.assign({},Module);var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var ENVIRONMENT_IS_WEB=typeof window=="object";var ENVIRONMENT_IS_WORKER=typeof importScripts=="function";var ENVIRONMENT_IS_NODE=typeof process=="object"&&typeof process.versions=="object"&&typeof process.versions.node=="string";var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var read_,readAsync,readBinary,setWindowTitle;if(ENVIRONMENT_IS_NODE){var fs=require("fs");var nodePath=require("path");if(ENVIRONMENT_IS_WORKER){scriptDirectory=nodePath.dirname(scriptDirectory)+"/"}else{scriptDirectory=__dirname+"/"}read_=(filename,binary)=>{filename=isFileURI(filename)?new URL(filename):nodePath.normalize(filename);return fs.readFileSync(filename,binary?undefined:"utf8")};readBinary=filename=>{var ret=read_(filename,true);if(!ret.buffer){ret=new Uint8Array(ret)}return ret};readAsync=(filename,onload,onerror,binary=true)=>{filename=isFileURI(filename)?new URL(filename):nodePath.normalize(filename);fs.readFile(filename,binary?undefined:"utf8",(err,data)=>{if(err)onerror(err);else onload(binary?data.buffer:data)})};if(!Module["thisProgram"]&&process.argv.length>1){thisProgram=process.argv[1].replace(/\\/g,"/")}arguments_=process.argv.slice(2);if(typeof module!="undefined"){module["exports"]=Module}process.on("uncaughtException",ex=>{if(ex!=="unwind"&&!(ex instanceof ExitStatus)&&!(ex.context instanceof ExitStatus)){throw ex}});quit_=(status,toThrow)=>{process.exitCode=status;throw toThrow};Module["inspect"]=()=>"[Emscripten Module object]"}else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(typeof document!="undefined"&&document.currentScript){scriptDirectory=document.currentScript.src}if(scriptDirectory.indexOf("blob:")!==0){scriptDirectory=scriptDirectory.substr(0,scriptDirectory.replace(/[?#].*/,"").lastIndexOf("/")+1)}else{scriptDirectory=""}{read_=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.send(null);return xhr.responseText};if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=(url,onload,onerror)=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,true);xhr.responseType="arraybuffer";xhr.onload=()=>{if(xhr.status==200||xhr.status==0&&xhr.response){onload(xhr.response);return}onerror()};xhr.onerror=onerror;xhr.send(null)}}setWindowTitle=title=>document.title=title}else{}var out=Module["print"]||console.log.bind(console);var err=Module["printErr"]||console.error.bind(console);Object.assign(Module,moduleOverrides);moduleOverrides=null;if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];if(Module["quit"])quit_=Module["quit"];var wasmBinary;if(Module["wasmBinary"])wasmBinary=Module["wasmBinary"];var noExitRuntime=Module["noExitRuntime"]||true;if(typeof WebAssembly!="object"){abort("no native wasm support detected")}var wasmMemory;var ABORT=false;var EXITSTATUS;var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;function updateMemoryViews(){var b=wasmMemory.buffer;Module["HEAP8"]=HEAP8=new Int8Array(b);Module["HEAP16"]=HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);Module["HEAPU16"]=HEAPU16=new Uint16Array(b);Module["HEAP32"]=HEAP32=new Int32Array(b);Module["HEAPU32"]=HEAPU32=new Uint32Array(b);Module["HEAPF32"]=HEAPF32=new Float32Array(b);Module["HEAPF64"]=HEAPF64=new Float64Array(b)}var wasmTable;var __ATPRERUN__=[];var __ATINIT__=[];var __ATPOSTRUN__=[];var runtimeInitialized=false;var runtimeKeepaliveCounter=0;function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift())}}callRuntimeCallbacks(__ATPRERUN__)}function initRuntime(){runtimeInitialized=true;callRuntimeCallbacks(__ATINIT__)}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift())}}callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(cb){__ATPRERUN__.unshift(cb)}function addOnInit(cb){__ATINIT__.unshift(cb)}function addOnPostRun(cb){__ATPOSTRUN__.unshift(cb)}var runDependencies=0;var runDependencyWatcher=null;var dependenciesFulfilled=null;function addRunDependency(id){runDependencies++;if(Module["monitorRunDependencies"]){Module["monitorRunDependencies"](runDependencies)}}function removeRunDependency(id){runDependencies--;if(Module["monitorRunDependencies"]){Module["monitorRunDependencies"](runDependencies)}if(runDependencies==0){if(runDependencyWatcher!==null){clearInterval(runDependencyWatcher);runDependencyWatcher=null}if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}function abort(what){if(Module["onAbort"]){Module["onAbort"](what)}what="Aborted("+what+")";err(what);ABORT=true;EXITSTATUS=1;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);throw e}var dataURIPrefix="data:application/octet-stream;base64,";function isDataURI(filename){return filename.startsWith(dataURIPrefix)}function isFileURI(filename){return filename.startsWith("file://")}var wasmBinaryFile;wasmBinaryFile="rlottie-wasm.wasm";if(!isDataURI(wasmBinaryFile)){wasmBinaryFile=locateFile(wasmBinaryFile)}function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}function getBinaryPromise(binaryFile){if(!wasmBinary&&(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER)){if(typeof fetch=="function"&&!isFileURI(binaryFile)){return fetch(binaryFile,{credentials:"same-origin"}).then(response=>{if(!response["ok"]){throw"failed to load wasm binary file at '"+binaryFile+"'"}return response["arrayBuffer"]()}).catch(()=>getBinarySync(binaryFile))}else if(readAsync){return new Promise((resolve,reject)=>{readAsync(binaryFile,response=>resolve(new Uint8Array(response)),reject)})}}return Promise.resolve().then(()=>getBinarySync(binaryFile))}function instantiateArrayBuffer(binaryFile,imports,receiver){return getBinaryPromise(binaryFile).then(binary=>WebAssembly.instantiate(binary,imports)).then(instance=>instance).then(receiver,reason=>{err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)})}function instantiateAsync(binary,binaryFile,imports,callback){if(!binary&&typeof WebAssembly.instantiateStreaming=="function"&&!isDataURI(binaryFile)&&!isFileURI(binaryFile)&&!ENVIRONMENT_IS_NODE&&typeof fetch=="function"){return fetch(binaryFile,{credentials:"same-origin"}).then(response=>{var result=WebAssembly.instantiateStreaming(response,imports);return result.then(callback,function(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation");return instantiateArrayBuffer(binaryFile,imports,callback)})})}return instantiateArrayBuffer(binaryFile,imports,callback)}function createWasm(){var info={"env":wasmImports,"wasi_snapshot_preview1":wasmImports};function receiveInstance(instance,module){var exports=instance.exports;wasmExports=exports;wasmMemory=wasmExports["memory"];updateMemoryViews();wasmTable=wasmExports["__indirect_function_table"];addOnInit(wasmExports["__wasm_call_ctors"]);removeRunDependency("wasm-instantiate");return exports}addRunDependency("wasm-instantiate");function receiveInstantiationResult(result){receiveInstance(result["instance"])}if(Module["instantiateWasm"]){try{return Module["instantiateWasm"](info,receiveInstance)}catch(e){err(`Module.instantiateWasm callback failed with error: ${e}`);return false}}instantiateAsync(wasmBinary,wasmBinaryFile,info,receiveInstantiationResult);return{}}var tempDouble;function ExitStatus(status){this.name="ExitStatus";this.message=`Program terminated with exit(${status})`;this.status=status}var callRuntimeCallbacks=callbacks=>{while(callbacks.length>0){callbacks.shift()(Module)}};var UTF8Decoder=typeof TextDecoder!="undefined"?new TextDecoder("utf8"):undefined;var UTF8ArrayToString=(heapOrArray,idx,maxBytesToRead)=>{var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heapOrArray[endPtr]&&!(endPtr>=endIdx))++endPtr;if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead):"";var ___assert_fail=(condition,filename,line,func)=>{abort(`Assertion failed: ${UTF8ToString(condition)}, at: `+[filename?UTF8ToString(filename):"unknown filename",line,func?UTF8ToString(func):"unknown function"])};function ExceptionInfo(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24;this.set_type=function(type){HEAPU32[this.ptr+4>>2]=type};this.get_type=function(){return HEAPU32[this.ptr+4>>2]};this.set_destructor=function(destructor){HEAPU32[this.ptr+8>>2]=destructor};this.get_destructor=function(){return HEAPU32[this.ptr+8>>2]};this.set_caught=function(caught){caught=caught?1:0;HEAP8[this.ptr+12>>0]=caught};this.get_caught=function(){return HEAP8[this.ptr+12>>0]!=0};this.set_rethrown=function(rethrown){rethrown=rethrown?1:0;HEAP8[this.ptr+13>>0]=rethrown};this.get_rethrown=function(){return HEAP8[this.ptr+13>>0]!=0};this.init=function(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)};this.set_adjusted_ptr=function(adjustedPtr){HEAPU32[this.ptr+16>>2]=adjustedPtr};this.get_adjusted_ptr=function(){return HEAPU32[this.ptr+16>>2]};this.get_exception_ptr=function(){var isPointer=___cxa_is_pointer_type(this.get_type());if(isPointer){return HEAPU32[this.excPtr>>2]}var adjusted=this.get_adjusted_ptr();if(adjusted!==0)return adjusted;return this.excPtr}}var exceptionLast=0;var uncaughtExceptionCount=0;var ___cxa_throw=(ptr,type,destructor)=>{var info=new ExceptionInfo(ptr);info.init(type,destructor);exceptionLast=ptr;uncaughtExceptionCount++;throw exceptionLast};var SYSCALLS={varargs:undefined,get(){var ret=HEAP32[SYSCALLS.varargs>>2];SYSCALLS.varargs+=4;return ret},getp(){return SYSCALLS.get()},getStr(ptr){var ret=UTF8ToString(ptr);return ret}};function ___syscall_ioctl(fd,op,varargs){SYSCALLS.varargs=varargs;return 0}function ___syscall_openat(dirfd,path,flags,varargs){SYSCALLS.varargs=varargs}var __embind_register_bigint=(primitiveType,name,size,minRange,maxRange)=>{};var embind_init_charCodes=()=>{var codes=new Array(256);for(var i=0;i<256;++i){codes[i]=String.fromCharCode(i)}embind_charCodes=codes};var embind_charCodes=undefined;var readLatin1String=ptr=>{var ret="";var c=ptr;while(HEAPU8[c]){ret+=embind_charCodes[HEAPU8[c++]]}return ret};var awaitingDependencies={};var registeredTypes={};var typeDependencies={};var BindingError=undefined;var throwBindingError=message=>{throw new BindingError(message)};var InternalError=undefined;var throwInternalError=message=>{throw new InternalError(message)};var whenDependentTypesAreResolved=(myTypes,dependentTypes,getTypeConverters)=>{myTypes.forEach(function(type){typeDependencies[type]=dependentTypes});function onComplete(typeConverters){var myTypeConverters=getTypeConverters(typeConverters);if(myTypeConverters.length!==myTypes.length){throwInternalError("Mismatched type converter count")}for(var i=0;i{if(registeredTypes.hasOwnProperty(dt)){typeConverters[i]=registeredTypes[dt]}else{unregisteredTypes.push(dt);if(!awaitingDependencies.hasOwnProperty(dt)){awaitingDependencies[dt]=[]}awaitingDependencies[dt].push(()=>{typeConverters[i]=registeredTypes[dt];++registered;if(registered===unregisteredTypes.length){onComplete(typeConverters)}})}});if(0===unregisteredTypes.length){onComplete(typeConverters)}};function sharedRegisterType(rawType,registeredInstance,options={}){var name=registeredInstance.name;if(!rawType){throwBindingError(`type "${name}" must have a positive integer typeid pointer`)}if(registeredTypes.hasOwnProperty(rawType)){if(options.ignoreDuplicateRegistrations){return}else{throwBindingError(`Cannot register type '${name}' twice`)}}registeredTypes[rawType]=registeredInstance;delete typeDependencies[rawType];if(awaitingDependencies.hasOwnProperty(rawType)){var callbacks=awaitingDependencies[rawType];delete awaitingDependencies[rawType];callbacks.forEach(cb=>cb())}}function registerType(rawType,registeredInstance,options={}){if(!("argPackAdvance"in registeredInstance)){throw new TypeError("registerType registeredInstance requires argPackAdvance")}return sharedRegisterType(rawType,registeredInstance,options)}var GenericWireTypeSize=8;var __embind_register_bool=(rawType,name,trueValue,falseValue)=>{name=readLatin1String(name);registerType(rawType,{name:name,"fromWireType":function(wt){return!!wt},"toWireType":function(destructors,o){return o?trueValue:falseValue},"argPackAdvance":GenericWireTypeSize,"readValueFromPointer":function(pointer){return this["fromWireType"](HEAPU8[pointer])},destructorFunction:null})};function ClassHandle_isAliasOf(other){if(!(this instanceof ClassHandle)){return false}if(!(other instanceof ClassHandle)){return false}var leftClass=this.$$.ptrType.registeredClass;var left=this.$$.ptr;var rightClass=other.$$.ptrType.registeredClass;var right=other.$$.ptr;while(leftClass.baseClass){left=leftClass.upcast(left);leftClass=leftClass.baseClass}while(rightClass.baseClass){right=rightClass.upcast(right);rightClass=rightClass.baseClass}return leftClass===rightClass&&left===right}var shallowCopyInternalPointer=o=>({count:o.count,deleteScheduled:o.deleteScheduled,preservePointerOnDelete:o.preservePointerOnDelete,ptr:o.ptr,ptrType:o.ptrType,smartPtr:o.smartPtr,smartPtrType:o.smartPtrType});var throwInstanceAlreadyDeleted=obj=>{function getInstanceTypeName(handle){return handle.$$.ptrType.registeredClass.name}throwBindingError(getInstanceTypeName(obj)+" instance already deleted")};var finalizationRegistry=false;var detachFinalizer=handle=>{};var runDestructor=$$=>{if($$.smartPtr){$$.smartPtrType.rawDestructor($$.smartPtr)}else{$$.ptrType.registeredClass.rawDestructor($$.ptr)}};var releaseClassHandle=$$=>{$$.count.value-=1;var toDelete=0===$$.count.value;if(toDelete){runDestructor($$)}};var downcastPointer=(ptr,ptrClass,desiredClass)=>{if(ptrClass===desiredClass){return ptr}if(undefined===desiredClass.baseClass){return null}var rv=downcastPointer(ptr,ptrClass,desiredClass.baseClass);if(rv===null){return null}return desiredClass.downcast(rv)};var registeredPointers={};var getInheritedInstanceCount=()=>Object.keys(registeredInstances).length;var getLiveInheritedInstances=()=>{var rv=[];for(var k in registeredInstances){if(registeredInstances.hasOwnProperty(k)){rv.push(registeredInstances[k])}}return rv};var deletionQueue=[];var flushPendingDeletes=()=>{while(deletionQueue.length){var obj=deletionQueue.pop();obj.$$.deleteScheduled=false;obj["delete"]()}};var delayFunction=undefined;var setDelayFunction=fn=>{delayFunction=fn;if(deletionQueue.length&&delayFunction){delayFunction(flushPendingDeletes)}};var init_embind=()=>{Module["getInheritedInstanceCount"]=getInheritedInstanceCount;Module["getLiveInheritedInstances"]=getLiveInheritedInstances;Module["flushPendingDeletes"]=flushPendingDeletes;Module["setDelayFunction"]=setDelayFunction};var registeredInstances={};var getBasestPointer=(class_,ptr)=>{if(ptr===undefined){throwBindingError("ptr should not be undefined")}while(class_.baseClass){ptr=class_.upcast(ptr);class_=class_.baseClass}return ptr};var getInheritedInstance=(class_,ptr)=>{ptr=getBasestPointer(class_,ptr);return registeredInstances[ptr]};var makeClassHandle=(prototype,record)=>{if(!record.ptrType||!record.ptr){throwInternalError("makeClassHandle requires ptr and ptrType")}var hasSmartPtrType=!!record.smartPtrType;var hasSmartPtr=!!record.smartPtr;if(hasSmartPtrType!==hasSmartPtr){throwInternalError("Both smartPtrType and smartPtr must be specified")}record.count={value:1};return attachFinalizer(Object.create(prototype,{$$:{value:record}}))};function RegisteredPointer_fromWireType(ptr){var rawPointer=this.getPointee(ptr);if(!rawPointer){this.destructor(ptr);return null}var registeredInstance=getInheritedInstance(this.registeredClass,rawPointer);if(undefined!==registeredInstance){if(0===registeredInstance.$$.count.value){registeredInstance.$$.ptr=rawPointer;registeredInstance.$$.smartPtr=ptr;return registeredInstance["clone"]()}else{var rv=registeredInstance["clone"]();this.destructor(ptr);return rv}}function makeDefaultHandle(){if(this.isSmartPointer){return makeClassHandle(this.registeredClass.instancePrototype,{ptrType:this.pointeeType,ptr:rawPointer,smartPtrType:this,smartPtr:ptr})}else{return makeClassHandle(this.registeredClass.instancePrototype,{ptrType:this,ptr:ptr})}}var actualType=this.registeredClass.getActualType(rawPointer);var registeredPointerRecord=registeredPointers[actualType];if(!registeredPointerRecord){return makeDefaultHandle.call(this)}var toType;if(this.isConst){toType=registeredPointerRecord.constPointerType}else{toType=registeredPointerRecord.pointerType}var dp=downcastPointer(rawPointer,this.registeredClass,toType.registeredClass);if(dp===null){return makeDefaultHandle.call(this)}if(this.isSmartPointer){return makeClassHandle(toType.registeredClass.instancePrototype,{ptrType:toType,ptr:dp,smartPtrType:this,smartPtr:ptr})}else{return makeClassHandle(toType.registeredClass.instancePrototype,{ptrType:toType,ptr:dp})}}var attachFinalizer=handle=>{if("undefined"===typeof FinalizationRegistry){attachFinalizer=handle=>handle;return handle}finalizationRegistry=new FinalizationRegistry(info=>{releaseClassHandle(info.$$)});attachFinalizer=handle=>{var $$=handle.$$;var hasSmartPtr=!!$$.smartPtr;if(hasSmartPtr){var info={$$:$$};finalizationRegistry.register(handle,info,handle)}return handle};detachFinalizer=handle=>finalizationRegistry.unregister(handle);return attachFinalizer(handle)};function ClassHandle_clone(){if(!this.$$.ptr){throwInstanceAlreadyDeleted(this)}if(this.$$.preservePointerOnDelete){this.$$.count.value+=1;return this}else{var clone=attachFinalizer(Object.create(Object.getPrototypeOf(this),{$$:{value:shallowCopyInternalPointer(this.$$)}}));clone.$$.count.value+=1;clone.$$.deleteScheduled=false;return clone}}function ClassHandle_delete(){if(!this.$$.ptr){throwInstanceAlreadyDeleted(this)}if(this.$$.deleteScheduled&&!this.$$.preservePointerOnDelete){throwBindingError("Object already scheduled for deletion")}detachFinalizer(this);releaseClassHandle(this.$$);if(!this.$$.preservePointerOnDelete){this.$$.smartPtr=undefined;this.$$.ptr=undefined}}function ClassHandle_isDeleted(){return!this.$$.ptr}function ClassHandle_deleteLater(){if(!this.$$.ptr){throwInstanceAlreadyDeleted(this)}if(this.$$.deleteScheduled&&!this.$$.preservePointerOnDelete){throwBindingError("Object already scheduled for deletion")}deletionQueue.push(this);if(deletionQueue.length===1&&delayFunction){delayFunction(flushPendingDeletes)}this.$$.deleteScheduled=true;return this}var init_ClassHandle=()=>{ClassHandle.prototype["isAliasOf"]=ClassHandle_isAliasOf;ClassHandle.prototype["clone"]=ClassHandle_clone;ClassHandle.prototype["delete"]=ClassHandle_delete;ClassHandle.prototype["isDeleted"]=ClassHandle_isDeleted;ClassHandle.prototype["deleteLater"]=ClassHandle_deleteLater};function ClassHandle(){}var char_0=48;var char_9=57;var makeLegalFunctionName=name=>{if(undefined===name){return"_unknown"}name=name.replace(/[^a-zA-Z0-9_]/g,"$");var f=name.charCodeAt(0);if(f>=char_0&&f<=char_9){return`_${name}`}return name};function createNamedFunction(name,body){name=makeLegalFunctionName(name);return{[name]:function(){return body.apply(this,arguments)}}[name]}var ensureOverloadTable=(proto,methodName,humanName)=>{if(undefined===proto[methodName].overloadTable){var prevFunc=proto[methodName];proto[methodName]=function(){if(!proto[methodName].overloadTable.hasOwnProperty(arguments.length)){throwBindingError(`Function '${humanName}' called with an invalid number of arguments (${arguments.length}) - expects one of (${proto[methodName].overloadTable})!`)}return proto[methodName].overloadTable[arguments.length].apply(this,arguments)};proto[methodName].overloadTable=[];proto[methodName].overloadTable[prevFunc.argCount]=prevFunc}};var exposePublicSymbol=(name,value,numArguments)=>{if(Module.hasOwnProperty(name)){if(undefined===numArguments||undefined!==Module[name].overloadTable&&undefined!==Module[name].overloadTable[numArguments]){throwBindingError(`Cannot register public name '${name}' twice`)}ensureOverloadTable(Module,name,name);if(Module.hasOwnProperty(numArguments)){throwBindingError(`Cannot register multiple overloads of a function with the same number of arguments (${numArguments})!`)}Module[name].overloadTable[numArguments]=value}else{Module[name]=value;if(undefined!==numArguments){Module[name].numArguments=numArguments}}};function RegisteredClass(name,constructor,instancePrototype,rawDestructor,baseClass,getActualType,upcast,downcast){this.name=name;this.constructor=constructor;this.instancePrototype=instancePrototype;this.rawDestructor=rawDestructor;this.baseClass=baseClass;this.getActualType=getActualType;this.upcast=upcast;this.downcast=downcast;this.pureVirtualFunctions=[]}var upcastPointer=(ptr,ptrClass,desiredClass)=>{while(ptrClass!==desiredClass){if(!ptrClass.upcast){throwBindingError(`Expected null or instance of ${desiredClass.name}, got an instance of ${ptrClass.name}`)}ptr=ptrClass.upcast(ptr);ptrClass=ptrClass.baseClass}return ptr};function constNoSmartPtrRawPointerToWireType(destructors,handle){if(handle===null){if(this.isReference){throwBindingError(`null is not a valid ${this.name}`)}return 0}if(!handle.$$){throwBindingError(`Cannot pass "${embindRepr(handle)}" as a ${this.name}`)}if(!handle.$$.ptr){throwBindingError(`Cannot pass deleted object as a pointer of type ${this.name}`)}var handleClass=handle.$$.ptrType.registeredClass;var ptr=upcastPointer(handle.$$.ptr,handleClass,this.registeredClass);return ptr}function genericPointerToWireType(destructors,handle){var ptr;if(handle===null){if(this.isReference){throwBindingError(`null is not a valid ${this.name}`)}if(this.isSmartPointer){ptr=this.rawConstructor();if(destructors!==null){destructors.push(this.rawDestructor,ptr)}return ptr}else{return 0}}if(!handle.$$){throwBindingError(`Cannot pass "${embindRepr(handle)}" as a ${this.name}`)}if(!handle.$$.ptr){throwBindingError(`Cannot pass deleted object as a pointer of type ${this.name}`)}if(!this.isConst&&handle.$$.ptrType.isConst){throwBindingError(`Cannot convert argument of type ${handle.$$.smartPtrType?handle.$$.smartPtrType.name:handle.$$.ptrType.name} to parameter type ${this.name}`)}var handleClass=handle.$$.ptrType.registeredClass;ptr=upcastPointer(handle.$$.ptr,handleClass,this.registeredClass);if(this.isSmartPointer){if(undefined===handle.$$.smartPtr){throwBindingError("Passing raw pointer to smart pointer is illegal")}switch(this.sharingPolicy){case 0:if(handle.$$.smartPtrType===this){ptr=handle.$$.smartPtr}else{throwBindingError(`Cannot convert argument of type ${handle.$$.smartPtrType?handle.$$.smartPtrType.name:handle.$$.ptrType.name} to parameter type ${this.name}`)}break;case 1:ptr=handle.$$.smartPtr;break;case 2:if(handle.$$.smartPtrType===this){ptr=handle.$$.smartPtr}else{var clonedHandle=handle["clone"]();ptr=this.rawShare(ptr,Emval.toHandle(()=>clonedHandle["delete"]()));if(destructors!==null){destructors.push(this.rawDestructor,ptr)}}break;default:throwBindingError("Unsupporting sharing policy")}}return ptr}function nonConstNoSmartPtrRawPointerToWireType(destructors,handle){if(handle===null){if(this.isReference){throwBindingError(`null is not a valid ${this.name}`)}return 0}if(!handle.$$){throwBindingError(`Cannot pass "${embindRepr(handle)}" as a ${this.name}`)}if(!handle.$$.ptr){throwBindingError(`Cannot pass deleted object as a pointer of type ${this.name}`)}if(handle.$$.ptrType.isConst){throwBindingError(`Cannot convert argument of type ${handle.$$.ptrType.name} to parameter type ${this.name}`)}var handleClass=handle.$$.ptrType.registeredClass;var ptr=upcastPointer(handle.$$.ptr,handleClass,this.registeredClass);return ptr}function readPointer(pointer){return this["fromWireType"](HEAPU32[pointer>>2])}function RegisteredPointer_getPointee(ptr){if(this.rawGetPointee){ptr=this.rawGetPointee(ptr)}return ptr}function RegisteredPointer_destructor(ptr){if(this.rawDestructor){this.rawDestructor(ptr)}}var RegisteredPointer_deleteObject=handle=>{if(handle!==null){handle["delete"]()}};var init_RegisteredPointer=()=>{RegisteredPointer.prototype.getPointee=RegisteredPointer_getPointee;RegisteredPointer.prototype.destructor=RegisteredPointer_destructor;RegisteredPointer.prototype["argPackAdvance"]=GenericWireTypeSize;RegisteredPointer.prototype["readValueFromPointer"]=readPointer;RegisteredPointer.prototype["deleteObject"]=RegisteredPointer_deleteObject;RegisteredPointer.prototype["fromWireType"]=RegisteredPointer_fromWireType};function RegisteredPointer(name,registeredClass,isReference,isConst,isSmartPointer,pointeeType,sharingPolicy,rawGetPointee,rawConstructor,rawShare,rawDestructor){this.name=name;this.registeredClass=registeredClass;this.isReference=isReference;this.isConst=isConst;this.isSmartPointer=isSmartPointer;this.pointeeType=pointeeType;this.sharingPolicy=sharingPolicy;this.rawGetPointee=rawGetPointee;this.rawConstructor=rawConstructor;this.rawShare=rawShare;this.rawDestructor=rawDestructor;if(!isSmartPointer&®isteredClass.baseClass===undefined){if(isConst){this["toWireType"]=constNoSmartPtrRawPointerToWireType;this.destructorFunction=null}else{this["toWireType"]=nonConstNoSmartPtrRawPointerToWireType;this.destructorFunction=null}}else{this["toWireType"]=genericPointerToWireType}}var replacePublicSymbol=(name,value,numArguments)=>{if(!Module.hasOwnProperty(name)){throwInternalError("Replacing nonexistant public symbol")}if(undefined!==Module[name].overloadTable&&undefined!==numArguments){Module[name].overloadTable[numArguments]=value}else{Module[name]=value;Module[name].argCount=numArguments}};var dynCallLegacy=(sig,ptr,args)=>{var f=Module["dynCall_"+sig];return args&&args.length?f.apply(null,[ptr].concat(args)):f.call(null,ptr)};var wasmTableMirror=[];var getWasmTableEntry=funcPtr=>{var func=wasmTableMirror[funcPtr];if(!func){if(funcPtr>=wasmTableMirror.length)wasmTableMirror.length=funcPtr+1;wasmTableMirror[funcPtr]=func=wasmTable.get(funcPtr)}return func};var dynCall=(sig,ptr,args)=>{if(sig.includes("j")){return dynCallLegacy(sig,ptr,args)}var rtn=getWasmTableEntry(ptr).apply(null,args);return rtn};var getDynCaller=(sig,ptr)=>{var argCache=[];return function(){argCache.length=0;Object.assign(argCache,arguments);return dynCall(sig,ptr,argCache)}};var embind__requireFunction=(signature,rawFunction)=>{signature=readLatin1String(signature);function makeDynCaller(){if(signature.includes("j")){return getDynCaller(signature,rawFunction)}return getWasmTableEntry(rawFunction)}var fp=makeDynCaller();if(typeof fp!="function"){throwBindingError(`unknown function pointer with signature ${signature}: ${rawFunction}`)}return fp};var extendError=(baseErrorType,errorName)=>{var errorClass=createNamedFunction(errorName,function(message){this.name=errorName;this.message=message;var stack=new Error(message).stack;if(stack!==undefined){this.stack=this.toString()+"\n"+stack.replace(/^Error(:[^\n]*)?\n/,"")}});errorClass.prototype=Object.create(baseErrorType.prototype);errorClass.prototype.constructor=errorClass;errorClass.prototype.toString=function(){if(this.message===undefined){return this.name}else{return`${this.name}: ${this.message}`}};return errorClass};var UnboundTypeError=undefined;var getTypeName=type=>{var ptr=___getTypeName(type);var rv=readLatin1String(ptr);_free(ptr);return rv};var throwUnboundTypeError=(message,types)=>{var unboundTypes=[];var seen={};function visit(type){if(seen[type]){return}if(registeredTypes[type]){return}if(typeDependencies[type]){typeDependencies[type].forEach(visit);return}unboundTypes.push(type);seen[type]=true}types.forEach(visit);throw new UnboundTypeError(`${message}: `+unboundTypes.map(getTypeName).join([", "]))};var __embind_register_class=(rawType,rawPointerType,rawConstPointerType,baseClassRawType,getActualTypeSignature,getActualType,upcastSignature,upcast,downcastSignature,downcast,name,destructorSignature,rawDestructor)=>{name=readLatin1String(name);getActualType=embind__requireFunction(getActualTypeSignature,getActualType);if(upcast){upcast=embind__requireFunction(upcastSignature,upcast)}if(downcast){downcast=embind__requireFunction(downcastSignature,downcast)}rawDestructor=embind__requireFunction(destructorSignature,rawDestructor);var legalFunctionName=makeLegalFunctionName(name);exposePublicSymbol(legalFunctionName,function(){throwUnboundTypeError(`Cannot construct ${name} due to unbound types`,[baseClassRawType])});whenDependentTypesAreResolved([rawType,rawPointerType,rawConstPointerType],baseClassRawType?[baseClassRawType]:[],function(base){base=base[0];var baseClass;var basePrototype;if(baseClassRawType){baseClass=base.registeredClass;basePrototype=baseClass.instancePrototype}else{basePrototype=ClassHandle.prototype}var constructor=createNamedFunction(legalFunctionName,function(){if(Object.getPrototypeOf(this)!==instancePrototype){throw new BindingError("Use 'new' to construct "+name)}if(undefined===registeredClass.constructor_body){throw new BindingError(name+" has no accessible constructor")}var body=registeredClass.constructor_body[arguments.length];if(undefined===body){throw new BindingError(`Tried to invoke ctor of ${name} with invalid number of parameters (${arguments.length}) - expected (${Object.keys(registeredClass.constructor_body).toString()}) parameters instead!`)}return body.apply(this,arguments)});var instancePrototype=Object.create(basePrototype,{constructor:{value:constructor}});constructor.prototype=instancePrototype;var registeredClass=new RegisteredClass(name,constructor,instancePrototype,rawDestructor,baseClass,getActualType,upcast,downcast);if(registeredClass.baseClass){if(registeredClass.baseClass.__derivedClasses===undefined){registeredClass.baseClass.__derivedClasses=[]}registeredClass.baseClass.__derivedClasses.push(registeredClass)}var referenceConverter=new RegisteredPointer(name,registeredClass,true,false,false);var pointerConverter=new RegisteredPointer(name+"*",registeredClass,false,false,false);var constPointerConverter=new RegisteredPointer(name+" const*",registeredClass,false,true,false);registeredPointers[rawType]={pointerType:pointerConverter,constPointerType:constPointerConverter};replacePublicSymbol(legalFunctionName,constructor);return[referenceConverter,pointerConverter,constPointerConverter]})};var heap32VectorToArray=(count,firstElement)=>{var array=[];for(var i=0;i>2])}return array};var runDestructors=destructors=>{while(destructors.length){var ptr=destructors.pop();var del=destructors.pop();del(ptr)}};function newFunc(constructor,argumentList){if(!(constructor instanceof Function)){throw new TypeError(`new_ called with constructor type ${typeof constructor} which is not a function`)}var dummy=createNamedFunction(constructor.name||"unknownFunctionName",function(){});dummy.prototype=constructor.prototype;var obj=new dummy;var r=constructor.apply(obj,argumentList);return r instanceof Object?r:obj}function craftInvokerFunction(humanName,argTypes,classType,cppInvokerFunc,cppTargetFunc,isAsync){var argCount=argTypes.length;if(argCount<2){throwBindingError("argTypes array size mismatch! Must at least get return value and 'this' types!")}var isClassMethodFunc=argTypes[1]!==null&&classType!==null;var needsDestructorStack=false;for(var i=1;i0?", ":"")+argsListWired}invokerFnBody+=(returns||isAsync?"var rv = ":"")+"invoker(fn"+(argsListWired.length>0?", ":"")+argsListWired+");\n";if(needsDestructorStack){invokerFnBody+="runDestructors(destructors);\n"}else{for(var i=isClassMethodFunc?1:2;i{var rawArgTypes=heap32VectorToArray(argCount,rawArgTypesAddr);invoker=embind__requireFunction(invokerSignature,invoker);whenDependentTypesAreResolved([],[rawClassType],function(classType){classType=classType[0];var humanName=`constructor ${classType.name}`;if(undefined===classType.registeredClass.constructor_body){classType.registeredClass.constructor_body=[]}if(undefined!==classType.registeredClass.constructor_body[argCount-1]){throw new BindingError(`Cannot register multiple constructors with identical number of parameters (${argCount-1}) for class '${classType.name}'! Overload resolution is currently only performed using the parameter count, not actual type info!`)}classType.registeredClass.constructor_body[argCount-1]=()=>{throwUnboundTypeError(`Cannot construct ${classType.name} due to unbound types`,rawArgTypes)};whenDependentTypesAreResolved([],rawArgTypes,argTypes=>{argTypes.splice(1,0,null);classType.registeredClass.constructor_body[argCount-1]=craftInvokerFunction(humanName,argTypes,null,invoker,rawConstructor);return[]});return[]})};var __embind_register_class_function=(rawClassType,methodName,argCount,rawArgTypesAddr,invokerSignature,rawInvoker,context,isPureVirtual,isAsync)=>{var rawArgTypes=heap32VectorToArray(argCount,rawArgTypesAddr);methodName=readLatin1String(methodName);rawInvoker=embind__requireFunction(invokerSignature,rawInvoker);whenDependentTypesAreResolved([],[rawClassType],function(classType){classType=classType[0];var humanName=`${classType.name}.${methodName}`;if(methodName.startsWith("@@")){methodName=Symbol[methodName.substring(2)]}if(isPureVirtual){classType.registeredClass.pureVirtualFunctions.push(methodName)}function unboundTypesHandler(){throwUnboundTypeError(`Cannot call ${humanName} due to unbound types`,rawArgTypes)}var proto=classType.registeredClass.instancePrototype;var method=proto[methodName];if(undefined===method||undefined===method.overloadTable&&method.className!==classType.name&&method.argCount===argCount-2){unboundTypesHandler.argCount=argCount-2;unboundTypesHandler.className=classType.name;proto[methodName]=unboundTypesHandler}else{ensureOverloadTable(proto,methodName,humanName);proto[methodName].overloadTable[argCount-2]=unboundTypesHandler}whenDependentTypesAreResolved([],rawArgTypes,function(argTypes){var memberFunction=craftInvokerFunction(humanName,argTypes,classType,rawInvoker,context,isAsync);if(undefined===proto[methodName].overloadTable){memberFunction.argCount=argCount-2;proto[methodName]=memberFunction}else{proto[methodName].overloadTable[argCount-2]=memberFunction}return[]});return[]})};function handleAllocatorInit(){Object.assign(HandleAllocator.prototype,{get(id){return this.allocated[id]},has(id){return this.allocated[id]!==undefined},allocate(handle){var id=this.freelist.pop()||this.allocated.length;this.allocated[id]=handle;return id},free(id){this.allocated[id]=undefined;this.freelist.push(id)}})}function HandleAllocator(){this.allocated=[undefined];this.freelist=[]}var emval_handles=new HandleAllocator;var __emval_decref=handle=>{if(handle>=emval_handles.reserved&&0===--emval_handles.get(handle).refcount){emval_handles.free(handle)}};var count_emval_handles=()=>{var count=0;for(var i=emval_handles.reserved;i{emval_handles.allocated.push({value:undefined},{value:null},{value:true},{value:false});emval_handles.reserved=emval_handles.allocated.length;Module["count_emval_handles"]=count_emval_handles};var Emval={toValue:handle=>{if(!handle){throwBindingError("Cannot use deleted val. handle = "+handle)}return emval_handles.get(handle).value},toHandle:value=>{switch(value){case undefined:return 1;case null:return 2;case true:return 3;case false:return 4;default:{return emval_handles.allocate({refcount:1,value:value})}}}};function simpleReadValueFromPointer(pointer){return this["fromWireType"](HEAP32[pointer>>2])}var __embind_register_emval=(rawType,name)=>{name=readLatin1String(name);registerType(rawType,{name:name,"fromWireType":handle=>{var rv=Emval.toValue(handle);__emval_decref(handle);return rv},"toWireType":(destructors,value)=>Emval.toHandle(value),"argPackAdvance":GenericWireTypeSize,"readValueFromPointer":simpleReadValueFromPointer,destructorFunction:null})};var embindRepr=v=>{if(v===null){return"null"}var t=typeof v;if(t==="object"||t==="array"||t==="function"){return v.toString()}else{return""+v}};var floatReadValueFromPointer=(name,width)=>{switch(width){case 4:return function(pointer){return this["fromWireType"](HEAPF32[pointer>>2])};case 8:return function(pointer){return this["fromWireType"](HEAPF64[pointer>>3])};default:throw new TypeError(`invalid float width (${width}): ${name}`)}};var __embind_register_float=(rawType,name,size)=>{name=readLatin1String(name);registerType(rawType,{name:name,"fromWireType":value=>value,"toWireType":(destructors,value)=>value,"argPackAdvance":GenericWireTypeSize,"readValueFromPointer":floatReadValueFromPointer(name,size),destructorFunction:null})};var integerReadValueFromPointer=(name,width,signed)=>{switch(width){case 1:return signed?pointer=>HEAP8[pointer>>0]:pointer=>HEAPU8[pointer>>0];case 2:return signed?pointer=>HEAP16[pointer>>1]:pointer=>HEAPU16[pointer>>1];case 4:return signed?pointer=>HEAP32[pointer>>2]:pointer=>HEAPU32[pointer>>2];default:throw new TypeError(`invalid integer width (${width}): ${name}`)}};var __embind_register_integer=(primitiveType,name,size,minRange,maxRange)=>{name=readLatin1String(name);if(maxRange===-1){maxRange=4294967295}var fromWireType=value=>value;if(minRange===0){var bitshift=32-8*size;fromWireType=value=>value<>>bitshift}var isUnsignedType=name.includes("unsigned");var checkAssertions=(value,toTypeName)=>{};var toWireType;if(isUnsignedType){toWireType=function(destructors,value){checkAssertions(value,this.name);return value>>>0}}else{toWireType=function(destructors,value){checkAssertions(value,this.name);return value}}registerType(primitiveType,{name:name,"fromWireType":fromWireType,"toWireType":toWireType,"argPackAdvance":GenericWireTypeSize,"readValueFromPointer":integerReadValueFromPointer(name,size,minRange!==0),destructorFunction:null})};var __embind_register_memory_view=(rawType,dataTypeIndex,name)=>{var typeMapping=[Int8Array,Uint8Array,Int16Array,Uint16Array,Int32Array,Uint32Array,Float32Array,Float64Array];var TA=typeMapping[dataTypeIndex];function decodeMemoryView(handle){var size=HEAPU32[handle>>2];var data=HEAPU32[handle+4>>2];return new TA(HEAP8.buffer,data,size)}name=readLatin1String(name);registerType(rawType,{name:name,"fromWireType":decodeMemoryView,"argPackAdvance":GenericWireTypeSize,"readValueFromPointer":decodeMemoryView},{ignoreDuplicateRegistrations:true})};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=55296&&u<=57343){var u1=str.charCodeAt(++i);u=65536+((u&1023)<<10)|u1&1023}if(u<=127){if(outIdx>=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}}heap[outIdx]=0;return outIdx-startIdx};var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite);var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var __embind_register_std_string=(rawType,name)=>{name=readLatin1String(name);var stdStringIsUTF8=name==="std::string";registerType(rawType,{name:name,"fromWireType":value=>{var length=HEAPU32[value>>2];var payload=value+4;var str;if(stdStringIsUTF8){var decodeStartPtr=payload;for(var i=0;i<=length;++i){var currentBytePtr=payload+i;if(i==length||HEAPU8[currentBytePtr]==0){var maxRead=currentBytePtr-decodeStartPtr;var stringSegment=UTF8ToString(decodeStartPtr,maxRead);if(str===undefined){str=stringSegment}else{str+=String.fromCharCode(0);str+=stringSegment}decodeStartPtr=currentBytePtr+1}}}else{var a=new Array(length);for(var i=0;i{if(value instanceof ArrayBuffer){value=new Uint8Array(value)}var length;var valueIsOfTypeString=typeof value=="string";if(!(valueIsOfTypeString||value instanceof Uint8Array||value instanceof Uint8ClampedArray||value instanceof Int8Array)){throwBindingError("Cannot pass non-string to std::string")}if(stdStringIsUTF8&&valueIsOfTypeString){length=lengthBytesUTF8(value)}else{length=value.length}var base=_malloc(4+length+1);var ptr=base+4;HEAPU32[base>>2]=length;if(stdStringIsUTF8&&valueIsOfTypeString){stringToUTF8(value,ptr,length+1)}else{if(valueIsOfTypeString){for(var i=0;i255){_free(ptr);throwBindingError("String has UTF-16 code units that do not fit in 8 bits")}HEAPU8[ptr+i]=charCode}}else{for(var i=0;i_free(ptr)})};var UTF16Decoder=typeof TextDecoder!="undefined"?new TextDecoder("utf-16le"):undefined;var UTF16ToString=(ptr,maxBytesToRead)=>{var endPtr=ptr;var idx=endPtr>>1;var maxIdx=idx+maxBytesToRead/2;while(!(idx>=maxIdx)&&HEAPU16[idx])++idx;endPtr=idx<<1;if(endPtr-ptr>32&&UTF16Decoder)return UTF16Decoder.decode(HEAPU8.subarray(ptr,endPtr));var str="";for(var i=0;!(i>=maxBytesToRead/2);++i){var codeUnit=HEAP16[ptr+i*2>>1];if(codeUnit==0)break;str+=String.fromCharCode(codeUnit)}return str};var stringToUTF16=(str,outPtr,maxBytesToWrite)=>{if(maxBytesToWrite===undefined){maxBytesToWrite=2147483647}if(maxBytesToWrite<2)return 0;maxBytesToWrite-=2;var startPtr=outPtr;var numCharsToWrite=maxBytesToWrite>1]=codeUnit;outPtr+=2}HEAP16[outPtr>>1]=0;return outPtr-startPtr};var lengthBytesUTF16=str=>str.length*2;var UTF32ToString=(ptr,maxBytesToRead)=>{var i=0;var str="";while(!(i>=maxBytesToRead/4)){var utf32=HEAP32[ptr+i*4>>2];if(utf32==0)break;++i;if(utf32>=65536){var ch=utf32-65536;str+=String.fromCharCode(55296|ch>>10,56320|ch&1023)}else{str+=String.fromCharCode(utf32)}}return str};var stringToUTF32=(str,outPtr,maxBytesToWrite)=>{if(maxBytesToWrite===undefined){maxBytesToWrite=2147483647}if(maxBytesToWrite<4)return 0;var startPtr=outPtr;var endPtr=startPtr+maxBytesToWrite-4;for(var i=0;i=55296&&codeUnit<=57343){var trailSurrogate=str.charCodeAt(++i);codeUnit=65536+((codeUnit&1023)<<10)|trailSurrogate&1023}HEAP32[outPtr>>2]=codeUnit;outPtr+=4;if(outPtr+4>endPtr)break}HEAP32[outPtr>>2]=0;return outPtr-startPtr};var lengthBytesUTF32=str=>{var len=0;for(var i=0;i=55296&&codeUnit<=57343)++i;len+=4}return len};var __embind_register_std_wstring=(rawType,charSize,name)=>{name=readLatin1String(name);var decodeString,encodeString,getHeap,lengthBytesUTF,shift;if(charSize===2){decodeString=UTF16ToString;encodeString=stringToUTF16;lengthBytesUTF=lengthBytesUTF16;getHeap=()=>HEAPU16;shift=1}else if(charSize===4){decodeString=UTF32ToString;encodeString=stringToUTF32;lengthBytesUTF=lengthBytesUTF32;getHeap=()=>HEAPU32;shift=2}registerType(rawType,{name:name,"fromWireType":value=>{var length=HEAPU32[value>>2];var HEAP=getHeap();var str;var decodeStartPtr=value+4;for(var i=0;i<=length;++i){var currentBytePtr=value+4+i*charSize;if(i==length||HEAP[currentBytePtr>>shift]==0){var maxReadBytes=currentBytePtr-decodeStartPtr;var stringSegment=decodeString(decodeStartPtr,maxReadBytes);if(str===undefined){str=stringSegment}else{str+=String.fromCharCode(0);str+=stringSegment}decodeStartPtr=currentBytePtr+charSize}}_free(value);return str},"toWireType":(destructors,value)=>{if(!(typeof value=="string")){throwBindingError(`Cannot pass non-string to C++ string type ${name}`)}var length=lengthBytesUTF(value);var ptr=_malloc(4+length+charSize);HEAPU32[ptr>>2]=length>>shift;encodeString(value,ptr+4,length+charSize);if(destructors!==null){destructors.push(_free,ptr)}return ptr},"argPackAdvance":GenericWireTypeSize,"readValueFromPointer":simpleReadValueFromPointer,destructorFunction:ptr=>_free(ptr)})};var __embind_register_void=(rawType,name)=>{name=readLatin1String(name);registerType(rawType,{isVoid:true,name:name,"argPackAdvance":0,"fromWireType":()=>undefined,"toWireType":(destructors,o)=>undefined})};var __emscripten_throw_longjmp=()=>{throw Infinity};var __emval_incref=handle=>{if(handle>4){emval_handles.get(handle).refcount+=1}};var requireRegisteredType=(rawType,humanName)=>{var impl=registeredTypes[rawType];if(undefined===impl){throwBindingError(humanName+" has unknown type "+getTypeName(rawType))}return impl};var __emval_take_value=(type,arg)=>{type=requireRegisteredType(type,"_emval_take_value");var v=type["readValueFromPointer"](arg);return Emval.toHandle(v)};var isLeapYear=year=>year%4===0&&(year%100!==0||year%400===0);var MONTH_DAYS_LEAP_CUMULATIVE=[0,31,60,91,121,152,182,213,244,274,305,335];var MONTH_DAYS_REGULAR_CUMULATIVE=[0,31,59,90,120,151,181,212,243,273,304,334];var ydayFromDate=date=>{var leap=isLeapYear(date.getFullYear());var monthDaysCumulative=leap?MONTH_DAYS_LEAP_CUMULATIVE:MONTH_DAYS_REGULAR_CUMULATIVE;var yday=monthDaysCumulative[date.getMonth()]+date.getDate()-1;return yday};var convertI32PairToI53Checked=(lo,hi)=>hi+2097152>>>0<4194305-!!lo?(lo>>>0)+hi*4294967296:NaN;function __localtime_js(time_low,time_high,tmPtr){var time=convertI32PairToI53Checked(time_low,time_high);var date=new Date(time*1e3);HEAP32[tmPtr>>2]=date.getSeconds();HEAP32[tmPtr+4>>2]=date.getMinutes();HEAP32[tmPtr+8>>2]=date.getHours();HEAP32[tmPtr+12>>2]=date.getDate();HEAP32[tmPtr+16>>2]=date.getMonth();HEAP32[tmPtr+20>>2]=date.getFullYear()-1900;HEAP32[tmPtr+24>>2]=date.getDay();var yday=ydayFromDate(date)|0;HEAP32[tmPtr+28>>2]=yday;HEAP32[tmPtr+36>>2]=-(date.getTimezoneOffset()*60);var start=new Date(date.getFullYear(),0,1);var summerOffset=new Date(date.getFullYear(),6,1).getTimezoneOffset();var winterOffset=start.getTimezoneOffset();var dst=(summerOffset!=winterOffset&&date.getTimezoneOffset()==Math.min(winterOffset,summerOffset))|0;HEAP32[tmPtr+32>>2]=dst}var __mktime_js=function(tmPtr){var ret=(()=>{var date=new Date(HEAP32[tmPtr+20>>2]+1900,HEAP32[tmPtr+16>>2],HEAP32[tmPtr+12>>2],HEAP32[tmPtr+8>>2],HEAP32[tmPtr+4>>2],HEAP32[tmPtr>>2],0);var dst=HEAP32[tmPtr+32>>2];var guessedOffset=date.getTimezoneOffset();var start=new Date(date.getFullYear(),0,1);var summerOffset=new Date(date.getFullYear(),6,1).getTimezoneOffset();var winterOffset=start.getTimezoneOffset();var dstOffset=Math.min(winterOffset,summerOffset);if(dst<0){HEAP32[tmPtr+32>>2]=Number(summerOffset!=winterOffset&&dstOffset==guessedOffset)}else if(dst>0!=(dstOffset==guessedOffset)){var nonDstOffset=Math.max(winterOffset,summerOffset);var trueOffset=dst>0?dstOffset:nonDstOffset;date.setTime(date.getTime()+(trueOffset-guessedOffset)*6e4)}HEAP32[tmPtr+24>>2]=date.getDay();var yday=ydayFromDate(date)|0;HEAP32[tmPtr+28>>2]=yday;HEAP32[tmPtr>>2]=date.getSeconds();HEAP32[tmPtr+4>>2]=date.getMinutes();HEAP32[tmPtr+8>>2]=date.getHours();HEAP32[tmPtr+12>>2]=date.getDate();HEAP32[tmPtr+16>>2]=date.getMonth();HEAP32[tmPtr+20>>2]=date.getYear();return date.getTime()/1e3})();return setTempRet0((tempDouble=ret,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)),ret>>>0};var stringToNewUTF8=str=>{var size=lengthBytesUTF8(str)+1;var ret=_malloc(size);if(ret)stringToUTF8(str,ret,size);return ret};var __tzset_js=(timezone,daylight,tzname)=>{var currentYear=(new Date).getFullYear();var winter=new Date(currentYear,0,1);var summer=new Date(currentYear,6,1);var winterOffset=winter.getTimezoneOffset();var summerOffset=summer.getTimezoneOffset();var stdTimezoneOffset=Math.max(winterOffset,summerOffset);HEAPU32[timezone>>2]=stdTimezoneOffset*60;HEAP32[daylight>>2]=Number(winterOffset!=summerOffset);function extractZone(date){var match=date.toTimeString().match(/\(([A-Za-z ]+)\)$/);return match?match[1]:"GMT"}var winterName=extractZone(winter);var summerName=extractZone(summer);var winterNamePtr=stringToNewUTF8(winterName);var summerNamePtr=stringToNewUTF8(summerName);if(summerOffset>2]=winterNamePtr;HEAPU32[tzname+4>>2]=summerNamePtr}else{HEAPU32[tzname>>2]=summerNamePtr;HEAPU32[tzname+4>>2]=winterNamePtr}};var _abort=()=>{abort("")};var _emscripten_memcpy_big=(dest,src,num)=>HEAPU8.copyWithin(dest,src,src+num);var getHeapMax=()=>2147483648;var growMemory=size=>{var b=wasmMemory.buffer;var pages=(size-b.byteLength+65535)/65536;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}var alignUp=(x,multiple)=>x+(multiple-x%multiple)%multiple;for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignUp(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var ENV={};var getExecutableName=()=>thisProgram||"./this.program";var getEnvStrings=()=>{if(!getEnvStrings.strings){var lang=(typeof navigator=="object"&&navigator.languages&&navigator.languages[0]||"C").replace("-","_")+".UTF-8";var env={"USER":"web_user","LOGNAME":"web_user","PATH":"/","PWD":"/","HOME":"/home/web_user","LANG":lang,"_":getExecutableName()};for(var x in ENV){if(ENV[x]===undefined)delete env[x];else env[x]=ENV[x]}var strings=[];for(var x in env){strings.push(`${x}=${env[x]}`)}getEnvStrings.strings=strings}return getEnvStrings.strings};var stringToAscii=(str,buffer)=>{for(var i=0;i>0]=str.charCodeAt(i)}HEAP8[buffer>>0]=0};var _environ_get=(__environ,environ_buf)=>{var bufSize=0;getEnvStrings().forEach((string,i)=>{var ptr=environ_buf+bufSize;HEAPU32[__environ+i*4>>2]=ptr;stringToAscii(string,ptr);bufSize+=string.length+1});return 0};var _environ_sizes_get=(penviron_count,penviron_buf_size)=>{var strings=getEnvStrings();HEAPU32[penviron_count>>2]=strings.length;var bufSize=0;strings.forEach(string=>bufSize+=string.length+1);HEAPU32[penviron_buf_size>>2]=bufSize;return 0};var _fd_close=fd=>52;var _fd_read=(fd,iov,iovcnt,pnum)=>52;function _fd_seek(fd,offset_low,offset_high,whence,newOffset){var offset=convertI32PairToI53Checked(offset_low,offset_high);return 70}var printCharBuffers=[null,[],[]];var printChar=(stream,curr)=>{var buffer=printCharBuffers[stream];if(curr===0||curr===10){(stream===1?out:err)(UTF8ArrayToString(buffer,0));buffer.length=0}else{buffer.push(curr)}};var _fd_write=(fd,iov,iovcnt,pnum)=>{var num=0;for(var i=0;i>2];var len=HEAPU32[iov+4>>2];iov+=8;for(var j=0;j>2]=num;return 0};var arraySum=(array,index)=>{var sum=0;for(var i=0;i<=index;sum+=array[i++]){}return sum};var MONTH_DAYS_LEAP=[31,29,31,30,31,30,31,31,30,31,30,31];var MONTH_DAYS_REGULAR=[31,28,31,30,31,30,31,31,30,31,30,31];var addDays=(date,days)=>{var newDate=new Date(date.getTime());while(days>0){var leap=isLeapYear(newDate.getFullYear());var currentMonth=newDate.getMonth();var daysInCurrentMonth=(leap?MONTH_DAYS_LEAP:MONTH_DAYS_REGULAR)[currentMonth];if(days>daysInCurrentMonth-newDate.getDate()){days-=daysInCurrentMonth-newDate.getDate()+1;newDate.setDate(1);if(currentMonth<11){newDate.setMonth(currentMonth+1)}else{newDate.setMonth(0);newDate.setFullYear(newDate.getFullYear()+1)}}else{newDate.setDate(newDate.getDate()+days);return newDate}}return newDate};function intArrayFromString(stringy,dontAddNull,length){var len=length>0?length:lengthBytesUTF8(stringy)+1;var u8array=new Array(len);var numBytesWritten=stringToUTF8Array(stringy,u8array,0,u8array.length);if(dontAddNull)u8array.length=numBytesWritten;return u8array}var writeArrayToMemory=(array,buffer)=>{HEAP8.set(array,buffer)};var _strftime=(s,maxsize,format,tm)=>{var tm_zone=HEAPU32[tm+40>>2];var date={tm_sec:HEAP32[tm>>2],tm_min:HEAP32[tm+4>>2],tm_hour:HEAP32[tm+8>>2],tm_mday:HEAP32[tm+12>>2],tm_mon:HEAP32[tm+16>>2],tm_year:HEAP32[tm+20>>2],tm_wday:HEAP32[tm+24>>2],tm_yday:HEAP32[tm+28>>2],tm_isdst:HEAP32[tm+32>>2],tm_gmtoff:HEAP32[tm+36>>2],tm_zone:tm_zone?UTF8ToString(tm_zone):""};var pattern=UTF8ToString(format);var EXPANSION_RULES_1={"%c":"%a %b %d %H:%M:%S %Y","%D":"%m/%d/%y","%F":"%Y-%m-%d","%h":"%b","%r":"%I:%M:%S %p","%R":"%H:%M","%T":"%H:%M:%S","%x":"%m/%d/%y","%X":"%H:%M:%S","%Ec":"%c","%EC":"%C","%Ex":"%m/%d/%y","%EX":"%H:%M:%S","%Ey":"%y","%EY":"%Y","%Od":"%d","%Oe":"%e","%OH":"%H","%OI":"%I","%Om":"%m","%OM":"%M","%OS":"%S","%Ou":"%u","%OU":"%U","%OV":"%V","%Ow":"%w","%OW":"%W","%Oy":"%y"};for(var rule in EXPANSION_RULES_1){pattern=pattern.replace(new RegExp(rule,"g"),EXPANSION_RULES_1[rule])}var WEEKDAYS=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];var MONTHS=["January","February","March","April","May","June","July","August","September","October","November","December"];function leadingSomething(value,digits,character){var str=typeof value=="number"?value.toString():value||"";while(str.length0?1:0}var compare;if((compare=sgn(date1.getFullYear()-date2.getFullYear()))===0){if((compare=sgn(date1.getMonth()-date2.getMonth()))===0){compare=sgn(date1.getDate()-date2.getDate())}}return compare}function getFirstWeekStartDate(janFourth){switch(janFourth.getDay()){case 0:return new Date(janFourth.getFullYear()-1,11,29);case 1:return janFourth;case 2:return new Date(janFourth.getFullYear(),0,3);case 3:return new Date(janFourth.getFullYear(),0,2);case 4:return new Date(janFourth.getFullYear(),0,1);case 5:return new Date(janFourth.getFullYear()-1,11,31);case 6:return new Date(janFourth.getFullYear()-1,11,30)}}function getWeekBasedYear(date){var thisDate=addDays(new Date(date.tm_year+1900,0,1),date.tm_yday);var janFourthThisYear=new Date(thisDate.getFullYear(),0,4);var janFourthNextYear=new Date(thisDate.getFullYear()+1,0,4);var firstWeekStartThisYear=getFirstWeekStartDate(janFourthThisYear);var firstWeekStartNextYear=getFirstWeekStartDate(janFourthNextYear);if(compareByDay(firstWeekStartThisYear,thisDate)<=0){if(compareByDay(firstWeekStartNextYear,thisDate)<=0){return thisDate.getFullYear()+1}return thisDate.getFullYear()}return thisDate.getFullYear()-1}var EXPANSION_RULES_2={"%a":date=>WEEKDAYS[date.tm_wday].substring(0,3),"%A":date=>WEEKDAYS[date.tm_wday],"%b":date=>MONTHS[date.tm_mon].substring(0,3),"%B":date=>MONTHS[date.tm_mon],"%C":date=>{var year=date.tm_year+1900;return leadingNulls(year/100|0,2)},"%d":date=>leadingNulls(date.tm_mday,2),"%e":date=>leadingSomething(date.tm_mday,2," "),"%g":date=>getWeekBasedYear(date).toString().substring(2),"%G":date=>getWeekBasedYear(date),"%H":date=>leadingNulls(date.tm_hour,2),"%I":date=>{var twelveHour=date.tm_hour;if(twelveHour==0)twelveHour=12;else if(twelveHour>12)twelveHour-=12;return leadingNulls(twelveHour,2)},"%j":date=>leadingNulls(date.tm_mday+arraySum(isLeapYear(date.tm_year+1900)?MONTH_DAYS_LEAP:MONTH_DAYS_REGULAR,date.tm_mon-1),3),"%m":date=>leadingNulls(date.tm_mon+1,2),"%M":date=>leadingNulls(date.tm_min,2),"%n":()=>"\n","%p":date=>{if(date.tm_hour>=0&&date.tm_hour<12){return"AM"}return"PM"},"%S":date=>leadingNulls(date.tm_sec,2),"%t":()=>"\t","%u":date=>date.tm_wday||7,"%U":date=>{var days=date.tm_yday+7-date.tm_wday;return leadingNulls(Math.floor(days/7),2)},"%V":date=>{var val=Math.floor((date.tm_yday+7-(date.tm_wday+6)%7)/7);if((date.tm_wday+371-date.tm_yday-2)%7<=2){val++}if(!val){val=52;var dec31=(date.tm_wday+7-date.tm_yday-1)%7;if(dec31==4||dec31==5&&isLeapYear(date.tm_year%400-1)){val++}}else if(val==53){var jan1=(date.tm_wday+371-date.tm_yday)%7;if(jan1!=4&&(jan1!=3||!isLeapYear(date.tm_year)))val=1}return leadingNulls(val,2)},"%w":date=>date.tm_wday,"%W":date=>{var days=date.tm_yday+7-(date.tm_wday+6)%7;return leadingNulls(Math.floor(days/7),2)},"%y":date=>(date.tm_year+1900).toString().substring(2),"%Y":date=>date.tm_year+1900,"%z":date=>{var off=date.tm_gmtoff;var ahead=off>=0;off=Math.abs(off)/60;off=off/60*100+off%60;return(ahead?"+":"-")+String("0000"+off).slice(-4)},"%Z":date=>date.tm_zone,"%%":()=>"%"};pattern=pattern.replace(/%%/g,"\0\0");for(var rule in EXPANSION_RULES_2){if(pattern.includes(rule)){pattern=pattern.replace(new RegExp(rule,"g"),EXPANSION_RULES_2[rule](date))}}pattern=pattern.replace(/\0\0/g,"%");var bytes=intArrayFromString(pattern,false);if(bytes.length>maxsize){return 0}writeArrayToMemory(bytes,s);return bytes.length-1};var _strftime_l=(s,maxsize,format,tm,loc)=>_strftime(s,maxsize,format,tm);embind_init_charCodes();BindingError=Module["BindingError"]=class BindingError extends Error{constructor(message){super(message);this.name="BindingError"}};InternalError=Module["InternalError"]=class InternalError extends Error{constructor(message){super(message);this.name="InternalError"}};init_ClassHandle();init_embind();init_RegisteredPointer();UnboundTypeError=Module["UnboundTypeError"]=extendError(Error,"UnboundTypeError");handleAllocatorInit();init_emval();var wasmImports={__assert_fail:___assert_fail,__cxa_throw:___cxa_throw,__syscall_ioctl:___syscall_ioctl,__syscall_openat:___syscall_openat,_embind_register_bigint:__embind_register_bigint,_embind_register_bool:__embind_register_bool,_embind_register_class:__embind_register_class,_embind_register_class_constructor:__embind_register_class_constructor,_embind_register_class_function:__embind_register_class_function,_embind_register_emval:__embind_register_emval,_embind_register_float:__embind_register_float,_embind_register_integer:__embind_register_integer,_embind_register_memory_view:__embind_register_memory_view,_embind_register_std_string:__embind_register_std_string,_embind_register_std_wstring:__embind_register_std_wstring,_embind_register_void:__embind_register_void,_emscripten_throw_longjmp:__emscripten_throw_longjmp,_emval_decref:__emval_decref,_emval_incref:__emval_incref,_emval_take_value:__emval_take_value,_localtime_js:__localtime_js,_mktime_js:__mktime_js,_tzset_js:__tzset_js,abort:_abort,emscripten_memcpy_big:_emscripten_memcpy_big,emscripten_resize_heap:_emscripten_resize_heap,environ_get:_environ_get,environ_sizes_get:_environ_sizes_get,fd_close:_fd_close,fd_read:_fd_read,fd_seek:_fd_seek,fd_write:_fd_write,invoke_vi:invoke_vi,invoke_viii:invoke_viii,invoke_viiii:invoke_viiii,invoke_viiiii:invoke_viiiii,strftime_l:_strftime_l};var wasmExports=createWasm();var ___wasm_call_ctors=()=>(___wasm_call_ctors=wasmExports["__wasm_call_ctors"])();var _free=a0=>(_free=wasmExports["free"])(a0);var _malloc=a0=>(_malloc=wasmExports["malloc"])(a0);var ___getTypeName=a0=>(___getTypeName=wasmExports["__getTypeName"])(a0);var __embind_initialize_bindings=Module["__embind_initialize_bindings"]=()=>(__embind_initialize_bindings=Module["__embind_initialize_bindings"]=wasmExports["_embind_initialize_bindings"])();var ___errno_location=()=>(___errno_location=wasmExports["__errno_location"])();var _setThrew=(a0,a1)=>(_setThrew=wasmExports["setThrew"])(a0,a1);var setTempRet0=a0=>(setTempRet0=wasmExports["setTempRet0"])(a0);var stackSave=()=>(stackSave=wasmExports["stackSave"])();var stackRestore=a0=>(stackRestore=wasmExports["stackRestore"])(a0);var stackAlloc=a0=>(stackAlloc=wasmExports["stackAlloc"])(a0);var ___cxa_increment_exception_refcount=a0=>(___cxa_increment_exception_refcount=wasmExports["__cxa_increment_exception_refcount"])(a0);var ___cxa_is_pointer_type=a0=>(___cxa_is_pointer_type=wasmExports["__cxa_is_pointer_type"])(a0);var dynCall_iijii=Module["dynCall_iijii"]=(a0,a1,a2,a3,a4,a5)=>(dynCall_iijii=Module["dynCall_iijii"]=wasmExports["dynCall_iijii"])(a0,a1,a2,a3,a4,a5);var dynCall_jiji=Module["dynCall_jiji"]=(a0,a1,a2,a3,a4)=>(dynCall_jiji=Module["dynCall_jiji"]=wasmExports["dynCall_jiji"])(a0,a1,a2,a3,a4);var dynCall_viijii=Module["dynCall_viijii"]=(a0,a1,a2,a3,a4,a5,a6)=>(dynCall_viijii=Module["dynCall_viijii"]=wasmExports["dynCall_viijii"])(a0,a1,a2,a3,a4,a5,a6);var dynCall_iiiiij=Module["dynCall_iiiiij"]=(a0,a1,a2,a3,a4,a5,a6)=>(dynCall_iiiiij=Module["dynCall_iiiiij"]=wasmExports["dynCall_iiiiij"])(a0,a1,a2,a3,a4,a5,a6);var dynCall_iiiiijj=Module["dynCall_iiiiijj"]=(a0,a1,a2,a3,a4,a5,a6,a7,a8)=>(dynCall_iiiiijj=Module["dynCall_iiiiijj"]=wasmExports["dynCall_iiiiijj"])(a0,a1,a2,a3,a4,a5,a6,a7,a8);var dynCall_iiiiiijj=Module["dynCall_iiiiiijj"]=(a0,a1,a2,a3,a4,a5,a6,a7,a8,a9)=>(dynCall_iiiiiijj=Module["dynCall_iiiiiijj"]=wasmExports["dynCall_iiiiiijj"])(a0,a1,a2,a3,a4,a5,a6,a7,a8,a9);function invoke_vi(index,a1){var sp=stackSave();try{getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viii(index,a1,a2,a3){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiii(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}var calledRun;dependenciesFulfilled=function runCaller(){if(!calledRun)run();if(!calledRun)dependenciesFulfilled=runCaller};function run(){if(runDependencies>0){return}preRun();if(runDependencies>0){return}function doRun(){if(calledRun)return;calledRun=true;Module["calledRun"]=true;if(ABORT)return;initRuntime();if(Module["onRuntimeInitialized"])Module["onRuntimeInitialized"]();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(function(){setTimeout(function(){Module["setStatus"]("")},1);doRun()},1)}else{doRun()}}if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].pop()()}}run(); diff --git a/discord/discord-command-handler.js b/discord/discord-command-handler.js deleted file mode 100644 index f69346f..0000000 --- a/discord/discord-command-handler.js +++ /dev/null @@ -1,273 +0,0 @@ -// @ts-check - -const assert = require("assert").strict -const util = require("util") -const DiscordTypes = require("discord-api-types/v10") -const reg = require("../matrix/read-registration") -const {addbot} = require("../addbot") - -const {discord, sync, db, select} = require("../passthrough") -/** @type {import("../matrix/api")}) */ -const api = sync.require("../matrix/api") -/** @type {import("../matrix/file")} */ -const file = sync.require("../matrix/file") -/** @type {import("../d2m/actions/create-space")} */ -const createSpace = sync.require("../d2m/actions/create-space") -/** @type {import("./utils")} */ -const utils = sync.require("./utils") - -const PREFIX = "//" - -let buttons = [] - -/** - * @param {string} channelID where to add the button - * @param {string} messageID where to add the button - * @param {string} emoji emoji to add as a button - * @param {string} userID only listen for responses from this user - * @returns {Promise} - */ -async function addButton(channelID, messageID, emoji, userID) { - await discord.snow.channel.createReaction(channelID, messageID, emoji) - return new Promise(resolve => { - buttons.push({channelID, messageID, userID, 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 {import("discord-api-types/v10").GatewayMessageReactionAddDispatchData} data */ -function onReactionAdd(data) { - const button = buttons.find(b => b.channelID === data.channel_id && b.messageID === data.message_id && b.userID === data.user_id) - if (button) { - buttons = buttons.filter(b => b !== button) // remove button data so it can't be clicked again - button.resolve(data) - } -} - -/** - * @callback CommandExecute - * @param {DiscordTypes.GatewayMessageCreateDispatchData} message - * @param {DiscordTypes.APIGuildTextChannel} channel - * @param {DiscordTypes.APIGuild} guild - * @param {Partial} [ctx] - */ - -/** - * @typedef Command - * @property {string[]} aliases - * @property {(message: DiscordTypes.GatewayMessageCreateDispatchData, channel: DiscordTypes.APIGuildTextChannel, guild: DiscordTypes.APIGuild) => Promise} execute - */ - -/** @param {CommandExecute} execute */ -function replyctx(execute) { - /** @type {CommandExecute} */ - return function(message, channel, guild, ctx = {}) { - ctx.message_reference = { - message_id: message.id, - channel_id: channel.id, - guild_id: guild.id, - fail_if_not_exists: false - } - return execute(message, channel, guild, ctx) - } -} - -/** @type {Command[]} */ -const commands = [{ - aliases: ["icon", "avatar", "roomicon", "roomavatar", "channelicon", "channelavatar"], - execute: replyctx( - async (message, channel, guild, ctx) => { - // Guard - const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() - if (!roomID) return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: "This channel isn't bridged to the other side." - }) - - // Current avatar - const avatarEvent = await api.getStateEvent(roomID, "m.room.avatar", "") - const avatarURLParts = avatarEvent?.url.match(/^mxc:\/\/([^/]+)\/(\w+)$/) - let currentAvatarMessage = - ( avatarURLParts ? `Current room-specific avatar: ${reg.ooye.server_origin}/_matrix/media/r0/download/${avatarURLParts[1]}/${avatarURLParts[2]}` - : "No avatar. Now's your time to strike. Use `//icon` again with a link or upload to set the room-specific avatar.") - - // Next potential avatar - const nextAvatarURL = message.attachments.find(a => a.content_type?.startsWith("image/"))?.url || message.content.match(/https?:\/\/[^ ]+\.[^ ]+\.(?:png|jpg|jpeg|webp)\b/)?.[0] - let nextAvatarMessage = - ( nextAvatarURL ? `\nYou want to set it to: ${nextAvatarURL}\nHit ✅ to make it happen.` - : "") - - const sent = await discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: currentAvatarMessage + nextAvatarMessage - }) - - if (nextAvatarURL) { - addButton(channel.id, sent.id, "✅", message.author.id).then(async data => { - const mxcUrl = await file.uploadDiscordFileToMxc(nextAvatarURL) - await api.sendState(roomID, "m.room.avatar", "", { - url: mxcUrl - }) - db.prepare("UPDATE channel_room SET custom_avatar = ? WHERE channel_id = ?").run(mxcUrl, channel.id) - await discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: "Your creation is unleashed. Any complaints will be redirected to Grelbo." - }) - }) - } - } - ) -}, { - aliases: ["invite"], - execute: replyctx( - async (message, channel, guild, ctx) => { - // Check guild is bridged - const spaceID = select("guild_space", "space_id", {guild_id: guild.id}).pluck().get() - const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() - if (!spaceID || !roomID) return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: "This server isn't bridged to Matrix, so you can't invite Matrix users." - }) - - // Check CREATE_INSTANT_INVITE permission - assert(message.member) - const guildPermissions = utils.getPermissions(message.member.roles, guild.roles) - if (!(guildPermissions & DiscordTypes.PermissionFlagsBits.CreateInstantInvite)) { - return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: "You don't have permission to invite people to this Discord server." - }) - } - - // Guard against accidental mentions instead of the MXID - if (message.content.match(/<[@#:].*>/)) return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: "You have to say the Matrix ID of the person you want to invite, but you mentioned a Discord user in your message.\nOne way to fix this is by writing `` ` `` backticks `` ` `` around the Matrix ID." - }) - - // Get named MXID - const mxid = message.content.match(/@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)/)?.[0] - if (!mxid) return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`" - }) - - // Check for existing invite to the space - let spaceMember - try { - spaceMember = await api.getStateEvent(spaceID, "m.room.member", mxid) - } catch (e) {} - if (spaceMember && spaceMember.membership === "invite") { - return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: `\`${mxid}\` already has an invite, which they haven't accepted yet.` - }) - } - - // Invite Matrix user if not in space - if (!spaceMember || spaceMember.membership !== "join") { - await api.inviteToRoom(spaceID, mxid) - return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: `You invited \`${mxid}\` to the server.` - }) - } - - // The Matrix user *is* in the space, maybe we want to invite them to this channel? - let roomMember - try { - roomMember = await api.getStateEvent(roomID, "m.room.member", mxid) - } catch (e) {} - if (!roomMember || (roomMember.membership !== "join" && roomMember.membership !== "invite")) { - const sent = await discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: `\`${mxid}\` is already in this server. Would you like to additionally invite them to this specific channel?\nHit ✅ to make it happen.` - }) - return addButton(channel.id, sent.id, "✅", message.author.id).then(async data => { - await api.inviteToRoom(roomID, mxid) - await discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: `You invited \`${mxid}\` to the channel.` - }) - }) - } - - // The Matrix user *is* in the space and in the channel. - await discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: `\`${mxid}\` is already in this server and this channel.` - }) - } - ) -}, { - aliases: ["addbot"], - execute: replyctx( - async (message, channel, guild, ctx) => { - return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: addbot() - }) - } - ) -}, { - aliases: ["privacy", "discoverable", "publish", "published"], - execute: replyctx( - async (message, channel, guild, ctx) => { - const current = select("guild_space", "privacy_level", {guild_id: guild.id}).pluck().get() - if (current == null) { - return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: "This server isn't bridged to the other side." - }) - } - - const levels = ["invite", "link", "directory"] - const level = levels.findIndex(x => message.content.includes(x)) - if (level === -1) { - return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: "**Usage: `//privacy `**. This will set who can join the space on Matrix-side. There are three levels:" - + "\n`invite`: Can only join with a direct in-app invite from another Matrix user, or the //invite command." - + "\n`link`: Matrix links can be created and shared like Discord's invite links. `invite` features also work." - + "\n`directory`: Publishes to the Matrix in-app directory, like Server Discovery. Preview enabled. `invite` and `link` also work." - + `\n**Current privacy level: \`${levels[current]}\`**` - }) - } - - assert(message.member) - const guildPermissions = utils.getPermissions(message.member.roles, guild.roles) - if (guild.owner_id !== message.author.id && !(guildPermissions & BigInt(0x28))) { // MANAGE_GUILD | ADMINISTRATOR - return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: "You don't have permission to change the privacy level. You need Manage Server or Administrator." - }) - } - - db.prepare("UPDATE guild_space SET privacy_level = ? WHERE guild_id = ?").run(level, guild.id) - discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: `Privacy level updated to \`${levels[level]}\`. Changes will apply shortly.` - }) - await createSpace.syncSpaceFully(guild.id) - } - ) -}] - -/** @type {CommandExecute} */ -async function execute(message, channel, guild) { - if (!message.content.startsWith(PREFIX)) return - const words = message.content.slice(PREFIX.length).split(" ") - const commandName = words[0] - const command = commands.find(c => c.aliases.includes(commandName)) - if (!command) return - - await command.execute(message, channel, guild) -} - -module.exports.execute = execute -module.exports.onReactionAdd = onReactionAdd diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..4689db1 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,52 @@ +# API + +There is a web API for getting information about things that are bridged with Out Of Your Element. + +The base URL is the URL of the particular OOYE instance, for example, https://bridge.cadence.moe. + +No authentication is required. + +I'm happy to add more endpoints, just ask for them. + +## Endpoint: GET /api/message + +|Query parameter|Type|Description| +|---------------|----|-----------| +|`message_id`|regexp `/^[0-9]+$/`|Discord message ID to look up information for| + +Response: + +```typescript +{ + source: "matrix" | "discord" // Which platform the message originated on + matrix_author?: { // Only for Matrix messages; should be up-to-date rather than historical data + displayname: string, // Matrix user's current display name + avatar_url: string | null, // Absolute HTTP(S) URL to download the Matrix user's current avatar + mxid: string // Matrix user ID, can never change + }, + events: [ // Data about each individual event + { + metadata: { // Data from OOYE's database about how bridging was performed + sender: string, // Same as matrix user ID + event_id: string, // Unique ID of the event on Matrix, can never change + event_type: "m.room.message" | string, // Event type + event_subtype: "m.text" | string | null, // For m.room.message events, this is the msgtype property + part: 0 | 1, // For multi-event messages, 0 if this is the first part + reaction_part: 0 | 1, // For multi-event messages, 0 if this is the last part + room_id: string, // Room ID that the event was sent in, linked to the Discord channel + source: number + }, + raw: { // Raw historical event data from the Matrix API. Contains at least these properties: + content: any, // The only non-metadata property, entirely client-generated + type: string, + room_id: string, + sender: string, + origin_server_ts: number, + unsigned?: any, + event_id: string, + user_id: string + } + } + ] +} +``` diff --git a/docs/developer-orientation.md b/docs/developer-orientation.md new file mode 100644 index 0000000..94a420c --- /dev/null +++ b/docs/developer-orientation.md @@ -0,0 +1,129 @@ +# Development setup + +* Install development dependencies with `npm install --save-dev` so you can run the tests. +* Most files you change, such as actions, converters, and web, will automatically be reloaded. +* If developing on a different computer to the one running the homeserver, use SSH port forwarding so that Synapse can connect on its `localhost:6693` to reach the running bridge on your computer. Example: `ssh -T -v -R 6693:localhost:6693 me@matrix.cadence.moe` +* I recommend developing in Visual Studio Code so that the JSDoc x TypeScript annotation comments just work automatically. I don't know which other editors or language servers support annotations and type inference. + +# Efficiency details + +Using WeatherStack as a thin layer between the bridge application and the Discord API lets us control exactly what data is cached in memory. Only necessary information is cached. For example, member data, user data, message content, and past edits are never stored in memory. This keeps the memory usage low and also prevents it ballooning in size over the bridge's runtime. + +The bridge uses a small SQLite database to store relationships like which Discord messages correspond to which Matrix messages. This is so the bridge knows what to edit when some message is edited on Discord. Using `without rowid` on the database tables stores the index and the data in the same B-tree. Since Matrix and Discord's internal IDs are quite long, this vastly reduces storage space because those IDs do not have to be stored twice separately. Some event IDs and URLs are actually stored as xxhash integers to reduce storage requirements even more. On my personal instance of OOYE, every 300,000 messages (representing a year of conversations) requires 47.3 MB of storage space in the SQLite database. + +Only necessary data and columns are queried from the database. We only contact the homeserver API if the database doesn't contain what we need. + +File uploads (like avatars from bridged members) are checked locally and deduplicated. Only brand new files are uploaded to the homeserver. This saves loads of space in the homeserver's media repo, especially for Synapse. + +Switching to [WAL mode](https://www.sqlite.org/wal.html) could improve your database access speed even more. Run `node scripts/wal.js` if you want to switch to WAL mode. (This will also enable `synchronous = NORMAL`.) + + +# Repository structure + + . + * Runtime configuration, like tokens and user info: + ├── registration.yaml + * You are here! :) + ├── readme.md + * The bridge's SQLite database is stored here: + ├── ooye.db* + * Source code + └── src + * Database schema: + ├── db + │   ├── orm.js, orm-defs.d.ts + │   * Migrations change the database schema when you update to a newer version of OOYE: + │   ├── migrate.js + │   └── migrations + │       └── *.sql, *.js + * Discord-to-Matrix bridging: + ├── d2m + │   * Execute actions through the whole flow, like sending a Discord message to Matrix: + │   ├── actions + │   │   └── *.js + │   * Convert data from one form to another without depending on bridge state. Called by actions: + │   ├── converters + │   │   └── *.js + │   * Making Discord work: + │   ├── discord-*.js + │   * Listening to events from Discord and dispatching them to the correct `action`: + │   └── event-dispatcher.js + * Discord bot commands and menus: + ├── discord + │   ├── interactions + │   │   └── *.js + │   └── discord-command-handler.js + * Matrix-to-Discord bridging: + ├── m2d + │   * Execute actions through the whole flow, like sending a Matrix message to Discord: + │   ├── actions + │   │   └── *.js + │   * Convert data from one form to another without depending on bridge state. Called by actions: + │   ├── converters + │   │   └── *.js + │   * Listening to events from Matrix and dispatching them to the correct `action`: + │   └── event-dispatcher.js + * We aren't using the matrix-js-sdk, so here are all the functions for the Matrix C-S and Appservice APIs: + ├── matrix + │   └── *.js + * Various files you can run once if you need them. + └── scripts + * First time running a new bridge? Run this file to set up prerequisites on the Matrix server: + ├── setup.js + * Hopefully you won't need the rest of these. Code quality varies wildly. + └── *.js + +# Read next + +If you haven't set up Out Of Your Element yet, you might find [Simplified homeserver setup](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/simplified-homeserver-setup.md) helpful. + +If you don't know what the Matrix event JSON generally looks like, turn on developer tools in your client (Element has pretty good ones). Right click a couple of messages and see what they look like on the inside. + +I recommend first reading [How to add a new event type](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/how-to-add-a-new-event-type.md) as this will fill you in on key information in how the codebase is organised, which data structures are important, and what level of abstraction we're working on. + +If you haven't seen the [Discord API documentation](https://discord.com/developers/docs/) before, have a quick look at one of the pages on there. Same with the [Matrix Client-Server APIs](https://spec.matrix.org/latest/client-server-api/). You don't need to know these inside out, they're primarily references, not stories. But it is useful to have an idea of what a couple of the API endpoints look like, the kind of data they tend to accept, and the kind of data they tend to return. + +Then you might like to peruse the other files in the docs folder. Most of these were written stream-of-thought style as I try to work through a problem and find the best way to implement it. You might enjoy getting inside my head and seeing me invent and evaluate ways to solve the problem. + +Whether you read those or not, I'm more than happy to help you 1-on-1 with coding your dream feature. Join the chatroom [#out-of-your-element:cadence.moe](https://matrix.to/#/#out-of-your-element:cadence.moe) or PM me [@cadence:cadence.moe](https://matrix.to/#/@cadence:cadence.moe) and ask away. + +# Dependency justification + +Total transitive production dependencies: 137 + +### 🦕 + +* (31) better-sqlite3: SQLite is the best database, and this is the best library for it. +* (27) @cloudrac3r/pug: Language for dynamic web pages. This is my fork. (I released code that hadn't made it to npm, and removed the heavy pug-filters feature.) +* (16) stream-mime-type@1: This seems like the best option. Version 1 is used because version 2 is ESM-only. +* (9) h3: Web server. OOYE needs this for the appservice listener, authmedia proxy, self-service, and more. +* (11) sharp: Image resizing and compositing. OOYE needs this for the emoji sprite sheets. + +### 🪱 + +* (0) @chriscdn/promise-semaphore: It does what I want. +* (1) @cloudrac3r/discord-markdown: This is my fork. +* (0) @cloudrac3r/giframe: This is my fork. +* (1) @cloudrac3r/html-template-tag: This is my fork. +* (0) @cloudrac3r/in-your-element: This is my Matrix Appservice API library. It depends on h3 and zod, which are already pulled in by OOYE. +* (0) @cloudrac3r/mixin-deep: This is my fork. (It fixes a bug in regular mixin-deep.) +* (0) @cloudrac3r/pngjs: Lottie stickers are converted to bitmaps with the vendored Rlottie WASM build, then the bitmaps are converted to PNG with pngjs. +* (0) @cloudrac3r/turndown: This HTML-to-Markdown converter looked the most suitable. I forked it to change the escaping logic to match the way Discord works. +* (3) @stackoverflow/stacks: Stack Overflow design language and icons. +* (0) ansi-colors: Helps with interactive prompting for the initial setup, and it's already pulled in by enquirer. +* (1) chunk-text: It does what I want. +* (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust. +* (0) discord-api-types: Bitfields needed at runtime and types needed for development. +* (0) domino: DOM implementation that's already pulled in by turndown. +* (1) enquirer: Interactive prompting for the initial setup rather than forcing users to edit YAML non-interactively. +* (0) entities: Looks fine. No dependencies. +* (0) get-relative-path: Looks fine. No dependencies. +* (1) heatsync: Module hot-reloader that I trust. +* (1) js-yaml: Will be removed in the future after registration.yaml is converted to JSON. +* (0) lru-cache: For holding unused nonce in memory and letting them be overwritten later if never used. +* (0) prettier-bytes: It does what I want and has no dependencies. +* (0) snowtransfer: Discord API library with bring-your-own-caching that I trust. +* (0) try-to-catch: Not strictly necessary, but it's already pulled in by supertape, so I may as well. +* (0) uqr: QR code SVG generator. Used on the website to scan in an invite link. +* (0) xxhash-wasm: Used where cryptographically secure hashing is not required. +* (0) zod: Input validation for the web server. It's popular and easy to use. diff --git a/docs/foreign-keys.md b/docs/foreign-keys.md new file mode 100644 index 0000000..1e5e21c --- /dev/null +++ b/docs/foreign-keys.md @@ -0,0 +1,98 @@ +# Foreign keys in the Out Of Your Element database + +Historically, Out Of Your Element did not use foreign keys in the database, but since I found a need for them, I have decided to add them. Referential integrity is probably valuable as well. + +The need is that unlinking a channel and room using the web interface should clear up all related entries from `message_channel`, `event_message`, `reaction`, etc. Without foreign keys, this requires multiple DELETEs with tricky queries. With foreign keys and ON DELETE CASCADE, this just works. + +## Quirks + +* **REPLACE INTO** internally causes a DELETE followed by an INSERT, and the DELETE part **will trigger any ON DELETE CASCADE** foreign key conditions on the table, even when the primary key being replaced is the same. + * ```sql + CREATE TABLE discord_channel (channel_id TEXT NOT NULL, name TEXT NOT NULL, PRIMARY KEY (channel_id)); + CREATE TABLE discord_message (message_id TEXT NOT NULL, channel_id TEXT NOT NULL, PRIMARY KEY (message_id), + FOREIGN KEY (channel_id) REFERENCES discord_channel (channel_id) ON DELETE CASCADE); + INSERT INTO discord_channel (channel_id, name) VALUES ("c_1", "place"); + INSERT INTO discord_message (message_id, channel_id) VALUES ("m_2", "c_1"); -- i love my message + REPLACE INTO discord_channel (channel_id, name) VALUES ("c_1", "new place"); -- replace into time + -- i love my message + SELECT * FROM discord_message; -- where is my message + ``` +* In SQLite, `pragma foreign_keys = on` must be set **for each connection** after it's established. I've added this at the start of `migrate.js`, which is called by all database connections. + * Pragma? Pragma keys +* Whenever a child row is inserted, SQLite will look up a row from the parent table to ensure referential integrity. This means **the parent table should be sufficiently keyed or indexed on columns referenced by foreign keys**, or SQLite won't let you do it, with a cryptic error message later on during DML. Due to normal forms, foreign keys naturally tend to reference the parent table's primary key, which is indexed, so that's okay. But still keep this in mind, since many of OOYE's tables effectively have two primary keys, for the Discord and Matrix IDs. A composite primary key doesn't count, even when it's the first column. A unique index counts. + +## Where keys + +Here are some tables that could potentially have foreign keys added between them, and my thought process of whether foreign keys would be a good idea: + +* `guild_active` <--(PK guild_id FK)-- `channel_room` ✅ + * Could be good for referential integrity. + * Linking to guild_space would be pretty scary in case the guild was being relinked to a different space - since rooms aren't tied to a space, this wouldn't actually disturb anything. So I pick guild_active instead. +* `channel_room` <--(PK channel_id FK)-- `message_channel` ✅ + * Seems useful as we want message records to be deleted when a channel is unlinked. +* `message_channel` <--(PK message_id PK)-- `event_message` ✅ + * Seems useful as we want event information to be deleted when a channel is unlinked. +* `guild_active` <--(PK guild_id PK)-- `guild_space` ✅ + * All bridged guilds should have a corresponding guild_active entry, so referential integrity would be useful here to make sure we haven't got any weird states. +* `channel_room` <--(**C** room_id PK)-- `member_cache` ✅ + * Seems useful as we want to clear the member cache when a channel is unlinked. + * There is no index on `channel_room.room_id` right now. It would be good to create this index. Will just make it UNIQUE in the table definition. +* `message_channel` <--(PK message_id FK)-- `reaction` ✅ + * Seems useful as we want to clear the reactions cache when a channel is unlinked. +* `sim` <--(**C** mxid FK)-- `sim_member` + * OOYE inner joins on this. + * Sims are never deleted so if this was added it would only be used for enforcing referential integrity. + * The storage cost of the additional index on `sim` would not be worth the benefits. +* `channel_room` <--(**C** room_id PK)-- `sim_member` + * If a room is being permanently unlinked, it may be useful to see a populated member list. If it's about to be relinked to another channel, we want to keep the sims in the room for more speed and to avoid spamming state events into the timeline. + * Either way, the sims could remain in the room even after it's been unlinked. So no referential integrity is desirable here. +* `sim` <--(PK user_id PK)-- `sim_proxy` + * OOYE left joins on this. In normal operation, this relationship might not exist. +* `channel_room` <--(PK channel_id PK)-- `webhook` ✅ + * Seems useful. Webhooks should be deleted from Discord just before the channel is unlinked. That should be mirrored in the database too. + +## Occurrences of REPLACE INTO/DELETE FROM + +* `edit-message.js` — `REPLACE INTO message_channel` + * Scary! Changed to INSERT OR IGNORE +* `send-message.js` — `REPLACE INTO message_channel` + * Changed to INSERT OR IGNORE +* `add-reaction.js` — `REPLACE INTO reaction` +* `channel-webhook.js` — `REPLACE INTO webhook` +* `send-event.js` — `REPLACE INTO message_channel` + * Seems incorrect? Maybe?? Originally added in fcbb045. Changed to INSERT +* `event-to-message.js` — `REPLACE INTO member_cache` +* `oauth.js` — `REPLACE INTO guild_active` + * Very scary!! Changed to INSERT .. ON CONFLICT DO UPDATE +* `create-room.js` — `DELETE FROM channel_room` + * Please cascade +* `delete-message.js` + * Removed redundant DELETEs +* `edit-message.js` — `DELETE FROM event_message` +* `register-pk-user.js` — `DELETE FROM sim` + * It's a failsafe during creation +* `register-user.js` — `DELETE FROM sim` + * It's a failsafe during creation +* `remove-reaction.js` — `DELETE FROM reaction` +* `event-dispatcher.js` — `DELETE FROM member_cache` +* `redact.js` — `DELETE FROM event_message` + * Removed this redundant DELETE +* `send-event.js` — `DELETE FROM event_message` + * Removed this redundant DELETE + +## How keys + +SQLite does not have a complete ALTER TABLE command, so I have to DROP and CREATE. According to [the docs](https://www.sqlite.org/lang_altertable.html), the correct strategy is: + +1. (Not applicable) *If foreign key constraints are enabled, disable them using PRAGMA foreign_keys=OFF.* +2. Start a transaction. +3. (Not applicable) *Remember the format of all indexes, triggers, and views associated with table X. This information will be needed in step 8 below. One way to do this is to run a query like the following: SELECT type, sql FROM sqlite_schema WHERE tbl_name='X'.* +4. Use CREATE TABLE to construct a new table "new_X" that is in the desired revised format of table X. Make sure that the name "new_X" does not collide with any existing table name, of course. +5. Transfer content from X into new_X using a statement like: INSERT INTO new_X SELECT ... FROM X. +6. Drop the old table X: DROP TABLE X. +7. Change the name of new_X to X using: ALTER TABLE new_X RENAME TO X. +8. (Not applicable) *Use CREATE INDEX, CREATE TRIGGER, and CREATE VIEW to reconstruct indexes, triggers, and views associated with table X. Perhaps use the old format of the triggers, indexes, and views saved from step 3 above as a guide, making changes as appropriate for the alteration.* +9. (Not applicable) *If any views refer to table X in a way that is affected by the schema change, then drop those views using DROP VIEW and recreate them with whatever changes are necessary to accommodate the schema change using CREATE VIEW.* +10. If foreign key constraints were originally enabled then run PRAGMA foreign_key_check to verify that the schema change did not break any foreign key constraints. +11. Commit the transaction started in step 2. +12. (Not applicable) *If foreign keys constraints were originally enabled, reenable them now.* diff --git a/docs/get-started.md b/docs/get-started.md new file mode 100644 index 0000000..ae31f5f --- /dev/null +++ b/docs/get-started.md @@ -0,0 +1,108 @@ +# Setup + +If you get stuck, you're welcome to message [#out-of-your-element:cadence.moe](https://matrix.to/#/#out-of-your-element:cadence.moe) or [@cadence:cadence.moe](https://matrix.to/#/@cadence:cadence.moe) to ask for help setting up OOYE! + +You'll need: + +* Administrative access to a homeserver +* Discord bot +* Domain name for the bridge's website - [more info](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/why-does-the-bridge-have-a-website.md) +* Reverse proxy for that domain - an interactive process will help you set this up in step 5! + +Follow these steps: + +1. [Get Node.js version 22 or later](https://nodejs.org/en/download/prebuilt-installer). If you're on Linux, you may prefer to install through system's package manager, though Debian and Ubuntu have hopelessly out of date packages. + +1. Switch to a normal user account. (i.e. do not run any of the following commands as root or sudo.) + +1. Clone this repo and checkout a specific tag. (Development happens on main. Stable versions are tagged.) + * The latest release tag is ![](https://img.shields.io/gitea/v/release/cadence/out-of-your-element?gitea_url=https%3A%2F%2Fgitdab.com&style=flat-square&label=%20&color=black). + +1. Install dependencies: `npm install` + +1. Run `npm run setup` to check your setup and set the bot's initial state. You only need to run this once ever. This command will guide you precisely through the following steps: + + * First, you'll be asked for information like your homeserver URL. + + * Then you'll be prompted to set up a reverse proxy pointing from your domain to the bridge's web server. Sample configurations can be found at the end of this guide. It will check that the reverse proxy works before you continue. + + * Then you'll need to provide information about your Discord bot, and you'll be asked to change some of its settings. + + * Finally, a registration.yaml file will be generated, which you need to give to your homeserver. You'll be told how to do this. It will check that it's done properly. + +1. Start the bridge: `npm run start` + +## Update + +New versions are announced in [#updates](https://matrix.to/#/#ooye-updates:cadence.moe) and listed on [releases](https://gitdab.com/cadence/out-of-your-element/releases). Here's how to update: + +1. Fetch the repo and checkout the latest release tag. + +1. Install dependencies: `npm install` + +1. Restart the bridge: Stop the currently running process, and then start the new one with `npm run start` + +# Get Started + +Visit the website on the domain name you set up, and click the button to add the bot to your Discord server. + +* If you click the Easy Mode button, it will automatically create a Matrix room corresponding to each Discord channel. This happens next time a message is sent on Discord (so that your Matrix-side isn't immediately cluttered with lots of inactive rooms). + +* If you click the Self Service button, it won't create anything for you. You'll have to provide your own Matrix space and rooms. After you click, you'll be prompted through the process. Use this if you're migrating from another bridge! + +After that, to get into the rooms on your Matrix account, use the invite form on the website, or the `/invite [your mxid here]` command on Discord. + +I hope you enjoy Out Of Your Element! + +---- +




+ +# Appendix + +## Example reverse proxy for nginx, dedicated domain name + +Replace `bridge.cadence.moe` with the hostname you're using. + +```nix +server { + listen 80; + listen [::]:80; + server_name bridge.cadence.moe; + + return 301 https://bridge.cadence.moe$request_uri; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name bridge.cadence.moe; + + # ssl parameters here... + client_max_body_size 5M; + + location / { + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + proxy_pass http://127.0.0.1:6693; + } +} +``` + +## Example reverse proxy for nginx, sharing a domain name + +Same as above, but change the following: + +- `location / {` -> `location /ooye/ {` (any sub-path you want; you MUST use a trailing slash or it won't work) +- `proxy_pass http://127.0.0.1:6693;` -> `proxy_pass http://127.0.0.1:6693/;` (you MUST use a trailing slash on this too or it won't work) + +## Example reverse proxy for Caddy, dedicated domain name + +```nix +bridge.cadence.moe { + log { + output file /var/log/caddy/access.log + format console + } + encode gzip + reverse_proxy 127.0.0.1:6693 +} +``` diff --git a/docs/self-service-room-creation-rules.md b/docs/self-service-room-creation-rules.md new file mode 100644 index 0000000..4156f26 --- /dev/null +++ b/docs/self-service-room-creation-rules.md @@ -0,0 +1,77 @@ +# Self-service room creation rules + +Before version 3 of Out Of Your Element, new Matrix rooms would be created on-demand when a Discord channel is spoken in for the first time. This has worked pretty well. + +This is done through functions like ensureRoom and ensureSpace in actions: + +```js +async function sendMessage(message, channel, guild, row) { + const roomID = await createRoom.ensureRoom(message.channel_id) + ... +} + +/** + * Ensures the room exists. If it doesn't, creates the room with an accurate initial state. + * @param {string} channelID + * @returns {Promise} Matrix room ID + */ +function ensureRoom(channelID) { + return _syncRoom(channelID, /* shouldActuallySync */ false) /* calls ensureSpace */ +} + +/** + * Ensures the space exists. If it doesn't, creates the space with an accurate initial state. + * @param {DiscordTypes.APIGuild} guild + * @returns {Promise} Matrix space ID + */ +function ensureSpace(guild) { + return _syncSpace(guild, /* shouldActuallySync */ false) +} +``` + +With the introduction of self-service mode, we still want to retain this as a possible mode of operation, since some people prefer to have OOYE handle this administrative work. However, other people prefer to manage the links between channels and rooms themselves, and have control over what new rooms get linked up to. + +Visibly, this is managed through the web interface. The web interface lets moderators enable/disable auto-creation of new rooms, as well as set which channels and rooms are linked together. + +There is a small complication. Not only are Matrix rooms created automatically, their Matrix spaces are also created automatically during room sync: ensureRoom calls ensureSpace. If a user opts in to self-service mode by clicking the specific button in the web portal, we must ensure the _space is not created automatically either,_ because the Matrix user will provide a space to link to. + +To solve this, we need a way to suppress specific guilds from having auto-created spaces. The natural way to represent this is a column on guild_space, but that doesn't work, because each guild_space row requires a guild and space to be linked, and we _don't want_ them to be linked. + +So, internally, OOYE keeps track of this through a new table: + +```sql +CREATE TABLE "guild_active" ( + "guild_id" TEXT NOT NULL, -- only guilds that are bridged are present in this table + "autocreate" INTEGER NOT NULL, -- 0 or 1 + PRIMARY KEY("guild_id") +) WITHOUT ROWID; +``` + +There is one more complication. When adding a Discord bot through web oauth with a redirect_uri, Discord adds the bot to the server normally, _then_ redirects back to OOYE, and only then does OOYE know which guild the bot was just added to. So, for a short time between the bot being added and the user being redirected, OOYE might receive Discord events in the server before it has the chance to create the guild_active database row. + +So to prevent this, self-service behaviour needs to be an implicit default, and users must firmly choose one system or another to begin using OOYE. It is important for me to design this in a way that doesn't force users to do any extra work or make a choice they don't understand to keep the pre-v3 behaviour. + +So there will be 3 states of whether a guild is self-service or not. At first, it could be absent from the table, in which case events for it will be dropped. Or it could be in the table with autocomplete = 0, in which case only rooms that already exist in channel_room will have messages bridged. Or it could have autocomplete = 1, in which case Matrix rooms will be created as needed, as per the pre-v3 behaviour. + +| Auto-create | Meaning | +| -- | ------------ | +| 😶‍🌫️ | Unbridged - waiting | +| ❌ | Bridged - self-service | +| ✅ | Bridged - auto-create | + +Pressing buttons on web or using the /invite command on a guild will insert a row into guild_active, allowing it to be bridged. + +So here's all the technical changes needed to support self-service in v3: + +- New guild_active table showing whether, and how, a guild is bridged. +- When /invite command is used, INSERT OR IGNORE INTO state 1 and ensureRoom + ensureSpace. +- When bot is added through "easy mode" web button, REPLACE INTO state 1 and ensureSpace. +- When bot is added through "self-service" web button, REPLACE INTO state 0. +- Event dispatcher will only ensureRoom if the guild_active state is 1. +- createRoom will only create other dependencies if the guild is autocreate. + +## Enough with your theory. How do rooms actually get bridged now? + +After clicking the easy mode button on web and adding the bot to a server, it will create new Matrix rooms on-demand when any invite features are used (web or command) OR just when any message is sent on Discord. + +Alternatively, pressing the self-service mode button and adding the bot to a server will prompt the web user to link it with a space. After doing so, they'll be on the standard guild management page where they can invite to the space and manually link rooms. Nothing will be autocreated. diff --git a/docs/why-does-the-bridge-have-a-website.md b/docs/why-does-the-bridge-have-a-website.md new file mode 100644 index 0000000..387f59b --- /dev/null +++ b/docs/why-does-the-bridge-have-a-website.md @@ -0,0 +1,55 @@ +# Why does the bridge have a website? + +## It's essential for making images work + +Matrix has a feature called [Authenticated Media](https://matrix.org/blog/2024/06/26/sunsetting-unauthenticated-media/), where uploaded media (like user avatars and uploaded files) is restricted to Matrix users only. This means Discord users wouldn't be able to see important parts of the conversation. + +To keep things working for Discord users, OOYE's web server can act as a proxy files that were uploaded on Matrix-side. This will automatically take effect when needed, so Discord users shouldn't notice any issues. + +## Why now? + +I knew a web interface had a lot of potential, but I was reluctant to add one because it would make the initial setup more complicated. However, when authenticated media forced my hand, I saw an opportunity to introduce new useful features. Hopefully you'll agree that it's worth it! + +# What else does it do? + +## Makes it easy to invite the bot + +The home page of the website has buttons to add the bridge bot to a Discord server. If you are primarly a Matrix user and you want somebody else (who may be less technical) to add the bot to their own Discord server, this should make it a lot more intuitive for them. + +## Makes it easy to invite yourself or others + +After your hypothetical less-technical friend adds the bot to their Discord server, you need to generate an invite for your Matrix account on Matrix-side. Without the website, you might need to guide them through running a /invite command with your user ID. With the website, they don't have to do anything extra. You can use your phone to scan the QR code on their screen, which lets you invite your user ID in your own time. + +You can also set the person's permissions when you invite them, so you can easily bootstrap the Matrix side with your trusted ones as moderators. + +## To link channels and rooms + +Without a website, to link Discord channels to existing Matrix rooms, you'd need to run a /link command with the internal IDs of each room. This is tedious and error-prone, especially if you want to set up a lot of channels. With the web interface, you can see a list of all the available rooms and click them to link them together. + +## To change settings + +Important settings, like whether the Matrix rooms should be private or publicly accessible, can be configured by clicking buttons rather than memorising commands. Changes take effect immediately. + +# Permissions + +## Bot invites + +Anybody who can access the home page can use the buttons to add your bot - but even without the website, they can already do this by manually constructing a URL. If you want to make it so _only you_ can add your bot to servers, you need to edit [your Discord application](https://discord.com/developers/applications), go to the Bot section, and turn off the switch that says Public Bot. + +## Server settings + +If you have either the Administrator or Manage Server permissions in a Discord server, you can use the website to manage that server, including linking channels and changing settings. + +# Initial setup + +The website is built in to OOYE and is always running as part of the bridge. For authenticated media proxy to work, you'll need to make the web server accessible on the public internet over HTTPS, presumably using a reverse proxy. + +When you use `npm run setup` as part of OOYE's initial setup, it will guide you through this process, and it will do a thorough self-test to make sure it's configured correctly. If you get stuck or want a configuration template, check the notes below. + +## Reverse proxy + +When OOYE is running, the web server runs on port 6693. (To use a different port or a UNIX socket, edit registration.yaml's `socket` setting and restart.) + +It doesn't have to have its own dedicated domain name, you can also use a sub-path on an existing domain, like the domain of your Matrix homeserver. You are likely already using a reverse proxy to run your homeserver, so this should just be a configuration change. + +[See here for sample configurations!](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/get-started.md#appendix) diff --git a/m2d/converters/emoji.js b/m2d/converters/emoji.js deleted file mode 100644 index bb6a3b0..0000000 --- a/m2d/converters/emoji.js +++ /dev/null @@ -1,57 +0,0 @@ -// @ts-check - -const assert = require("assert").strict -const Ty = require("../../types") - -const passthrough = require("../../passthrough") -const {sync, select} = passthrough - -/** - * @param {string} input - * @param {string | null | undefined} shortcode - * @returns {string?} - */ -function encodeEmoji(input, shortcode) { - let discordPreferredEncoding - if (input.startsWith("mxc://")) { - // Custom emoji - let row = select("emoji", ["emoji_id", "name"], {mxc_url: input}).get() - if (!row && shortcode) { - // Use the name to try to find a known emoji with the same name. - const name = shortcode.replace(/^:|:$/g, "") - row = select("emoji", ["emoji_id", "name"], {name: name}).get() - } - if (!row) { - // We don't have this emoji and there's no realistic way to just-in-time upload a new emoji somewhere. - // Sucks! - return null - } - // Cool, we got an exact or a candidate emoji. - discordPreferredEncoding = encodeURIComponent(`${row.name}:${row.emoji_id}`) - } else { - // Default emoji - // https://github.com/discord/discord-api-docs/issues/2723#issuecomment-807022205 ???????????? - const encoded = encodeURIComponent(input) - const encodedTrimmed = encoded.replace(/%EF%B8%8F/g, "") - - const forceTrimmedList = [ - "%F0%9F%91%8D", // 👍 - "%F0%9F%91%8E", // 👎️ - "%E2%AD%90", // ⭐ - "%F0%9F%90%88", // 🐈 - "%E2%9D%93", // ❓ - "%F0%9F%8F%86", // 🏆️ - "%F0%9F%93%9A", // 📚️ - ] - - discordPreferredEncoding = - ( forceTrimmedList.includes(encodedTrimmed) ? encodedTrimmed - : encodedTrimmed !== encoded && [...input].length === 2 ? encoded - : encodedTrimmed) - - console.log("add reaction from matrix:", input, encoded, encodedTrimmed, "chosen:", discordPreferredEncoding) - } - return discordPreferredEncoding -} - -module.exports.encodeEmoji = encodeEmoji diff --git a/m2d/event-dispatcher.js b/m2d/event-dispatcher.js deleted file mode 100644 index f7e5e1b..0000000 --- a/m2d/event-dispatcher.js +++ /dev/null @@ -1,152 +0,0 @@ -// @ts-check - -/* - * Grab Matrix events we care about, check them, and bridge them. - */ - -const util = require("util") -const Ty = require("../types") -const {discord, db, sync, as} = require("../passthrough") - -/** @type {import("./actions/send-event")} */ -const sendEvent = sync.require("./actions/send-event") -/** @type {import("./actions/add-reaction")} */ -const addReaction = sync.require("./actions/add-reaction") -/** @type {import("./actions/redact")} */ -const redact = sync.require("./actions/redact") -/** @type {import("../matrix/matrix-command-handler")} */ -const matrixCommandHandler = sync.require("../matrix/matrix-command-handler") -/** @type {import("./converters/utils")} */ -const utils = sync.require("./converters/utils") -/** @type {import("../matrix/api")}) */ -const api = sync.require("../matrix/api") -/** @type {import("../matrix/read-registration")}) */ -const reg = sync.require("../matrix/read-registration") - -let lastReportedEvent = 0 - -function guard(type, fn) { - return async function(event, ...args) { - try { - return await fn(event, ...args) - } catch (e) { - console.error("hit event-dispatcher's error handler with this exception:") - console.error(e) // TODO: also log errors into a file or into the database, maybe use a library for this? or just wing it? - console.error(`while handling this ${type} gateway event:`) - console.dir(event, {depth: null}) - - if (Date.now() - lastReportedEvent < 5000) return - lastReportedEvent = Date.now() - - let stackLines = e.stack.split("\n") - api.sendEvent(event.room_id, "m.room.message", { - msgtype: "m.text", - body: "\u26a0 Matrix event not delivered to Discord. See formatted content for full details.", - format: "org.matrix.custom.html", - formatted_body: "\u26a0 Matrix event not delivered to Discord" - + `
Event type: ${type}` - + `
${e.toString()}` - + `
Error trace` - + `
${stackLines.join("\n")}
` - + `
Original payload` - + `
${util.inspect(event, false, 4, false)}
`, - "moe.cadence.ooye.error": { - source: "matrix", - payload: event - }, - "m.mentions": { - user_ids: ["@cadence:cadence.moe"] - } - }) - } - } -} - -async function retry(roomID, eventID) { - const event = await api.getEvent(roomID, eventID) - const error = event.content["moe.cadence.ooye.error"] - if (event.sender !== `@${reg.sender_localpart}:${reg.ooye.server_name}` || !error) return - if (error.source === "matrix") { - as.emit("type:" + error.payload.type, error.payload) - } else if (error.source === "discord") { - discord.cloud.emit("event", error.payload) - } -} - -sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message", -/** - * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} event it is a m.room.message because that's what this listener is filtering for - */ -async event => { - if (utils.eventSenderIsFromDiscord(event.sender)) return - const messageResponses = await sendEvent.sendEvent(event) - if (event.type === "m.room.message" && event.content.msgtype === "m.text") { - // @ts-ignore - await matrixCommandHandler.execute(event) - } -})) - -sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker", -/** - * @param {Ty.Event.Outer_M_Sticker} event it is a m.sticker because that's what this listener is filtering for - */ -async event => { - if (utils.eventSenderIsFromDiscord(event.sender)) return - const messageResponses = await sendEvent.sendEvent(event) -})) - -sync.addTemporaryListener(as, "type:m.reaction", guard("m.reaction", -/** - * @param {Ty.Event.Outer} event it is a m.reaction because that's what this listener is filtering for - */ -async event => { - if (utils.eventSenderIsFromDiscord(event.sender)) return - if (event.content["m.relates_to"].key === "🔁") { - // Try to bridge a failed event again? - await retry(event.room_id, event.content["m.relates_to"].event_id) - } else { - matrixCommandHandler.onReactionAdd(event) - await addReaction.addReaction(event) - } -})) - -sync.addTemporaryListener(as, "type:m.room.redaction", guard("m.room.redaction", -/** - * @param {Ty.Event.Outer_M_Room_Redaction} event it is a m.room.redaction because that's what this listener is filtering for - */ -async event => { - if (utils.eventSenderIsFromDiscord(event.sender)) return - await redact.handle(event) -})) - -sync.addTemporaryListener(as, "type:m.room.avatar", guard("m.room.avatar", -/** - * @param {Ty.Event.StateOuter} event - */ -async event => { - if (event.state_key !== "") return - if (utils.eventSenderIsFromDiscord(event.sender)) return - const url = event.content.url || null - db.prepare("UPDATE channel_room SET custom_avatar = ? WHERE room_id = ?").run(url, event.room_id) -})) - -sync.addTemporaryListener(as, "type:m.room.name", guard("m.room.name", -/** - * @param {Ty.Event.StateOuter} event - */ -async event => { - if (event.state_key !== "") return - if (utils.eventSenderIsFromDiscord(event.sender)) return - const name = event.content.name || null - db.prepare("UPDATE channel_room SET nick = ? WHERE room_id = ?").run(name, event.room_id) -})) - -sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member", -/** - * @param {Ty.Event.StateOuter} event - */ -async event => { - if (event.state_key[0] !== "@") return - if (utils.eventSenderIsFromDiscord(event.state_key)) return - db.prepare("REPLACE INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?)").run(event.room_id, event.state_key, event.content.displayname || null, event.content.avatar_url || null) -})) diff --git a/matrix/api.js b/matrix/api.js deleted file mode 100644 index 82b1c10..0000000 --- a/matrix/api.js +++ /dev/null @@ -1,263 +0,0 @@ -// @ts-check - -const Ty = require("../types") -const assert = require("assert").strict - -const passthrough = require("../passthrough") -const { discord, sync, db } = passthrough -/** @type {import("./mreq")} */ -const mreq = sync.require("./mreq") -/** @type {import("./file")} */ -const file = sync.require("./file") -/** @type {import("./txnid")} */ -const makeTxnId = sync.require("./txnid") - -/** - * @param {string} p endpoint to access - * @param {string?} [mxid] optional: user to act as, for the ?user_id parameter - * @param {{[x: string]: any}} [otherParams] optional: any other query parameters to add - * @returns {string} the new endpoint - */ -function path(p, mxid, otherParams = {}) { - const u = new URL(p, "http://localhost") - if (mxid) u.searchParams.set("user_id", mxid) - for (const entry of Object.entries(otherParams)) { - if (entry[1] != undefined) { - u.searchParams.set(entry[0], entry[1]) - } - } - let result = u.pathname - const str = u.searchParams.toString() - if (str) result += "?" + str - return result -} - -/** - * @param {string} username - * @returns {Promise} - */ -function register(username) { - console.log(`[api] register: ${username}`) - return mreq.mreq("POST", "/client/v3/register", { - type: "m.login.application_service", - username - }) -} - -/** - * @returns {Promise} room ID - */ -async function createRoom(content) { - console.log(`[api] create room:`, content) - /** @type {Ty.R.RoomCreated} */ - const root = await mreq.mreq("POST", "/client/v3/createRoom", content) - return root.room_id -} - -/** - * @returns {Promise} room ID - */ -async function joinRoom(roomIDOrAlias, mxid) { - /** @type {Ty.R.RoomJoined} */ - const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid)) - return root.room_id -} - -async function inviteToRoom(roomID, mxidToInvite, mxid) { - await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/invite`, mxid), { - user_id: mxidToInvite - }) -} - -async function leaveRoom(roomID, mxid) { - await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, mxid), {}) -} - -/** - * @param {string} roomID - * @param {string} eventID - * @template T - */ -async function getEvent(roomID, eventID) { - /** @type {Ty.Event.Outer} */ - const root = await mreq.mreq("GET", `/client/v3/rooms/${roomID}/event/${eventID}`) - return root -} - -/** - * @param {string} roomID - * @param {number} ts unix silliseconds - */ -async function getEventForTimestamp(roomID, ts) { - /** @type {{event_id: string, origin_server_ts: number}} */ - const root = await mreq.mreq("GET", path(`/client/v1/rooms/${roomID}/timestamp_to_event`, null, {ts})) - return root -} - -/** - * @param {string} roomID - * @returns {Promise} - */ -function getAllState(roomID) { - return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state`) -} - -/** - * @param {string} roomID - * @param {string} type - * @param {string} key - * @returns the *content* of the state event - */ -function getStateEvent(roomID, type, key) { - return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state/${type}/${key}`) -} - -/** - * "Any of the AS's users must be in the room. This API is primarily for Application Services and should be faster to respond than /members as it can be implemented more efficiently on the server." - * @param {string} roomID - * @returns {Promise<{joined: {[mxid: string]: {avatar_url: string?, display_name: string?}}}>} - */ -function getJoinedMembers(roomID) { - return mreq.mreq("GET", `/client/v3/rooms/${roomID}/joined_members`) -} - -/** - * @param {string} roomID - * @param {{from?: string, limit?: any}} pagination - * @returns {Promise>} - */ -function getHierarchy(roomID, pagination) { - let path = `/client/v1/rooms/${roomID}/hierarchy` - if (!pagination.from) delete pagination.from - if (!pagination.limit) pagination.limit = 50 - path += `?${new URLSearchParams(pagination)}` - return mreq.mreq("GET", path) -} - -/** - * @param {string} roomID - * @param {string} eventID - * @param {{from?: string, limit?: any}} pagination - * @param {string?} [relType] - * @returns {Promise>>} - */ -function getRelations(roomID, eventID, pagination, relType) { - let path = `/client/v1/rooms/${roomID}/relations/${eventID}` - if (relType) path += `/${relType}` - if (!pagination.from) delete pagination.from - if (!pagination.limit) pagination.limit = 50 // get a little more consistency between homeservers - path += `?${new URLSearchParams(pagination)}` - return mreq.mreq("GET", path) -} - -/** - * @param {string} roomID - * @param {string} type - * @param {string} stateKey - * @param {string} [mxid] - * @returns {Promise} event ID - */ -async function sendState(roomID, type, stateKey, content, mxid) { - console.log(`[api] state: ${roomID}: ${type}/${stateKey}`) - assert.ok(type) - assert.ok(typeof stateKey === "string") - /** @type {Ty.R.EventSent} */ - // encodeURIComponent is necessary because state key can contain some special characters like / but you must encode them so they fit in a single component of the URI - const root = await mreq.mreq("PUT", path(`/client/v3/rooms/${roomID}/state/${type}/${encodeURIComponent(stateKey)}`, mxid), content) - return root.event_id -} - -/** - * @param {string} roomID - * @param {string} type - * @param {any} content - * @param {string?} [mxid] - * @param {number} [timestamp] timestamp of the newly created event, in unix milliseconds - */ -async function sendEvent(roomID, type, content, mxid, timestamp) { - if (!["m.room.message", "m.reaction", "m.sticker"].includes(type)) { - console.log(`[api] event ${type} to ${roomID} as ${mxid || "default sim"}`) - } - /** @type {Ty.R.EventSent} */ - const root = await mreq.mreq("PUT", path(`/client/v3/rooms/${roomID}/send/${type}/${makeTxnId.makeTxnId()}`, mxid, {ts: timestamp}), content) - return root.event_id -} - -/** - * @param {string} roomID - * @param {string} eventID - * @param {string?} [mxid] - * @returns {Promise} event ID - */ -async function redactEvent(roomID, eventID, mxid) { - /** @type {Ty.R.EventRedacted} */ - const root = await mreq.mreq("PUT", path(`/client/v3/rooms/${roomID}/redact/${eventID}/${makeTxnId.makeTxnId()}`, mxid), {}) - return root.event_id -} - -/** - * @param {string} roomID - * @param {boolean} isTyping - * @param {string} mxid - * @param {number} [duration] milliseconds - */ -async function sendTyping(roomID, isTyping, mxid, duration) { - await mreq.mreq("PUT", path(`/client/v3/rooms/${roomID}/typing/${mxid}`, mxid), { - typing: isTyping, - timeout: duration - }) -} - -async function profileSetDisplayname(mxid, displayname) { - await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/displayname`, mxid), { - displayname - }) -} - -async function profileSetAvatarUrl(mxid, avatar_url) { - await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/avatar_url`, mxid), { - avatar_url - }) -} - -/** - * Set a user's power level within a room. - * @param {string} roomID - * @param {string} mxid - * @param {number} power - */ -async function setUserPower(roomID, mxid, power) { - assert(roomID[0] === "!") - assert(mxid[0] === "@") - // Yes there's no shortcut https://github.com/matrix-org/matrix-appservice-bridge/blob/2334b0bae28a285a767fe7244dad59f5a5963037/src/components/intent.ts#L352 - const powerLevels = await getStateEvent(roomID, "m.room.power_levels", "") - powerLevels.users = powerLevels.users || {} - if (power != null) { - powerLevels.users[mxid] = power - } else { - delete powerLevels.users[mxid] - } - await sendState(roomID, "m.room.power_levels", "", powerLevels) - return powerLevels -} - -module.exports.path = path -module.exports.register = register -module.exports.createRoom = createRoom -module.exports.joinRoom = joinRoom -module.exports.inviteToRoom = inviteToRoom -module.exports.leaveRoom = leaveRoom -module.exports.getEvent = getEvent -module.exports.getEventForTimestamp = getEventForTimestamp -module.exports.getAllState = getAllState -module.exports.getStateEvent = getStateEvent -module.exports.getJoinedMembers = getJoinedMembers -module.exports.getHierarchy = getHierarchy -module.exports.getRelations = getRelations -module.exports.sendState = sendState -module.exports.sendEvent = sendEvent -module.exports.redactEvent = redactEvent -module.exports.sendTyping = sendTyping -module.exports.profileSetDisplayname = profileSetDisplayname -module.exports.profileSetAvatarUrl = profileSetAvatarUrl -module.exports.setUserPower = setUserPower diff --git a/matrix/appservice.js b/matrix/appservice.js deleted file mode 100644 index e99a822..0000000 --- a/matrix/appservice.js +++ /dev/null @@ -1,8 +0,0 @@ -const reg = require("../matrix/read-registration") -const AppService = require("matrix-appservice").AppService -const as = new AppService({ - homeserverToken: reg.hs_token -}) -as.listen(+(new URL(reg.url).port)) - -module.exports = as diff --git a/matrix/read-registration.js b/matrix/read-registration.js deleted file mode 100644 index c431907..0000000 --- a/matrix/read-registration.js +++ /dev/null @@ -1,14 +0,0 @@ -// @ts-check - -const fs = require("fs") -const assert = require("assert").strict -const yaml = require("js-yaml") - -/** @ts-ignore @type {import("../types").AppServiceRegistrationConfig} */ -const reg = yaml.load(fs.readFileSync("registration.yaml", "utf8")) -reg["ooye"].invite = (reg.ooye.invite || []).filter(mxid => mxid.endsWith(`:${reg.ooye.server_name}`)) // one day I will understand why typescript disagrees with dot notation on this line -assert(reg.ooye.max_file_size) -assert(reg.ooye.namespace_prefix) -assert(reg.ooye.server_name) - -module.exports = reg diff --git a/matrix/read-registration.test.js b/matrix/read-registration.test.js deleted file mode 100644 index e5123b9..0000000 --- a/matrix/read-registration.test.js +++ /dev/null @@ -1,10 +0,0 @@ -const {test} = require("supertape") -const reg = require("./read-registration") - -test("reg: has necessary parameters", t => { - const propertiesToCheck = ["sender_localpart", "id", "as_token", "ooye"] - t.deepEqual( - propertiesToCheck.filter(p => p in reg), - propertiesToCheck - ) -}) diff --git a/package-lock.json b/package-lock.json index 2d5284a..aa7822f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,64 +1,134 @@ { "name": "out-of-your-element", - "version": "1.1.1", + "version": "3.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "out-of-your-element", - "version": "1.1.1", + "version": "3.1.0", "license": "AGPL-3.0-or-later", "dependencies": { - "@chriscdn/promise-semaphore": "^2.0.1", - "@cloudrac3r/discord-markdown": "^2.6.2", + "@chriscdn/promise-semaphore": "^3.0.1", + "@cloudrac3r/discord-markdown": "^2.6.6", "@cloudrac3r/giframe": "^0.4.3", "@cloudrac3r/html-template-tag": "^5.0.1", - "@cloudrac3r/mixin-deep": "^3.0.0", + "@cloudrac3r/in-your-element": "^1.1.1", + "@cloudrac3r/mixin-deep": "^3.0.1", "@cloudrac3r/pngjs": "^7.0.3", - "better-sqlite3": "^9.0.0", + "@cloudrac3r/pug": "^4.0.4", + "@cloudrac3r/turndown": "^7.1.4", + "@stackoverflow/stacks": "^2.5.4", + "@stackoverflow/stacks-icons": "^6.0.2", + "ansi-colors": "^4.1.3", + "better-sqlite3": "^12.2.0", "chunk-text": "^2.0.1", - "cloudstorm": "^0.10.8", - "entities": "^4.5.0", - "get-stream": "^6.0.1", - "heatsync": "^2.5.3", - "js-yaml": "^4.1.0", - "matrix-appservice": "^2.0.0", - "minimist": "^1.2.8", - "node-fetch": "^2.6.7", + "cloudstorm": "^0.14.0", + "discord-api-types": "^0.38.19", + "domino": "^2.1.6", + "enquirer": "^2.4.1", + "entities": "^5.0.0", + "get-relative-path": "^1.0.2", + "h3": "^1.15.1", + "heatsync": "^2.7.2", + "htmx.org": "^2.0.4", + "lru-cache": "^11.0.2", "prettier-bytes": "^1.0.4", - "sharp": "^0.32.6", - "snowtransfer": "^0.10.5", + "sharp": "^0.33.4", + "snowtransfer": "^0.15.0", "stream-mime-type": "^1.0.2", "try-to-catch": "^3.0.1", - "turndown": "^7.1.2", - "xxhash-wasm": "^1.0.2" + "uqr": "^0.1.2", + "xxhash-wasm": "^1.0.2", + "zod": "^4.0.17" }, "devDependencies": { - "@cloudrac3r/tap-dot": "^2.0.2", - "@types/node": "^18.16.0", - "@types/node-fetch": "^2.6.3", - "c8": "^8.0.1", - "colorette": "^1.4.0", + "@cloudrac3r/tap-dot": "^2.0.3", + "@types/node": "^22.17.1", + "c8": "^10.1.2", "cross-env": "^7.0.3", - "discord-api-types": "^0.37.60", - "supertape": "^10.4.0" + "supertape": "^11.3.0" + }, + "engines": { + "node": ">=20" + } + }, + "../tap-dot": { + "name": "@cloudrac3r/tap-dot", + "version": "2.0.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@cloudrac3r/tap-out": "^3.2.3", + "ansi-colors": "^4.1.3" + }, + "bin": { + "tap-dot": "bin/dot" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", + "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", + "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.6.tgz", + "integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==", + "dependencies": { + "@babel/types": "^7.25.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.6.tgz", + "integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==", + "dependencies": { + "@babel/helper-string-parser": "^7.24.8", + "@babel/helper-validator-identifier": "^7.24.7", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" } }, "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "engines": { + "node": ">=18" + } }, "node_modules/@chriscdn/promise-semaphore": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@chriscdn/promise-semaphore/-/promise-semaphore-2.0.7.tgz", - "integrity": "sha512-xsa5SAYSBnYjqvGnzmaLca4X/RFeOl+ziCsIHl5iHkFBgE4NgWupB4z3A1rVMBM2I8TEKaah+5iu9Cm7gQu9JQ==" + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@chriscdn/promise-semaphore/-/promise-semaphore-3.1.1.tgz", + "integrity": "sha512-ALLLLYlPfd/QZLptcVi6HQRK1zaCDWZoqYYw+axLmCatFs4gVTSZ5nqlyxwFe4qwR/K84HvOMa9hxda881FqMA==", + "license": "MIT" }, "node_modules/@cloudcmd/stub": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@cloudcmd/stub/-/stub-4.0.1.tgz", "integrity": "sha512-7x7tVxJZOdQowHv/VKwHLo9aoNNoVRc6PdKYqyKcDHX+xrF78jSXnqEWrOplnD/gF+tCnyFafu1Is+lFfWCILw==", + "dev": true, "dependencies": { "chalk": "^4.0.0", "jest-diff": "^27.0.6", @@ -72,6 +142,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "engines": { "node": ">=8" } @@ -80,6 +151,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, "engines": { "node": ">=10" }, @@ -91,6 +163,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==", + "dev": true, "engines": { "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } @@ -99,6 +172,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz", "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==", + "dev": true, "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^27.5.1", @@ -113,6 +187,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==", + "dev": true, "engines": { "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } @@ -121,6 +196,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -133,12 +209,14 @@ "node_modules/@cloudcmd/stub/node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true }, "node_modules/@cloudcmd/stub/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -147,12 +225,12 @@ } }, "node_modules/@cloudrac3r/discord-markdown": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.6.2.tgz", - "integrity": "sha512-nHk7HPRK6smrYP+BbEHdLEsv/utXdsylLmdqVgiB4U+PgWnILR7RlcKnKjS5P+K8guIzh+3HvTzWkF0PchO54g==", + "version": "2.6.6", + "resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.6.6.tgz", + "integrity": "sha512-4FNO7WmACPvcTrQjeLQLr9WRuP7JDUVUGFrRJvmAjiMs2UlUAsShfSRuU2SCqz3QqmX8vyJ06wy2hkjTTyRtbw==", + "license": "MIT", "dependencies": { - "simple-markdown": "^0.7.3", - "supertape": "^10.7.2" + "simple-markdown": "^0.7.3" } }, "node_modules/@cloudrac3r/giframe": { @@ -168,10 +246,24 @@ "html-es6cape": "^2.0.0" } }, + "node_modules/@cloudrac3r/in-your-element": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cloudrac3r/in-your-element/-/in-your-element-1.1.1.tgz", + "integrity": "sha512-AKp9vnSDA9wzJl4O3C/LA8jgI5m1r0M3MRBQGHcVVL22SrrZMdcy+kWjlZWK343KVLOkuTAISA2D+Jb/zyZS6A==", + "license": "AGPL-3.0-or-later", + "dependencies": { + "h3": "^1.12.0", + "zod": "^4.0.17" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@cloudrac3r/mixin-deep": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@cloudrac3r/mixin-deep/-/mixin-deep-3.0.0.tgz", - "integrity": "sha512-yQz1wHSZbHfbKaGSjrV3wIG0e9MnElKlmekMKJPRdTn2jhF2Mt8wfMPX8U7v6rTyzR/7BTrX8CCUcrJMLgoQqw==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@cloudrac3r/mixin-deep/-/mixin-deep-3.0.1.tgz", + "integrity": "sha512-awxfIraHjJ/URNlZ0ROc78Tdjtfk/fM/Gnj1embfrSN08h/HpRtLmPc3xlG3T2vFAy1AkONaebd52u7o6kDaYw==", + "license": "MIT", "engines": { "node": ">=6" } @@ -184,14 +276,54 @@ "node": ">=14.19.0" } }, + "node_modules/@cloudrac3r/pug": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@cloudrac3r/pug/-/pug-4.0.4.tgz", + "integrity": "sha512-RZhxM/WfSHT0n39URlwDdugBfGfwEWmr+w+mCyiT9jaiqCjeZPpXkps/cWLA1XRLo7fzq0+9THtGzVKXS487/A==", + "dependencies": { + "@cloudrac3r/pug-code-gen": "3.0.5", + "@cloudrac3r/pug-lexer": "5.0.3", + "pug-linker": "^4.0.0", + "pug-load": "^3.0.0", + "pug-parser": "^6.0.0", + "pug-runtime": "^3.0.1", + "pug-strip-comments": "^2.0.0" + } + }, + "node_modules/@cloudrac3r/pug-code-gen": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@cloudrac3r/pug-code-gen/-/pug-code-gen-3.0.5.tgz", + "integrity": "sha512-dKKpy3i9YlVa3lBgu5Jds513c7AtzmmsR2/lGhY2NOODSpIiTcbWLw1obA9YEmmH1tAJny+J6ePYN1N1RgjjQA==", + "dependencies": { + "constantinople": "^4.0.1", + "doctypes": "^1.1.0", + "js-stringify": "^1.0.2", + "pug-attrs": "^3.0.0", + "pug-error": "^2.1.0", + "pug-runtime": "^3.0.1", + "void-elements": "^3.1.0", + "with": "^7.0.0" + } + }, + "node_modules/@cloudrac3r/pug-lexer": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@cloudrac3r/pug-lexer/-/pug-lexer-5.0.3.tgz", + "integrity": "sha512-ym4g4q+l9IC2H1wXCDnF79AQZ48xtxO675JOT316e17W2wHWtgRccXpT6DkBAaRDZycmkGzSxID1S15T2lZj+g==", + "dependencies": { + "character-parser": "^4.0.0", + "is-expression": "^4.0.0", + "pug-error": "^2.1.0" + } + }, "node_modules/@cloudrac3r/tap-dot": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@cloudrac3r/tap-dot/-/tap-dot-2.0.2.tgz", - "integrity": "sha512-q2IzqZvFIIjy8dBnuC5+baZ6KHroxOjWzaaBd+FGPk2NvPpsRdf8dFv0rTTntco8Z62grmCuMPc4/Nblx+Ot3A==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@cloudrac3r/tap-dot/-/tap-dot-2.0.3.tgz", + "integrity": "sha512-GmdDvc9LCdD4IvKf5DhTTSywGzIT8KbxjhNFSewnaUEfiGDer65CK62KzdciZgWBZfow8xaHISGfXgThn5/rqg==", "dev": true, + "license": "MIT", "dependencies": { "@cloudrac3r/tap-out": "^3.2.3", - "colorette": "^1.0.5" + "ansi-colors": "^4.1.3" }, "bin": { "tap-dot": "bin/dot" @@ -202,6 +334,7 @@ "resolved": "https://registry.npmjs.org/@cloudrac3r/tap-out/-/tap-out-3.2.3.tgz", "integrity": "sha512-WS89ziuTMLZlOVTXArkok32HcpWWxejQmZAUOl6nbB2Y114M8LfSOQ7t/rmgVsYB4oc6Ehe/nzevUMt4GGbCsA==", "dev": true, + "license": "MIT", "dependencies": { "re-emitter": "1.1.4", "split2": "^4.2.0" @@ -213,10 +346,419 @@ "node": ">=8.0.0" } }, + "node_modules/@cloudrac3r/turndown": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/@cloudrac3r/turndown/-/turndown-7.1.4.tgz", + "integrity": "sha512-bQAwcvcSqBTdEHPMt+IAZWIoDh+2eRuy9TgD0FUdxVurbvj3CUHTxLfzlmsO0UTi+GHpgYqDSsVdV7kYTNq5Qg==", + "dependencies": { + "domino": "^2.1.6" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.2.0.tgz", + "integrity": "sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@hotwired/stimulus": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.2.tgz", + "integrity": "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A==" + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", @@ -233,6 +775,8 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -243,12 +787,16 @@ "node_modules/@isaacs/cliui/node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", @@ -265,6 +813,8 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", @@ -286,15 +836,37 @@ "node": ">=8" } }, + "node_modules/@jest/diff-sequences": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", + "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.0.1.tgz", + "integrity": "sha512-AyYdemXCptSRFirI5EPazNxyPwAL0jXt3zceFjaj8NFiKP9pOi0bfXonf6qkf82z2t3QWPeLCWWw4stPBzctLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", "dependencies": { - "@sinclair/typebox": "^0.27.8" + "@sinclair/typebox": "^0.34.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -326,27 +898,41 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", "optional": true, "engines": { "node": ">=14" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@putout/cli-keypress": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@putout/cli-keypress/-/cli-keypress-2.0.0.tgz", - "integrity": "sha512-EXJv2HaXM+5scjoxE6Tf+o4+pxwL1tYJZJBDMygrF7cocjirGcU05GgNr9WHOaUPaVOpVjVU98ugYD7XJLmMkw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@putout/cli-keypress/-/cli-keypress-3.0.0.tgz", + "integrity": "sha512-RwODGTbcWNaulEPvVPdxH/vnddf5dE627G3s8gyou3kexa6zQerQHvbKFX0wywNdA3HD2O/9STPv/r5mjXFUgw==", + "dev": true, + "license": "MIT", "dependencies": { "ci-info": "^4.0.0", "fullstore": "^3.0.0" }, "engines": { - "node": ">=16" + "node": ">=20" } }, "node_modules/@putout/cli-validate-args": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@putout/cli-validate-args/-/cli-validate-args-2.0.0.tgz", "integrity": "sha512-/tl1XiBog6XMb1T9kalFerYU86sYsl6EtrlvGI5RVtlHOQdEEJAIPRxmX4vnKG3uoY5aVEkJOWzbPM5tsncmFQ==", + "dev": true, "dependencies": { "fastest-levenshtein": "^1.0.12", "just-kebab-case": "^4.2.0" @@ -356,14 +942,33 @@ } }, "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==" + "version": "0.34.38", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.38.tgz", + "integrity": "sha512-HpkxMmc2XmZKhvaKIZZThlHmx1L0I/V1hWK1NubtlFnr6ZqdiOpV72TKudZUNQjZNsyDBay72qFEhEvb+bcwcA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stackoverflow/stacks": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/@stackoverflow/stacks/-/stacks-2.8.4.tgz", + "integrity": "sha512-FfA7Bw7a0AQrMw3/bG6G4BUrZ698F7Cdk6HkR9T7jdaufORkiX5d16wI4j4b5Sqm1FwkaZAF+ZSKLL1w0tAsew==", + "license": "MIT", + "dependencies": { + "@hotwired/stimulus": "^3.2.2", + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/@stackoverflow/stacks-icons": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/@stackoverflow/stacks-icons/-/stacks-icons-6.6.1.tgz", + "integrity": "sha512-upa2jajYTKAHfILFbPWMsml0nlh4fbIEb2V9SS0txjOJEoZE2oBnNJXbg29vShp7Nyn1VwrMjaraX63WkKT07w==", + "license": "MIT" }, "node_modules/@supertape/engine-loader": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@supertape/engine-loader/-/engine-loader-2.0.0.tgz", "integrity": "sha512-1G2MmfZnSxx546omLPAVNgvG/iqOQZGiXHnjJ2JXKvuf2lpPdDRnNm5eLl81lvEG473zE9neX979TzeFcr3Dxw==", + "dev": true, "dependencies": { "try-catch": "^3.0.0" }, @@ -372,21 +977,24 @@ } }, "node_modules/@supertape/formatter-fail": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@supertape/formatter-fail/-/formatter-fail-3.0.2.tgz", - "integrity": "sha512-mSBnNprfLFmGvZkP+ODGroPLFCIN5BWE/06XaD5ghiTVWqek7eH8IDqvKyEduvuQu1O5tvQiaTwQsyxvikF+2w==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@supertape/formatter-fail/-/formatter-fail-4.0.0.tgz", + "integrity": "sha512-+isArOXmGkIqH14PQoq2WhJmSwO8rzpQnhurVMuBmC+kYB96R95kRdjo/KO9d9yP1KoSjum0kX94s0SwqlZ8yA==", + "dev": true, + "license": "MIT", "dependencies": { - "@supertape/formatter-tap": "^3.0.3", + "@supertape/formatter-tap": "^4.0.0", "fullstore": "^3.0.0" }, "engines": { - "node": ">=16" + "node": ">=20" } }, "node_modules/@supertape/formatter-json-lines": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@supertape/formatter-json-lines/-/formatter-json-lines-2.0.1.tgz", "integrity": "sha512-9LWOCu4yOF9orf4QJseS8lP3hXkYn24qn57VqYt/3r2aRJv4vWTPfaL1ot5JRHCZs0qXrV1sqPmN6E05rRLDYA==", + "dev": true, "dependencies": { "fullstore": "^3.0.0" }, @@ -395,9 +1003,11 @@ } }, "node_modules/@supertape/formatter-progress-bar": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@supertape/formatter-progress-bar/-/formatter-progress-bar-6.1.0.tgz", - "integrity": "sha512-BVnLW08BMbF/Xf9DNxTtc5V5Ong4VCj0w46Ts2cc1EboX+RQGuxGO0/wrzTBTt4t30iUzFhG/t2g280MfLHutQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@supertape/formatter-progress-bar/-/formatter-progress-bar-7.0.0.tgz", + "integrity": "sha512-JDCT86hFJkoaqE/KS8BQsRaYiy3ipMpf0j+o+vwQMcFYm0mgG35JwbotBMUQM7LFifh68bTqU4xuewy7kUS1EA==", + "dev": true, + "license": "MIT", "dependencies": { "chalk": "^5.3.0", "ci-info": "^4.0.0", @@ -406,13 +1016,15 @@ "once": "^1.4.0" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@supertape/formatter-progress-bar/node_modules/chalk": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", - "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.5.0.tgz", + "integrity": "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==", + "dev": true, + "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" }, @@ -421,25 +1033,31 @@ } }, "node_modules/@supertape/formatter-short": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@supertape/formatter-short/-/formatter-short-2.0.1.tgz", - "integrity": "sha512-zxFrZfCccFV+bf6A7MCEqT/Xsf0Elc3qa0P3jShfdEfrpblEcpSo0T/Wd9jFwc7uHA3ABgxgcHy7LNIpyrFTCg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@supertape/formatter-short/-/formatter-short-3.0.0.tgz", + "integrity": "sha512-lKiIMekxQgkF4YBj/IiFoRUQrF/Ow7D8zt9ZEBdHTkRys30vhRFn9557okECKGdpnAcSsoTHWwgikS/NPc3g/g==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=16" + "node": ">=20" } }, "node_modules/@supertape/formatter-tap": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@supertape/formatter-tap/-/formatter-tap-3.0.3.tgz", - "integrity": "sha512-U5OuMotfYhGo9cZ8IgdAXRTH5Yy8yfLDZzYo1upTPTwlJJquKwtvuz7ptiB7BN3OFr5YakkDYlFxOYPcLo7urg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@supertape/formatter-tap/-/formatter-tap-4.0.0.tgz", + "integrity": "sha512-cupeiik+FeTQ24d0fihNdS901Ct720UhUqgtPl2DiLWadEIT/B8+TIB4MG60sTmaE8xclbCieanbS/I94CQTPw==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=16" + "node": ">=20" } }, "node_modules/@supertape/formatter-time": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@supertape/formatter-time/-/formatter-time-1.0.2.tgz", - "integrity": "sha512-QihQWA/3LSNuODHrL8MGNHkdRunaEqNQkuMUDGNgEQO8MYBB0d83WGlNxDFGjn4kRlq47hovw3Skq7Btb2i2JA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@supertape/formatter-time/-/formatter-time-2.0.0.tgz", + "integrity": "sha512-5UPvVHwpg5ZJmz0nII2f5rBFqNdMxHQnBybetmhgkSDIZHb+3NTPz/VrDggZERWOGxmIf4NKebaA+BWHTBQMeA==", + "dev": true, + "license": "MIT", "dependencies": { "chalk": "^5.3.0", "ci-info": "^4.0.0", @@ -449,13 +1067,15 @@ "timer-node": "^5.0.7" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@supertape/formatter-time/node_modules/chalk": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", - "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.5.0.tgz", + "integrity": "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==", + "dev": true, + "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" }, @@ -467,6 +1087,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/@supertape/operator-stub/-/operator-stub-3.1.0.tgz", "integrity": "sha512-jzC56u1k+3DLRo854+J6v/DP/4SjRV2mAqfR6qzsyaAocC9OFe7NHYQQMmlJ4cUJwgFjUh7AVnjFfC0Z0XuH+g==", + "dev": true, "dependencies": { "@cloudcmd/stub": "^4.0.0" }, @@ -486,19 +1107,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "18.18.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.18.5.tgz", - "integrity": "sha512-4slmbtwV59ZxitY4ixUZdy1uRLf9eSIvBWPQxNjhHYWEtn0FryfKpyS2cvADYXTayWdKEIsJengncrVvkI4I6A==", - "dev": true - }, - "node_modules/@types/node-fetch": { - "version": "2.6.6", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.6.tgz", - "integrity": "sha512-95X8guJYhfqiuVVhRFxVQcf4hW/2bCuoPwDasMf/531STFoNoWTT7YDnWdXHEZKqAGUigmpG31r2FE70LwnzJw==", + "version": "22.18.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.0.tgz", + "integrity": "sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==", "dev": true, + "license": "MIT", "dependencies": { - "@types/node": "*", - "form-data": "^4.0.0" + "undici-types": "~6.21.0" } }, "node_modules/@types/prop-types": { @@ -521,22 +1136,30 @@ "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==" }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" + "node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "bin": { + "acorn": "bin/acorn" }, "engines": { - "node": ">= 0.6" + "node": ">=0.4.0" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "engines": { + "node": ">=6" } }, "node_modules/ansi-regex": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, "engines": { "node": ">=12" }, @@ -548,6 +1171,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -558,44 +1182,42 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" - }, "node_modules/as-table": { "version": "1.0.55", "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", + "dev": true, "dependencies": { "printable-characters": "^1.0.42" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "node_modules/assert-never": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.3.0.tgz", + "integrity": "sha512-9Z3vxQ+berkL/JJo0dK+EY3Lp0s3NtSnP3VCLsh5HDcZPrh0M+KQRK5sWhUeyPPH+/RCxZqOxLMR+YC6vlviEQ==" }, - "node_modules/b4a": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", - "integrity": "sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==" + "node_modules/babel-walk": { + "version": "3.0.0-canary-5", + "resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz", + "integrity": "sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==", + "dependencies": { + "@babel/types": "^7.9.6" + }, + "engines": { + "node": ">= 10.0.0" + } }, "node_modules/backtracker": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/backtracker/-/backtracker-4.0.0.tgz", - "integrity": "sha512-XG2ldN+WDRq9niJMnoZDjLLUnhDOQGhFZc6qZQotN59xj8oOa4KXSCu6YyZQawPqi6gG3HilGFt91zT6Hbdh1w==" + "integrity": "sha512-XG2ldN+WDRq9niJMnoZDjLLUnhDOQGhFZc6qZQotN59xj8oOa4KXSCu6YyZQawPqi6gG3HilGFt91zT6Hbdh1w==", + "license": "MIT" }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true }, "node_modules/base64-js": { "version": "1.5.1", @@ -616,30 +1238,18 @@ } ] }, - "node_modules/basic-auth": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", - "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", - "dependencies": { - "safe-buffer": "5.1.2" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/basic-auth/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, "node_modules/better-sqlite3": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.0.0.tgz", - "integrity": "sha512-lDxQ9qg/XuUHZG6xzrQaMHkNWl37t35/LPB/VJGV8DdScSuGFNfFSqgscXEd8UIuyk/d9wU8iaMxQa4If5Wqog==", + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.2.0.tgz", + "integrity": "sha512-eGbYq2CT+tos1fBwLQ/tkBt9J5M3JEHjku4hbvQUePCckkvVf14xWj+1m7dGoK81M/fOjFT7yM9UMeKT/+vFLQ==", "hasInstallScript": true, + "license": "MIT", "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x" } }, "node_modules/bindings": { @@ -696,60 +1306,30 @@ "node": ">= 6" } }, - "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/c8": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/c8/-/c8-8.0.1.tgz", - "integrity": "sha512-EINpopxZNH1mETuI0DzRA4MZpAUH+IFiRhnmFD3vFr3vdrgxqi3VfE3KL0AIL+zDq8rC9bZqwM/VDmmoe04y7w==", + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", + "integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==", "dev": true, "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", + "@bcoe/v8-coverage": "^1.0.1", "@istanbuljs/schema": "^0.1.3", "find-up": "^5.0.0", - "foreground-child": "^2.0.0", + "foreground-child": "^3.1.1", "istanbul-lib-coverage": "^3.2.0", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.1.6", - "rimraf": "^3.0.2", - "test-exclude": "^6.0.0", + "test-exclude": "^7.0.1", "v8-to-istanbul": "^9.0.0", "yargs": "^17.7.2", "yargs-parser": "^21.1.1" @@ -758,31 +1338,22 @@ "c8": "bin/c8.js" }, "engines": { - "node": ">=12" - } - }, - "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" + "node": ">=18" }, - "engines": { - "node": ">= 0.4" + "peerDependencies": { + "monocart-coverage-reports": "^2" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } } }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -794,6 +1365,11 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-parser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/character-parser/-/character-parser-4.0.0.tgz", + "integrity": "sha512-jWburCrDpd+aPopB7esjh/gLyZoHZS4C2xwwJlkTPyhhJdXG+FCG0P4qCOInvOd9yhiuAEJYlZsUMQ0JSK4ykw==" + }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -812,15 +1388,17 @@ } }, "node_modules/ci-info": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.0.0.tgz", - "integrity": "sha512-TdHqgGf9odd8SXNuxtUBVx8Nv+qZOejE6qyqiy5NtbYYQOeFa6zmHkxlPzmaLxWWHsU6nJmB7AETdVPi+2NBUg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", + "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==", + "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/sibiraj-s" } ], + "license": "MIT", "engines": { "node": ">=8" } @@ -829,6 +1407,8 @@ "version": "3.12.0", "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", + "dev": true, + "license": "MIT", "dependencies": { "string-width": "^4.2.3" }, @@ -872,15 +1452,16 @@ } }, "node_modules/cloudstorm": { - "version": "0.10.8", - "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.10.8.tgz", - "integrity": "sha512-ZVy4HbEiNRyRO0p0d0DtIMoVCg8rxoRpA07hflq2MQpPpmFpostkF7nd+zOsQ+piAfhUdvPsgtQLHdGFmTZVPQ==", + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.14.1.tgz", + "integrity": "sha512-x95WCKg818E1rE1Ru45NPD3RoIq0pg3WxwvF0GE7Eq07pAeLcjSRqM1lUmbmfjdOqZrWdSRYA1NETVZ8QhVrIA==", + "license": "MIT", "dependencies": { - "discord-api-types": "^0.37.70", - "snowtransfer": "^0.10.5" + "discord-api-types": "^0.38.21", + "snowtransfer": "^0.15.0" }, "engines": { - "node": ">=14.8.0" + "node": ">=22.0.0" } }, "node_modules/color": { @@ -920,47 +1501,13 @@ "simple-swizzle": "^0.2.2" } }, - "node_modules/colorette": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", - "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", - "dev": true - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, + "node_modules/constantinople": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/constantinople/-/constantinople-4.0.1.tgz", + "integrity": "sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==", "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "engines": { - "node": ">= 0.6" + "@babel/parser": "^7.6.0", + "@babel/types": "^7.6.1" } }, "node_modules/convert-source-map": { @@ -969,18 +1516,11 @@ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "dev": true }, - "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + "node_modules/cookie-es": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.2.tgz", + "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==", + "license": "MIT" }, "node_modules/cross-env": { "version": "7.0.3", @@ -1001,9 +1541,10 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -1013,6 +1554,15 @@ "node": ">= 8" } }, + "node_modules/crossws": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -1021,15 +1571,8 @@ "node_modules/data-uri-to-buffer": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", - "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==" - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } + "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", + "dev": true }, "node_modules/decompress-response": { "version": "6.0.0", @@ -1053,68 +1596,38 @@ "node": ">=4.0.0" } }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==" }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" }, "node_modules/detect-libc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", - "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", "engines": { "node": ">=8" } }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/discord-api-types": { - "version": "0.37.70", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.70.tgz", - "integrity": "sha512-8EtfZR0KwOK+yP5q/llWILdUAPmGmF1LmcVUYf7+gtGigz2pu6WR38ZN+IWtMzohY1Ujl2u3KOdbFvrEz9EC8w==" + "version": "0.38.22", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.22.tgz", + "integrity": "sha512-2gnYrgXN3yTlv2cKBISI/A8btZwsSZLwKpIQXeI1cS8a7W7wP3sFVQOm3mPuuinTD8jJCKGPGNH399zE7Un1kA==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/doctypes": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/doctypes/-/doctypes-1.1.0.tgz", + "integrity": "sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==" }, "node_modules/domino": { "version": "2.1.6", @@ -1124,25 +1637,15 @@ "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "engines": { - "node": ">= 0.8" - } + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true }, "node_modules/end-of-stream": { "version": "1.4.4", @@ -1152,10 +1655,41 @@ "once": "^1.4.0" } }, + "node_modules/enquirer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", + "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "dependencies": { + "ansi-colors": "^4.1.1", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/enquirer/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/enquirer/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-5.0.0.tgz", + "integrity": "sha512-BeJFvFRJddxobhvEdm5GqHzRV/X+ACeuw0/BuuxsCh1EUZcAIz8+kYmBp/LrQuloy6K1f3a0M7+IhmZ7QnkISA==", "engines": { "node": ">=0.12" }, @@ -1163,25 +1697,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -1191,19 +1706,6 @@ "node": ">=6" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -1212,56 +1714,11 @@ "node": ">=6" } }, - "node_modules/express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.2", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.6.0", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.2.0", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", - "proxy-addr": "~2.0.7", - "qs": "6.11.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==" - }, "node_modules/fastest-levenshtein": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, "engines": { "node": ">= 4.9.1" } @@ -1287,23 +1744,6 @@ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" }, - "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -1323,49 +1763,24 @@ "node_modules/flatted": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==" + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true }, "node_modules/foreground-child": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", - "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, + "license": "ISC", "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^3.0.2" + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" }, "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" + "node": ">=14" }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "engines": { - "node": ">= 0.6" + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/fs-constants": { @@ -1373,16 +1788,11 @@ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, "node_modules/fullstore": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/fullstore/-/fullstore-3.0.0.tgz", "integrity": "sha512-EEIdG+HWpyygWRwSLIZy+x4u0xtghjHNfhQb0mI5825Mmjq6oFESFUY0hoZigEgd3KH8GX+ZOCK9wgmOiS7VBQ==", + "dev": true, "engines": { "node": ">=4" } @@ -1391,6 +1801,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -1404,153 +1815,78 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "node_modules/get-relative-path": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-relative-path/-/get-relative-path-1.0.2.tgz", + "integrity": "sha512-dGkopYfmB4sXMTcZslq5SojEYakpdCSj/SVSHLhv7D6RBHzvDtd/3Q8lTEOAhVKxPPeAHu/YYkENbbz3PaH+8w==" }, "node_modules/get-source": { "version": "2.0.12", "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", + "dev": true, "dependencies": { "data-uri-to-buffer": "^2.0.0", "source-map": "^0.6.1" } }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" }, "node_modules/glob": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.0.tgz", - "integrity": "sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", - "jackspeak": "^4.0.1", - "minimatch": "^10.0.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" + "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" }, - "engines": { - "node": "20 || >=22" - }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/glob/node_modules/foreground-child": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz", - "integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==", + "node_modules/h3": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.4.tgz", + "integrity": "sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ==", + "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dependencies": { - "get-intrinsic": "^1.1.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "cookie-es": "^1.2.2", + "crossws": "^0.3.5", + "defu": "^6.1.4", + "destr": "^2.0.5", + "iron-webcrypto": "^1.2.1", + "node-mock-http": "^1.0.2", + "radix3": "^1.1.2", + "ufo": "^1.6.1", + "uncrypto": "^0.1.3" } }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hasown": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dev": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -1559,11 +1895,15 @@ } }, "node_modules/heatsync": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/heatsync/-/heatsync-2.5.3.tgz", - "integrity": "sha512-NvR6wZZquJqDO/Tl1JGMGdNph0TTfhgWdj3sMWOkdQa9rs4fspHV+kGiZEgZQ0j8AQiYUEPQgRI7vdNZCKH+uw==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/heatsync/-/heatsync-2.8.2.tgz", + "integrity": "sha512-zO5ivWP1NYoYmngdqVxzeQGX2Q68rfLkXKbO8Dhcguj5eS2eBDVpcWPh3+KCQagM7xYP5QVzvrUryWDu4mt6Eg==", + "license": "MIT", "dependencies": { "backtracker": "^4.0.0" + }, + "engines": { + "node": ">=14.6.0" } }, "node_modules/html-es6cape": { @@ -1577,31 +1917,11 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } + "node_modules/htmx.org": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.6.tgz", + "integrity": "sha512-7ythjYneGSk3yCHgtCnQeaoF+D+o7U2LF37WU3O0JYv3gTZSicdEFiI/Ai/NJyC5ZpYJWMpUb11OC5Lr6AfAqA==", + "license": "0BSD" }, "node_modules/ieee754": { "version": "1.2.1", @@ -1622,16 +1942,6 @@ } ] }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -1642,12 +1952,13 @@ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "engines": { - "node": ">= 0.10" + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" } }, "node_modules/is-arrayish": { @@ -1659,6 +1970,7 @@ "version": "2.13.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dev": true, "dependencies": { "hasown": "^2.0.0" }, @@ -1666,10 +1978,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-expression": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-expression/-/is-expression-4.0.0.tgz", + "integrity": "sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==", + "dependencies": { + "acorn": "^7.1.1", + "object-assign": "^4.1.1" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "engines": { "node": ">=8" } @@ -1677,7 +1999,8 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true }, "node_modules/istanbul-lib-coverage": { "version": "3.2.0", @@ -1716,15 +2039,14 @@ } }, "node_modules/jackspeak": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.0.1.tgz", - "integrity": "sha512-cub8rahkh0Q/bw1+GxP7aeSe29hHHn2V4m29nnDlvCdlgU+3UGxkZp7Z53jLUdpX3jdTO0nJZUDl3xvbWc2Xog==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" }, - "engines": { - "node": "20 || >=22" - }, "funding": { "url": "https://github.com/sponsors/isaacs" }, @@ -1733,42 +2055,38 @@ } }, "node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.0.5.tgz", + "integrity": "sha512-1UIqE9PoEKaHcIKvq2vbibrCog4Y8G0zmOxgQUVEiTqwR5hJVMCoDsN1vFvI5JvwD37hjueZ1C4l2FyGnfpE0A==", + "dev": true, + "license": "MIT", "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.0.1", + "chalk": "^4.1.2", + "pretty-format": "30.0.5" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } + "node_modules/js-stringify": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz", + "integrity": "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==" }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } + "node_modules/json-with-bigint": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.4.4.tgz", + "integrity": "sha512-AhpYAAaZsPjU7smaBomDt1SOQshi9rEm6BlTbfVwsG1vNmeHKtEedJi62sHZzJTyKNtwzmNnrsd55kjwJ7054A==", + "dev": true, + "license": "MIT" }, "node_modules/just-kebab-case": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/just-kebab-case/-/just-kebab-case-4.2.0.tgz", - "integrity": "sha512-p2BdO7o4BI+pMun3J+dhaOfYan5JsZrw9wjshRjkWY9+p+u+kKSMhNWYnot2yHDR9CSahZ9iT3dcqJ+V72qHMw==" + "integrity": "sha512-p2BdO7o4BI+pMun3J+dhaOfYan5JsZrw9wjshRjkWY9+p+u+kKSMhNWYnot2yHDR9CSahZ9iT3dcqJ+V72qHMw==", + "dev": true }, "node_modules/locate-path": { "version": "6.0.0", @@ -1786,14 +2104,12 @@ } }, "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", + "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==", + "license": "ISC", "engines": { - "node": ">=10" + "node": "20 || >=22" } }, "node_modules/make-dir": { @@ -1811,52 +2127,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/matrix-appservice": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/matrix-appservice/-/matrix-appservice-2.0.0.tgz", - "integrity": "sha512-HCIuJ5i0YuO8b0dMyGe5dqlsE4f3RzHU0MuMg/2gGAZ4HL3r7aSWOFbyIWStSSUrk1qCa9Eml0i4EnEi0pOtdA==", - "dependencies": { - "body-parser": "^1.19.0", - "express": "^4.18.1", - "js-yaml": "^4.1.0", - "morgan": "^1.10.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -1888,14 +2158,16 @@ } }, "node_modules/minimatch": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", - "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, "engines": { - "node": "20 || >=22" + "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -1913,6 +2185,7 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, "engines": { "node": ">=16 || 14 >=14.17" } @@ -1922,50 +2195,11 @@ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" }, - "node_modules/morgan": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz", - "integrity": "sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==", - "dependencies": { - "basic-auth": "~2.0.1", - "debug": "2.6.9", - "depd": "~2.0.0", - "on-finished": "~2.3.0", - "on-headers": "~1.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/morgan/node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, "node_modules/napi-build-utils": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==" }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/node-abi": { "version": "3.40.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.40.0.tgz", @@ -1977,55 +2211,18 @@ "node": ">=10" } }, - "node_modules/node-addon-api": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", - "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==" - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/on-headers": { + "node_modules/node-mock-http": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.2.tgz", + "integrity": "sha512-zWaamgDUdo9SSLw47we78+zYw/bDr5gH8pH7oRRs8V3KmBtu8GLgGIbV2p/gRPd3LWpEOpjQj7X1FOU3VFMJ8g==", + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "engines": { - "node": ">= 0.8" + "node": ">=0.10.0" } }, "node_modules/once": { @@ -2069,15 +2266,8 @@ "node_modules/package-json-from-dist": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", - "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==" - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "engines": { - "node": ">= 0.8" - } + "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", + "dev": true }, "node_modules/path-exists": { "version": "4.0.0", @@ -2088,19 +2278,11 @@ "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "engines": { "node": ">=8" } @@ -2108,35 +2290,31 @@ "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true }, "node_modules/path-scurry": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", - "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": "20 || >=22" + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.0.tgz", - "integrity": "sha512-Qv32eSV1RSCfhY3fpPE2GNZ8jgM9X7rdAfemLWqTUxwiyIC4jJ6Sy0fZ8H+oLWevO6i4/bizg7c8d8i6bxrzbA==", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true }, "node_modules/peek-readable": { "version": "4.1.0", @@ -2181,22 +2359,26 @@ "integrity": "sha512-dLbWOa4xBn+qeWeIF60qRoB6Pk2jX5P3DIVgOQyMyvBpu931Q+8dXz8X0snJiFkQdohDDLnZQECjzsAj75hgZQ==" }, "node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", + "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "dev": true, + "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/pretty-format/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -2207,20 +2389,69 @@ "node_modules/printable-characters": { "version": "1.0.42", "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", - "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==" + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", + "dev": true }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "node_modules/pug-attrs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pug-attrs/-/pug-attrs-3.0.0.tgz", + "integrity": "sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==", "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" + "constantinople": "^4.0.1", + "js-stringify": "^1.0.2", + "pug-runtime": "^3.0.0" } }, + "node_modules/pug-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pug-error/-/pug-error-2.1.0.tgz", + "integrity": "sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==" + }, + "node_modules/pug-linker": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pug-linker/-/pug-linker-4.0.0.tgz", + "integrity": "sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==", + "dependencies": { + "pug-error": "^2.0.0", + "pug-walk": "^2.0.0" + } + }, + "node_modules/pug-load": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pug-load/-/pug-load-3.0.0.tgz", + "integrity": "sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==", + "dependencies": { + "object-assign": "^4.1.1", + "pug-walk": "^2.0.0" + } + }, + "node_modules/pug-parser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pug-parser/-/pug-parser-6.0.0.tgz", + "integrity": "sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==", + "dependencies": { + "pug-error": "^2.0.0", + "token-stream": "1.0.0" + } + }, + "node_modules/pug-runtime": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/pug-runtime/-/pug-runtime-3.0.1.tgz", + "integrity": "sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg==" + }, + "node_modules/pug-strip-comments": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pug-strip-comments/-/pug-strip-comments-2.0.0.tgz", + "integrity": "sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==", + "dependencies": { + "pug-error": "^2.0.0" + } + }, + "node_modules/pug-walk": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pug-walk/-/pug-walk-2.0.0.tgz", + "integrity": "sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==" + }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -2230,46 +2461,11 @@ "once": "^1.3.1" } }, - "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", - "dependencies": { - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/queue-tick": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", - "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==" - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } + "node_modules/radix3": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", + "license": "MIT" }, "node_modules/rc": { "version": "1.2.8", @@ -2289,12 +2485,15 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/re-emitter/-/re-emitter-1.1.4.tgz", "integrity": "sha512-C0SIXdXDSus2yqqvV7qifnb4NoWP7mEBXJq3axci301mXHCZb8Djwm4hrEZo4UeXRaEnfjH98uQ8EBppk2oNWA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" }, "node_modules/readable-web-to-node-stream": { "version": "3.0.2", @@ -2337,6 +2536,7 @@ "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", @@ -2349,63 +2549,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/runes": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/runes/-/runes-0.4.3.tgz", @@ -2433,18 +2576,10 @@ } ] }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, "node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dependencies": { - "lru-cache": "^6.0.0" - }, + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "bin": { "semver": "bin/semver.js" }, @@ -2452,115 +2587,50 @@ "node": ">=10" } }, - "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", - "dependencies": { - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.18.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/set-function-length": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.1.tgz", - "integrity": "sha512-j4t6ccc+VsKwYHso+kElc5neZpjtq9EnRICFZtWyBsLojhmeF/ZBd/elqm22WJh/BziDe/SBiOeAt0m2mfLD0g==", - "dependencies": { - "define-data-property": "^1.1.2", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.3", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" - }, "node_modules/sharp": { - "version": "0.32.6", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", - "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", "hasInstallScript": true, + "license": "Apache-2.0", "dependencies": { "color": "^4.2.3", - "detect-libc": "^2.0.2", - "node-addon-api": "^6.1.0", - "prebuild-install": "^7.1.1", - "semver": "^7.5.4", - "simple-get": "^4.0.1", - "tar-fs": "^3.0.4", - "tunnel-agent": "^0.6.0" + "detect-libc": "^2.0.3", + "semver": "^7.6.3" }, "engines": { - "node": ">=14.15.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" - } - }, - "node_modules/sharp/node_modules/tar-fs": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.4.tgz", - "integrity": "sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==", - "dependencies": { - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^3.1.5" - } - }, - "node_modules/sharp/node_modules/tar-stream": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.6.tgz", - "integrity": "sha512-B/UyjYwPpMBv+PaFSWAmtYjwdrlEaZQEhMIBFNC5oEG8lpiW8XjcSdmEaClj28ArfKScKHs2nshz3k2le6crsg==", - "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" } }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -2572,29 +2642,23 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "engines": { "node": ">=8" } }, - "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true - }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -2655,21 +2719,22 @@ } }, "node_modules/snowtransfer": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.10.5.tgz", - "integrity": "sha512-8enl43t7Ca1KYvipWIXaOjnNJBY2FFZfoKKs+b7tlb64mlEZgb5DbV0owc/X96hFzgVPj7akpcQKp7LNTlQJGA==", + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.15.0.tgz", + "integrity": "sha512-kEDGKtFiH5nSkHsDZonEUuDx99lUasJoZ7AGrgvE8HzVG59vjvqc//C+pjWj4DuJqTj4Q+Z1L/M/MYNim8F2VA==", + "license": "MIT", "dependencies": { - "discord-api-types": "^0.37.70", - "undici": "^6.6.2" + "discord-api-types": "^0.38.21" }, "engines": { - "node": ">=14.18.0" + "node": ">=16.15.0" } }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -2679,6 +2744,7 @@ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "dev": true, + "license": "ISC", "engines": { "node": ">= 10.x" } @@ -2687,19 +2753,12 @@ "version": "2.1.8", "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.1.8.tgz", "integrity": "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw==", + "dev": true, "dependencies": { "as-table": "^1.0.36", "get-source": "^2.0.12" } }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/stream-head": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/stream-head/-/stream-head-2.0.2.tgz", @@ -2724,15 +2783,6 @@ "node": ">=10" } }, - "node_modules/streamx": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.1.tgz", - "integrity": "sha512-fQMzy2O/Q47rgwErk/eGeLu/roaFWV0jVsogDmrszM9uIw8L5OA+t+V93MgYlufNptfjmYR1tOMWhei/Eh7TQA==", - "dependencies": { - "fast-fifo": "^1.1.0", - "queue-tick": "^1.0.1" - } - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -2745,6 +2795,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -2759,6 +2810,8 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -2772,6 +2825,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -2780,6 +2835,8 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -2791,6 +2848,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "engines": { "node": ">=8" } @@ -2799,6 +2857,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -2810,6 +2869,7 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, "dependencies": { "ansi-regex": "^6.0.1" }, @@ -2825,6 +2885,8 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -2836,6 +2898,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -2865,46 +2929,133 @@ } }, "node_modules/supertape": { - "version": "10.7.2", - "resolved": "https://registry.npmjs.org/supertape/-/supertape-10.7.2.tgz", - "integrity": "sha512-pBWvzBtefVgokjJRk2ks+6q8/Sy1fIIvMBL8nXYaXk3100cgcPsw4iYhFSW41tg/cEe4IOlV/G+FZFN4kSMtOg==", + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/supertape/-/supertape-11.3.0.tgz", + "integrity": "sha512-2LP36xLtxsb3bBYrfvWIilhWpA/vs7/vIgElpsqEhZZ0vcOAMlhMIxH6eHAl5u9KcxGD28IrJrw8lREqeMtZeQ==", + "dev": true, + "license": "MIT", "dependencies": { "@cloudcmd/stub": "^4.0.0", - "@putout/cli-keypress": "^2.0.0", + "@putout/cli-keypress": "^3.0.0", "@putout/cli-validate-args": "^2.0.0", "@supertape/engine-loader": "^2.0.0", - "@supertape/formatter-fail": "^3.0.0", + "@supertape/formatter-fail": "^4.0.0", "@supertape/formatter-json-lines": "^2.0.0", - "@supertape/formatter-progress-bar": "^6.0.0", - "@supertape/formatter-short": "^2.0.0", - "@supertape/formatter-tap": "^3.0.0", - "@supertape/formatter-time": "^1.0.0", + "@supertape/formatter-progress-bar": "^7.0.0", + "@supertape/formatter-short": "^3.0.0", + "@supertape/formatter-tap": "^4.0.0", + "@supertape/formatter-time": "^2.0.0", "@supertape/operator-stub": "^3.0.0", "cli-progress": "^3.8.2", "flatted": "^3.3.1", "fullstore": "^3.0.0", - "glob": "^11.0.0", - "jest-diff": "^29.0.1", + "glob": "^11.0.1", + "jest-diff": "^30.0.3", + "json-with-bigint": "^3.4.4", "once": "^1.4.0", "resolve": "^1.17.0", "stacktracey": "^2.1.7", "strip-ansi": "^7.0.0", "try-to-catch": "^3.0.0", "wraptile": "^3.0.0", - "yargs-parser": "^21.0.0" + "yargs-parser": "^22.0.0" }, "bin": { "supertape": "bin/tracer.mjs", "tape": "bin/tracer.mjs" }, "engines": { - "node": ">=18" + "node": ">=20" + } + }, + "node_modules/supertape/node_modules/glob": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", + "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.0.3", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/supertape/node_modules/jackspeak": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/supertape/node_modules/minimatch": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", + "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/supertape/node_modules/path-scurry": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", + "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/supertape/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -2916,6 +3067,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -2924,9 +3076,10 @@ } }, "node_modules/tar-fs": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", - "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", + "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", + "license": "MIT", "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -2963,59 +3116,17 @@ } }, "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", "dev": true, "dependencies": { "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" + "glob": "^10.4.1", + "minimatch": "^9.0.4" }, "engines": { - "node": ">=8" - } - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/test-exclude/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" + "node": ">=18" } }, "node_modules/through2": { @@ -3040,18 +3151,25 @@ } }, "node_modules/timer-node": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/timer-node/-/timer-node-5.0.7.tgz", - "integrity": "sha512-M1aP6ASmuVD0PSxl5fqjCAGY9WyND3DHZ8RwT5I8o7469XE53Lb5zbPai20Dhj7TProyaapfVj3TaT0P+LoSEA==" + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/timer-node/-/timer-node-5.0.9.tgz", + "integrity": "sha512-zXxCE/5/YDi0hY9pygqgRqjRbrFRzigYxOudG0I3syaqAAmX9/w9sxex1bNFCN6c1S66RwPtEIJv65dN+1psew==", + "dev": true, + "license": "MIT" }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", "engines": { - "node": ">=0.6" + "node": ">=4" } }, + "node_modules/token-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/token-stream/-/token-stream-1.0.0.tgz", + "integrity": "sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==" + }, "node_modules/token-types": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz", @@ -3068,15 +3186,11 @@ "url": "https://github.com/sponsors/Borewit" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" - }, "node_modules/try-catch": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/try-catch/-/try-catch-3.0.1.tgz", "integrity": "sha512-91yfXw1rr/P6oLpHSyHDOHm0vloVvUoo9FVdw8YwY05QjJQG9OT0LUxe2VRAzmHG+0CUOmI3nhxDUMLxDN/NEQ==", + "dev": true, "engines": { "node": ">=6" } @@ -3089,6 +3203,13 @@ "node": ">=6" } }, + "node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "license": "0BSD", + "optional": true + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -3100,55 +3221,36 @@ "node": "*" } }, - "node_modules/turndown": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.1.2.tgz", - "integrity": "sha512-ntI9R7fcUKjqBP6QU8rBK2Ehyt8LAzt3UBT9JR9tgo6GtuKvyUzpayWmeMKJw1DPdXzktvtIT8m2mVXz+bL/Qg==", - "dependencies": { - "domino": "^2.1.6" - } + "node_modules/ufo": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "license": "MIT" }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" }, - "node_modules/undici": { - "version": "6.19.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.19.2.tgz", - "integrity": "sha512-JfjKqIauur3Q6biAtHJ564e3bWa8VvT+7cSiOJHFbX4Erv6CLGDpg8z+Fmg/1OI/47RA+GI2QZaF48SSaLvyBA==", - "engines": { - "node": ">=18.17" - } + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "engines": { - "node": ">= 0.8" - } + "node_modules/uqr": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.2.tgz", + "integrity": "sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==", + "license": "MIT" }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/v8-to-istanbul": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz", @@ -3163,32 +3265,19 @@ "node": ">=10.12.0" } }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", "engines": { - "node": ">= 0.8" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" + "node": ">=0.10.0" } }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -3199,6 +3288,20 @@ "node": ">= 8" } }, + "node_modules/with": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/with/-/with-7.0.2.tgz", + "integrity": "sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==", + "dependencies": { + "@babel/parser": "^7.9.6", + "@babel/types": "^7.9.6", + "assert-never": "^1.2.1", + "babel-walk": "3.0.0-canary-5" + }, + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -3221,6 +3324,8 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -3237,6 +3342,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -3245,6 +3352,8 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -3281,12 +3390,13 @@ "node_modules/wraptile": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/wraptile/-/wraptile-3.0.0.tgz", - "integrity": "sha512-23LJhkIw940uTcDFyJZmNyO0z8lEINOTGCr4vR5YCG3urkdXwduRIhivBm9wKaVynLHYvxoHHYbKsDiafCLp6w==" + "integrity": "sha512-23LJhkIw940uTcDFyJZmNyO0z8lEINOTGCr4vR5YCG3urkdXwduRIhivBm9wKaVynLHYvxoHHYbKsDiafCLp6w==", + "dev": true }, "node_modules/xxhash-wasm": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.0.2.tgz", - "integrity": "sha512-ibF0Or+FivM9lNrg+HGJfVX8WJqgo+kCLDc4vx6xMeTce7Aj+DLttKbxxRR/gNLSAelRc1omAPlJ77N/Jem07A==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==" }, "node_modules/y18n": { "version": "5.0.8", @@ -3297,11 +3407,6 @@ "node": ">=10" } }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -3324,6 +3429,7 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, "engines": { "node": ">=12" } @@ -3339,6 +3445,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.5.tgz", + "integrity": "sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 806785c..2fb21f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "out-of-your-element", - "version": "1.1.1", + "version": "3.1.0", "description": "A bridge between Matrix and Discord", "main": "index.js", "repository": { @@ -14,45 +14,56 @@ ], "author": "Cadence, PapiOphidian", "license": "AGPL-3.0-or-later", + "engines": { + "node": ">=20" + }, "dependencies": { - "@chriscdn/promise-semaphore": "^2.0.1", - "@cloudrac3r/discord-markdown": "^2.6.2", + "@chriscdn/promise-semaphore": "^3.0.1", + "@cloudrac3r/discord-markdown": "^2.6.6", "@cloudrac3r/giframe": "^0.4.3", "@cloudrac3r/html-template-tag": "^5.0.1", - "@cloudrac3r/mixin-deep": "^3.0.0", + "@cloudrac3r/in-your-element": "^1.1.1", + "@cloudrac3r/mixin-deep": "^3.0.1", "@cloudrac3r/pngjs": "^7.0.3", - "better-sqlite3": "^9.0.0", + "@cloudrac3r/pug": "^4.0.4", + "@cloudrac3r/turndown": "^7.1.4", + "@stackoverflow/stacks": "^2.5.4", + "@stackoverflow/stacks-icons": "^6.0.2", + "ansi-colors": "^4.1.3", + "better-sqlite3": "^12.2.0", "chunk-text": "^2.0.1", - "cloudstorm": "^0.10.8", - "entities": "^4.5.0", - "get-stream": "^6.0.1", - "heatsync": "^2.5.3", - "js-yaml": "^4.1.0", - "matrix-appservice": "^2.0.0", - "minimist": "^1.2.8", - "node-fetch": "^2.6.7", + "cloudstorm": "^0.14.0", + "discord-api-types": "^0.38.19", + "domino": "^2.1.6", + "enquirer": "^2.4.1", + "entities": "^5.0.0", + "get-relative-path": "^1.0.2", + "h3": "^1.15.1", + "heatsync": "^2.7.2", + "htmx.org": "^2.0.4", + "lru-cache": "^11.0.2", "prettier-bytes": "^1.0.4", - "sharp": "^0.32.6", - "snowtransfer": "^0.10.5", + "sharp": "^0.33.4", + "snowtransfer": "^0.15.0", "stream-mime-type": "^1.0.2", "try-to-catch": "^3.0.1", - "turndown": "^7.1.2", - "xxhash-wasm": "^1.0.2" + "uqr": "^0.1.2", + "xxhash-wasm": "^1.0.2", + "zod": "^4.0.17" }, "devDependencies": { - "@cloudrac3r/tap-dot": "^2.0.2", - "@types/node": "^18.16.0", - "@types/node-fetch": "^2.6.3", - "c8": "^8.0.1", - "colorette": "^1.4.0", + "@cloudrac3r/tap-dot": "^2.0.3", + "@types/node": "^22.17.1", + "c8": "^10.1.2", "cross-env": "^7.0.3", - "discord-api-types": "^0.37.60", - "supertape": "^10.4.0" + "supertape": "^11.3.0" }, "scripts": { + "start": "node --enable-source-maps start.js", + "setup": "node --enable-source-maps scripts/setup.js", "addbot": "node addbot.js", - "test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap test/test.js | tap-dot", + "test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js | tap-dot", "test-slow": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js -- --slow | tap-dot", - "cover": "c8 --skip-full -x db/migrations -x matrix/file.js -x matrix/api.js -x matrix/mreq.js -x d2m/converters/rlottie-wasm.js -r html -r text supertape --no-check-assertions-count --format fail --no-worker test/test.js -- --slow" + "cover": "c8 -o test/coverage --skip-full -x db/migrations -x src/matrix/file.js -x src/matrix/api.js -x src/d2m/converters/rlottie-wasm.js -r html -r text supertape --no-check-assertions-count --format fail --no-worker test/test.js -- --slow" } } diff --git a/readme.md b/readme.md index b64f780..8c9fc90 100644 --- a/readme.md +++ b/readme.md @@ -6,15 +6,13 @@ Modern Matrix-to-Discord appservice bridge, created by [@cadence:cadence.moe](ht [![Releases](https://img.shields.io/gitea/v/release/cadence/out-of-your-element?gitea_url=https%3A%2F%2Fgitdab.com&style=plastic&color=green)](https://gitdab.com/cadence/out-of-your-element/releases) [![Discuss on Matrix](https://img.shields.io/badge/discuss-%23out--of--your--element-white?style=plastic)](https://matrix.to/#/#out-of-your-element:cadence.moe) -## Docs - -This readme has the most important info. The rest is [in the docs folder.](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs) +![](https://cadence.moe/i/f42a3f) ## Why a new bridge? * Modern: Supports new Discord features like replies, threads and stickers, and new Matrix features like edits, spaces and space membership. -* Efficient: Special attention has been given to memory usage, database indexes, disk footprint, runtime algorithms, and queries to the homeserver. -* Reliable: Any errors on either side are notified on Matrix and can be retried. +* Efficient: Special attention has been given to memory usage, database indexes, disk footprint, runtime algorithms, and queries to the homeserver. [Efficiency details.](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/developer-orientation.md) +* Reliable: Any errors on either side are notified on Matrix and can be retried. Messages sent during bridge downtime will still be bridged after it comes back up. * Tested: A test suite and code coverage make sure all the logic and special cases work. * Simple development: No build step (it's JavaScript, not TypeScript), minimal/lightweight dependencies, and abstraction only where necessary so that less background knowledge is required. No need to learn about Intents or library functions. * No locking algorithm: Other bridges use a locking algorithm which is a source of frequent bugs. This bridge avoids the need for one. @@ -22,27 +20,17 @@ This readme has the most important info. The rest is [in the docs folder.](https ## What works? -Most features you'd expect in both directions, plus a little extra spice: +Most features you'd expect in both directions: messages, edits, deletions, formatting (including spoilers), reactions, custom emojis, custom emoji reactions, mentions, channel mentions, replies, threads, stickers (all formats: PNG, APNG, GIF, Lottie), attachments, spoiler attachments (compatible with most clients), embeds, URL previews, presence, discord.com hyperlinks, and more. -* Messages -* Edits -* Deletions -* Text formatting, including spoilers -* Reactions -* Mentions -* Replies -* Threads -* Stickers (all formats: PNG, APNG, GIF, and Lottie) -* Attachments -* Spoiler attachments -* Embeds -* Guild-Space details syncing -* Channel-Room details syncing -* Custom emoji list syncing -* Custom emojis in messages -* Custom room names/avatars can be applied on Matrix-side -* Larger files from Discord are linked instead of reuploaded to Matrix +Metadata is also synced: people's names, avatars, usernames; channel names, icons, topics; spaces containing rooms; custom emoji lists. Syncing Matrix rooms, room icons, and topics is optional: you can keep them different from the Discord ones if you prefer. + +I've also added some interesting features that I haven't seen in any other bridge: + +* Members using the PluralKit bot each get their own persistent accounts +* Replies from PluralKit members are restyled into native Matrix replies * Simulated user accounts are named @the_persons_username rather than @112233445566778899 +* Matrix custom emojis from private rooms are still visible on Discord as a sprite sheet +* To save space, larger files from Discord are linked instead of reuploaded to Matrix (links don't expire) For more information about features, [see the user guide.](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/user-guide.md) @@ -51,135 +39,6 @@ For more information about features, [see the user guide.](https://gitdab.com/ca * This bridge is not designed for puppetting. * Direct Messaging is not supported until I figure out a good way of doing it. -## Efficiency details +## Get started! -Using WeatherStack as a thin layer between the bridge application and the Discord API lets us control exactly what data is cached in memory. Only necessary information is cached. For example, member data, user data, message content, and past edits are never stored in memory. This keeps the memory usage low and also prevents it ballooning in size over the bridge's runtime. - -The bridge uses a small SQLite database to store relationships like which Discord messages correspond to which Matrix messages. This is so the bridge knows what to edit when some message is edited on Discord. Using `without rowid` on the database tables stores the index and the data in the same B-tree. Since Matrix and Discord's internal IDs are quite long, this vastly reduces storage space because those IDs do not have to be stored twice separately. Some event IDs are actually stored as xxhash integers to reduce storage requirements even more. On my personal instance of OOYE, every 100,000 messages require 16.1 MB of storage space in the SQLite database. - -Only necessary data and columns are queried from the database. We only contact the homeserver API if the database doesn't contain what we need. - -File uploads (like avatars from bridged members) are checked locally and deduplicated. Only brand new files are uploaded to the homeserver. This saves loads of space in the homeserver's media repo, especially for Synapse. - -Switching to [WAL mode](https://www.sqlite.org/wal.html) could improve your database access speed even more. Run `node scripts/wal.js` if you want to switch to WAL mode. This will also enable `synchronous = NORMAL`. - -# Setup - -If you get stuck, you're welcome to message [#out-of-your-element:cadence.moe](https://matrix.to/#/#out-of-your-element:cadence.moe) or [@cadence:cadence.moe](https://matrix.to/#/@cadence:cadence.moe) to ask for help setting up OOYE! - -You'll need: - -* Administrative access to a homeserver -* Discord bot - -Follow these steps: - -1. [Get Node.js version 18 or later](https://nodejs.org/en/download/releases) (the version is required by the better-sqlite3 and matrix-appservice dependencies) - -1. Clone this repo and checkout a specific tag. (Development happens on main. Stable versions are tagged.) - * The latest release tag is ![](https://img.shields.io/gitea/v/release/cadence/out-of-your-element?gitea_url=https%3A%2F%2Fgitdab.com&style=flat-square&label=%20&color=black). - -1. Install dependencies: `npm install --save-dev` (omit --save-dev if you will not run the automated tests) - -1. Copy `config.example.js` to `config.js` and fill in Discord token. - -1. Copy `registration.example.yaml` to `registration.yaml` and fill in bracketed values. You could generate each hex string with `dd if=/dev/urandom bs=32 count=1 2> /dev/null | basenc --base16 | dd conv=lcase 2> /dev/null`. Register the registration in Synapse's `homeserver.yaml` through the usual appservice installation process, then restart Synapse. - -1. Run `node scripts/seed.js` to check your setup and set the bot's initial state. You only need to run this once ever. -1. Make sure the tests work by running `npm t` - -1. Start the bridge: `node start.js` - -1. Add the bot to a server - use any *one* of the following commands for an invite link: - * (in the REPL) `addbot` - * (in a chat) `//addbot` - * $ `node addbot.js` - * $ `npm run addbot` - * $ `./addbot.sh` - -Now any message on Discord will create the corresponding rooms on Matrix-side. After the rooms have been created, Matrix and Discord users can chat back and forth. - -To get into the rooms on your Matrix account, either add yourself to `invite` in `registration.yaml`, or use the `//invite [your mxid here]` command on Discord. - -# Development setup - -* Be sure to install dependencies with `--save-dev` so you can run the tests. -* Any files you change will automatically be reloaded, except for `stdin.js` and `d2m/discord-*.js`. -* If developing on a different computer to the one running the homeserver, use SSH port forwarding so that Synapse can connect on its `localhost:6693` to reach the running bridge on your computer. Example: `ssh -T -v -R 6693:localhost:6693 me@matrix.cadence.moe` -* I recommend developing in Visual Studio Code so that the JSDoc x TypeScript annotation comments work. I don't know which other editors or language servers support annotations and type inference. - -## Repository structure - - . - * Run this to start the bridge: - ├── start.js - * Runtime configuration, like tokens and user info: - ├── config.js - ├── registration.yaml - * The bridge's SQLite database is stored here: - ├── db - │   ├── *.sql, *.db - │   * Migrations change the database schema when you update to a newer version of OOYE: - │   └── migrations - │       └── *.sql, *.js - * Discord-to-Matrix bridging: - ├── d2m - │   * Execute actions through the whole flow, like sending a Discord message to Matrix: - │   ├── actions - │   │   └── *.js - │   * Convert data from one form to another without depending on bridge state. Called by actions: - │   ├── converters - │   │   └── *.js - │   * Making Discord work: - │   ├── discord-*.js - │   * Listening to events from Discord and dispatching them to the correct `action`: - │   └── event-dispatcher.js - ├── discord - │   └── discord-command-handler.js - * Matrix-to-Discord bridging: - ├── m2d - │   * Execute actions through the whole flow, like sending a Matrix message to Discord: - │   ├── actions - │   │   └── *.js - │   * Convert data from one form to another without depending on bridge state. Called by actions: - │   ├── converters - │   │   └── *.js - │   * Listening to events from Matrix and dispatching them to the correct `action`: - │   └── event-dispatcher.js - * We aren't using the matrix-js-sdk, so here are all the functions for the Matrix C-S and Appservice APIs: - ├── matrix - │   └── *.js - * Various files you can run once if you need them. - ├── scripts - │   * First time running a new bridge? Run this file to plant a seed, which will flourish into state for the bridge: - │   ├── seed.js - │   * Hopefully you won't need the rest of these. Code quality varies wildly. - │   └── *.js - * You are here! :) - └── readme.md - -## Dependency justification - -(deduped transitive dependency count) dependency name: explanation - -* (0) @chriscdn/promise-semaphore: It does what I want! I like it! -* (42) better-sqlite3: SQLite3 is the best database, and this is the best library for it. Really! I love it. -* (1) chunk-text: It does what I want. -* (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust. -* (8) snowtransfer: Discord API library with bring-your-own-caching that I trust. -* (1) discord-markdown: This is my fork! -* (0) get-stream: Only needed if content_length_workaround is true. -* (0) giframe: This is my fork! -* (1) heatsync: Module hot-reloader that I trust. -* (0) entities: Looks fine. No dependencies. -* (1) html-template-tag: This is my fork! -* (1) js-yaml: It seems to do what I want, and it's already pulled in by matrix-appservice. -* (70) matrix-appservice: I wish it didn't pull in express :( -* (0) minimist: It's already pulled in by better-sqlite3->prebuild-install. -* (0) mixin-deep: This is my fork! (It fixes a bug in regular mixin-deep.) -* (3) node-fetch@2: I like it and it does what I want. -* (0) pngjs: Lottie stickers are converted to bitmaps with the vendored Rlottie WASM build, then the bitmaps are converted to PNG with pngjs. -* (0) prettier-bytes: It does what I want and has no dependencies. -* (51) sharp: Jimp has fewer dependencies, but sharp is faster. -* (0) try-to-catch: Not strictly necessary, but it does what I want and has no dependencies. -* (1) turndown: I need an HTML-to-Markdown converter and this one looked suitable enough. It has some bugs that I've worked around, so I might switch away from it later. +[Read the installation instructions →](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/get-started.md) diff --git a/registration.example.yaml b/registration.example.yaml deleted file mode 100644 index d38f5ae..0000000 --- a/registration.example.yaml +++ /dev/null @@ -1,25 +0,0 @@ -id: de8c56117637cb5d9f4ac216f612dc2adb1de4c09ae8d13553f28c33a28147c7 -hs_token: [a unique 64 character hex string] -as_token: [a unique 64 character hex string] -url: http://localhost:6693 -sender_localpart: _ooye_bot -protocols: - - discord -namespaces: - users: - - exclusive: true - regex: '@_ooye_.*' - aliases: - - exclusive: true - regex: '#_ooye_.*' -rate_limited: false -ooye: - namespace_prefix: _ooye_ - max_file_size: 5000000 - server_name: [the part after the colon in your matrix id, like cadence.moe] - server_origin: [the full protocol and domain of your actual matrix server's location, with no trailing slash, like https://matrix.cadence.moe] - content_length_workaround: false - include_user_id_in_mxid: false - invite: - # uncomment this to auto-invite the named user to newly created spaces and mark them as admin (PL 100) everywhere - # - '@cadence:cadence.moe' diff --git a/scripts/backfill.js b/scripts/backfill.js new file mode 100644 index 0000000..12d9da3 --- /dev/null +++ b/scripts/backfill.js @@ -0,0 +1,79 @@ +#!/usr/bin/env node +// @ts-check + +console.log("-=- This script is experimental. It WILL mess up the room history on Matrix. -=-") +console.log() + +const {channel: channelID} = require("minimist")(process.argv.slice(2), {string: ["channel"]}) +if (!channelID) { + console.error("Usage: ./scripts/backfill.js --channel=") + process.exit(1) +} + +const assert = require("assert/strict") +const sqlite = require("better-sqlite3") +const backfill = new sqlite("scripts/backfill.db") +backfill.prepare("CREATE TABLE IF NOT EXISTS backfill (channel_id TEXT NOT NULL, message_id INTEGER NOT NULL, PRIMARY KEY (channel_id, message_id))").run() + +const HeatSync = require("heatsync") + +const {reg} = require("../src/matrix/read-registration") +const passthrough = require("../src/passthrough") + +const sync = new HeatSync({watchFS: false}) +const db = new sqlite("ooye.db") +Object.assign(passthrough, {sync, db}) + +const DiscordClient = require("../src/d2m/discord-client") + +const discord = new DiscordClient(reg.ooye.discord_token, "half") +passthrough.discord = discord + +const orm = sync.require("../src/db/orm") +passthrough.from = orm.from +passthrough.select = orm.select + +/** @type {import("../src/d2m/event-dispatcher")}*/ +const eventDispatcher = sync.require("../src/d2m/event-dispatcher") + +const roomID = passthrough.select("channel_room", "room_id", {channel_id: channelID}).pluck().get() +if (!roomID) { + console.error("Please choose a channel that's already bridged.") + process.exit(1) +} + +;(async () => { + await discord.cloud.connect() + console.log("Connected, waiting for data about requested channel...") + + discord.cloud.on("event", event) +})() + +const preparedInsert = backfill.prepare("INSERT INTO backfill (channel_id, message_id) VALUES (?, ?)") + +async function event(event) { + if (event.t !== "GUILD_CREATE") return + const channel = event.d.channels.find(c => c.id === channelID) + if (!channel) return + const guild_id = event.d.id + + let last = backfill.prepare("SELECT cast(max(message_id) as TEXT) FROM backfill WHERE channel_id = ?").pluck().get(channelID) || "0" + console.log(`OK, processing messages for #${channel.name}, continuing from ${last}`) + + while (last) { + const messages = await discord.snow.channel.getChannelMessages(channelID, {limit: 50, after: String(last)}) + messages.reverse() // More recent messages come first -> More recent messages come last + for (const message of messages) { + const simulatedGatewayDispatchData = { + guild_id, + backfill: true, + ...message + } + await eventDispatcher.onMessageCreate(discord, simulatedGatewayDispatchData) + preparedInsert.run(channelID, message.id) + } + last = messages.at(-1)?.id + } + + process.exit() +} diff --git a/scripts/capture-message-update-events.js b/scripts/capture-message-update-events.js old mode 100644 new mode 100755 index 671b062..fb29a5d --- a/scripts/capture-message-update-events.js +++ b/scripts/capture-message-update-events.js @@ -1,3 +1,4 @@ +#!/usr/bin/env node // @ts-check // **** @@ -16,16 +17,16 @@ function fieldToPresenceValue(field) { const sqlite = require("better-sqlite3") const HeatSync = require("heatsync") -const config = require("../config") -const passthrough = require("../passthrough") +const {reg} = require("../src/matrix/read-registration") +const passthrough = require("../src/passthrough") const sync = new HeatSync({watchFS: false}) -Object.assign(passthrough, {config, sync}) +Object.assign(passthrough, {sync}) -const DiscordClient = require("../d2m/discord-client") +const DiscordClient = require("../src/d2m/discord-client") -const discord = new DiscordClient(config.discordToken, "no") +const discord = new DiscordClient(reg.ooye.discord_token, "no") passthrough.discord = discord ;(async () => { diff --git a/scripts/check-migrate.js b/scripts/check-migrate.js old mode 100644 new mode 100755 index 308ea87..04a4402 --- a/scripts/check-migrate.js +++ b/scripts/check-migrate.js @@ -1,3 +1,4 @@ +#!/usr/bin/env node // @ts-check // Trigger the database migration flow and exit after committing. @@ -5,11 +6,10 @@ const sqlite = require("better-sqlite3") -const config = require("../config") -const passthrough = require("../passthrough") -const db = new sqlite("db/ooye.db") -const migrate = require("../db/migrate") +const passthrough = require("../src/passthrough") +const db = new sqlite("ooye.db") +const migrate = require("../src/db/migrate") -Object.assign(passthrough, {config, db }) +Object.assign(passthrough, {db}) migrate.migrate(db) diff --git a/scripts/emoji-surrogates-statistics.js b/scripts/emoji-surrogates-statistics.js new file mode 100644 index 0000000..29abce3 --- /dev/null +++ b/scripts/emoji-surrogates-statistics.js @@ -0,0 +1,77 @@ +// @ts-check + +const fs = require("fs") +const {join} = require("path") +const s = fs.readFileSync(join(__dirname, "..", "src", "m2d", "converters", "emojis.txt"), "utf8").split("\n").map(x => encodeURIComponent(x)) +const searchPattern = "%EF%B8%8F" + +/** + * adapted from es.map.group-by.js in core-js + * @template K,V + * @param {V[]} items + * @param {(item: V) => K} fn + * @returns {Map} + */ +function groupBy(items, fn) { + var map = new Map(); + for (const value of items) { + var key = fn(value); + if (!map.has(key)) map.set(key, [value]); + else map.get(key).push(value); + } + return map; +} + +/** + * @param {number[]} items + * @param {number} width + */ +function xhistogram(items, width) { + const chars = " ▏▎▍▌▋▊▉" + const max = items.reduce((a, c) => c > a ? c : a, 0) + return items.map(v => { + const p = v / max * (width-1) + return ( + Array(Math.floor(p)).fill("█").join("") /* whole part */ + + chars[Math.ceil((p % 1) * (chars.length-1))] /* decimal part */ + ).padEnd(width) + }) +} + +/** + * @param {number[]} items + * @param {[number, number]} xrange + */ +function yhistogram(items, xrange, printHeader = false) { + const chars = "░▁_▂▃▄▅▆▇█" + const ones = "₀₁₂₃₄₅₆₇₈₉" + const tens = "0123456789" + const xy = [] + let max = 0 + /** value (x) -> frequency (y) */ + const grouped = groupBy(items, x => x) + for (let i = xrange[0]; i <= xrange[1]; i++) { + if (printHeader) { + if (i === -1) process.stdout.write("-") + else if (i.toString().at(-1) === "0") process.stdout.write(tens[i/10]) + else process.stdout.write(ones[i%10]) + } + const y = grouped.get(i)?.length ?? 0 + if (y > max) max = y + xy.push(y) + } + if (printHeader) console.log() + return xy.map(y => chars[Math.ceil(y / max * (chars.length-1))]).join("") +} + +const grouped = groupBy(s, x => x.length) +const sortedGroups = [...grouped.entries()].sort((a, b) => b[0] - a[0]) +let length = 0 +const lengthHistogram = xhistogram(sortedGroups.map(v => v[1].length), 10) +for (let i = 0; i < sortedGroups.length; i++) { + const [k, v] = sortedGroups[i] + const l = lengthHistogram[i] + const h = yhistogram(v.map(x => x.indexOf(searchPattern)), [-1, k - searchPattern.length], i === 0) + if (i === 0) length = h.length + 1 + console.log(`${h.padEnd(length, i % 2 === 0 ? "⸱" : " ")}length ${k.toString().padEnd(3)} ${l} ${v.length}`) +} diff --git a/scripts/migrate-from-old-bridge.js b/scripts/migrate-from-old-bridge.js old mode 100644 new mode 100755 index aec345d..36cf884 --- a/scripts/migrate-from-old-bridge.js +++ b/scripts/migrate-from-old-bridge.js @@ -1,24 +1,22 @@ +#!/usr/bin/env node // @ts-check const assert = require("assert").strict -/** @type {any} */ // @ts-ignore bad types from semaphore -const Semaphore = require("@chriscdn/promise-semaphore") +const {Semaphore} = require("@chriscdn/promise-semaphore") const sqlite = require("better-sqlite3") const HeatSync = require("heatsync") -const config = require("../config") -const passthrough = require("../passthrough") +const passthrough = require("../src/passthrough") const sync = new HeatSync({watchFS: false}) -/** @type {import("../matrix/read-registration")} */ -const reg = sync.require("../matrix/read-registration") +const {reg} = require("../src/matrix/read-registration") assert(reg.old_bridge) const oldAT = reg.old_bridge.as_token const newAT = reg.as_token const oldDB = new sqlite(reg.old_bridge.database) -const db = new sqlite("db/ooye.db") +const db = new sqlite("ooye.db") db.exec(`CREATE TABLE IF NOT EXISTS half_shot_migration ( discord_channel TEXT NOT NULL, @@ -26,19 +24,19 @@ db.exec(`CREATE TABLE IF NOT EXISTS half_shot_migration ( PRIMARY KEY("discord_channel") ) WITHOUT ROWID;`) -Object.assign(passthrough, {config, sync, db}) +Object.assign(passthrough, {sync, db}) -const DiscordClient = require("../d2m/discord-client") -const discord = new DiscordClient(config.discordToken, "half") +const DiscordClient = require("../src/d2m/discord-client") +const discord = new DiscordClient(reg.ooye.discord_token, "half") passthrough.discord = discord -/** @type {import("../d2m/actions/create-space")} */ +/** @type {import("../src/d2m/actions/create-space")} */ const createSpace = sync.require("../d2m/actions/create-space") -/** @type {import("../d2m/actions/create-room")} */ +/** @type {import("../src/d2m/actions/create-room")} */ const createRoom = sync.require("../d2m/actions/create-room") -/** @type {import("../matrix/mreq")} */ +/** @type {import("../src/matrix/mreq")} */ const mreq = sync.require("../matrix/mreq") -/** @type {import("../matrix/api")} */ +/** @type {import("../src/matrix/api")} */ const api = sync.require("../matrix/api") const sema = new Semaphore() @@ -116,7 +114,7 @@ async function migrateGuild(guild) { // (By the way, thread_parent is always null here because thread rooms would never be migrated because they are not in the old bridge.) db.transaction(() => { db.prepare("DELETE FROM channel_room WHERE channel_id = ?").run(channel.id) - db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, custom_avatar) VALUES (?, ?, ?, ?, ?)").run(channel.id, row.matrix_id, channel.name, preMigrationRow.nick, preMigrationRow.custom_avatar) + db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, custom_avatar, guild_id) VALUES (?, ?, ?, ?, ?, ?)").run(channel.id, row.matrix_id, channel.name, preMigrationRow.nick, preMigrationRow.custom_avatar, guild.id) console.log(`-- -- Added to database (transferred properties from previous OOYE room)`) })() } else { diff --git a/scripts/register.js b/scripts/register.js deleted file mode 100644 index e19adb3..0000000 --- a/scripts/register.js +++ /dev/null @@ -1,22 +0,0 @@ -// @ts-check - -const { AppServiceRegistration } = require("matrix-appservice"); - -let id = AppServiceRegistration.generateToken() -try { - const reg = require("../matrix/read-registration") - if (reg.id) id = reg.id -} catch (e) {} - -// creating registration files -const newReg = new AppServiceRegistration(null); -newReg.setAppServiceUrl("http://localhost:6693"); -newReg.setId(id); -newReg.setHomeserverToken(AppServiceRegistration.generateToken()); -newReg.setAppServiceToken(AppServiceRegistration.generateToken()); -newReg.setSenderLocalpart("_ooye_bot"); -newReg.addRegexPattern("users", "@_ooye_.*", true); -newReg.addRegexPattern("aliases", "#_ooye_.*", true); -newReg.setProtocols(["discord"]); // For 3PID lookups -newReg.setRateLimited(false); -newReg.outputAsYaml("registration.yaml"); diff --git a/scripts/remove-old-bridged-users.js b/scripts/remove-old-bridged-users.js new file mode 100644 index 0000000..d8910bd --- /dev/null +++ b/scripts/remove-old-bridged-users.js @@ -0,0 +1,39 @@ +// @ts-check + +const HeatSync = require("heatsync") +const sync = new HeatSync({watchFS: false}) + +const sqlite = require("better-sqlite3") +const db = new sqlite("db/ooye.db") + +const passthrough = require("../src/passthrough") +Object.assign(passthrough, {db, sync}) + +const api = require("../src/matrix/api") +const mreq = require("../src/matrix/mreq") + +const rooms = db.prepare("select room_id from channel_room").pluck().all() + +;(async () => { + // Step 5: Kick users starting with @_discord_ + await mreq.withAccessToken("baby", async () => { + for (const roomID of rooms) { + try { + const members = await api.getJoinedMembers(roomID) + for (const mxid of Object.keys(members.joined)) { + if (mxid.startsWith("@_discord_") && !mxid.startsWith("@_discord_bot")) { + await api.leaveRoom(roomID, mxid) + } + } + await api.setUserPower(roomID, "@_discord_bot:cadence.moe", 0) + await api.leaveRoom(roomID) + } catch (e) { + if (e.message.includes("Appservice not in room")) { + // ok + } else { + throw e + } + } + } + }) +})() diff --git a/scripts/save-channel-names-to-db.js b/scripts/save-channel-names-to-db.js old mode 100644 new mode 100755 index 1e5c541..1f36a73 --- a/scripts/save-channel-names-to-db.js +++ b/scripts/save-channel-names-to-db.js @@ -1,19 +1,20 @@ +#!/usr/bin/env node // @ts-check const sqlite = require("better-sqlite3") const HeatSync = require("heatsync") -const config = require("../config") -const passthrough = require("../passthrough") -const db = new sqlite("db/ooye.db") +const {reg} = require("../src/matrix/read-registration") +const passthrough = require("../src/passthrough") +const db = new sqlite("ooye.db") const sync = new HeatSync({watchFS: false}) -Object.assign(passthrough, {config, sync, db}) +Object.assign(passthrough, {sync, db}) -const DiscordClient = require("../d2m/discord-client") +const DiscordClient = require("../src/d2m/discord-client") -const discord = new DiscordClient(config.discordToken, "no") +const discord = new DiscordClient(reg.ooye.discord_token, "no") passthrough.discord = discord ;(async () => { diff --git a/scripts/save-event-types-to-db.js b/scripts/save-event-types-to-db.js old mode 100644 new mode 100755 index 290aa4a..edfbb9c --- a/scripts/save-event-types-to-db.js +++ b/scripts/save-event-types-to-db.js @@ -1,16 +1,17 @@ +#!/usr/bin/env node // @ts-check const sqlite = require("better-sqlite3") const HeatSync = require("heatsync") -const passthrough = require("../passthrough") -const db = new sqlite("db/ooye.db") +const passthrough = require("../src/passthrough") +const db = new sqlite("ooye.db") const sync = new HeatSync({watchFS: false}) Object.assign(passthrough, {sync, db}) -const api = require("../matrix/api") +const api = require("../src/matrix/api") /** @type {{event_id: string, room_id: string, event_type: string}[]} */ // @ts-ignore const rows = db.prepare("SELECT event_id, room_id, event_type FROM event_message INNER JOIN message_channel USING (message_id) INNER JOIN channel_room USING (channel_id)").all() diff --git a/scripts/seed.js b/scripts/seed.js deleted file mode 100644 index 2ab3e02..0000000 --- a/scripts/seed.js +++ /dev/null @@ -1,138 +0,0 @@ -// @ts-check - -console.log("This could take up to 30 seconds. Please be patient.") - -const assert = require("assert").strict -const fs = require("fs") -const sqlite = require("better-sqlite3") -const HeatSync = require("heatsync") - -const args = require("minimist")(process.argv.slice(2), {string: ["emoji-guild"]}) - -const config = require("../config") -const passthrough = require("../passthrough") -const db = new sqlite("db/ooye.db") -const migrate = require("../db/migrate") - -const sync = new HeatSync({watchFS: false}) - -Object.assign(passthrough, { sync, config, db }) - -const orm = sync.require("../db/orm") -passthrough.from = orm.from -passthrough.select = orm.select - -const DiscordClient = require("../d2m/discord-client") -const discord = new DiscordClient(config.discordToken, "no") -passthrough.discord = discord - -const api = require("../matrix/api") -const file = require("../matrix/file") -const reg = require("../matrix/read-registration") -const utils = require("../m2d/converters/utils") - -function die(message) { - console.error(message) - process.exit(1) -} - -async function uploadAutoEmoji(guild, name, filename) { - let emoji = guild.emojis.find(e => e.name === name) - if (!emoji) { - console.log(` Uploading ${name}...`) - const data = fs.readFileSync(filename, null) - emoji = await discord.snow.guildAssets.createEmoji(guild.id, {name, image: "data:image/png;base64," + data.toString("base64")}) - } else { - console.log(` Reusing ${name}...`) - } - db.prepare("REPLACE INTO auto_emoji (name, emoji_id, guild_id) VALUES (?, ?, ?)").run(emoji.name, emoji.id, guild.id) - return emoji -} - -;(async () => { - const mxid = `@${reg.sender_localpart}:${reg.ooye.server_name}` - - // ensure registration is correctly set... - assert(reg.sender_localpart.startsWith(reg.ooye.namespace_prefix), "appservice's localpart must be in the namespace it controls") - assert(utils.eventSenderIsFromDiscord(mxid), "appservice's mxid must be in the namespace it controls") - assert(reg.ooye.server_origin.match(/^https?:\/\//), "server origin must start with http or https") - assert.notEqual(reg.ooye.server_origin.slice(-1), "/", "server origin must not end in slash") - const botID = Buffer.from(config.discordToken.split(".")[0], "base64").toString() - assert(botID.match(/^[0-9]{10,}$/), "discord token must follow the correct format") - console.log("✅ Configuration looks good...") - - // database ddl... - await migrate.migrate(db) - - // add initial rows to database, like adding the bot to sim... - db.prepare("INSERT OR IGNORE INTO sim (user_id, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(botID, reg.sender_localpart.slice(reg.ooye.namespace_prefix.length), reg.sender_localpart, mxid) - - console.log("✅ Database is ready...") - - // ensure appservice bot user is registered... - try { - await api.register(reg.sender_localpart) - } catch (e) { - if (e.errcode === "M_USER_IN_USE" || e.data?.error === "Internal server error") { - // "Internal server error" is the only OK error because older versions of Synapse say this if you try to register the same username twice. - } else { - throw e - } - } - - // upload initial images... - const avatarUrl = await file.uploadDiscordFileToMxc("https://cadence.moe/friends/out_of_your_element.png") - - console.log("✅ Matrix appservice login works...") - - // upload the L1 L2 emojis to some guild - const emojis = db.prepare("SELECT name FROM auto_emoji WHERE name = 'L1' OR name = 'L2'").pluck().all() - if (emojis.length !== 2) { - // If an argument was supplied, always use that one - let guild = null - if (args["emoji-guild"]) { - if (typeof args["emoji-guild"] === "string") { - guild = await discord.snow.guild.getGuild(args["emoji-guild"]) - } - if (!guild) return die(`Error: You asked emojis to be uploaded to guild ID ${args["emoji-guild"]}, but the bot isn't in that guild.`) - } - // Otherwise, check if we have already registered an auto emoji guild - if (!guild) { - const guildID = passthrough.select("auto_emoji", "guild_id", {name: "_"}).pluck().get() - if (guildID) { - guild = await discord.snow.guild.getGuild(guildID, false) - } - } - // Otherwise, check if we should create a new guild - if (!guild) { - const guilds = await discord.snow.user.getGuilds({limit: 11, with_counts: false}) - if (guilds.length < 10) { - console.log(" Creating a guild for emojis...") - guild = await discord.snow.guild.createGuild({name: "OOYE Emojis"}) - } - } - // Otherwise, it's the user's problem - if (!guild) { - return die(`Error: The bot needs to upload some emojis. Please say where to upload them to. Run seed.js again with --emoji-guild=GUILD_ID`) - } - // Upload those emojis to the chosen location - db.prepare("REPLACE INTO auto_emoji (name, emoji_id, guild_id) VALUES ('_', '_', ?)").run(guild.id) - await uploadAutoEmoji(guild, "L1", "docs/img/L1.png") - await uploadAutoEmoji(guild, "L2", "docs/img/L2.png") - } - console.log("✅ Emojis are ready...") - - // set profile data on discord... - const avatarImageBuffer = await fetch("https://cadence.moe/friends/out_of_your_element.png").then(res => res.arrayBuffer()) - await discord.snow.user.updateSelf({avatar: "data:image/png;base64," + Buffer.from(avatarImageBuffer).toString("base64")}) - await discord.snow.requestHandler.request(`/applications/@me`, {}, "patch", "json", {description: "Powered by **Out Of Your Element**\nhttps://gitdab.com/cadence/out-of-your-element"}) - console.log("✅ Discord profile updated...") - - // set profile data on homeserver... - await api.profileSetDisplayname(mxid, "Out Of Your Element") - await api.profileSetAvatarUrl(mxid, avatarUrl) - console.log("✅ Matrix profile updated...") - - console.log("Good to go. I hope you enjoy Out Of Your Element.") - process.exit() -})() diff --git a/scripts/setup.js b/scripts/setup.js new file mode 100644 index 0000000..6bff293 --- /dev/null +++ b/scripts/setup.js @@ -0,0 +1,368 @@ +#!/usr/bin/env node +// @ts-check + +const assert = require("assert").strict +const fs = require("fs") +const sqlite = require("better-sqlite3") +const {scheduler} = require("timers/promises") +const {isDeepStrictEqual} = require("util") +const {createServer} = require("http") +const {join} = require("path") + +const {prompt} = require("enquirer") +const Input = require("enquirer/lib/prompts/input") +const {magenta, bold, cyan} = require("ansi-colors") +const HeatSync = require("heatsync") +const {SnowTransfer} = require("snowtransfer") +const DiscordTypes = require("discord-api-types/v10") +const {createApp, defineEventHandler, toNodeListener} = require("h3") + +// Move database file if it's still in the old location +if (fs.existsSync("db")) { + if (fs.existsSync("db/ooye.db")) { + fs.renameSync("db/ooye.db", "ooye.db") + } + const files = fs.readdirSync("db") + if (files.length) { + console.error("The db folder is deprecated and must be removed. Your ooye.db database file has already been moved to the root of the repo. You must manually move or delete the remaining files:") + for (const file of files) { + console.error(file) + } + process.exit(1) + } + fs.rmSync("db", {recursive: true}) +} + +const passthrough = require("../src/passthrough") +const db = new sqlite("ooye.db") +const migrate = require("../src/db/migrate") + +const sync = new HeatSync({watchFS: false}) + +Object.assign(passthrough, {sync, db}) + +const orm = sync.require("../src/db/orm") +passthrough.from = orm.from +passthrough.select = orm.select + +let registration = require("../src/matrix/read-registration") +let {reg, getTemplateRegistration, writeRegistration, readRegistration, checkRegistration, registrationFilePath} = registration + +const {setupEmojis} = require("../src/m2d/actions/setup-emojis") + +function die(message) { + console.error(message) + process.exit(1) +} + +async function suggestWellKnown(serverUrlPrompt, url, otherwise) { + try { + var json = await fetch(`${url}/.well-known/matrix/client`).then(res => res.json()) + let baseURL = json["m.homeserver"].base_url.replace(/\/$/, "") + if (baseURL && baseURL !== url) { + serverUrlPrompt.initial = baseURL + return `Did you mean: ${bold(baseURL)}? (Enter to accept)` + } + } catch (e) {} + return otherwise +} + +async function validateHomeserverOrigin(serverUrlPrompt, url) { + if (!url.match(/^https?:\/\//)) return "Must be a URL" + if (url.match(/\/$/)) return "Must not end with a slash" + process.stdout.write(magenta(" checking, please wait...")) + try { + var res = await fetch(`${url}/_matrix/client/versions`) + if (res.status !== 200) { + return suggestWellKnown(serverUrlPrompt, url, `There is no Matrix server at that URL (${url}/_matrix/client/versions returned ${res.status})`) + } + } catch (e) { + return e.message + } + try { + var json = await res.json() + if (!Array.isArray(json?.versions) || !json.versions.includes("v1.11")) { + return `OOYE needs Matrix version v1.11, but ${url} doesn't support this` + } + } catch (e) { + return suggestWellKnown(serverUrlPrompt, url, `There is no Matrix server at that URL (${url}/_matrix/client/versions is not JSON)`) + } + return true +} + +function defineEchoHandler() { + return defineEventHandler(event => { + return "Out Of Your Element is listening.\n" + + `Received a ${event.method} request on path ${event.path}\n` + }) +} + +;(async () => { + // create registration file with prompts... + if (!reg) { + console.log("What is the name of your homeserver? This is the part after : in your username.") + /** @type {{server_name: string}} */ + const serverNameResponse = await prompt({ + type: "input", + name: "server_name", + message: "Homeserver name", + validate: serverName => !!serverName.match(/[a-z][a-z.]+[a-z]/) + }) + + console.log("What is the URL of your homeserver?") + const serverOriginPrompt = new Input({ + type: "input", + name: "server_origin", + message: "Homeserver URL", + initial: () => `https://${serverNameResponse.server_name}`, + validate: url => validateHomeserverOrigin(serverOriginPrompt, url) + }) + /** @type {string} */ // @ts-ignore + const serverOrigin = await serverOriginPrompt.run() + + const app = createApp() + app.use(defineEchoHandler()) + const server = createServer(toNodeListener(app)) + await server.listen(6693) + + console.log("OOYE has its own web server. It needs to be accessible on the public internet.") + console.log("You need to enter a public URL where you will be able to host this web server.") + console.log("OOYE listens on localhost:6693, so you will probably have to set up a reverse proxy.") + console.log("Examples: https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/get-started.md#appendix") + console.log("Now listening on port 6693. Feel free to send some test requests.") + /** @type {{bridge_origin: string}} */ + const bridgeOriginResponse = await prompt({ + type: "input", + name: "bridge_origin", + message: "URL to reach OOYE", + initial: () => `https://bridge.${serverNameResponse.server_name}`, + validate: async url => { + process.stdout.write(magenta(" checking, please wait...")) + try { + const res = await fetch(url) + if (res.status !== 200) return `Server returned status code ${res.status}` + const text = await res.text() + if (!text.startsWith("Out Of Your Element is listening.")) return `Server does not point to OOYE` + return true + } catch (e) { + return e.message + } + } + }) + bridgeOriginResponse.bridge_origin = bridgeOriginResponse.bridge_origin.replace(/\/+$/, "") // remove trailing slash + + await server.close() + + console.log("What is your Discord bot token?") + console.log("Go to https://discord.com/developers, create or pick an app, go to the Bot section, and reset the token.") + /** @type {SnowTransfer} */ // @ts-ignore + let snow = null + /** @type {{id: string, flags: number, redirect_uris: string[], description: string}} */ // @ts-ignore + let client = null + /** @type {{discord_token: string}} */ + const discordTokenResponse = await prompt({ + type: "input", + name: "discord_token", + message: "Bot token", + validate: async token => { + process.stdout.write(magenta(" checking, please wait...")) + try { + snow = new SnowTransfer(token) + client = await snow.requestHandler.request(`/applications/@me`, {}, "get", "json") + return true + } catch (e) { + return e.message + } + } + }) + + const mandatoryIntentFlags = DiscordTypes.ApplicationFlags.GatewayMessageContent | DiscordTypes.ApplicationFlags.GatewayMessageContentLimited + if (!(client.flags & mandatoryIntentFlags)) { + console.log(`On that same page, scroll down to Privileged Gateway Intents and enable all switches.`) + await prompt({ + type: "invisible", + name: "intents", + message: "Press Enter when you've enabled them", + validate: async token => { + process.stdout.write(magenta("checking, please wait...")) + client = await snow.requestHandler.request(`/applications/@me`, {}, "get", "json") + if (client.flags & mandatoryIntentFlags) { + return true + } else { + return "Switches have not been enabled yet" + } + } + }) + } + + console.log("Would you like to require a password to add your bot to servers? This will discourage others from using your bridge.") + console.log("Important: To make it truly private, you MUST ALSO disable Public Bot in the Discord bot configuration page.") + /** @type {{web_password: string}} */ + const passwordResponse = await prompt({ + type: "text", + name: "web_password", + message: "Choose a simple password (optional)" + }) + + console.log("To fulfill license obligations, I recommend mentioning Out Of Your Element in your Discord bot's profile.") + console.log("On the Discord bot configuration page, go to General and add something like this to the description:") + console.log(cyan("Powered by **Out Of Your Element**")) + console.log(cyan("https://gitdab.com/cadence/out-of-your-element")) + await prompt({ + type: "invisible", + name: "description", + message: "Press Enter to acknowledge", + validate: async token => { + process.stdout.write(magenta("checking, please wait...")) + client = await snow.requestHandler.request(`/applications/@me`, {}, "get", "json") + if (client.description?.match(/out.of.your.element/i)) { + return true + } else { + return "Description must name or link Out Of Your Element" + } + } + }) + + console.log("What is your Discord client secret?") + console.log(`You can find it in the application's OAuth2 section: https://discord.com/developers/applications/${client.id}/oauth2`) + /** @type {{discord_client_secret: string}} */ + const clientSecretResponse = await prompt({ + type: "input", + name: "discord_client_secret", + message: "Client secret" + }) + + const expectedUri = `${bridgeOriginResponse.bridge_origin}/oauth` + if (!client.redirect_uris.includes(expectedUri)) { + console.log(`On that same page, scroll down to Redirects and add this URI: ${cyan(expectedUri)}`) + await prompt({ + type: "invisible", + name: "redirect_uri", + message: "Press Enter when you've added it", + validate: async token => { + process.stdout.write(magenta("checking, please wait...")) + client = await snow.requestHandler.request(`/applications/@me`, {}, "get", "json") + if (client.redirect_uris.includes(expectedUri)) { + return true + } else { + return "Redirect URI has not been added yet" + } + } + }) + } + + const template = getTemplateRegistration(serverNameResponse.server_name) + reg = { + ...template, + url: bridgeOriginResponse.bridge_origin, + ooye: { + ...template.ooye, + ...bridgeOriginResponse, + server_origin: serverOrigin, + ...discordTokenResponse, + ...clientSecretResponse, + ...passwordResponse + } + } + registration.reg = reg + checkRegistration(reg) + writeRegistration(reg) + console.log(`✅ Your responses have been saved as ${registrationFilePath}`) + } else { + try { + checkRegistration(reg) + console.log(`✅ Skipped questions - reusing data from ${registrationFilePath}`) + } catch (e) { + console.log(`❌ Failed to reuse data from ${registrationFilePath}`) + console.log("Consider deleting this file. You can re-run setup to safely make a new one.") + console.log("") + console.log(e.toString().replace(/^ *\n/gm, "")) + process.exit(1) + } + } + console.log(` In ${cyan("Synapse")}, you need to reference that file in your homeserver.yaml and ${cyan("restart Synapse")}.`) + console.log(" https://element-hq.github.io/synapse/latest/application_services.html") + console.log(` In ${cyan("Conduit")}, you need to send the file contents to the #admins room.`) + console.log(" https://docs.conduit.rs/appservices.html") + console.log() + + // Done with user prompts, reg is now guaranteed to be valid + const api = require("../src/matrix/api") + const file = require("../src/matrix/file") + const DiscordClient = require("../src/d2m/discord-client") + const discord = new DiscordClient(reg.ooye.discord_token, "no") + passthrough.discord = discord + + const {as} = require("../src/matrix/appservice") + as.router.use("/**", defineEchoHandler()) + + console.log("⏳ Waiting for you to register the file with your homeserver... (Ctrl+C to cancel)") + process.once("SIGINT", () => { + console.log("(Ctrl+C) Quit early. Please re-run setup later and allow it to complete.") + process.exit(1) + }) + + let itWorks = false + let lastError = null + do { + const result = await api.ping().catch(e => ({ok: false, status: "net", root: e.message})) + // If it didn't work, log details and retry after some time + itWorks = result.ok + if (!itWorks) { + // Log the full error data if the error is different to last time + if (!isDeepStrictEqual(lastError, result.root)) { + if (typeof result.root === "string") { + console.log(`\nCannot reach homeserver: ${result.root}`) + } else if (result.root.error) { + console.log(`\nHomeserver said: [${result.status}] ${result.root.error}`) + } else { + console.log(`\nHomeserver said: [${result.status}] ${JSON.stringify(result.root)}`) + } + lastError = result.root + } else { + process.stderr.write(".") + } + await scheduler.wait(5000) + } + } while (!itWorks) + console.log("") + + as.close().catch(() => {}) + + const mxid = `@${reg.sender_localpart}:${reg.ooye.server_name}` + + // database ddl... + await migrate.migrate(db) + + // add initial rows to database, like adding the bot to sim... + const client = await discord.snow.user.getSelf() + db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING").run(client.id, client.username, reg.sender_localpart.slice(reg.ooye.namespace_prefix.length), mxid) + + console.log("✅ Database is ready...") + + // ensure appservice bot user is registered... + await api.register(reg.sender_localpart) + + // upload initial images... + const avatarUrl = await file.uploadDiscordFileToMxc("https://cadence.moe/friends/out_of_your_element.png") + + console.log("✅ Matrix appservice login works...") + + // upload the L1 L2 emojis to user emojis + await setupEmojis() + console.log("✅ Emojis are ready...") + + // set profile data on discord... + const avatarImageBuffer = await fetch("https://cadence.moe/friends/out_of_your_element.png").then(res => res.arrayBuffer()) + await discord.snow.user.updateSelf({avatar: "data:image/png;base64," + Buffer.from(avatarImageBuffer).toString("base64")}) + console.log("✅ Discord profile updated...") + + // set profile data on homeserver... + console.log("⏩ Updating Matrix profile... (If you've joined lots of rooms, this is slow. Please allow at least 30 seconds.)") + await api.profileSetDisplayname(mxid, "Out Of Your Element") + await api.profileSetAvatarUrl(mxid, avatarUrl) + console.log("✅ Matrix profile updated...") + + console.log("Good to go. I hope you enjoy Out Of Your Element.") + process.exit() +})() diff --git a/scripts/start-server.js b/scripts/start-server.js new file mode 100755 index 0000000..0d4753a --- /dev/null +++ b/scripts/start-server.js @@ -0,0 +1,38 @@ +#!/usr/bin/env node +// @ts-check + +const {createServer} = require("http") +const EventEmitter = require("events") +const {createApp, createRouter, toNodeListener} = require("h3") +const sqlite = require("better-sqlite3") +const migrate = require("../src/db/migrate") +const HeatSync = require("heatsync") + +const {reg} = require("../src/matrix/read-registration") +const passthrough = require("../src/passthrough") +const db = new sqlite("ooye.db") + +const sync = new HeatSync() + +Object.assign(passthrough, {sync, db}) + +const DiscordClient = require("../src/d2m/discord-client") + +const discord = new DiscordClient(reg.ooye.discord_token, "half") +passthrough.discord = discord + +const {as} = require("../src/matrix/appservice") +passthrough.as = as + +const orm = sync.require("../src/db/orm") +passthrough.from = orm.from +passthrough.select = orm.select + +;(async () => { + await migrate.migrate(db) + await discord.cloud.connect() + console.log("Discord gateway started") + sync.require("../src/web/server") + + require("../src/stdin") +})() diff --git a/scripts/wal.js b/scripts/wal.js old mode 100644 new mode 100755 index 1ad15d0..625f2ba --- a/scripts/wal.js +++ b/scripts/wal.js @@ -1,6 +1,7 @@ +#!/usr/bin/env node // @ts-check const sqlite = require("better-sqlite3") -const db = new sqlite("db/ooye.db", {fileMustExist: true}) +const db = new sqlite("ooye.db", {fileMustExist: true}) db.pragma("journal_mode = wal") db.close() diff --git a/d2m/actions/add-reaction.js b/src/d2m/actions/add-reaction.js similarity index 95% rename from d2m/actions/add-reaction.js rename to src/d2m/actions/add-reaction.js index b131f13..8d86e5f 100644 --- a/d2m/actions/add-reaction.js +++ b/src/d2m/actions/add-reaction.js @@ -25,7 +25,7 @@ async function addReaction(data) { if (!parentID) return // Nothing can be done if the parent message was never bridged. assert.equal(typeof parentID, "string") - const key = await emojiToKey.emojiToKey(data.emoji) + const key = await emojiToKey.emojiToKey(data.emoji, data.message_id) const shortcode = key.startsWith("mxc://") ? `:${data.emoji.name}:` : undefined const roomID = await createRoom.ensureRoom(data.channel_id) diff --git a/d2m/actions/announce-thread.js b/src/d2m/actions/announce-thread.js similarity index 100% rename from d2m/actions/announce-thread.js rename to src/d2m/actions/announce-thread.js diff --git a/d2m/actions/create-room.js b/src/d2m/actions/create-room.js similarity index 53% rename from d2m/actions/create-room.js rename to src/d2m/actions/create-room.js index 051e889..ff5782d 100644 --- a/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -2,20 +2,25 @@ const assert = require("assert").strict const DiscordTypes = require("discord-api-types/v10") -const reg = require("../../matrix/read-registration") +const Ty = require("../../types") +const {reg} = require("../../matrix/read-registration") const passthrough = require("../../passthrough") -const {discord, sync, db, select} = passthrough +const {discord, sync, db, select, from} = passthrough /** @type {import("../../matrix/file")} */ const file = sync.require("../../matrix/file") /** @type {import("../../matrix/api")} */ const api = sync.require("../../matrix/api") +/** @type {import("../../matrix/mreq")} */ +const mreq = sync.require("../../matrix/mreq") /** @type {import("../../matrix/kstate")} */ const ks = sync.require("../../matrix/kstate") /** @type {import("../../discord/utils")} */ -const utils = sync.require("../../discord/utils") -/** @type {import("./create-space")}) */ -const createSpace = sync.require("./create-space") // watch out for the require loop +const dUtils = sync.require("../../discord/utils") +/** @type {import("../../m2d/converters/utils")} */ +const mUtils = sync.require("../../m2d/converters/utils") +/** @type {import("./create-space")} */ +const createSpace = sync.require("./create-space") /** * There are 3 levels of room privacy: @@ -25,7 +30,7 @@ const createSpace = sync.require("./create-space") // watch out for the require */ const PRIVACY_ENUMS = { PRESET: ["private_chat", "public_chat", "public_chat"], - VISIBILITY: ["private", "private", "public"], + VISIBILITY: ["private", "private", "private"], SPACE_HISTORY_VISIBILITY: ["invited", "world_readable", "world_readable"], // copying from element client ROOM_HISTORY_VISIBILITY: ["shared", "shared", "world_readable"], // any events sent after 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 @@ -35,29 +40,11 @@ const PRIVACY_ENUMS = { const DEFAULT_PRIVACY_LEVEL = 0 +const READ_ONLY_ROOM_EVENTS_DEFAULT_POWER = 50 + /** @type {Map>} channel ID -> Promise */ const inflightRoomCreate = new Map() -/** - * Async because it gets all room state from the homeserver. - * @param {string} roomID - */ -async function roomToKState(roomID) { - const root = await api.getAllState(roomID) - return ks.stateToKState(root) -} - -/** - * @param {string} roomID - * @param {any} kstate - */ -async function applyKStateDiffToRoom(roomID, kstate) { - const events = await ks.kstateToState(kstate) - return Promise.all(events.map(({type, state_key, content}) => - api.sendState(roomID, type, state_key, content) - )) -} - /** * @param {{id: string, name: string, topic?: string?, type: number, parent_id?: string?}} channel * @param {{id: string}} guild @@ -69,6 +56,7 @@ function convertNameAndTopic(channel, guild, customName) { let channelPrefix = ( parentChannel?.type === DiscordTypes.ChannelType.GuildForum ? "" : channel.type === DiscordTypes.ChannelType.PublicThread ? "[⛓️] " + : channel.type === DiscordTypes.ChannelType.AnnouncementThread ? "[⛓️] " : channel.type === DiscordTypes.ChannelType.PrivateThread ? "[🔒⛓️] " : channel.type === DiscordTypes.ChannelType.GuildVoice ? "[🔊] " : "") @@ -89,41 +77,36 @@ function convertNameAndTopic(channel, guild, customName) { * Async because it may create the guild and/or upload the guild icon to mxc. * @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel * @param {DiscordTypes.APIGuild} guild + * @param {{api: {getStateEvent: typeof api.getStateEvent}}} di simple-as-nails dependency injection for the matrix API */ -async function channelToKState(channel, guild) { +async function channelToKState(channel, guild, di) { // @ts-ignore const parentChannel = discord.channels.get(channel.parent_id) - /** Used for membership/permission checks. */ - let guildSpaceID - /** Used as the literal parent on Matrix, for categorisation. Will be the same as `guildSpaceID` unless it's a forum channel's thread, in which case a different space is used to group those threads. */ - let parentSpaceID - let privacyLevel - if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum) { // it's a forum channel's thread, so use a different space to group those threads - guildSpaceID = await createSpace.ensureSpace(guild) - parentSpaceID = await ensureRoom(channel.parent_id) - privacyLevel = select("guild_space", "privacy_level", {space_id: guildSpaceID}).pluck().get() - } else { // otherwise use the guild's space like usual - parentSpaceID = await createSpace.ensureSpace(guild) - guildSpaceID = parentSpaceID - privacyLevel = select("guild_space", "privacy_level", {space_id: parentSpaceID}).pluck().get() - } - assert(typeof parentSpaceID === "string") - assert(typeof guildSpaceID === "string") - assert(typeof privacyLevel === "number") - const row = select("channel_room", ["nick", "custom_avatar"], {channel_id: channel.id}).get() - const customName = row?.nick - const customAvatar = row?.custom_avatar + /** Used for membership/permission checks. */ + const guildSpaceID = await createSpace.ensureSpace(guild) + /** Used as the literal parent on Matrix, for categorisation. Will be the same as `guildSpaceID` unless it's a forum channel's thread, in which case a different space is used to group those threads. */ + let parentSpaceID = guildSpaceID + if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum) { + parentSpaceID = await ensureRoom(channel.parent_id) + assert(typeof parentSpaceID === "string") + } + + const channelRow = select("channel_room", ["nick", "custom_avatar", "custom_topic"], {channel_id: channel.id}).get() + const customName = channelRow?.nick + const customAvatar = channelRow?.custom_avatar + const hasCustomTopic = channelRow?.custom_topic const [convertedName, convertedTopic] = convertNameAndTopic(channel, guild, customName) const avatarEventContent = {} if (customAvatar) { avatarEventContent.url = customAvatar } else 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 + avatarEventContent.url = {$url: file.guildIcon(guild)} } + const privacyLevel = select("guild_space", "privacy_level", {guild_id: guild.id}).pluck().get() + assert(privacyLevel != null) // already ensured the space exists let history_visibility = PRIVACY_ENUMS.ROOM_HISTORY_VISIBILITY[privacyLevel] if (channel["thread_metadata"]) history_visibility = "world_readable" @@ -139,9 +122,18 @@ async function channelToKState(channel, guild) { join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]} } - const everyonePermissions = utils.getPermissions([], guild.roles, undefined, channel.permission_overwrites) - const everyoneCanMentionEveryone = utils.hasAllPermissions(everyonePermissions, ["MentionEveryone"]) + const everyonePermissions = dUtils.getPermissions([], guild.roles, undefined, channel.permission_overwrites) + const everyoneCanSend = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendMessages) + const everyoneCanMentionEveryone = dUtils.hasAllPermissions(everyonePermissions, ["MentionEveryone"]) + const globalAdmins = select("member_power", ["mxid", "power_level"], {room_id: "*"}).all() + const globalAdminPower = globalAdmins.reduce((a, c) => (a[c.mxid] = c.power_level, a), {}) + + /** @type {Ty.Event.M_Power_Levels} */ + const spacePowerEvent = await di.api.getStateEvent(guildSpaceID, "m.room.power_levels", "") + const spacePower = spacePowerEvent.users + + /** @type {any} */ const channelKState = { "m.room.name/": {name: convertedName}, "m.room.topic/": {topic: convertedTopic}, @@ -154,17 +146,19 @@ async function channelToKState(channel, guild) { }, /** @type {{join_rule: string, [x: string]: any}} */ "m.room.join_rules/": join_rules, + /** @type {Ty.Event.M_Power_Levels} */ "m.room.power_levels/": { + events_default: everyoneCanSend ? 0 : READ_ONLY_ROOM_EVENTS_DEFAULT_POWER, events: { - "m.room.avatar": 0 + "m.reaction": 0, + "m.room.redaction": 0 // only affects redactions of own events, required to be able to un-react }, notifications: { room: everyoneCanMentionEveryone ? 0 : 20 }, - users: reg.ooye.invite.reduce((a, c) => (a[c] = 100, a), {}) + users: {...spacePower, ...globalAdminPower} }, "chat.schildi.hide_ui/read_receipts": { - hidden: true }, [`uk.half-shot.bridge/moe.cadence.ooye://discord/${guild.id}/${channel.id}`]: { bridgebot: `@${reg.sender_localpart}:${reg.ooye.server_name}`, @@ -175,7 +169,7 @@ async function channelToKState(channel, guild) { network: { id: guild.id, displayname: guild.name, - avatar_url: await file.uploadDiscordFileToMxc(file.guildIcon(guild)) + avatar_url: {$url: file.guildIcon(guild)} }, channel: { id: channel.id, @@ -185,6 +179,16 @@ async function channelToKState(channel, guild) { } } + // Don't overwrite room topic if the topic has been customised + if (hasCustomTopic) delete channelKState["m.room.topic/"] + + // Don't add a space parent if it's self service + // (The person setting up self-service has already put it in their preferred space to be able to get this far.) + const autocreate = select("guild_active", "autocreate", {guild_id: guild.id}).pluck().get() + if (autocreate === 0 && ![DiscordTypes.ChannelType.PrivateThread, DiscordTypes.ChannelType.PublicThread, DiscordTypes.ChannelType.AnnouncementThread].includes(channel.type)) { + delete channelKState[`m.space.parent/${parentSpaceID}`] + } + return {spaceID: parentSpaceID, privacyLevel, channelKState} } @@ -229,8 +233,8 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) { return roomID }) - // Put the newly created child into the space, no need to await this - _syncSpaceMember(channel, spaceID, roomID) + // Put the newly created child into the space + await _syncSpaceMember(channel, spaceID, roomID, guild.id) return roomID } @@ -249,15 +253,16 @@ async function postApplyPowerLevels(kstate, callback) { const powerLevelContent = kstate["m.room.power_levels/"] const kstateWithoutPowerLevels = {...kstate} delete kstateWithoutPowerLevels["m.room.power_levels/"] + delete kstateWithoutPowerLevels["chat.schildi.hide_ui/read_receipts"] /** @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 newRoomKState = await ks.roomToKState(roomID) const newRoomPowerLevelsDiff = ks.diffKState(newRoomKState, {"m.room.power_levels/": powerLevelContent}) - await applyKStateDiffToRoom(roomID, newRoomPowerLevelsDiff) + await ks.applyKStateDiffToRoom(roomID, newRoomPowerLevelsDiff) } return roomID @@ -274,6 +279,61 @@ function channelToGuild(channel) { return guild } +/** + * This function handles whether it's allowed to bridge messages in this channel, and if so, where to. + * This has to account for whether self-service is enabled for the guild or not. + * This also has to account for different channel types, like forum channels (which need the + * parent forum to already exist, and ignore the self-service setting), or thread channels (which + * need the parent channel to already exist, and ignore the self-service setting). + * @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel text channel or thread + * @param {string} guildID + * @returns obj if bridged; 1 if autocreatable; null/undefined if guild is not bridged; 0 if self-service and not autocreatable thread + */ +function existsOrAutocreatable(channel, guildID) { + // 1. If the channel is already linked somewhere, it's always okay to bridge to that destination, no matter what. Yippee! + const existing = select("channel_room", ["room_id", "thread_parent"], {channel_id: channel.id}).get() + if (existing) return existing + + // 2. If the guild is an autocreate guild, it's always okay to bridge to that destination, and + // we'll need to create any dependent resources recursively. + const autocreate = select("guild_active", "autocreate", {guild_id: guildID}).pluck().get() + if (autocreate === 1) return autocreate + + // 3. If the guild is not approved for bridging yet, we can't bridge there. + // They need to decide one way or another whether it's self-service before we can continue. + if (autocreate == null) return autocreate + + // 4. If we got here, the guild is in self-service mode. + // New channels won't be able to create new rooms. But forum threads or channel threads could be fine. + if ([DiscordTypes.ChannelType.PublicThread, DiscordTypes.ChannelType.PrivateThread, DiscordTypes.ChannelType.AnnouncementThread].includes(channel.type)) { + // In self-service mode, threads rely on the parent resource already existing. + /** @type {DiscordTypes.APIGuildTextChannel} */ // @ts-ignore + const parent = discord.channels.get(channel.parent_id) + assert(parent) + const parentExisting = existsOrAutocreatable(parent, guildID) + if (parentExisting) return 1 // Autocreatable + } + + // 5. If we got here, the guild is in self-service mode and the channel is truly not bridged. + return autocreate +} + +/** + * @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel text channel or thread + * @param {string} guildID + * @returns obj if bridged; 1 if autocreatable. (throws if not autocreatable) + */ +function assertExistsOrAutocreatable(channel, guildID) { + const existing = existsOrAutocreatable(channel, guildID) + if (existing === 0) { + throw new Error(`Guild ${guildID} is self-service, so won't create a Matrix room for channel ${channel.id}`) + } + if (!existing) { + throw new Error(`Guild ${guildID} is not bridged, so won't create a Matrix room for channel ${channel.id}`) + } + return existing +} + /* Ensure flow: 1. Get IDs @@ -292,6 +352,7 @@ function channelToGuild(channel) { */ /** + * Create room and/or sync room data. Please check that a channel_room entry exists or autocreate = 1 before calling this. * @param {string} channelID * @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} room ID @@ -306,11 +367,11 @@ async function _syncRoom(channelID, shouldActuallySync) { await inflightRoomCreate.get(channelID) // just waiting, and then doing a new db query afterwards, is the simplest way of doing it } - const existing = select("channel_room", ["room_id", "thread_parent"], {channel_id: channelID}).get() + const existing = assertExistsOrAutocreatable(channel, guild.id) - if (!existing) { + if (existing === 1) { const creation = (async () => { - const {spaceID, privacyLevel, channelKState} = await channelToKState(channel, guild) + const {spaceID, privacyLevel, channelKState} = await channelToKState(channel, guild, {api}) const roomID = await createRoom(channel, guild, spaceID, channelKState, privacyLevel) inflightRoomCreate.delete(channelID) // OK to release inflight waiters now. they will read the correct `existing` row return roomID @@ -327,10 +388,10 @@ async function _syncRoom(channelID, shouldActuallySync) { console.log(`[room sync] to matrix: ${channel.name}`) - const {spaceID, channelKState} = await channelToKState(channel, guild) // calling this in both branches because we don't want to calculate this if not syncing + const {spaceID, channelKState} = await channelToKState(channel, guild, {api}) // calling this in both branches because we don't want to calculate this if not syncing // sync channel state to room - const roomKState = await roomToKState(roomID) + const roomKState = await ks.roomToKState(roomID) 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/ @@ -338,45 +399,86 @@ async function _syncRoom(channelID, shouldActuallySync) { channelKState["m.room.join_rules/"] = {join_rule: "public"} } const roomDiff = ks.diffKState(roomKState, channelKState) - const roomApply = applyKStateDiffToRoom(roomID, roomDiff) + const roomApply = ks.applyKStateDiffToRoom(roomID, roomDiff) db.prepare("UPDATE channel_room SET name = ? WHERE room_id = ?").run(channel.name, roomID) // sync room as space member - const spaceApply = _syncSpaceMember(channel, spaceID, roomID) + const spaceApply = _syncSpaceMember(channel, spaceID, roomID, guild.id) await Promise.all([roomApply, spaceApply]) return roomID } -/** Ensures the room exists. If it doesn't, creates the room with an accurate initial state. */ +/** Ensures the room exists. If it doesn't, creates the room with an accurate initial state. Please check that a channel_room entry exists or guild autocreate = 1 before calling this. */ 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. */ +/** Actually syncs. Gets all room state from the homeserver in order to diff, and uploads the icon to mxc if it has changed. Please check that a channel_room entry exists or guild autocreate = 1 before calling this. */ function syncRoom(channelID) { return _syncRoom(channelID, true) } -async function _unbridgeRoom(channelID) { +async function unbridgeChannel(channelID) { /** @ts-ignore @type {DiscordTypes.APIGuildChannel} */ const channel = discord.channels.get(channelID) assert.ok(channel) - return unbridgeDeletedChannel(channel.id, channel.guild_id) + assert.ok(channel.guild_id) + return unbridgeDeletedChannel(channel, channel.guild_id) } -async function unbridgeDeletedChannel(channelID, guildID) { - const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get() +/** + * @param {{id: string, topic?: string?}} channel channel-ish (just needs an id, topic is optional) + * @param {string} guildID + */ +async function unbridgeDeletedChannel(channel, guildID) { + const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() assert.ok(roomID) - const spaceID = select("guild_space", "space_id", {guild_id: guildID}).pluck().get() - assert.ok(spaceID) + const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").get() + assert.ok(row) - // remove room from being a space member - await api.sendState(roomID, "m.space.parent", spaceID, {}) - await api.sendState(spaceID, "m.space.child", roomID, {}) + let botInRoom = true // remove declaration that the room is bridged - await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channelID}`, {}) + try { + await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channel.id}`, {}) + } catch (e) { + if (String(e).includes("not in room")) { + botInRoom = false + } else { + throw e + } + } + + if (botInRoom && "topic" in channel) { + // previously the Matrix topic would say the channel ID. we should remove that + await api.sendState(roomID, "m.room.topic", "", {topic: channel.topic || ""}) + } + + // delete webhook on discord + const webhook = select("webhook", ["webhook_id", "webhook_token"], {channel_id: channel.id}).get() + if (webhook) { + await discord.snow.webhook.deleteWebhook(webhook.webhook_id, webhook.webhook_token) + db.prepare("DELETE FROM webhook WHERE channel_id = ?").run(channel.id) + } + + // delete room from database + db.prepare("DELETE FROM member_cache WHERE room_id = ?").run(roomID) + db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channel.id) // cascades to most other tables, like messages + + if (!botInRoom) return + + // demote admins in room + /** @type {Ty.Event.M_Power_Levels} */ + const powerLevelContent = await api.getStateEvent(roomID, "m.room.power_levels", "") + powerLevelContent.users ??= {} + const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}` + for (const mxid of Object.keys(powerLevelContent.users)) { + if (powerLevelContent.users[mxid] >= 100 && mUtils.eventSenderIsFromDiscord(mxid) && mxid !== bot) { + delete powerLevelContent.users[mxid] + await api.sendState(roomID, "m.room.power_levels", "", powerLevelContent, mxid) + } + } // send a notification in the room await api.sendEvent(roomID, "m.room.message", { @@ -384,12 +486,28 @@ async function unbridgeDeletedChannel(channelID, guildID) { body: "⚠️ This room was removed from the bridge." }) + // if it is an easy mode room, clean up the room from the managed space and make it clear it's not being bridged + // (don't do this for self-service rooms, because they might continue to be used on Matrix or linked somewhere else later) + if (row.autocreate === 1) { + // remove room from being a space member + await api.sendState(roomID, "m.space.parent", row.space_id, {}) + await api.sendState(row.space_id, "m.space.child", roomID, {}) + } + + // if it is a self-service room, remove sim members + // (the room can be used with less clutter and the member list makes sense if it's bridged somewhere else) + if (row.autocreate === 0) { + // remove sim members + const members = db.prepare("SELECT mxid FROM sim_member WHERE room_id = ? AND mxid <> ?").pluck().all(roomID, bot) + const preparedDelete = db.prepare("DELETE FROM sim_member WHERE room_id = ? AND mxid = ?") + for (const mxid of members) { + await api.leaveRoom(roomID, mxid) + preparedDelete.run(roomID, mxid) + } + } + // 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) } /** @@ -397,14 +515,25 @@ async function unbridgeDeletedChannel(channelID, guildID) { * @param {DiscordTypes.APIGuildTextChannel} channel * @param {string} spaceID * @param {string} roomID + * @param {string} guild_id * @returns {Promise} */ -async function _syncSpaceMember(channel, spaceID, roomID) { - const spaceKState = await roomToKState(spaceID) +async function _syncSpaceMember(channel, spaceID, roomID, guild_id) { + // If space is self-service then only permit changes to space parenting for threads + // (The person setting up self-service has already put it in their preferred space to be able to get this far.) + const autocreate = select("guild_active", "autocreate", {guild_id}).pluck().get() + if (autocreate === 0 && ![DiscordTypes.ChannelType.PrivateThread, DiscordTypes.ChannelType.PublicThread, DiscordTypes.ChannelType.AnnouncementThread].includes(channel.type)) { + return [] + } + + const spaceKState = await ks.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) - && !channel["thread_metadata"]?.archived // archived threads do not belong in the space (don't offer people conversations that are no longer relevant) + && ( + !channel["thread_metadata"]?.archived // archived threads do not belong in the space (don't offer people conversations that are no longer relevant) + || discord.channels.get(channel.parent_id || "")?.type === DiscordTypes.ChannelType.GuildForum + ) ) { spaceEventContent = { via: [reg.ooye.server_name] @@ -413,7 +542,7 @@ async function _syncSpaceMember(channel, spaceID, roomID) { const spaceDiff = ks.diffKState(spaceKState, { [`m.space.child/${roomID}`]: spaceEventContent }) - return applyKStateDiffToRoom(spaceID, spaceDiff) + return ks.applyKStateDiffToRoom(spaceID, spaceDiff) } async function createAllForGuild(guildID) { @@ -430,15 +559,16 @@ async function createAllForGuild(guildID) { } module.exports.DEFAULT_PRIVACY_LEVEL = DEFAULT_PRIVACY_LEVEL +module.exports.READ_ONLY_ROOM_EVENTS_DEFAULT_POWER = READ_ONLY_ROOM_EVENTS_DEFAULT_POWER module.exports.PRIVACY_ENUMS = PRIVACY_ENUMS module.exports.createRoom = createRoom module.exports.ensureRoom = ensureRoom module.exports.syncRoom = syncRoom module.exports.createAllForGuild = createAllForGuild module.exports.channelToKState = channelToKState -module.exports.roomToKState = roomToKState -module.exports.applyKStateDiffToRoom = applyKStateDiffToRoom module.exports.postApplyPowerLevels = postApplyPowerLevels module.exports._convertNameAndTopic = convertNameAndTopic -module.exports._unbridgeRoom = _unbridgeRoom +module.exports.unbridgeChannel = unbridgeChannel module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel +module.exports.existsOrAutocreatable = existsOrAutocreatable +module.exports.assertExistsOrAutocreatable = assertExistsOrAutocreatable diff --git a/src/d2m/actions/create-room.test.js b/src/d2m/actions/create-room.test.js new file mode 100644 index 0000000..e653744 --- /dev/null +++ b/src/d2m/actions/create-room.test.js @@ -0,0 +1,247 @@ +// @ts-check + +const mixin = require("@cloudrac3r/mixin-deep") +const {channelToKState, _convertNameAndTopic} = require("./create-room") +const {kstateStripConditionals} = require("../../matrix/kstate") +const {test} = require("supertape") +const testData = require("../../../test/data") + +const passthrough = require("../../passthrough") +const {db} = passthrough + + +test("channel2room: discoverable privacy room", async t => { + let called = 0 + async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room + called++ + t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return {users: {"@example:matrix.org": 50}} + } + db.prepare("UPDATE guild_space SET privacy_level = 2").run() + t.deepEqual( + kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)), + Object.assign({}, testData.room.general, { + "m.room.guest_access/": {guest_access: "forbidden"}, + "m.room.join_rules/": {join_rule: "public"}, + "m.room.history_visibility/": {history_visibility: "world_readable"}, + "m.room.power_levels/": mixin({users: {"@example:matrix.org": 50}}, testData.room.general["m.room.power_levels/"]) + }) + ) + t.equal(called, 1) +}) + +test("channel2room: linkable privacy room", async t => { + let called = 0 + async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room + called++ + t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return {users: {"@example:matrix.org": 50}} + } + db.prepare("UPDATE guild_space SET privacy_level = 1").run() + t.deepEqual( + kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)), + Object.assign({}, testData.room.general, { + "m.room.guest_access/": {guest_access: "forbidden"}, + "m.room.join_rules/": {join_rule: "public"}, + "m.room.power_levels/": mixin({users: {"@example:matrix.org": 50}}, testData.room.general["m.room.power_levels/"]) + }) + ) + t.equal(called, 1) +}) + +test("channel2room: invite-only privacy room", async t => { + let called = 0 + async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room + called++ + t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return {users: {"@example:matrix.org": 50}} + } + db.prepare("UPDATE guild_space SET privacy_level = 0").run() + t.deepEqual( + kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)), + Object.assign({}, testData.room.general, { + "m.room.power_levels/": mixin({users: {"@example:matrix.org": 50}}, testData.room.general["m.room.power_levels/"]) + }) + ) + t.equal(called, 1) +}) + +test("channel2room: room where limited people can mention everyone", async t => { + let called = 0 + async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room + called++ + t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return {users: {"@example:matrix.org": 50}} + } + const limitedGuild = mixin({}, testData.guild.general) + limitedGuild.roles[0].permissions = (BigInt(limitedGuild.roles[0].permissions) - 131072n).toString() + const limitedRoom = mixin({}, testData.room.general, {"m.room.power_levels/": { + notifications: {room: 20}, + users: {"@example:matrix.org": 50} + }}) + t.deepEqual( + kstateStripConditionals(await channelToKState(testData.channel.general, limitedGuild, {api: {getStateEvent}}).then(x => x.channelKState)), + limitedRoom + ) + t.equal(called, 1) +}) + +test("channel2room: matrix room that already has a custom topic set", async t => { + let called = 0 + async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room + called++ + t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return {} + } + db.prepare("UPDATE channel_room SET custom_topic = 1 WHERE channel_id = ?").run(testData.channel.general.id) + const expected = mixin({}, testData.room.general, {"m.room.power_levels/": {notifications: {room: 20}}}) + // @ts-ignore + delete expected["m.room.topic/"] + t.deepEqual( + kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)), + expected + ) + t.equal(called, 1) +}) + +test("channel2room: read-only discord channel", async t => { + let called = 0 + async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room + called++ + t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return {} + } + const expected = { + "chat.schildi.hide_ui/read_receipts": {}, + "m.room.avatar/": { + url: { + $url: "/icons/112760669178241024/a_f83622e09ead74f0c5c527fe241f8f8c.png?size=1024", + }, + }, + "m.room.guest_access/": { + guest_access: "can_join", + }, + "m.room.history_visibility/": { + history_visibility: "shared", + }, + "m.room.join_rules/": { + allow: [ + { + room_id: "!jjmvBegULiLucuWEHU:cadence.moe", + type: "m.room_membership", + }, + ], + join_rule: "restricted", + }, + "m.room.name/": { + name: "updates", + }, + "m.room.topic/": { + topic: "Updates and release announcements for Out Of Your Element.\n\nChannel ID: 1161864271370666075\nGuild ID: 112760669178241024" + }, + "m.room.power_levels/": { + events_default: 50, // <-- it should be read-only! + events: { + "m.reaction": 0, + "m.room.redaction": 0 + }, + notifications: { + room: 20, + }, + users: { + "@test_auto_invite:example.org": 100, + }, + }, + "m.space.parent/!jjmvBegULiLucuWEHU:cadence.moe": { + canonical: true, + via: [ + "cadence.moe", + ], + }, + "uk.half-shot.bridge/moe.cadence.ooye://discord/112760669178241024/1161864271370666075": { + bridgebot: "@_ooye_bot:cadence.moe", + channel: { + displayname: "updates", + external_url: "https://discord.com/channels/112760669178241024/1161864271370666075", + id: "1161864271370666075", + }, + network: { + avatar_url: { + "$url": "/icons/112760669178241024/a_f83622e09ead74f0c5c527fe241f8f8c.png?size=1024", + }, + displayname: "Psychonauts 3", + id: "112760669178241024", + }, + protocol: { + displayname: "Discord", + id: "discord", + } + } + } + t.deepEqual( + kstateStripConditionals(await channelToKState(testData.channel.updates, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)), + expected + ) + t.equal(called, 1) +}) + +test("convertNameAndTopic: custom name and topic", t => { + t.deepEqual( + _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, "hauntings"), + ["hauntings", "#the-twilight-zone | Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"] + ) +}) + +test("convertNameAndTopic: custom name, no topic", t => { + t.deepEqual( + _convertNameAndTopic({id: "123", name: "the-twilight-zone", type: 0}, {id: "456"}, "hauntings"), + ["hauntings", "#the-twilight-zone\n\nChannel ID: 123\nGuild ID: 456"] + ) +}) + +test("convertNameAndTopic: original name and topic", t => { + t.deepEqual( + _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, null), + ["the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"] + ) +}) + +test("convertNameAndTopic: original name, no topic", t => { + t.deepEqual( + _convertNameAndTopic({id: "123", name: "the-twilight-zone", type: 0}, {id: "456"}, null), + ["the-twilight-zone", "Channel ID: 123\nGuild ID: 456"] + ) +}) + +test("convertNameAndTopic: public thread icon", t => { + t.deepEqual( + _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 11}, {id: "456"}, null), + ["[⛓️] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"] + ) +}) + +test("convertNameAndTopic: private thread icon", t => { + t.deepEqual( + _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 12}, {id: "456"}, null), + ["[🔒⛓️] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"] + ) +}) + +test("convertNameAndTopic: voice channel icon", t => { + t.deepEqual( + _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 2}, {id: "456"}, null), + ["[🔊] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"] + ) +}) diff --git a/d2m/actions/create-space.js b/src/d2m/actions/create-space.js similarity index 79% rename from d2m/actions/create-space.js rename to src/d2m/actions/create-space.js index dbefd6d..8bce3ad 100644 --- a/d2m/actions/create-space.js +++ b/src/d2m/actions/create-space.js @@ -4,7 +4,7 @@ const assert = require("assert").strict const {isDeepStrictEqual} = require("util") const DiscordTypes = require("discord-api-types/v10") const Ty = require("../../types") -const reg = require("../../matrix/read-registration") +const {reg} = require("../../matrix/read-registration") const passthrough = require("../../passthrough") const {discord, sync, db, select} = passthrough @@ -31,6 +31,10 @@ async function createSpace(guild, kstate) { const topic = kstate["m.room.topic/"]?.topic || undefined assert(name) + const memberCount = guild["member_count"] ?? guild.approximate_member_count ?? 0 + const enablePresenceByDefault = +(memberCount < 50) // scary! all active users in a presence-enabled guild will be pinging the server every <30 seconds to stay online + const globalAdmins = select("member_power", "mxid", {room_id: "*"}).pluck().all() + const roomID = await createRoom.postApplyPowerLevels(kstate, async kstate => { return api.createRoom({ name, @@ -40,7 +44,7 @@ async function createSpace(guild, kstate) { events_default: 100, // space can only be managed by bridge invite: 0 // any existing member can invite others }, - invite: reg.ooye.invite, + invite: globalAdmins, topic, creation_content: { type: "m.space" @@ -48,7 +52,7 @@ async function createSpace(guild, kstate) { initial_state: await ks.kstateToState(kstate) }) }) - db.prepare("INSERT INTO guild_space (guild_id, space_id) VALUES (?, ?)").run(guild.id, roomID) + db.prepare("INSERT INTO guild_space (guild_id, space_id, presence) VALUES (?, ?, ?)").run(guild.id, roomID, enablePresenceByDefault) return roomID } @@ -58,17 +62,17 @@ async function createSpace(guild, kstate) { */ async function guildToKState(guild, privacyLevel) { assert.equal(typeof privacyLevel, "number") + const globalAdmins = select("member_power", ["mxid", "power_level"], {room_id: "*"}).all() const guildKState = { "m.room.name/": {name: guild.name}, "m.room.avatar/": { $if: guild.icon, - discord_path: file.guildIcon(guild), url: {$url: file.guildIcon(guild)} }, "m.room.guest_access/": {guest_access: createRoom.PRIVACY_ENUMS.GUEST_ACCESS[privacyLevel]}, "m.room.history_visibility/": {history_visibility: createRoom.PRIVACY_ENUMS.SPACE_HISTORY_VISIBILITY[privacyLevel]}, "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), {})} + "m.room.power_levels/": {users: globalAdmins.reduce((a, c) => (a[c.mxid] = c.power_level, a), {})} // used in guild initial creation postApplyPowerLevels } return guildKState @@ -90,6 +94,9 @@ async function _syncSpace(guild, shouldActuallySync) { const row = select("guild_space", ["space_id", "privacy_level"], {guild_id: guild.id}).get() if (!row) { + const autocreate = select("guild_active", "autocreate", {guild_id: guild.id}).pluck().get() + assert.equal(autocreate, 1, `refusing to implicitly create a space for guild ${guild.id}. set the guild_active data first before calling ensureSpace/syncSpace.`) + const creation = (async () => { 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 const spaceID = await createSpace(guild, guildKState) @@ -111,9 +118,9 @@ async function _syncSpace(guild, shouldActuallySync) { const guildKState = await guildToKState(guild, privacy_level) // calling this in both branches because we don't want to calculate this if not syncing // sync guild state to space - const spaceKState = await createRoom.roomToKState(spaceID) + const spaceKState = await ks.roomToKState(spaceID) const spaceDiff = ks.diffKState(spaceKState, guildKState) - await createRoom.applyKStateDiffToRoom(spaceID, spaceDiff) + await ks.applyKStateDiffToRoom(spaceID, spaceDiff) // 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 @@ -122,16 +129,10 @@ async function _syncSpace(guild, shouldActuallySync) { // don't try to update rooms with custom avatars though const roomsWithCustomAvatars = select("channel_room", "room_id", {}, "WHERE custom_avatar IS NOT NULL").pluck().all() - const state = await ks.kstateToState(spaceKState) - const childRooms = state.filter(({type, state_key, content}) => { - return type === "m.space.child" && "via" in content && !roomsWithCustomAvatars.includes(state_key) - }).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) - } + for await (const room of api.generateFullHierarchy(spaceID)) { + if (room.avatar_url === newAvatarState.url) continue + if (roomsWithCustomAvatars.includes(room.room_id)) continue + await api.sendState(room.room_id, "m.room.avatar", "", newAvatarState) } } @@ -178,28 +179,19 @@ async function syncSpaceFully(guildID) { const guildKState = await guildToKState(guild, privacy_level) // sync guild state to space - const spaceKState = await createRoom.roomToKState(spaceID) + const spaceKState = await ks.roomToKState(spaceID) const spaceDiff = ks.diffKState(spaceKState, guildKState) - await createRoom.applyKStateDiffToRoom(spaceID, spaceDiff) + await ks.applyKStateDiffToRoom(spaceID, spaceDiff) - /** @type {string[]} room IDs */ - let childRooms = [] - /** @type {string | undefined} */ - let nextBatch = undefined - do { - /** @type {Ty.HierarchyPagination} */ - const res = await api.getHierarchy(spaceID, {from: nextBatch}) - childRooms.push(...res.rooms.map(room => room.room_id)) - nextBatch = res.next_batch - } while (nextBatch) + const childRooms = await api.getFullHierarchy(spaceID) - for (const roomID of childRooms) { - const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get() + for (const {room_id} of childRooms) { + const channelID = select("channel_room", "channel_id", {room_id}).pluck().get() if (!channelID) continue if (discord.channels.has(channelID)) { await createRoom.syncRoom(channelID) } else { - await createRoom.unbridgeDeletedChannel(channelID, guildID) + await createRoom.unbridgeDeletedChannel({id: channelID}, guildID) } } @@ -236,11 +228,11 @@ async function syncSpaceExpressions(data, checkBeforeSync) { } if (isDeepStrictEqual(existing, content)) return } - api.sendState(spaceID, "im.ponies.room_emotes", eventKey, content) + await api.sendState(spaceID, "im.ponies.room_emotes", eventKey, content) } - update(spaceID, "emojis", "moe.cadence.ooye.pack.emojis", expression.emojisToState) - update(spaceID, "stickers", "moe.cadence.ooye.pack.stickers", expression.stickersToState) + await update(spaceID, "emojis", "moe.cadence.ooye.pack.emojis", expression.emojisToState) + await update(spaceID, "stickers", "moe.cadence.ooye.pack.stickers", expression.stickersToState) } module.exports.createSpace = createSpace diff --git a/d2m/actions/create-space.test.js b/src/d2m/actions/create-space.test.js similarity index 87% rename from d2m/actions/create-space.test.js rename to src/d2m/actions/create-space.test.js index b1c1f06..cb4d90a 100644 --- a/d2m/actions/create-space.test.js +++ b/src/d2m/actions/create-space.test.js @@ -4,7 +4,7 @@ const mixin = require("@cloudrac3r/mixin-deep") const {guildToKState, ensureSpace} = require("./create-space") const {kstateStripConditionals, kstateUploadMxc} = require("../../matrix/kstate") const {test} = require("supertape") -const testData = require("../../test/data") +const testData = require("../../../test/data") const passthrough = require("../../passthrough") const {db} = passthrough @@ -14,7 +14,6 @@ test("guild2space: can generate kstate for a guild, passing privacy level 0", as await kstateUploadMxc(kstateStripConditionals(await guildToKState(testData.guild.general, 0))), { "m.room.avatar/": { - discord_path: "/icons/112760669178241024/a_f83622e09ead74f0c5c527fe241f8f8c.png?size=1024", url: "mxc://cadence.moe/zKXGZhmImMHuGQZWJEFKJbsF" }, "m.room.guest_access/": { diff --git a/d2m/actions/delete-message.js b/src/d2m/actions/delete-message.js similarity index 91% rename from d2m/actions/delete-message.js rename to src/d2m/actions/delete-message.js index bc8adfb..e9e0b08 100644 --- a/d2m/actions/delete-message.js +++ b/src/d2m/actions/delete-message.js @@ -16,7 +16,6 @@ async function deleteMessage(data) { const eventsToRedact = select("event_message", "event_id", {message_id: data.id}).pluck().all() db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(data.id) - db.prepare("DELETE FROM event_message WHERE message_id = ?").run(data.id) for (const eventID of eventsToRedact) { // Unfortunately, we can't specify a sender to do the redaction as, unless we find out that info via the audit logs await api.redactEvent(row.room_id, eventID) @@ -35,7 +34,6 @@ async function deleteMessageBulk(data) { const sids = JSON.stringify(data.ids) const eventsToRedact = from("event_message").pluck("event_id").and("WHERE message_id IN (SELECT value FROM json_each(?))").all(sids) db.prepare("DELETE FROM message_channel WHERE message_id IN (SELECT value FROM json_each(?))").run(sids) - db.prepare("DELETE FROM event_message WHERE message_id IN (SELECT value FROM json_each(?))").run(sids) for (const eventID of eventsToRedact) { // Awaiting will make it go slower, but since this could be a long-running operation either way, we want to leave rate limit capacity for other operations await api.redactEvent(roomID, eventID) diff --git a/d2m/actions/edit-message.js b/src/d2m/actions/edit-message.js similarity index 90% rename from d2m/actions/edit-message.js rename to src/d2m/actions/edit-message.js index d52fcbd..1afcb35 100644 --- a/d2m/actions/edit-message.js +++ b/src/d2m/actions/edit-message.js @@ -22,9 +22,7 @@ async function editMessage(message, guild, row) { if (row && row.speedbump_webhook_id === message.webhook_id) { // Handle the PluralKit public instance if (row.speedbump_id === "466378653216014359") { - const root = await registerPkUser.fetchMessage(message.id) - assert(root.member) - senderMxid = await registerPkUser.ensureSimJoined(root, roomID) + senderMxid = await registerPkUser.syncUser(message.id, message.author, roomID, false) } } @@ -53,7 +51,7 @@ async function editMessage(message, guild, row) { const sendNewEventParts = new Set() for (const promotion of promotions) { if ("eventID" in promotion) { - db.prepare(`UPDATE event_message SET ${promotion.column} = 0 WHERE event_id = ?`).run(promotion.eventID) + db.prepare(`UPDATE event_message SET ${promotion.column} = ? WHERE event_id = ?`).run(promotion.value ?? 0, promotion.eventID) } else if ("nextEvent" in promotion) { sendNewEventParts.add(promotion.column) } @@ -61,7 +59,7 @@ async function editMessage(message, guild, row) { // 4. Send all the things. if (eventsToSend.length) { - db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id) + db.prepare("INSERT OR IGNORE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id) } for (const content of eventsToSend) { const eventType = content.$type diff --git a/d2m/actions/expression.js b/src/d2m/actions/expression.js similarity index 90% rename from d2m/actions/expression.js rename to src/d2m/actions/expression.js index b7b5d5a..fd75aa5 100644 --- a/d2m/actions/expression.js +++ b/src/d2m/actions/expression.js @@ -30,7 +30,7 @@ async function emojisToState(emojis) { } db.prepare("INSERT OR IGNORE INTO emoji (emoji_id, name, animated, mxc_url) VALUES (?, ?, ?, ?)").run(emoji.id, emoji.name, +!!emoji.animated, url) }).catch(e => { - if (e.data.errcode === "M_TOO_LARGE") { // Very unlikely to happen. Only possible for 3x-series emojis uploaded shortly after animated emojis were introduced, when there was no 256 KB size limit. + if (e.data?.errcode === "M_TOO_LARGE") { // Very unlikely to happen. Only possible for 3x-series emojis uploaded shortly after animated emojis were introduced, when there was no 256 KB size limit. return } console.error(`Trying to handle emoji ${emoji.name} (${emoji.id}), but...`) @@ -66,7 +66,7 @@ async function stickersToState(stickers) { while (shortcodes.includes(shortcode)) shortcode = shortcode + "~" shortcodes.push(shortcode) - result.images[shortcodes] = { + result.images[shortcode] = { info: { mimetype: file.stickerFormat.get(sticker.format_type)?.mime || "image/png" }, diff --git a/d2m/actions/lottie.js b/src/d2m/actions/lottie.js similarity index 97% rename from d2m/actions/lottie.js rename to src/d2m/actions/lottie.js index 4635fed..0185980 100644 --- a/d2m/actions/lottie.js +++ b/src/d2m/actions/lottie.js @@ -33,7 +33,7 @@ async function convert(stickerItem) { if (res.status !== 200) throw new Error("Sticker data file not found.") const text = await res.text() - // Convert to PNG (readable stream) + // Convert to PNG (stream.Readable) const readablePng = await convertLottie.convert(text) // Upload to MXC diff --git a/d2m/actions/register-pk-user.js b/src/d2m/actions/register-pk-user.js similarity index 60% rename from d2m/actions/register-pk-user.js rename to src/d2m/actions/register-pk-user.js index 2ead4ff..27e949c 100644 --- a/d2m/actions/register-pk-user.js +++ b/src/d2m/actions/register-pk-user.js @@ -1,12 +1,11 @@ // @ts-check const assert = require("assert") -const reg = require("../../matrix/read-registration") +const {reg} = require("../../matrix/read-registration") const Ty = require("../../types") -const fetch = require("node-fetch").default const passthrough = require("../../passthrough") -const {discord, sync, db, select} = passthrough +const {sync, db, select, from} = passthrough /** @type {import("../../matrix/api")} */ const api = sync.require("../../matrix/api") /** @type {import("../../matrix/file")} */ @@ -21,6 +20,20 @@ const registerUser = sync.require("./register-user") * @prop {string} id */ +/** @returns {Promise} */ +async function fetchMessage(messageID) { + try { + var res = await fetch(`https://api.pluralkit.me/v2/messages/${messageID}`) + } catch (networkError) { + // Network issue, raise a more readable message + throw new Error(`Failed to connect to PK API: ${networkError.toString()}`) + } + if (!res.ok) throw new Error(`PK API returned an error: ${await res.text()}`) + const root = await res.json() + if (!root.member) throw new Error(`PK API didn't return member data: ${JSON.stringify(root)}`) + return root +} + /** * A sim is an account that is being simulated by the bridge to copy events from the other side. * @param {Ty.PkMessage} pkMessage @@ -33,7 +46,7 @@ async function createSim(pkMessage) { const mxid = `@${localpart}:${reg.ooye.server_name}` // Save chosen name in the database forever - db.prepare("INSERT INTO sim (user_id, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(pkMessage.member.uuid, simName, localpart, mxid) + db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?)").run(pkMessage.member.uuid, simName, simName, mxid) // Register matrix user with that name try { @@ -96,6 +109,7 @@ async function ensureSimJoined(pkMessage, roomID) { } /** + * Generate profile data based on webhook displayname and configured avatar. * @param {Ty.PkMessage} pkMessage * @param {WebhookAuthor} author */ @@ -116,49 +130,47 @@ async function memberToStateContent(pkMessage, author) { /** * 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 - * 3. Compare against the previously known state content, which is helpfully stored in the database - * 4. If the state content has changed, send it to Matrix and update it in the database for next time - * @param {WebhookAuthor} author - * @param {Ty.PkMessage} pkMessage - * @param {string} roomID + * 1. Look up data about proxy user from API + * 2. If this fails, try to use previously cached data (won't sync) + * 3. Create and join the sim to the room if needed + * 4. 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 + * 5. Compare against the previously known state content, which is helpfully stored in the database + * 6. If the state content has changed, send it to Matrix and update it in the database for next time + * @param {string} messageID to call API with + * @param {WebhookAuthor} author for profile data + * @param {string} roomID room to join member to + * @param {boolean} shouldActuallySync whether to actually sync updated user data or just ensure it's joined * @returns {Promise} mxid of the updated sim */ -async function syncUser(author, pkMessage, roomID) { - const mxid = await ensureSimJoined(pkMessage, roomID) - // Update the sim_proxy table, so mentions can look up the original sender later - db.prepare("INSERT OR IGNORE INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES (?, ?, ?)").run(pkMessage.member.uuid, pkMessage.sender, author.username) - // Sync the member state - const content = await memberToStateContent(pkMessage, author) - const currentHash = registerUser._hashProfileContent(content, 0) - const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get() - // only do the actual sync if the hash has changed since we last looked - if (existingHash !== currentHash) { - await api.sendState(roomID, "m.room.member", mxid, content, mxid) - db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid) +async function syncUser(messageID, author, roomID, shouldActuallySync) { + try { + // API lookup + var pkMessage = await fetchMessage(messageID) + db.prepare("REPLACE INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES (?, ?, ?)").run(pkMessage.member.uuid, pkMessage.sender, author.username) + } catch (e) { + // Fall back to offline cache + const senderMxid = from("sim_proxy").join("sim", "user_id").join("sim_member", "mxid").where({displayname: author.username, room_id: roomID}).pluck("mxid").get() + if (!senderMxid) throw e + return senderMxid } + + // Create and join the sim to the room if needed + const mxid = await ensureSimJoined(pkMessage, roomID) + + if (shouldActuallySync) { + // Build current profile data + const content = await memberToStateContent(pkMessage, author) + const currentHash = registerUser._hashProfileContent(content, 0) + const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get() + + // Only do the actual sync if the hash has changed since we last looked + if (existingHash !== currentHash) { + await api.sendState(roomID, "m.room.member", mxid, content, mxid) + db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid) + } + } + return mxid } -/** @returns {Promise} */ -async function fetchMessage(messageID) { - // Their backend is weird. Sometimes it says "message not found" (code 20006) on the first try, so we make multiple attempts. - let attempts = 0 - do { - var res = await fetch(`https://api.pluralkit.me/v2/messages/${messageID}`) - if (res.ok) return res.json() - - // I think the backend needs some time to update. - await new Promise(resolve => setTimeout(resolve, 2000)) - } while (++attempts < 3) - - const errorMessage = await res.json() - throw new Error(`PK API returned an error after ${attempts} tries: ${JSON.stringify(errorMessage)}`) -} - -module.exports._memberToStateContent = memberToStateContent -module.exports.ensureSim = ensureSim -module.exports.ensureSimJoined = ensureSimJoined module.exports.syncUser = syncUser -module.exports.fetchMessage = fetchMessage diff --git a/d2m/actions/register-user.js b/src/d2m/actions/register-user.js similarity index 82% rename from d2m/actions/register-user.js rename to src/d2m/actions/register-user.js index 6bd4661..674853a 100644 --- a/d2m/actions/register-user.js +++ b/src/d2m/actions/register-user.js @@ -1,8 +1,9 @@ // @ts-check const assert = require("assert").strict -const reg = require("../../matrix/read-registration") +const {reg} = require("../../matrix/read-registration") const DiscordTypes = require("discord-api-types/v10") +const Ty = require("../../types") const mixin = require("@cloudrac3r/mixin-deep") const passthrough = require("../../passthrough") @@ -15,6 +16,8 @@ const file = sync.require("../../matrix/file") const utils = sync.require("../../discord/utils") /** @type {import("../converters/user-to-mxid")} */ const userToMxid = sync.require("../converters/user-to-mxid") +/** @type {import("./create-room")} */ +const createRoom = sync.require("./create-room") /** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore let hasher = null // @ts-ignore @@ -33,7 +36,7 @@ async function createSim(user) { // Save chosen name in the database forever // 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 - db.prepare("INSERT INTO sim (user_id, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(user.id, simName, localpart, mxid) + db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?)").run(user.id, user.username, simName, mxid) // Register matrix user with that name try { @@ -97,12 +100,12 @@ async function ensureSimJoined(user, roomID) { /** * @param {DiscordTypes.APIUser} user - * @param {Omit} member + * @param {Omit | undefined} member */ async function memberToStateContent(user, member, guildID) { let displayname = user.username if (user.global_name) displayname = user.global_name - if (member.nick) displayname = member.nick + if (member?.nick) displayname = member.nick const content = { displayname, @@ -117,7 +120,7 @@ async function memberToStateContent(user, member, guildID) { } } - if (member.avatar || user.avatar) { + 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 @@ -130,13 +133,16 @@ async function memberToStateContent(user, member, guildID) { /** * https://gitdab.com/cadence/out-of-your-element/issues/9 * @param {DiscordTypes.APIUser} user - * @param {Omit} member + * @param {Omit | undefined} member * @param {DiscordTypes.APIGuild} guild * @param {DiscordTypes.APIGuildChannel} channel * @returns {number} 0 to 100 */ function memberToPowerLevel(user, member, guild, channel) { + if (!member) return 0 + const permissions = utils.getPermissions(member.roles, guild.roles, user.id, channel.permission_overwrites) + const everyonePermissions = utils.getPermissions([], guild.roles, undefined, channel.permission_overwrites) /* * PL 100 = Administrator = People who can brick the room. RATIONALE: * - Administrator. @@ -156,8 +162,14 @@ function memberToPowerLevel(user, member, guild, channel) { * - Moderate Members. */ if (utils.hasSomePermissions(permissions, ["ManageMessages", "ManageNicknames", "ManageThreads", "KickMembers", "BanMembers", "MuteMembers", "DeafenMembers", "ModerateMembers"])) return 50 + /* PL 50 = if room is read-only but the user has been specially allowed to send messages */ + const everyoneCanSend = utils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendMessages) + const userCanSend = utils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.SendMessages) + if (!everyoneCanSend && userCanSend) return createRoom.READ_ONLY_ROOM_EVENTS_DEFAULT_POWER /* PL 20 = Mention Everyone for technical reasons. */ - if (utils.hasSomePermissions(permissions, ["MentionEveryone"])) return 20 + const everyoneCanMentionEveryone = utils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.MentionEveryone) + const userCanMentionEveryone = utils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.MentionEveryone) + if (!everyoneCanMentionEveryone && userCanMentionEveryone) return 20 return 0 } @@ -179,7 +191,7 @@ function _hashProfileContent(content, powerLevel) { * 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} member + * @param {Omit | undefined} member * @param {DiscordTypes.APIGuildChannel} channel * @param {DiscordTypes.APIGuild} guild * @param {string} roomID @@ -192,16 +204,14 @@ async function syncUser(user, member, channel, guild, roomID) { const currentHash = _hashProfileContent(content, powerLevel) const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get() // only do the actual sync if the hash has changed since we last looked - if (existingHash !== currentHash) { + const hashHasChanged = existingHash !== currentHash + // however, do not overwrite pre-existing data if we already have data and `member` is not accessible, because this would replace good data with bad data + const wouldOverwritePreExisting = existingHash && !member + if (hashHasChanged && !wouldOverwritePreExisting) { // Update room member state await api.sendState(roomID, "m.room.member", mxid, content, mxid) // Update power levels - const powerLevelsStateContent = await api.getStateEvent(roomID, "m.room.power_levels", "") - const oldPowerLevel = powerLevelsStateContent.users?.[mxid] || 0 - mixin(powerLevelsStateContent, {users: {[mxid]: powerLevel}}) - if (powerLevel === 0) delete powerLevelsStateContent.users[mxid] // keep the event compact - const sendPowerLevelAs = powerLevel < oldPowerLevel ? mxid : undefined // bridge bot won't not have permission to demote equal power users, so do this action as themselves - await api.sendState(roomID, "m.room.power_levels", "", powerLevelsStateContent, sendPowerLevelAs) + await api.setUserPower(roomID, mxid, powerLevel) // Update cached hash db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid) } @@ -245,3 +255,4 @@ module.exports.ensureSim = ensureSim module.exports.ensureSimJoined = ensureSimJoined module.exports.syncUser = syncUser module.exports.syncAllUsersInRoom = syncAllUsersInRoom +module.exports._memberToPowerLevel = memberToPowerLevel diff --git a/src/d2m/actions/register-user.test.js b/src/d2m/actions/register-user.test.js new file mode 100644 index 0000000..13971b3 --- /dev/null +++ b/src/d2m/actions/register-user.test.js @@ -0,0 +1,126 @@ +const {_memberToStateContent, _memberToPowerLevel} = require("./register-user") +const {test} = require("supertape") +const data = require("../../../test/data") +const mixin = require("@cloudrac3r/mixin-deep") +const DiscordTypes = require("discord-api-types/v10") + +test("member2state: without member nick or avatar", async t => { + t.deepEqual( + await _memberToStateContent(data.member.kumaccino.user, data.member.kumaccino, data.guild.general.id), + { + avatar_url: "mxc://cadence.moe/UpAeIqeclhKfeiZNdIWNcXXL", + displayname: "kumaccino", + membership: "join", + "moe.cadence.ooye.member": { + avatar: "/avatars/113340068197859328/b48302623a12bc7c59a71328f72ccb39.png?size=1024" + }, + "uk.half-shot.discord.member": { + bot: false, + displayColor: 10206929, + id: "113340068197859328", + username: "@kumaccino" + } + } + ) +}) + +test("member2state: with global name, without member nick or avatar", async t => { + t.deepEqual( + await _memberToStateContent(data.member.papiophidian.user, data.member.papiophidian, data.guild.general.id), + { + avatar_url: "mxc://cadence.moe/JPzSmALLirnIprlSMKohSSoX", + displayname: "PapiOphidian", + membership: "join", + "moe.cadence.ooye.member": { + avatar: "/avatars/320067006521147393/5fc4ad85c1ea876709e9a7d3374a78a1.png?size=1024" + }, + "uk.half-shot.discord.member": { + bot: false, + displayColor: 1579292, + id: "320067006521147393", + username: "@papiophidian" + } + } + ) +}) + +test("member2state: with member nick and avatar", async t => { + t.deepEqual( + await _memberToStateContent(data.member.sheep.user, data.member.sheep, data.guild.general.id), + { + avatar_url: "mxc://cadence.moe/rfemHmAtcprjLEiPiEuzPhpl", + displayname: "The Expert's Submarine", + membership: "join", + "moe.cadence.ooye.member": { + avatar: "/guilds/112760669178241024/users/134826546694193153/avatars/38dd359aa12bcd52dd3164126c587f8c.png?size=1024" + }, + "uk.half-shot.discord.member": { + bot: false, + displayColor: null, + id: "134826546694193153", + username: "@aprilsong" + } + } + ) +}) + +test("member2power: default to zero if member roles unknown", async t => { + const power = _memberToPowerLevel(data.user.clyde_ai, null, data.guild.data_horde, data.channel.saving_the_world) + t.equal(power, 0) +}) + +test("member2power: unremarkable = 0", async t => { + const power = _memberToPowerLevel(data.user.clyde_ai, { + roles: [] + }, data.guild.data_horde, data.channel.general) + t.equal(power, 0) +}) + +test("member2power: can mention everyone = 20", async t => { + const power = _memberToPowerLevel(data.user.clyde_ai, { + roles: ["684524730274807911"] + }, data.guild.data_horde, data.channel.general) + t.equal(power, 20) +}) + +test("member2power: can send messages in protected channel due to role = 50", async t => { + const power = _memberToPowerLevel(data.user.clyde_ai, { + roles: ["684524730274807911"] + }, data.guild.data_horde, data.channel.saving_the_world) + t.equal(power, 50) +}) + +test("member2power: can send messages in protected channel due to user override = 50", async t => { + const power = _memberToPowerLevel(data.user.clyde_ai, { + roles: [] + }, data.guild.data_horde, mixin({}, data.channel.saving_the_world, { + permission_overwrites: data.channel.saving_the_world.permission_overwrites.concat({ + type: DiscordTypes.OverwriteType.member, + id: data.user.clyde_ai.id, + allow: String(DiscordTypes.PermissionFlagsBits.SendMessages), + deny: "0" + }) + })) + t.equal(power, 50) +}) + +test("member2power: can kick users = 50", async t => { + const power = _memberToPowerLevel(data.user.clyde_ai, { + roles: ["682789592390281245"] + }, data.guild.data_horde, data.channel.general) + t.equal(power, 50) +}) + +test("member2power: can manage channels = 100", async t => { + const power = _memberToPowerLevel(data.user.clyde_ai, { + roles: ["665290147377578005"] + }, data.guild.data_horde, data.channel.saving_the_world) + t.equal(power, 100) +}) + +test("member2power: pathfinder use case", async t => { + const power = _memberToPowerLevel(data.user.jerassicore, { + roles: ["1235396773510647810", "1359752622130593802", "1249165855632265267", "1380768596929806356", "1380756348190462015"] + }, data.guild.pathfinder, data.channel.character_art) + t.equal(power, 50) +}) diff --git a/d2m/actions/remove-reaction.js b/src/d2m/actions/remove-reaction.js similarity index 82% rename from d2m/actions/remove-reaction.js rename to src/d2m/actions/remove-reaction.js index 95fc0aa..06c4b59 100644 --- a/d2m/actions/remove-reaction.js +++ b/src/d2m/actions/remove-reaction.js @@ -23,16 +23,7 @@ async function removeSomeReactions(data) { const eventIDForMessage = select("event_message", "event_id", {message_id: data.message_id, reaction_part: 0}).pluck().get() if (!eventIDForMessage) return - /** @type {Ty.Event.Outer[]} */ - let reactions = [] - /** @type {string | undefined} */ - let nextBatch = undefined - do { - /** @type {Ty.Pagination>} */ - const res = await api.getRelations(roomID, eventIDForMessage, {from: nextBatch}, "m.annotation") - reactions = reactions.concat(res.chunk) - nextBatch = res.next_batch - } while (nextBatch) + const reactions = await api.getFullRelations(roomID, eventIDForMessage, "m.annotation") // Run the proper strategy and any strategy-specific database changes const removals = await @@ -52,7 +43,7 @@ async function removeSomeReactions(data) { * @param {Ty.Event.Outer[]} reactions */ async function removeReaction(data, reactions) { - const key = await emojiToKey.emojiToKey(data.emoji) + const key = await emojiToKey.emojiToKey(data.emoji, data.message_id) return converter.removeReaction(data, reactions, key) } @@ -61,8 +52,8 @@ async function removeReaction(data, reactions) { * @param {Ty.Event.Outer[]} reactions */ async function removeEmojiReaction(data, reactions) { - const key = await emojiToKey.emojiToKey(data.emoji) - const discordPreferredEncoding = emoji.encodeEmoji(key, undefined) + const key = await emojiToKey.emojiToKey(data.emoji, data.message_id) + const discordPreferredEncoding = await emoji.encodeEmoji(key, undefined) db.prepare("DELETE FROM reaction WHERE message_id = ? AND encoded_emoji = ?").run(data.message_id, discordPreferredEncoding) return converter.removeEmojiReaction(data, reactions, key) diff --git a/d2m/actions/retrigger.js b/src/d2m/actions/retrigger.js similarity index 66% rename from d2m/actions/retrigger.js rename to src/d2m/actions/retrigger.js index 030ffbf..aa79a79 100644 --- a/d2m/actions/retrigger.js +++ b/src/d2m/actions/retrigger.js @@ -12,6 +12,7 @@ function debugRetrigger(message) { } } +const paused = new Set() const emitter = new EventEmitter() /** @@ -25,13 +26,15 @@ const emitter = new EventEmitter() * @returns {boolean} false if the event was found and the function will be ignored, true if the event was not found and the function will be retriggered */ function eventNotFoundThenRetrigger(messageID, fn, ...rest) { - const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get() - if (eventID) { - debugRetrigger(`[retrigger] OK mid <-> eid = ${messageID} <-> ${eventID}`) - return false // event was found so don't retrigger + if (!paused.has(messageID)) { + const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get() + if (eventID) { + debugRetrigger(`[retrigger] OK mid <-> eid = ${messageID} <-> ${eventID}`) + return false // event was found so don't retrigger + } } - debugRetrigger(`[retrigger] WAIT mid <-> eid = ${messageID} <-> ${eventID}`) + debugRetrigger(`[retrigger] WAIT mid = ${messageID}`) emitter.once(messageID, () => { debugRetrigger(`[retrigger] TRIGGER mid = ${messageID}`) fn(...rest) @@ -46,6 +49,25 @@ function eventNotFoundThenRetrigger(messageID, fn, ...rest) { return true // event was not found, then retrigger } +/** + * Anything calling retrigger during the callback will be paused and retriggered after the callback resolves. + * @template T + * @param {string} messageID + * @param {Promise} promise + * @returns {Promise} + */ +async function pauseChanges(messageID, promise) { + try { + debugRetrigger(`[retrigger] PAUSE mid = ${messageID}`) + paused.add(messageID) + return await promise + } finally { + debugRetrigger(`[retrigger] RESUME mid = ${messageID}`) + paused.delete(messageID) + messageFinishedBridging(messageID) + } +} + /** * Triggers any pending operations that were waiting on the corresponding event ID. * @param {string} messageID @@ -59,3 +81,4 @@ function messageFinishedBridging(messageID) { module.exports.eventNotFoundThenRetrigger = eventNotFoundThenRetrigger module.exports.messageFinishedBridging = messageFinishedBridging +module.exports.pauseChanges = pauseChanges diff --git a/d2m/actions/send-message.js b/src/d2m/actions/send-message.js similarity index 86% rename from d2m/actions/send-message.js rename to src/d2m/actions/send-message.js index 6d100d4..b1cb680 100644 --- a/d2m/actions/send-message.js +++ b/src/d2m/actions/send-message.js @@ -31,24 +31,21 @@ async function sendMessage(message, channel, guild, row) { if (!dUtils.isWebhookMessage(message)) { if (message.author.id === discord.application.id) { // no need to sync the bot's own user - } else if (message.member) { // available on a gateway message create event + } else { senderMxid = await registerUser.syncUser(message.author, message.member, channel, guild, roomID) - } else { // well, good enough... - senderMxid = await registerUser.ensureSimJoined(message.author, roomID) } } else if (row && row.speedbump_webhook_id === message.webhook_id) { // Handle the PluralKit public instance if (row.speedbump_id === "466378653216014359") { - const pkMessage = await registerPkUser.fetchMessage(message.id) - senderMxid = await registerPkUser.syncUser(message.author, pkMessage, roomID) + senderMxid = await registerPkUser.syncUser(message.id, message.author, roomID, true) } } - const events = await messageToEvent.messageToEvent(message, guild, {}, {api}) + const events = await messageToEvent.messageToEvent(message, guild, {}, {api, snow: discord.snow}) const eventIDs = [] if (events.length) { - db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id) - if (senderMxid) api.sendTyping(roomID, false, senderMxid) + db.prepare("INSERT OR IGNORE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id) + if (senderMxid) api.sendTyping(roomID, false, senderMxid).catch(() => {}) } for (const event of events) { const part = event === events[0] ? 0 : 1 diff --git a/src/d2m/actions/set-presence.js b/src/d2m/actions/set-presence.js new file mode 100644 index 0000000..f26668f --- /dev/null +++ b/src/d2m/actions/set-presence.js @@ -0,0 +1,114 @@ +// @ts-check + +const passthrough = require("../../passthrough") +const {sync, select} = passthrough +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") + +/* + We do this in two phases for optimisation reasons. + Discord sends us an event when the presence *changes.* + We need to keep the event data in memory because we need to *repeatedly* send it to Matrix using a long-lived loop. + + There are two phases to get it from Discord to Matrix. + The first phase stores Discord presence data in memory. + The second phase loops over the memory and sends it on to Matrix. + + Optimisations: + * Presence can be deactivated per-guild in OOYE settings. If the user doesn't share any presence-enabled-guilds with us, we don't need to do anything. + * Presence can be sent for users without sims. In this case, they will be discarded from memory when the next loop begins. + * Matrix ID is cached in memory on the Presence class. The alternative to this is querying it every time we receive a presence change event in a valid guild. + * Presence can be sent multiple times in a row for the same user for each guild we share. The loop timer prevents these "changes" from individually reaching the homeserver. +*/ + +// Synapse expires each user's presence after 30 seconds and makes them offline, so we have to loop every 28 seconds and update each user again. +const presenceLoopInterval = 28e3 + +// Cache the list of enabled guilds rather than accessing it like multiple times per second when any user changes presence +const guildPresenceSetting = new class { + /** @private @type {Set} */ guilds + constructor() { + this.update() + } + update() { + this.guilds = new Set(select("guild_space", "guild_id", {presence: 1}).pluck().all()) + } + isEnabled(guildID) { + return this.guilds.has(guildID) + } +} + +class Presence extends sync.reloadClassMethods(() => Presence) { + /** @type {string} */ userID + /** @type {{presence: "online" | "offline" | "unavailable", status_msg?: string}} */ data + /** @private @type {?string | undefined} */ mxid + /** @private @type {number} */ delay = Math.random() + + constructor(userID) { + super() + this.userID = userID + } + + /** + * @param {string} status status field from Discord's PRESENCE_UPDATE event + */ + setData(status) { + const presence = + ( status === "online" ? "online" + : status === "offline" ? "offline" + : "unavailable") + this.data = {presence} + } + + sync(presences) { + const mxid = this.mxid ??= select("sim", "mxid", {user_id: this.userID}).pluck().get() + if (!mxid) return presences.delete(this.userID) + // I haven't tried, but I assume Synapse explodes if you try to update too many presences at the same time. + // This random delay will space them out over the whole 28 second cycle. + setTimeout(() => { + api.setPresence(this.data, mxid).catch(() => {}) + }, this.delay * presenceLoopInterval).unref() + } +} + +const presenceTracker = new class { + /** @private @type {Map} userID -> Presence */ presences = sync.remember(() => new Map()) + + constructor() { + sync.addTemporaryInterval(() => this.syncPresences(), presenceLoopInterval) + } + + /** + * This function is called for each Discord presence packet. + * @param {string} userID Discord user ID + * @param {string} guildID Discord guild ID that this presence applies to (really, the same presence applies to every single guild, but is delivered separately by Discord for some reason) + * @param {string} status status field from Discord's PRESENCE_UPDATE event + */ + incomingPresence(userID, guildID, status) { + // stop tracking offline presence objects - they will naturally expire and fall offline on the homeserver + if (status === "offline") return this.presences.delete(userID) + // check if we care about this guild + if (!guildPresenceSetting.isEnabled(guildID)) return + // start tracking presence for user (we'll check if they have a sim in the next sync loop) + this.getOrCreatePresence(userID).setData(status) + } + + /** @private */ + getOrCreatePresence(userID) { + return this.presences.get(userID) || (() => { + const presence = new Presence(userID) + this.presences.set(userID, presence) + return presence + })() + } + + /** @private */ + syncPresences() { + for (const presence of this.presences.values()) { + presence.sync(this.presences) + } + } +} + +module.exports.presenceTracker = presenceTracker +module.exports.guildPresenceSetting = guildPresenceSetting diff --git a/d2m/actions/speedbump.js b/src/d2m/actions/speedbump.js similarity index 100% rename from d2m/actions/speedbump.js rename to src/d2m/actions/speedbump.js diff --git a/d2m/actions/update-pins.js b/src/d2m/actions/update-pins.js similarity index 60% rename from d2m/actions/update-pins.js rename to src/d2m/actions/update-pins.js index 5d98501..15febaa 100644 --- a/d2m/actions/update-pins.js +++ b/src/d2m/actions/update-pins.js @@ -6,6 +6,8 @@ const {discord, sync, db} = passthrough const pinsToList = sync.require("../converters/pins-to-list") /** @type {import("../../matrix/api")} */ const api = sync.require("../../matrix/api") +/** @type {import("../../matrix/kstate")} */ +const ks = sync.require("../../matrix/kstate") /** * @template {string | null | undefined} T @@ -23,13 +25,21 @@ function convertTimestamp(timestamp) { * @param {number?} convertedTimestamp */ async function updatePins(channelID, roomID, convertedTimestamp) { - const pins = await discord.snow.channel.getChannelPinnedMessages(channelID) - const eventIDs = pinsToList.pinsToList(pins) - if (pins.length === eventIDs.length || eventIDs.length) { - await api.sendState(roomID, "m.room.pinned_events", "", { - pinned: eventIDs - }) + try { + var discordPins = await discord.snow.channel.getChannelPinnedMessages(channelID) + } catch (e) { + if (e.message === `{"message": "Missing Access", "code": 50001}`) { + return // Discord sends channel pins update events even for channels that the bot can't view/get pins in, just ignore it + } + throw e } + + const kstate = await ks.roomToKState(roomID) + const pinned = pinsToList.pinsToList(discordPins, kstate) + + const diff = ks.diffKState(kstate, {"m.room.pinned_events/": {pinned}}) + await ks.applyKStateDiffToRoom(roomID, diff) + db.prepare("UPDATE channel_room SET last_bridged_pin_timestamp = ? WHERE channel_id = ?").run(convertedTimestamp || 0, channelID) } diff --git a/d2m/converters/edit-to-changes.js b/src/d2m/converters/edit-to-changes.js similarity index 73% rename from d2m/converters/edit-to-changes.js rename to src/d2m/converters/edit-to-changes.js index c80bb49..c615a3f 100644 --- a/d2m/converters/edit-to-changes.js +++ b/src/d2m/converters/edit-to-changes.js @@ -22,16 +22,19 @@ function eventCanBeEdited(ev) { return true } +function eventIsText(ev) { + return ev.old.event_type === "m.room.message" && (ev.old.event_subtype === "m.text" || ev.old.event_subtype === "m.notice") +} + /** * @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message - * IMPORTANT: This may not have all the normal fields! The API documentation doesn't provide possible types, just says it's all optional! - * Since I don't have a spec, I will have to capture some real traffic and add it as test cases... I hope they don't change anything later... * @param {import("discord-api-types/v10").APIGuild} guild * @param {import("../../matrix/api")} api simple-as-nails dependency injection for the matrix API */ async function editToChanges(message, guild, api) { // If it is a user edit, allow deleting old messages (e.g. they might have removed text from an image). // If it is the system adding a generated embed to a message, don't delete old messages since the system only sends partial data. + // Since an update in August 2024, the system always provides the full data of message updates. I'll leave in the old code since it won't cause problems. const isGeneratedEmbed = !("content" in message) @@ -122,29 +125,59 @@ async function editToChanges(message, guild, api) { unchangedEvents.push(...eventsToReplace.filter(ev => !eventCanBeEdited(ev))) // Move them from eventsToRedact to unchangedEvents. eventsToReplace = eventsToReplace.filter(eventCanBeEdited) + // Now, everything in eventsToReplace has the potential to have changed, but did it actually? + // (Example: if a URL preview was generated or updated, the message text won't have changed.) + // Only way to detect this is by text content. So we'll remove text events from eventsToReplace that have the same new text as text currently in the event. + for (let i = eventsToReplace.length; i--;) { // move backwards through array + const event = eventsToReplace[i] + if (!eventIsText(event)) continue // not text, can't analyse + const oldEvent = await api.getEvent(roomID, eventsToReplace[i].old.event_id) + const oldEventBodyWithoutQuotedReply = oldEvent.content.body?.replace(/^(>.*\n)*\n*/sm, "") + if (oldEventBodyWithoutQuotedReply !== event.newInnerContent.body) continue // event changed, must replace it + // Move it from eventsToRedact to unchangedEvents. + unchangedEvents.push(...eventsToReplace.filter(ev => ev.old.event_id === event.old.event_id)) + eventsToReplace = eventsToReplace.filter(ev => ev.old.event_id !== event.old.event_id) + } + // We want to maintain exactly one part = 0 and one reaction_part = 0 database row at all times. - /** @type {({column: string, eventID: string} | {column: string, nextEvent: true})[]} */ + // This would be disrupted if existing events that are (reaction_)part = 0 will be redacted. + // If that is the case, pick a different existing or newly sent event to be (reaction_)part = 0. + /** @type {({column: string, eventID: string, value?: number} | {column: string, nextEvent: true})[]} */ const promotions = [] for (const column of ["part", "reaction_part"]) { const candidatesForParts = unchangedEvents.concat(eventsToReplace) // If no events with part = 0 exist (or will exist), we need to do some management. if (!candidatesForParts.some(e => e.old[column] === 0)) { + // Try to find an existing event to promote. Bigger order is better. if (candidatesForParts.length) { - // We can choose an existing event to promote. Bigger order is better. const order = e => 2*+(e.event_type === "m.room.message") + 1*+(e.old.event_subtype === "m.text") candidatesForParts.sort((a, b) => order(b) - order(a)) if (column === "part") { promotions.push({column, eventID: candidatesForParts[0].old.event_id}) // part should be the first one + } else if (eventsToSend.length) { + promotions.push({column, nextEvent: true}) // reaction_part should be the last one } else { promotions.push({column, eventID: candidatesForParts[candidatesForParts.length - 1].old.event_id}) // reaction_part should be the last one } - } else { - // No existing events to promote, but new events are being sent. Whatever gets sent will be the next part = 0. + } + // Or, if there are no existing events to promote and new events will be sent, whatever gets sent will be the next part = 0. + else { promotions.push({column, nextEvent: true}) } } } + // If adding events, try to keep reactions attached to the bottom of the group (unless reactions have already been added) + if (eventsToSend.length && !promotions.length) { + const existingReaction = select("reaction", "message_id", {message_id: message.id}).pluck().get() + if (!existingReaction) { + const existingPartZero = unchangedEvents.concat(eventsToReplace).find(p => p.old.reaction_part === 0) + assert(existingPartZero) // will exist because a reaction_part=0 always exists and no events are being removed + promotions.push({column: "reaction_part", eventID: existingPartZero.old.event_id, value: 1}) // update the current reaction_part to 1 + promotions.push({column: "reaction_part", nextEvent: true}) // the newly created event will have reaction_part = 0 + } + } + // Removing unnecessary properties before returning eventsToRedact = eventsToRedact.map(e => e.old.event_id) eventsToReplace = eventsToReplace.map(e => ({oldID: e.old.event_id, newContent: makeReplacementEventContent(e.old.event_id, e.newFallbackContent, e.newInnerContent)})) @@ -178,4 +211,3 @@ function makeReplacementEventContent(oldID, newFallbackContent, newInnerContent) } module.exports.editToChanges = editToChanges -module.exports.makeReplacementEventContent = makeReplacementEventContent diff --git a/d2m/converters/edit-to-changes.test.js b/src/d2m/converters/edit-to-changes.test.js similarity index 70% rename from d2m/converters/edit-to-changes.test.js rename to src/d2m/converters/edit-to-changes.test.js index b00a2c4..30549c7 100644 --- a/d2m/converters/edit-to-changes.test.js +++ b/src/d2m/converters/edit-to-changes.test.js @@ -1,10 +1,17 @@ const {test} = require("supertape") const {editToChanges} = require("./edit-to-changes") -const data = require("../../test/data") +const data = require("../../../test/data") const Ty = require("../../types") test("edit2changes: edit by webhook", async t => { - const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edit_by_webhook, data.guild.general, {}) + let called = 0 + const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edit_by_webhook, data.guild.general, { + getEvent(roomID, eventID) { + called++ + t.equal(eventID, "$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10") + return {content: {body: "dummy"}} + } + }) t.deepEqual(eventsToRedact, []) t.deepEqual(eventsToSend, []) t.deepEqual(eventsToReplace, [{ @@ -28,10 +35,15 @@ test("edit2changes: edit by webhook", async t => { }]) t.equal(senderMxid, null) t.deepEqual(promotions, []) + t.equal(called, 1) }) test("edit2changes: bot response", async t => { const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.bot_response, data.guild.general, { + getEvent(roomID, eventID) { + t.equal(eventID, "$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY") + return {content: {body: "dummy"}} + }, async getJoinedMembers(roomID) { t.equal(roomID, "!hYnGGlPHlbujVVfktC:cadence.moe") return new Promise(resolve => { @@ -99,9 +111,9 @@ test("edit2changes: change file type", async t => { t.deepEqual(eventsToRedact, ["$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCJ"]) t.deepEqual(eventsToSend, [{ $type: "m.room.message", - body: "📝 Uploaded file: https://cdn.discordapp.com/attachments/112760669178241024/1141501302497615912/gaze_into_my_dark_mind.txt (20 MB)", + body: "📝 Uploaded file: https://bridge.example.org/download/discordcdn/112760669178241024/1141501302497615912/gaze_into_my_dark_mind.txt (20 MB)", format: "org.matrix.custom.html", - formatted_body: "📝 Uploaded file: gaze_into_my_dark_mind.txt (20 MB)", + formatted_body: "📝 Uploaded file: gaze_into_my_dark_mind.txt (20 MB)", "m.mentions": {}, msgtype: "m.text" }]) @@ -109,7 +121,7 @@ test("edit2changes: change file type", async t => { t.deepEqual(promotions, [{column: "part", nextEvent: true}, {column: "reaction_part", nextEvent: true}]) }) -test("edit2changes: add caption back to that image", async t => { +test("edit2changes: add caption back to that image (due to it having a reaction, the reaction_part will not be moved)", async t => { const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.added_caption_to_image, data.guild.general, {}) t.deepEqual(eventsToRedact, []) t.deepEqual(eventsToSend, [{ @@ -123,7 +135,14 @@ test("edit2changes: add caption back to that image", async t => { }) test("edit2changes: stickers and attachments are not changed, only the content can be edited", async t => { - const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments, data.guild.general, {}) + let called = 0 + const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments, data.guild.general, { + getEvent(roomID, eventID) { + called++ + t.equal(eventID, "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4") + return {content: {body: "dummy"}} + } + }) t.deepEqual(eventsToRedact, []) t.deepEqual(eventsToSend, []) t.deepEqual(eventsToReplace, [{ @@ -145,10 +164,16 @@ test("edit2changes: stickers and attachments are not changed, only the content c } } }]) + t.equal(called, 1) }) test("edit2changes: edit of reply to skull webp attachment with content", async t => { - const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edit_of_reply_to_skull_webp_attachment_with_content, data.guild.general, {}) + const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edit_of_reply_to_skull_webp_attachment_with_content, data.guild.general, { + getEvent(roomID, eventID) { + t.equal(eventID, "$vgTKOR5ZTYNMKaS7XvgEIDaOWZtVCEyzLLi5Pc5Gz4M") + return {content: {body: "dummy"}} + } + }) t.deepEqual(eventsToRedact, []) t.deepEqual(eventsToSend, []) t.deepEqual(eventsToReplace, [{ @@ -177,7 +202,12 @@ test("edit2changes: edit of reply to skull webp attachment with content", async }) test("edit2changes: edits the text event when multiple rows have part = 0 (should never happen in real life, but make sure the safety net works)", async t => { - const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments_but_all_parts_equal_0, data.guild.general, {}) + const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments_but_all_parts_equal_0, data.guild.general, { + getEvent(roomID, eventID) { + t.equal(eventID, "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd999") + return {content: {body: "dummy"}} + } + }) t.deepEqual(eventsToRedact, []) t.deepEqual(eventsToSend, []) t.deepEqual(eventsToReplace, [{ @@ -202,7 +232,12 @@ test("edit2changes: edits the text event when multiple rows have part = 0 (shoul }) test("edit2changes: promotes the text event when multiple rows have part = 1 (should never happen in real life, but make sure the safety net works)", async t => { - const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments_but_all_parts_equal_1, data.guild.general, {}) + const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments_but_all_parts_equal_1, data.guild.general, { + getEvent(roomID, eventID) { + t.equal(eventID, "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd111") + return {content: {body: "dummy"}} + } + }) t.deepEqual(eventsToRedact, []) t.deepEqual(eventsToSend, []) t.deepEqual(eventsToReplace, [{ @@ -266,7 +301,62 @@ test("edit2changes: generated embed", async t => { + `
  • Both players present their best five-or-less-card pok...
  • `, "m.mentions": {} }]) - t.deepEqual(promotions, []) // TODO: it would be ideal to promote this to reaction_part = 0. this is OK to do because the main message won't have had any reactions yet. + t.deepEqual(promotions, [{ + "column": "reaction_part", + "eventID": "$mPSzglkCu-6cZHbYro0RW2u5mHvbH9aXDjO5FCzosc0", + "value": 1, + }, { + "column": "reaction_part", + "nextEvent": true, + }]) + t.equal(senderMxid, "@_ooye_cadence:cadence.moe") + t.equal(called, 1) +}) + +test("edit2changes: generated embed on a reply", async t => { + let called = 0 + const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_on_reply, data.guild.general, { + getEvent(roomID, eventID) { + called++ + t.equal(eventID, "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF") + return { + type: "m.room.message", + content: { + // Unfortunately the edited message doesn't include the message_reference field. Fine. Whatever. It looks normal if you're using a good client. + body: "> a Discord user: [Replied-to message content wasn't provided by Discord]" + + "\n\nhttps://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM", + format: "org.matrix.custom.html", + formatted_body: "
    In reply to a Discord user
    [Replied-to message content wasn't provided by Discord]
    https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM", + "m.mentions": {}, + "m.relates_to": { + event_id: "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF", + rel_type: "m.replace", + }, + msgtype: "m.text", + } + } + } + }) + t.deepEqual(eventsToRedact, []) + t.deepEqual(eventsToReplace, []) + t.deepEqual(eventsToSend, [{ + $type: "m.room.message", + msgtype: "m.notice", + body: "| ## Matrix - Decentralised and secure communication https://matrix.to/" + + "\n| \n| You're invited to talk on Matrix. If you don't already have a client this link will help you pick one, and join the conversation. If you already have one, this link will help you join the conversation", + format: "org.matrix.custom.html", + formatted_body: `

    Matrix - Decentralised and secure communication` + + `

    You're invited to talk on Matrix. If you don't already have a client this link will help you pick one, and join the conversation. If you already have one, this link will help you join the conversation

    `, + "m.mentions": {} + }]) + t.deepEqual(promotions, [{ + "column": "reaction_part", + "eventID": "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF", + "value": 1, + }, { + "column": "reaction_part", + "nextEvent": true, + }]) t.equal(senderMxid, "@_ooye_cadence:cadence.moe") t.equal(called, 1) }) diff --git a/d2m/converters/emoji-to-key.js b/src/d2m/converters/emoji-to-key.js similarity index 71% rename from d2m/converters/emoji-to-key.js rename to src/d2m/converters/emoji-to-key.js index 267664c..54bda18 100644 --- a/d2m/converters/emoji-to-key.js +++ b/src/d2m/converters/emoji-to-key.js @@ -8,9 +8,10 @@ const file = sync.require("../../matrix/file") /** * @param {import("discord-api-types/v10").APIEmoji} emoji + * @param {string} message_id * @returns {Promise} */ -async function emojiToKey(emoji) { +async function emojiToKey(emoji, message_id) { let key if (emoji.id) { // Custom emoji @@ -30,7 +31,10 @@ async function emojiToKey(emoji) { // Default emoji const name = emoji.name assert(name) - key = name + // If the reaction was used on Matrix already, it might be using a different arrangement of Variation Selector 16 characters. + // We'll use the same arrangement that was originally used, otherwise a duplicate of the emoji will appear as a separate reaction. + const originalEncoding = select("reaction", "original_encoding", {message_id, encoded_emoji: encodeURIComponent(name)}).pluck().get() + key = originalEncoding || name } return key } diff --git a/d2m/converters/emoji-to-key.test.js b/src/d2m/converters/emoji-to-key.test.js similarity index 94% rename from d2m/converters/emoji-to-key.test.js rename to src/d2m/converters/emoji-to-key.test.js index 5af046c..544eada 100644 --- a/d2m/converters/emoji-to-key.test.js +++ b/src/d2m/converters/emoji-to-key.test.js @@ -2,7 +2,7 @@ const {test} = require("supertape") const {emojiToKey} = require("./emoji-to-key") -const data = require("../../test/data") +const data = require("../../../test/data") const Ty = require("../../types") test("emoji2key: unicode emoji works", async t => { diff --git a/d2m/converters/lottie.js b/src/d2m/converters/lottie.js similarity index 96% rename from d2m/converters/lottie.js rename to src/d2m/converters/lottie.js index 12a311a..969d345 100644 --- a/d2m/converters/lottie.js +++ b/src/d2m/converters/lottie.js @@ -21,7 +21,7 @@ const Rlottie = (async () => { /** * @param {string} text - * @returns {Promise} + * @returns {Promise} */ async function convert(text) { const r = await Rlottie @@ -41,6 +41,7 @@ async function convert(text) { png.data = Buffer.from(rendered) // png.pack() is a bad stream and will throw away any data it sends if it's not connected to a destination straight away. // We use Duplex.from to convert it into a good stream. + // @ts-ignore return stream.Duplex.from(png.pack()) } diff --git a/d2m/converters/lottie.test.js b/src/d2m/converters/lottie.test.js similarity index 100% rename from d2m/converters/lottie.test.js rename to src/d2m/converters/lottie.test.js diff --git a/d2m/converters/message-to-event.embeds.test.js b/src/d2m/converters/message-to-event.embeds.test.js similarity index 81% rename from d2m/converters/message-to-event.embeds.test.js rename to src/d2m/converters/message-to-event.embeds.test.js index 61a0822..ed165c6 100644 --- a/d2m/converters/message-to-event.embeds.test.js +++ b/src/d2m/converters/message-to-event.embeds.test.js @@ -1,11 +1,18 @@ const {test} = require("supertape") const {messageToEvent} = require("./message-to-event") -const data = require("../../test/data") -const Ty = require("../../types") +const data = require("../../../test/data") +const {db} = require("../../passthrough") test("message2event embeds: nothing but a field", async t => { const events = await messageToEvent(data.message_with_embeds.nothing_but_a_field, data.guild.general, {}) t.deepEqual(events, [{ + $type: "m.room.message", + body: "> ↪️ @papiophidian: used `/stats`", + format: "org.matrix.custom.html", + formatted_body: "
    ↪️ @papiophidian used /stats
    ", + "m.mentions": {}, + msgtype: "m.text", + }, { $type: "m.room.message", "m.mentions": {}, msgtype: "m.notice", @@ -26,7 +33,9 @@ test("message2event embeds: reply with just an embed", async t => { $type: "m.room.message", msgtype: "m.notice", "m.mentions": {}, - body: "| ## ⏺️ dynastic (@dynastic) https://twitter.com/i/user/719631291747078145" + body: "> In reply to an unbridged message:" + + "\n> PokemonGod: https://twitter.com/dynastic/status/1707484191963648161" + + "\n\n| ## ⏺️ dynastic (@dynastic) https://twitter.com/i/user/719631291747078145" + "\n| \n| does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?" + "\n| \n| ### Retweets" + "\n| 119" @@ -34,7 +43,8 @@ test("message2event embeds: reply with just an embed", async t => { + "\n| 5581" + "\n| — Twitter", format: "org.matrix.custom.html", - formatted_body: '

    ⏺️ dynastic (@dynastic)' + formatted_body: '

    In reply to an unbridged message from PokemonGod:
    https://twitter.com/dynastic/status/1707484191963648161
    ' + + '

    ⏺️ dynastic (@dynastic)' + '

    does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?' + '

    Retweets
    119

    Likes
    5581

    — Twitter
    ' }]) @@ -60,7 +70,7 @@ test("message2event embeds: image embed and attachment", async t => { msgtype: "m.image", url: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR", body: "Screenshot_20231001_034036.jpg", - external_url: "https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg?ex=651a1faa&is=6518ce2a&hm=eb5ca80a3fa7add8765bf404aea2028a28a2341e4a62435986bcdcf058da82f3&", + external_url: "https://bridge.example.org/download/discordcdn/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg", filename: "Screenshot_20231001_034036.jpg", info: { h: 1170, @@ -143,6 +153,13 @@ test("message2event embeds: crazy html is all escaped", async t => { test("message2event embeds: title without url", async t => { const events = await messageToEvent(data.message_with_embeds.title_without_url, data.guild.general) t.deepEqual(events, [{ + $type: "m.room.message", + body: "> ↪️ @papiophidian: used `/stats`", + format: "org.matrix.custom.html", + formatted_body: "
    ↪️ @papiophidian used /stats
    ", + "m.mentions": {}, + msgtype: "m.text", + }, { $type: "m.room.message", msgtype: "m.notice", body: "| ## Hi, I'm Amanda!\n| \n| I condone pirating music!", @@ -155,6 +172,13 @@ test("message2event embeds: title without url", async t => { test("message2event embeds: url without title", async t => { const events = await messageToEvent(data.message_with_embeds.url_without_title, data.guild.general) t.deepEqual(events, [{ + $type: "m.room.message", + body: "> ↪️ @papiophidian: used `/stats`", + format: "org.matrix.custom.html", + formatted_body: "
    ↪️ @papiophidian used /stats
    ", + "m.mentions": {}, + msgtype: "m.text", + }, { $type: "m.room.message", msgtype: "m.notice", body: "| I condone pirating music!", @@ -167,6 +191,13 @@ test("message2event embeds: url without title", async t => { test("message2event embeds: author without url", async t => { const events = await messageToEvent(data.message_with_embeds.author_without_url, data.guild.general) t.deepEqual(events, [{ + $type: "m.room.message", + body: "> ↪️ @papiophidian: used `/stats`", + format: "org.matrix.custom.html", + formatted_body: "
    ↪️ @papiophidian used /stats
    ", + "m.mentions": {}, + msgtype: "m.text", + }, { $type: "m.room.message", msgtype: "m.notice", body: "| ## Amanda\n| \n| I condone pirating music!", @@ -179,6 +210,13 @@ test("message2event embeds: author without url", async t => { test("message2event embeds: author url without name", async t => { const events = await messageToEvent(data.message_with_embeds.author_url_without_name, data.guild.general) t.deepEqual(events, [{ + $type: "m.room.message", + body: "> ↪️ @papiophidian: used `/stats`", + format: "org.matrix.custom.html", + formatted_body: "
    ↪️ @papiophidian used /stats
    ", + "m.mentions": {}, + msgtype: "m.text", + }, { $type: "m.room.message", msgtype: "m.notice", body: "| I condone pirating music!", @@ -283,6 +321,25 @@ test("message2event embeds: youtube video", async t => { }]) }) +test("message2event embeds: tenor gif should show a video link without a provider", async t => { + const events = await messageToEvent(data.message_with_embeds.tenor_gif, data.guild.general, {}, {}) + t.deepEqual(events, [{ + $type: "m.room.message", + msgtype: "m.text", + body: "@Realdditors: get real https://tenor.com/view/get-real-gif-26176788", + format: "org.matrix.custom.html", + formatted_body: "@Realdditors get real https://tenor.com/view/get-real-gif-26176788", + "m.mentions": {} + }, { + $type: "m.room.message", + msgtype: "m.notice", + body: "| 🎞️ https://media.tenor.com/Bz5pfRIu81oAAAPo/get-real.mp4", + format: "org.matrix.custom.html", + formatted_body: "

    🎞️ https://media.tenor.com/Bz5pfRIu81oAAAPo/get-real.mp4

    ", + "m.mentions": {} + }]) +}) + test("message2event embeds: if discord creates an embed preview for a discord channel link, don't copy that embed", async t => { const events = await messageToEvent(data.message_with_embeds.discord_server_included_punctuation_bad_discord, data.guild.general, {}, { api: { @@ -316,3 +373,16 @@ test("message2event embeds: if discord creates an embed preview for a discord ch "m.mentions": {} }]) }) + +test("message2event embeds: nothing generated if embeds are disabled in settings", async t => { + db.prepare("UPDATE guild_space SET url_preview = 0 WHERE guild_id = ?").run(data.guild.general.id) + const events = await messageToEvent(data.message_with_embeds.youtube_video, data.guild.general) + t.deepEqual(events, [{ + $type: "m.room.message", + msgtype: "m.text", + body: "https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E\n\n\nJutomi I'm gonna make these sounds in your walls tonight", + format: "org.matrix.custom.html", + formatted_body: `https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E


    Jutomi I'm gonna make these sounds in your walls tonight`, + "m.mentions": {} + }]) +}) diff --git a/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js similarity index 66% rename from d2m/converters/message-to-event.js rename to src/d2m/converters/message-to-event.js index 45c4032..1d6288a 100644 --- a/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -18,7 +18,7 @@ const lottie = sync.require("../actions/lottie") const mxUtils = sync.require("../../m2d/converters/utils") /** @type {import("../../discord/utils")} */ const dUtils = sync.require("../../discord/utils") -const reg = require("../../matrix/read-registration") +const {reg} = require("../../matrix/read-registration") const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) @@ -32,7 +32,12 @@ function getDiscordParseCallbacks(message, guild, useHTML) { /** @param {{id: string, type: "discordUser"}} node */ user: node => { const mxid = select("sim", "mxid", {user_id: node.id}).pluck().get() - const username = message.mentions.find(ment => ment.id === node.id)?.username || node.id + const interaction = message.interaction_metadata || message.interaction + const username = message.mentions?.find(ment => ment.id === node.id)?.username + || message.referenced_message?.mentions?.find(ment => ment.id === node.id)?.username + || (interaction?.user.id === node.id ? interaction.user.username : null) + || (message.author.id === node.id ? message.author.username : null) + || node.id if (mxid && useHTML) { return `@${username}` } else { @@ -58,7 +63,7 @@ function getDiscordParseCallbacks(message, guild, useHTML) { emoji: node => { if (useHTML) { const mxc = select("emoji", "mxc_url", {emoji_id: node.id}).pluck().get() - assert(mxc) // All emojis should have been added ahead of time in the messageToEvent function. + assert(mxc, `Emoji consistency assertion failed for ${node.name}:${node.id}`) // All emojis should have been added ahead of time in the messageToEvent function. return `:${node.name}:` } else { return `:${node.name}:` @@ -100,6 +105,7 @@ const embedTitleParser = markdown.markdownEngine.parserFor({ * @param {DiscordTypes.APIAttachment} attachment */ async function attachmentToEvent(mentions, attachment) { + const external_url = dUtils.getPublicUrlForCdn(attachment.url) const emoji = attachment.content_type?.startsWith("image/jp") ? "📸" : attachment.content_type?.startsWith("image/") ? "🖼️" @@ -113,9 +119,9 @@ async function attachmentToEvent(mentions, attachment) { $type: "m.room.message", "m.mentions": mentions, msgtype: "m.text", - body: `${emoji} Uploaded SPOILER file: ${attachment.url} (${pb(attachment.size)})`, + body: `${emoji} Uploaded SPOILER file: ${external_url} (${pb(attachment.size)})`, format: "org.matrix.custom.html", - formatted_body: `
    ${emoji} Uploaded SPOILER file: ${attachment.url} (${pb(attachment.size)})
    ` + formatted_body: `
    ${emoji} Uploaded SPOILER file: ${external_url} (${pb(attachment.size)})
    ` } } // for large files, always link them instead of uploading so I don't use up all the space in the content repo @@ -124,9 +130,9 @@ async function attachmentToEvent(mentions, attachment) { $type: "m.room.message", "m.mentions": mentions, msgtype: "m.text", - body: `${emoji} Uploaded file: ${attachment.url} (${pb(attachment.size)})`, + body: `${emoji} Uploaded file: ${external_url} (${pb(attachment.size)})`, format: "org.matrix.custom.html", - formatted_body: `${emoji} Uploaded file: ${attachment.filename} (${pb(attachment.size)})` + formatted_body: `${emoji} Uploaded file: ${attachment.filename} (${pb(attachment.size)})` } } else if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) { return { @@ -134,7 +140,7 @@ async function attachmentToEvent(mentions, attachment) { "m.mentions": mentions, msgtype: "m.image", url: await file.uploadDiscordFileToMxc(attachment.url), - external_url: attachment.url, + external_url, body: attachment.description || attachment.filename, filename: attachment.filename, info: { @@ -150,7 +156,7 @@ async function attachmentToEvent(mentions, attachment) { "m.mentions": mentions, msgtype: "m.video", url: await file.uploadDiscordFileToMxc(attachment.url), - external_url: attachment.url, + external_url, body: attachment.description || attachment.filename, filename: attachment.filename, info: { @@ -166,13 +172,13 @@ async function attachmentToEvent(mentions, attachment) { "m.mentions": mentions, msgtype: "m.audio", url: await file.uploadDiscordFileToMxc(attachment.url), - external_url: attachment.url, + external_url, body: attachment.description || attachment.filename, filename: attachment.filename, info: { mimetype: attachment.content_type, size: attachment.size, - duration: attachment.duration_secs ? attachment.duration_secs * 1000 : undefined + duration: attachment.duration_secs ? Math.round(attachment.duration_secs * 1000) : undefined } } } else { @@ -181,7 +187,7 @@ async function attachmentToEvent(mentions, attachment) { "m.mentions": mentions, msgtype: "m.file", url: await file.uploadDiscordFileToMxc(attachment.url), - external_url: attachment.url, + external_url, body: attachment.description || attachment.filename, filename: attachment.filename, info: { @@ -193,12 +199,14 @@ async function attachmentToEvent(mentions, attachment) { } /** - * @param {import("discord-api-types/v10").APIMessage} message - * @param {import("discord-api-types/v10").APIGuild} guild - * @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean}} options default values: + * @param {DiscordTypes.APIMessage} message + * @param {DiscordTypes.APIGuild} guild + * @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean, alwaysReturnFormattedBody?: boolean, scanTextForMentions?: boolean}} options default values: * - includeReplyFallback: true * - includeEditFallbackStar: false - * @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API + * - alwaysReturnFormattedBody: false - formatted_body will be skipped if it is the same as body because the message is plaintext. if you want the formatted_body to be returned anyway, for example to merge it with another message, then set this to true. + * - scanTextForMentions: true - needs to be set to false when converting forwarded messages etc which may be from a different channel that can't be scanned. + * @param {{api: import("../../matrix/api"), snow?: import("snowtransfer").SnowTransfer}} di simple-as-nails dependency injection for the matrix API */ async function messageToEvent(message, guild, options = {}, di) { const events = [] @@ -229,6 +237,16 @@ async function messageToEvent(message, guild, options = {}, di) { }] } + const interaction = message.interaction_metadata || message.interaction + if (message.type === DiscordTypes.MessageType.ChatInputCommand && interaction && "name" in interaction) { + // Commands are sent by the responding bot. Need to attach the metadata of the person using the command at the top. + let content = message.content + if (content) content = `\n${content}` + else if ((message.flags || 0) & DiscordTypes.MessageFlags.Loading) content = " — interaction loading..." + content = `> ↪️ <@${interaction.user.id}> used \`/${interaction.name}\`${content}` + message = {...message, content} // editToChanges reuses the object so we can't mutate it. have to clone it + } + /** @type {{room?: boolean, user_ids?: string[]}} We should consider the following scenarios for mentions: @@ -246,7 +264,9 @@ async function messageToEvent(message, guild, options = {}, di) { - So make sure we don't do anything in this case. */ const mentions = {} + /** @type {{event_id: string, room_id: string, source: number}?} */ let repliedToEventRow = null + let repliedToUnknownEvent = false let repliedToEventSenderMxid = null if (message.mention_everyone) mentions.room = true @@ -262,6 +282,8 @@ async function messageToEvent(message, guild, options = {}, di) { const row = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id").select("event_id", "room_id", "source").and("WHERE message_id = ? AND part = 0").get(message.message_reference.message_id) if (row) { repliedToEventRow = row + } else if (message.referenced_message) { + repliedToUnknownEvent = true } } else if (dUtils.isWebhookMessage(message) && message.embeds[0]?.author?.name?.endsWith("↩️")) { // It could be a PluralKit emulated reply, let's see if it has a message link @@ -338,8 +360,13 @@ async function messageToEvent(message, guild, options = {}, di) { result = `https://matrix.to/#/${roomID}/${eventID}?${via}` } else { const ts = dUtils.snowflakeToTimestampExact(messageID) - const {event_id} = await di.api.getEventForTimestamp(roomID, ts) - result = `https://matrix.to/#/${roomID}/${event_id}?${via}` + try { + const {event_id} = await di.api.getEventForTimestamp(roomID, ts) + result = `https://matrix.to/#/${roomID}/${event_id}?${via}` + } catch (e) { + // M_NOT_FOUND: Unable to find event from in direction Direction.FORWARDS + result = `[unknown event, timestamp resolution failed, in room: https://matrix.to/#/${roomID}?${via}]` + } } } else { result = `${match[0]} [event is from another server]` @@ -351,6 +378,13 @@ async function messageToEvent(message, guild, options = {}, di) { return content } + /** + * Translate Discord attachment links into links that go via the bridge, so they last forever. + */ + function transformAttachmentLinks(content) { + return content.replace(/https:\/\/(cdn|media)\.discordapp\.(?:com|net)\/attachments\/([0-9]+)\/([0-9]+)\/([-A-Za-z0-9_.,]+)/g, url => dUtils.getPublicUrlForCdn(url)) + } + /** * Translate links and emojis and mentions and stuff. Give back the text and HTML so they can be combined into bigger events. * @param {string} content Partial or complete Discord message content @@ -359,16 +393,17 @@ async function messageToEvent(message, guild, options = {}, di) { * @param {any} customHtmlOutput */ async function transformContent(content, customOptions = {}, customParser = null, customHtmlOutput = null) { + content = transformAttachmentLinks(content) content = await transformContentMessageLinks(content) // Handling emojis that we don't know about. The emoji has to be present in the DB for it to be picked up in the emoji markdown converter. // So we scan the message ahead of time for all its emojis and ensure they are in the DB. - const emojiMatches = [...content.matchAll(/<(a?):([^:>]{2,64}):([0-9]+)>/g)] + const emojiMatches = [...content.matchAll(/<(a?):([^:>]{1,64}):([0-9]+)>/g)] await Promise.all(emojiMatches.map(match => { const id = match[3] const name = match[2] const animated = !!match[1] - return emojiToKey.emojiToKey({id, name, animated}) // Register the custom emoji if needed + return emojiToKey.emojiToKey({id, name, animated}, message.id) // Register the custom emoji if needed })) async function transformParsedVia(parsed) { @@ -379,8 +414,10 @@ async function messageToEvent(message, guild, options = {}, di) { node.via = await getViaServersMemo(node.row.room_id) } } - if (Array.isArray(node.content)) { - await transformParsedVia(node.content) + ;for (const maybeChildNodesArray of [node, node.content, node.items]) { + if (Array.isArray(maybeChildNodesArray)) { + await transformParsedVia(maybeChildNodesArray) + } } } return parsed @@ -401,8 +438,13 @@ async function messageToEvent(message, guild, options = {}, di) { return {body, html} } - // FIXME: What was the scanMentions parameter supposed to activate? It's unused. - async function addTextEvent(body, html, msgtype, {scanMentions}) { + /** + * After converting Discord content to Matrix plaintext and HTML content, post-process the bodies and push the resulting text event + * @param {string} body matrix event plaintext body + * @param {string} html matrix event HTML body + * @param {string} msgtype matrix event msgtype (maybe m.text or m.notice) + */ + async function addTextEvent(body, html, msgtype) { // Star * prefix for fallback edits if (options.includeEditFallbackStar) { body = "* " + body @@ -410,14 +452,14 @@ async function messageToEvent(message, guild, options = {}, di) { } const flags = message.flags || 0 - if (flags & 2) { + if (flags & DiscordTypes.MessageFlags.IsCrosspost) { body = `[🔀 ${message.author.username}]\n` + body html = `🔀 ${message.author.username}
    ` + html } // Fallback body/formatted_body for replies // This branch is optional - do NOT change anything apart from the reply fallback, since it may not be run - if (repliedToEventRow && options.includeReplyFallback !== false) { + if ((repliedToEventRow || repliedToUnknownEvent) && options.includeReplyFallback !== false) { let repliedToDisplayName let repliedToUserHtml if (repliedToEventRow?.source === 0 && repliedToEventSenderMxid) { @@ -430,27 +472,35 @@ async function messageToEvent(message, guild, options = {}, di) { repliedToUserHtml = repliedToDisplayName } let repliedToContent = message.referenced_message?.content - if (repliedToContent?.startsWith("> <:L1:")) { + if (repliedToContent?.match(/^(-# )?> (-# )?<:L1:/)) { // If the Discord user is replying to a Matrix user's reply, the fallback is going to contain the emojis and stuff from the bridged rep of the Matrix user's reply quote. // Need to remove that previous reply rep from this fallback body. The fallbody body should only contain the Matrix user's actual message. - repliedToContent = repliedToContent.split("\n").slice(2).join("\n") + // ┌──────A─────┐ A reply rep starting with >quote or -#smalltext >quote. Match until the end of the line. + // ┆ ┆┌─B─┐ There may be up to 2 reply rep lines in a row if it was created in the old format. Match all lines. + repliedToContent = repliedToContent.replace(/^((-# )?> .*\n){1,2}/, "") } if (repliedToContent == "") repliedToContent = "[Media]" else if (!repliedToContent) repliedToContent = "[Replied-to message content wasn't provided by Discord]" - const repliedToHtml = markdown.toHTML(repliedToContent, { - discordCallback: getDiscordParseCallbacks(message, guild, true) - }) - const repliedToBody = markdown.toHTML(repliedToContent, { - discordCallback: getDiscordParseCallbacks(message, guild, false), - discordOnly: true, - escapeHTML: false, - }) - html = `
    In reply to ${repliedToUserHtml}` - + `
    ${repliedToHtml}
    ` - + html - body = (`${repliedToDisplayName}: ` // scenario 1 part B for mentions - + repliedToBody).split("\n").map(line => "> " + line).join("\n") - + "\n\n" + body + const {body: repliedToBody, html: repliedToHtml} = await transformContent(repliedToContent) + if (repliedToEventRow) { + // Generate a reply pointing to the Matrix event we found + html = `
    In reply to ${repliedToUserHtml}` + + `
    ${repliedToHtml}
    ` + + html + body = (`${repliedToDisplayName}: ` // scenario 1 part B for mentions + + repliedToBody).split("\n").map(line => "> " + line).join("\n") + + "\n\n" + body + } else { // repliedToUnknownEvent + // This reply can't point to the Matrix event because it isn't bridged, we need to indicate this. + assert(message.referenced_message) + const dateDisplay = dUtils.howOldUnbridgedMessage(message.referenced_message.timestamp, message.timestamp) + html = `
    In reply to ${dateDisplay} from ${repliedToDisplayName}:` + + `
    ${repliedToHtml}
    ` + + html + body = (`In reply to ${dateDisplay}:\n${repliedToDisplayName}: ` + + repliedToBody).split("\n").map(line => "> " + line).join("\n") + + "\n\n" + body + } } const newTextMessageEvent = { @@ -462,7 +512,7 @@ async function messageToEvent(message, guild, options = {}, di) { const isPlaintext = body === html - if (!isPlaintext) { + if (!isPlaintext || options.alwaysReturnFormattedBody) { Object.assign(newTextMessageEvent, { format: "org.matrix.custom.html", formatted_body: html @@ -480,11 +530,62 @@ async function messageToEvent(message, guild, options = {}, di) { message.content = "changed the channel name to **" + message.content + "**" } + // Forwarded content appears first + if (message.message_reference?.type === DiscordTypes.MessageReferenceType.Forward && message.message_snapshots?.length) { + // Forwarded notice + const eventID = select("event_message", "event_id", {message_id: message.message_reference.message_id}).pluck().get() + const room = select("channel_room", ["room_id", "name", "nick"], {channel_id: message.message_reference.channel_id}).get() + const forwardedNotice = new mxUtils.MatrixStringBuilder() + if (room) { + const roomName = room && (room.nick || room.name) + const via = await getViaServersMemo(room.room_id) + if (eventID) { + forwardedNotice.addLine( + `[🔀 Forwarded from #${roomName}]`, + tag`🔀 Forwarded from ${roomName}` + ) + } else { + forwardedNotice.addLine( + `[🔀 Forwarded from #${roomName}]`, + tag`🔀 Forwarded from ${roomName}` + ) + } + } else { + forwardedNotice.addLine( + `[🔀 Forwarded message]`, + tag`🔀 Forwarded message` + ) + } + // Forwarded content + // @ts-ignore + const forwardedEvents = await messageToEvent(message.message_snapshots[0].message, guild, {includeReplyFallback: false, includeEditFallbackStar: false, alwaysReturnFormattedBody: true, scanTextForMentions: false}, di) + + // Indent + for (const event of forwardedEvents) { + if (["m.text", "m.notice"].includes(event.msgtype)) { + event.msgtype = "m.notice" + event.body = event.body.split("\n").map(l => "» " + l).join("\n") + event.formatted_body = `
    ${event.formatted_body}
    ` + } + } + + // Try to merge the forwarded content with the forwarded notice + let {body, formatted_body} = forwardedNotice.get() + if (forwardedEvents.length >= 1 && ["m.text", "m.notice"].includes(forwardedEvents[0].msgtype)) { // Try to merge the forwarded content and the forwarded notice + forwardedEvents[0].body = body + "\n" + forwardedEvents[0].body + forwardedEvents[0].formatted_body = formatted_body + "
    " + forwardedEvents[0].formatted_body + } else { + await addTextEvent(body, formatted_body, "m.notice") + } + events.push(...forwardedEvents) + } + + // Then text content if (message.content) { // Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention. const matches = [...message.content.matchAll(/@ ?([a-z0-9._]+)\b/gi)] - if (matches.length && matches.some(m => m[1].match(/[a-z]/i) && m[1] !== "everyone" && m[1] !== "here")) { + if (options.scanTextForMentions !== false && matches.length && matches.some(m => m[1].match(/[a-z]/i) && m[1] !== "everyone" && m[1] !== "here")) { const writtenMentionsText = matches.map(m => m[1].toLowerCase()) const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get() assert(roomID) @@ -499,9 +600,51 @@ async function messageToEvent(message, guild, options = {}, di) { } } - // Text content appears first const {body, html} = await transformContent(message.content) - await addTextEvent(body, html, msgtype, {scanMentions: true}) + await addTextEvent(body, html, msgtype) + } + + // Then scheduled events + if (message.content && di?.snow) { + for (const match of [...message.content.matchAll(/discord\.gg\/([A-Za-z0-9]+)\?event=([0-9]{18,})/g)]) { // snowflake has minimum 18 because the events feature is at least that old + const invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]}) + const event = invite.guild_scheduled_event + if (!event) continue // the event ID provided was not valid + + const formatter = new Intl.DateTimeFormat("en-NZ", {month: "long", day: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "shortGeneric"}) // 9 June at 3:00 pm NZT + const rep = new mxUtils.MatrixStringBuilder() + + // Add time + if (event.scheduled_end_time) { + // @ts-ignore - no definition available for formatRange + rep.addParagraph(`Scheduled Event - ${formatter.formatRange(new Date(event.scheduled_start_time), new Date(event.scheduled_end_time))}`) + } else { + rep.addParagraph(`Scheduled Event - ${formatter.format(new Date(event.scheduled_start_time))}`) + } + + // Add details + rep.addLine(`## ${event.name}`, tag`${event.name}`) + if (event.description) rep.addLine(event.description) + + // Add location + if (event.entity_metadata?.location) { + rep.addParagraph(`📍 ${event.entity_metadata.location}`) + } else if (invite.channel?.name) { + const roomID = select("channel_room", "room_id", {channel_id: invite.channel.id}).pluck().get() + if (roomID) { + const via = await getViaServersMemo(roomID) + rep.addParagraph(`🔊 ${invite.channel.name} - https://matrix.to/#/${roomID}?${via}`, tag`🔊 ${invite.channel.name} - ${invite.channel.name}`) + } else { + rep.addParagraph(`🔊 ${invite.channel.name}`) + } + } + + // Send like an embed + let {body, formatted_body: html} = rep.get() + body = body.split("\n").map(l => "| " + l).join("\n") + html = `
    ${html}
    ` + await addTextEvent(body, html, "m.notice") + } } // Then attachments @@ -511,7 +654,12 @@ async function messageToEvent(message, guild, options = {}, di) { } // Then embeds + const urlPreviewEnabled = select("guild_space", "url_preview", {guild_id: guild?.id}).pluck().get() ?? 1 for (const embed of message.embeds || []) { + if (!urlPreviewEnabled && !message.author?.bot) { + continue // show embeds for everyone if enabled, or bot users only if disabled (bots often send content in embeds) + } + if (embed.type === "image") { continue // Matrix's own URL previews are fine for images. } @@ -524,7 +672,7 @@ async function messageToEvent(message, guild, options = {}, di) { const rep = new mxUtils.MatrixStringBuilder() // Provider - if (embed.provider?.name) { + if (embed.provider?.name && embed.provider.name !== "Tenor") { if (embed.provider.url) { rep.addParagraph(`via ${embed.provider.name} ${embed.provider.url}`, tag`${embed.provider.name}`) } else { @@ -573,9 +721,9 @@ async function messageToEvent(message, guild, options = {}, di) { let chosenImage = embed.image?.url // the thumbnail seems to be used for "article" type but displayed big at the bottom by discord if (embed.type === "article" && embed.thumbnail?.url && !chosenImage) chosenImage = embed.thumbnail.url - if (chosenImage) rep.addParagraph(`📸 ${chosenImage}`) + if (chosenImage) rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`) - if (embed.video?.url) rep.addParagraph(`🎞️ ${embed.video.url}`) + if (embed.video?.url) rep.addParagraph(`🎞️ ${dUtils.getPublicUrlForCdn(embed.video.url)}`) if (embed.footer?.text) rep.addLine(`— ${embed.footer.text}`, tag`— ${embed.footer.text}`) let {body, formatted_body: html} = rep.get() @@ -583,7 +731,7 @@ async function messageToEvent(message, guild, options = {}, di) { html = `
    ${html}
    ` // Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person - await addTextEvent(body, html, "m.notice", {scanMentions: false}) + await addTextEvent(body, html, "m.notice") } // Then stickers diff --git a/d2m/converters/message-to-event.pk.test.js b/src/d2m/converters/message-to-event.pk.test.js similarity index 98% rename from d2m/converters/message-to-event.pk.test.js rename to src/d2m/converters/message-to-event.pk.test.js index c490469..ce83d54 100644 --- a/d2m/converters/message-to-event.pk.test.js +++ b/src/d2m/converters/message-to-event.pk.test.js @@ -1,6 +1,6 @@ const {test} = require("supertape") const {messageToEvent} = require("./message-to-event") -const data = require("../../test/data") +const data = require("../../../test/data") const Ty = require("../../types") /** diff --git a/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js similarity index 55% rename from d2m/converters/message-to-event.test.js rename to src/d2m/converters/message-to-event.test.js index edcb44c..fc933e3 100644 --- a/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -1,6 +1,7 @@ const {test} = require("supertape") const {messageToEvent} = require("./message-to-event") -const data = require("../../test/data") +const {MatrixServerError} = require("../../matrix/mreq") +const data = require("../../../test/data") const Ty = require("../../types") /** @@ -64,6 +65,44 @@ test("message2event: simple user mention", async t => { test("message2event: simple room mention", async t => { let called = 0 const events = await messageToEvent(data.message.simple_room_mention, data.guild.general, {}, { + api: { + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return { + users: { + "@_ooye_bot:cadence.moe": 100 + } + } + }, + async getJoinedMembers(roomID) { + called++ + t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe") + return { + joined: { + "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, + "@user:matrix.org": {display_name: null, avatar_url: null} + } + } + } + } + }) + t.deepEqual(events, [{ + $type: "m.room.message", + "m.mentions": {}, + msgtype: "m.text", + body: "#worm-farm", + format: "org.matrix.custom.html", + formatted_body: '#worm-farm' + }]) + t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each") +}) + +test("message2event: nicked room mention", async t => { + let called = 0 + const events = await messageToEvent(data.message.nicked_room_mention, data.guild.general, {}, { api: { async getStateEvent(roomID, type, key) { called++ @@ -229,6 +268,54 @@ test("message2event: message link that OOYE doesn't know about", async t => { t.equal(called, 3, "getEventForTimestamp, getStateEvent, and getJoinedMembers should be called once each") }) +test("message2event: message timestamp failed to fetch", async t => { + let called = 0 + const events = await messageToEvent(data.message.message_link_to_before_ooye, data.guild.general, {}, { + api: { + async getEventForTimestamp(roomID, ts) { + called++ + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") + throw new MatrixServerError({ + errcode: "M_NOT_FOUND", + error: "Unable to find event from 1726762095974 in direction Direction.FORWARDS" + }, {}) + }, + async getStateEvent(roomID, type, key) { // for ?via calculation + called++ + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return { + users: { + "@_ooye_bot:cadence.moe": 100 + } + } + }, + async getJoinedMembers(roomID) { // for ?via calculation + called++ + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") + return { + joined: { + "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, + "@user:matrix.org": {display_name: null, avatar_url: null} + } + } + } + } + }) + t.deepEqual(events, [{ + $type: "m.room.message", + "m.mentions": {}, + msgtype: "m.text", + body: "Me: I'll scroll up to find a certain message I'll send\n_scrolls up and clicks message links for god knows how long_\n_completely forgets what they were looking for and simply begins scrolling up to find some fun moments_\n_stumbles upon:_ " + + "[unknown event, timestamp resolution failed, in room: https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&via=matrix.org]", + format: "org.matrix.custom.html", + formatted_body: "Me: I'll scroll up to find a certain message I'll send
    scrolls up and clicks message links for god knows how long
    completely forgets what they were looking for and simply begins scrolling up to find some fun moments
    stumbles upon: " + + '[unknown event, timestamp resolution failed, in room: https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&via=matrix.org]' + }]) + t.equal(called, 3, "getEventForTimestamp, getStateEvent, and getJoinedMembers should be called once each") +}) + test("message2event: message link from another server", async t => { const events = await messageToEvent(data.message.message_link_from_another_server, data.guild.general) t.deepEqual(events, [{ @@ -250,7 +337,7 @@ test("message2event: attachment with no content", async t => { msgtype: "m.image", url: "mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM", body: "image.png", - external_url: "https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png", + external_url: "https://bridge.example.org/download/discordcdn/497161332244742154/1124628646431297546/image.png", filename: "image.png", info: { mimetype: "image/png", @@ -267,9 +354,9 @@ test("message2event: spoiler attachment", async t => { $type: "m.room.message", "m.mentions": {}, msgtype: "m.text", - body: "📄 Uploaded SPOILER file: https://cdn.discordapp.com/attachments/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)", + body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)", format: "org.matrix.custom.html", - formatted_body: "
    📄 Uploaded SPOILER file: https://cdn.discordapp.com/attachments/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)
    " + formatted_body: "
    📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)
    " }]) }) @@ -286,7 +373,7 @@ test("message2event: stickers", async t => { msgtype: "m.image", url: "mxc://cadence.moe/ZDCNYnkPszxGKgObUIFmvjus", body: "image.png", - external_url: "https://cdn.discordapp.com/attachments/122155380120748034/1106366167486038016/image.png", + external_url: "https://bridge.example.org/download/discordcdn/122155380120748034/1106366167486038016/image.png", filename: "image.png", info: { mimetype: "image/png", @@ -340,7 +427,7 @@ test("message2event: skull webp attachment with content", async t => { mimetype: "image/webp", size: 74290 }, - external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1128084747910918195/skull.webp", + external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1128084747910918195/skull.webp", filename: "skull.webp", url: "mxc://cadence.moe/sDxWmDErBhYBxtDcJQgBETes" }]) @@ -374,7 +461,7 @@ test("message2event: reply to skull webp attachment with content", async t => { mimetype: "image/jpeg", size: 85906 }, - external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1128084851023675515/RDT_20230704_0936184915846675925224905.jpg", + external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1128084851023675515/RDT_20230704_0936184915846675925224905.jpg", filename: "RDT_20230704_0936184915846675925224905.jpg", url: "mxc://cadence.moe/WlAbFSiNRIHPDEwKdyPeGywa" }]) @@ -445,6 +532,43 @@ test("message2event: simple reply to matrix user, reply fallbacks disabled", asy }]) }) +test("message2event: reply to matrix user with mention", async t => { + const events = await messageToEvent(data.message.reply_to_matrix_user_mention, data.guild.general, {}, { + api: { + getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$7P2O_VTQNHvavX5zNJ35DV-dbJB1Ag80tGQP_JzGdhk", { + type: "m.room.message", + content: { + msgtype: "m.text", + body: "@_ooye_extremity:cadence.moe you owe me $30", + format: "org.matrix.custom.html", + formatted_body: "@_ooye_extremity:cadence.moe you owe me $30" + }, + sender: "@cadence:cadence.moe" + }) + } + }) + t.deepEqual(events, [{ + $type: "m.room.message", + "m.relates_to": { + "m.in_reply_to": { + event_id: "$7P2O_VTQNHvavX5zNJ35DV-dbJB1Ag80tGQP_JzGdhk" + } + }, + "m.mentions": { + user_ids: [ + "@cadence:cadence.moe" + ] + }, + msgtype: "m.text", + body: "> okay 🤍 yay 🤍: @extremity: you owe me $30\n\nkys", + format: "org.matrix.custom.html", + formatted_body: + '
    In reply to okay 🤍 yay 🤍' + + '
    @extremity you owe me $30
    ' + + 'kys' + }]) +}) + test("message2event: reply with a video", async t => { const events = await messageToEvent(data.message.reply_with_video, data.guild.general, { api: { @@ -464,7 +588,7 @@ test("message2event: reply with a video", async t => { body: "Ins_1960637570.mp4", filename: "Ins_1960637570.mp4", url: "mxc://cadence.moe/kMqLycqMURhVpwleWkmASpnU", - external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1197621094786531358/Ins_1960637570.mp4?ex=65bbee8f&is=65a9798f&hm=ae14f7824c3d526c5e11c162e012e1ee405fd5776e1e9302ed80ccd86503cfda&", + external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1197621094786531358/Ins_1960637570.mp4", info: { h: 854, mimetype: "video/mp4", @@ -485,10 +609,10 @@ test("message2event: voice message", async t => { t.deepEqual(events, [{ $type: "m.room.message", body: "voice-message.ogg", - external_url: "https://cdn.discordapp.com/attachments/1099031887500034088/1112476845502365786/voice-message.ogg?ex=65c92d4c&is=65b6b84c&hm=0654bab5027474cbe23875954fa117cf44d8914c144cd151879590fa1baf8b1c&", + external_url: "https://bridge.example.org/download/discordcdn/1099031887500034088/1112476845502365786/voice-message.ogg", filename: "voice-message.ogg", info: { - duration: 3960.0000381469727, + duration: 3960, mimetype: "audio/ogg", size: 10584, }, @@ -508,7 +632,7 @@ test("message2event: misc file", async t => { }, { $type: "m.room.message", body: "the.yml", - external_url: "https://cdn.discordapp.com/attachments/122155380120748034/1174514575220158545/the.yml?ex=65cd6270&is=65baed70&hm=8c5f1b571784e3c7f99628492298815884e351ae0dc7c2ae40dd22d97caf27d9&", + external_url: "https://bridge.example.org/download/discordcdn/122155380120748034/1174514575220158545/the.yml", filename: "the.yml", info: { mimetype: "text/plain; charset=utf-8", @@ -559,6 +683,98 @@ test("message2event: simple reply in thread to a matrix user's reply", async t = }]) }) +test("message2event: infinidoge's reply to ami's matrix smalltext reply to infinidoge", async t => { + const events = await messageToEvent(data.message.infinidoge_reply_to_ami_matrix_smalltext_reply_to_infinidoge, data.guild.general, {}, { + api: { + getEvent: mockGetEvent(t, "!BnKuBPCvyfOkhcUjEu:cadence.moe", "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4", { + type: "m.room.message", + sender: "@ami:the-apothecary.club", + content: { + msgtype: "m.text", + body: `> <@_ooye_infinidoge:cadence.moe> Neat that they thought of that\n\nlet me guess they got a lot of bug reports like "empty chest with no loot?"`, + format: "org.matrix.custom.html", + formatted_body: `
    In reply to @_ooye_infinidoge:cadence.moe
    Neat that they thought of that
    let me guess they got a lot of bug reports like "empty chest with no loot?"`, + "m.relates_to": { + "m.in_reply_to": { + event_id: "$baby" + } + } + }, + event_id: "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4", + room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe" + }) + } + }) + t.deepEqual(events, [{ + $type: "m.room.message", + "m.relates_to": { + "m.in_reply_to": { + event_id: "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4" + } + }, + "m.mentions": { + user_ids: ["@ami:the-apothecary.club"] + }, + msgtype: "m.text", + body: `> Ami (she/her): let me guess they got a lot of bug reports like "empty chest with no loot?"\n\nMost likely`, + format: "org.matrix.custom.html", + formatted_body: `
    In reply to Ami (she/her)
    let me guess they got a lot of bug reports like "empty chest with no loot?"
    Most likely`, + }]) +}) + +test("message2event: infinidoge's reply to ami's matrix smalltext singleline reply to infinidoge", async t => { + const events = await messageToEvent(data.message.infinidoge_reply_to_ami_matrix_smalltext_singleline_reply_to_infinidoge, data.guild.general, {}, { + api: { + getEvent: mockGetEvent(t, "!BnKuBPCvyfOkhcUjEu:cadence.moe", "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4", { + type: "m.room.message", + sender: "@ami:the-apothecary.club", + content: { + msgtype: "m.text", + body: `> <@_ooye_infinidoge:cadence.moe> Neat that they thought of that\n\nlet me guess they got a lot of bug reports like "empty chest with no loot?"`, + format: "org.matrix.custom.html", + formatted_body: `
    In reply to @_ooye_infinidoge:cadence.moe
    Neat that they thought of that
    let me guess they got a lot of bug reports like "empty chest with no loot?"`, + "m.relates_to": { + "m.in_reply_to": { + event_id: "$baby" + } + } + }, + event_id: "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4", + room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe" + }) + } + }) + t.deepEqual(events, [{ + $type: "m.room.message", + "m.relates_to": { + "m.in_reply_to": { + event_id: "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4" + } + }, + "m.mentions": { + user_ids: ["@ami:the-apothecary.club"] + }, + msgtype: "m.text", + body: `> Ami (she/her): let me guess they got a lot of bug reports like "empty chest with no loot?"\n\nMost likely`, + format: "org.matrix.custom.html", + formatted_body: `
    In reply to Ami (she/her)
    let me guess they got a lot of bug reports like "empty chest with no loot?"
    Most likely`, + }]) +}) + +test("message2event: reply to a Discord message that wasn't bridged", async t => { + const events = await messageToEvent(data.message.reply_to_unknown_message, data.guild.general) + t.deepEqual(events, [{ + $type: "m.room.message", + msgtype: "m.text", + body: `> In reply to a 1-day-old unbridged message:` + + `\n> Occimyy: BILLY BOB THE GREAT` + + `\n\nenigmatic`, + format: "org.matrix.custom.html", + formatted_body: `
    In reply to a 1-day-old unbridged message from Occimyy:
    BILLY BOB THE GREAT
    enigmatic`, + "m.mentions": {} + }]) +}) + test("message2event: simple written @mention for matrix user", async t => { const events = await messageToEvent(data.message.simple_written_at_mention_for_matrix, data.guild.general, {}, { api: { @@ -672,7 +888,7 @@ test("message2event: very large attachment is linked instead of being uploaded", content: "hey", attachments: [{ filename: "hey.jpg", - url: "https://discord.com/404/hey.jpg", + url: "https://cdn.discordapp.com/attachments/123/456/789.mega", content_type: "application/i-made-it-up", size: 100e6 }] @@ -686,9 +902,9 @@ test("message2event: very large attachment is linked instead of being uploaded", $type: "m.room.message", "m.mentions": {}, msgtype: "m.text", - body: "📄 Uploaded file: https://discord.com/404/hey.jpg (100 MB)", + body: "📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)", format: "org.matrix.custom.html", - formatted_body: '📄 Uploaded file: hey.jpg (100 MB)' + formatted_body: '📄 Uploaded file: hey.jpg (100 MB)' }]) }) @@ -849,3 +1065,311 @@ test("message2event: @everyone within a link", async t => { "m.mentions": {} }]) }) + +test("message2event: forwarded image", async t => { + const events = await messageToEvent(data.message.forwarded_image) + t.deepEqual(events, [ + { + $type: "m.room.message", + body: "[🔀 Forwarded message]", + format: "org.matrix.custom.html", + formatted_body: "🔀 Forwarded message", + "m.mentions": {}, + msgtype: "m.notice", + }, + { + $type: "m.room.message", + body: "100km.gif", + external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1296237494987133070/100km.gif", + filename: "100km.gif", + info: { + h: 300, + mimetype: "image/gif", + size: 2965649, + w: 300, + }, + "m.mentions": {}, + msgtype: "m.image", + url: "mxc://cadence.moe/qDAotmebTfEIfsAIVCEZptLh", + }, + ]) +}) + +test("message2event: constructed forwarded message", async t => { + const events = await messageToEvent(data.message.constructed_forwarded_message, {}, {}, { + api: { + async getJoinedMembers() { + return { + joined: { + "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, + "@user:matrix.org": {display_name: null, avatar_url: null} + } + } + } + } + }) + t.deepEqual(events, [ + { + $type: "m.room.message", + body: "[🔀 Forwarded from #wonderland]" + + "\n» What's cooking, good looking? :hipposcope:", + format: "org.matrix.custom.html", + formatted_body: `🔀 Forwarded from wonderland` + + `
    What's cooking, good looking? :hipposcope:
    `, + "m.mentions": {}, + msgtype: "m.notice", + }, + { + $type: "m.room.message", + body: "100km.gif", + external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1296237494987133070/100km.gif", + filename: "100km.gif", + info: { + h: 300, + mimetype: "image/gif", + size: 2965649, + w: 300, + }, + "m.mentions": {}, + msgtype: "m.image", + url: "mxc://cadence.moe/qDAotmebTfEIfsAIVCEZptLh", + }, + { + $type: "m.room.message", + body: "» | ## This man" + + "\n» | " + + "\n» | ## This man is 100 km away from your house" + + "\n» | " + + "\n» | ### Distance away" + + "\n» | 99 km" + + "\n» | " + + "\n» | ### Distance away" + + "\n» | 98 km", + format: "org.matrix.custom.html", + formatted_body: "

    This man

    This man is 100 km away from your house

    Distance away
    99 km

    Distance away
    98 km

    ", + "m.mentions": {}, + msgtype: "m.notice" + } + ]) +}) + +test("message2event: constructed forwarded text", async t => { + const events = await messageToEvent(data.message.constructed_forwarded_text, {}, {}, { + api: { + async getJoinedMembers() { + return { + joined: { + "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, + "@user:matrix.org": {display_name: null, avatar_url: null} + } + } + } + } + }) + t.deepEqual(events, [ + { + $type: "m.room.message", + body: "[🔀 Forwarded from #amanda-spam]" + + "\n» What's cooking, good looking?", + format: "org.matrix.custom.html", + formatted_body: `🔀 Forwarded from amanda-spam` + + `
    What's cooking, good looking?
    `, + "m.mentions": {}, + msgtype: "m.notice", + }, + { + $type: "m.room.message", + body: "What's cooking everybody ‼️", + "m.mentions": {}, + msgtype: "m.text", + } + ]) +}) + + +test("message2event: don't scan forwarded messages for mentions", async t => { + const events = await messageToEvent(data.message.forwarded_dont_scan_for_mentions, {}, {}, {}) + t.deepEqual(events, [ + { + $type: "m.room.message", + body: "[🔀 Forwarded message]" + + "\n» If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile https://social.luca.run/@luca/113950834185678114", + format: "org.matrix.custom.html", + formatted_body: `🔀 Forwarded message` + + `
    If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile https://social.luca.run/@luca/113950834185678114
    `, + "m.mentions": {}, + msgtype: "m.notice" + } + ]) +}) + +test("message2event: invite no details embed if no event", async t => { + const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381190945646710824"}, {}, {}, { + snow: { + invite: { + getInvite: async () => ({...data.invite.irl, guild_scheduled_event: null}) + } + } + }) + t.deepEqual(events, [ + { + $type: "m.room.message", + body: "https://discord.gg/placeholder?event=1381190945646710824", + format: "org.matrix.custom.html", + formatted_body: "https://discord.gg/placeholder?event=1381190945646710824", + "m.mentions": {}, + msgtype: "m.text", + } + ]) +}) + +test("message2event: irl invite event renders embed", async t => { + const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381190945646710824"}, {}, {}, { + snow: { + invite: { + getInvite: async () => data.invite.irl + } + } + }) + t.deepEqual(events, [ + { + $type: "m.room.message", + body: "https://discord.gg/placeholder?event=1381190945646710824", + format: "org.matrix.custom.html", + formatted_body: "https://discord.gg/placeholder?event=1381190945646710824", + "m.mentions": {}, + msgtype: "m.text", + }, + { + $type: "m.room.message", + msgtype: "m.notice", + body: `| Scheduled Event - 8 June at 10:00 pm NZT – 9 June at 12:00 am NZT` + + `\n| ## forest exploration` + + `\n| ` + + `\n| 📍 the dark forest`, + format: "org.matrix.custom.html", + formatted_body: `

    Scheduled Event - 8 June at 10:00 pm NZT – 9 June at 12:00 am NZT

    ` + + `forest exploration` + + `

    📍 the dark forest

    `, + "m.mentions": {} + } + ]) +}) + +test("message2event: vc invite event renders embed", async t => { + const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381174024801095751"}, {}, {}, { + snow: { + invite: { + getInvite: async () => data.invite.vc + } + } + }) + t.deepEqual(events, [ + { + $type: "m.room.message", + body: "https://discord.gg/placeholder?event=1381174024801095751", + format: "org.matrix.custom.html", + formatted_body: "https://discord.gg/placeholder?event=1381174024801095751", + "m.mentions": {}, + msgtype: "m.text", + }, + { + $type: "m.room.message", + msgtype: "m.notice", + body: `| Scheduled Event - 9 June at 3:00 pm NZT` + + `\n| ## Cooking (Netrunners)` + + `\n| Short circuited brain interfaces actually just means your brain is medium rare, yum.` + + `\n| ` + + `\n| 🔊 Cooking`, + format: "org.matrix.custom.html", + formatted_body: `

    Scheduled Event - 9 June at 3:00 pm NZT

    ` + + `Cooking (Netrunners)
    Short circuited brain interfaces actually just means your brain is medium rare, yum.` + + `

    🔊 Cooking

    `, + "m.mentions": {} + } + ]) +}) + +test("message2event: vc invite event renders embed with room link", async t => { + const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381174024801095751"}, {}, {}, { + api: { + getJoinedMembers: async () => ({ + joined: { + "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, + } + }) + }, + snow: { + invite: { + getInvite: async () => data.invite.known_vc + } + } + }) + t.deepEqual(events, [ + { + $type: "m.room.message", + body: "https://discord.gg/placeholder?event=1381174024801095751", + format: "org.matrix.custom.html", + formatted_body: "https://discord.gg/placeholder?event=1381174024801095751", + "m.mentions": {}, + msgtype: "m.text", + }, + { + $type: "m.room.message", + msgtype: "m.notice", + body: `| Scheduled Event - 9 June at 3:00 pm NZT` + + `\n| ## Cooking (Netrunners)` + + `\n| Short circuited brain interfaces actually just means your brain is medium rare, yum.` + + `\n| ` + + `\n| 🔊 Hey. - https://matrix.to/#/!FuDZhlOAtqswlyxzeR:cadence.moe?via=cadence.moe`, + format: "org.matrix.custom.html", + formatted_body: `

    Scheduled Event - 9 June at 3:00 pm NZT

    ` + + `Cooking (Netrunners)
    Short circuited brain interfaces actually just means your brain is medium rare, yum.` + + `

    🔊 Hey. - Hey.

    `, + "m.mentions": {} + } + ]) +}) + +test("message2event: channel links are converted even inside lists (parser post-processer descends into list items)", async t => { + let called = 0 + const events = await messageToEvent({ + content: "1. Don't be a dick" + + "\n2. Follow rule number 1" + + "\n3. Follow Discord TOS" + + "\n4. Do **not** post NSFW content, shock content, suggestive content" + + "\n5. Please keep <#176333891320283136> professional and helpful, no random off-topic joking" + + "\nThis list will probably change in the future" + }, data.guild.general, {}, { + api: { + getJoinedMembers(roomID) { + called++ + t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") + return { + joined: { + "@quadradical:federated.nexus": { + membership: "join", + display_name: "quadradical" + } + } + } + } + } + }) + t.deepEqual(events, [ + { + $type: "m.room.message", + body: "1. Don't be a dick" + + "\n2. Follow rule number 1" + + "\n3. Follow Discord TOS" + + "\n4. Do **not** post NSFW content, shock content, suggestive content" + + "\n5. Please keep #wonderland professional and helpful, no random off-topic joking" + + "\nThis list will probably change in the future", + format: "org.matrix.custom.html", + formatted_body: "
    1. Don't be a dick
    2. Follow rule number 1
    3. Follow Discord TOS
    4. Do not post NSFW content, shock content, suggestive content
    5. Please keep #wonderland professional and helpful, no random off-topic joking
    This list will probably change in the future", + "m.mentions": {}, + msgtype: "m.text" + } + ]) + t.equal(called, 1) +}) diff --git a/src/d2m/converters/pins-to-list.js b/src/d2m/converters/pins-to-list.js new file mode 100644 index 0000000..3e890ea --- /dev/null +++ b/src/d2m/converters/pins-to-list.js @@ -0,0 +1,31 @@ +// @ts-check + +const {select} = require("../../passthrough") + +/** + * @param {import("discord-api-types/v10").RESTGetAPIChannelPinsResult} pins + * @param {{"m.room.pinned_events/"?: {pinned?: string[]}}} kstate + */ +function pinsToList(pins, kstate) { + let alreadyPinned = kstate["m.room.pinned_events/"]?.pinned || [] + + // If any of the already pinned messages are bridged messages then remove them from the already pinned list. + // * If a bridged message is still pinned then it'll be added back in the next step. + // * If a bridged message was unpinned from Discord-side then it'll be unpinned from our side due to this step. + // * Matrix-only unbridged messages that are pinned will remain pinned. + alreadyPinned = alreadyPinned.filter(event_id => { + const messageID = select("event_message", "message_id", {event_id}).pluck().get() + return !messageID || pins.find(m => m.id === messageID) // if it is bridged then remove it from the filter + }) + + /** @type {string[]} */ + const result = [] + for (const message of pins) { + const eventID = select("event_message", "event_id", {message_id: message.id, part: 0}).pluck().get() + if (eventID && !alreadyPinned.includes(eventID)) result.push(eventID) + } + result.reverse() + return alreadyPinned.concat(result) +} + +module.exports.pinsToList = pinsToList diff --git a/src/d2m/converters/pins-to-list.test.js b/src/d2m/converters/pins-to-list.test.js new file mode 100644 index 0000000..d0657cb --- /dev/null +++ b/src/d2m/converters/pins-to-list.test.js @@ -0,0 +1,61 @@ +const {test} = require("supertape") +const data = require("../../../test/data") +const {pinsToList} = require("./pins-to-list") + +test("pins2list: converts known IDs, ignores unknown IDs", t => { + const result = pinsToList(data.pins.faked, {}) + t.deepEqual(result, [ + "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4", + "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", + "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" + ]) +}) + +test("pins2list: already pinned duplicate items are not moved", t => { + const result = pinsToList(data.pins.faked, { + "m.room.pinned_events/": { + pinned: [ + "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA" + ] + } + }) + t.deepEqual(result, [ + "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", + "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4", + "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" + ]) +}) + +test("pins2list: already pinned unknown items are not moved", t => { + const result = pinsToList(data.pins.faked, { + "m.room.pinned_events/": { + pinned: [ + "$unknown1", + "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", + "$unknown2" + ] + } + }) + t.deepEqual(result, [ + "$unknown1", + "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", + "$unknown2", + "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4", + "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" + ]) +}) + +test("pins2list: bridged messages can be unpinned", t => { + const result = pinsToList(data.pins.faked.slice(0, -2), { + "m.room.pinned_events/": { + pinned: [ + "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", + "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4" + ] + } + }) + t.deepEqual(result, [ + "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", + "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", + ]) +}) diff --git a/d2m/converters/remove-reaction.js b/src/d2m/converters/remove-reaction.js similarity index 100% rename from d2m/converters/remove-reaction.js rename to src/d2m/converters/remove-reaction.js diff --git a/d2m/converters/remove-reaction.test.js b/src/d2m/converters/remove-reaction.test.js similarity index 98% rename from d2m/converters/remove-reaction.test.js rename to src/d2m/converters/remove-reaction.test.js index dc6eda5..b42c11a 100644 --- a/d2m/converters/remove-reaction.test.js +++ b/src/d2m/converters/remove-reaction.test.js @@ -10,7 +10,9 @@ function fakeSpecificReactionRemoval(userID, emoji, emojiID) { channel_id: "THE_CHANNEL", message_id: "THE_MESSAGE", user_id: userID, - emoji: {id: emojiID, name: emoji} + emoji: {id: emojiID, name: emoji}, + burst: false, + type: 0 } } diff --git a/src/d2m/converters/rlottie-wasm.js b/src/d2m/converters/rlottie-wasm.js new file mode 100644 index 0000000..986a299 --- /dev/null +++ b/src/d2m/converters/rlottie-wasm.js @@ -0,0 +1,2635 @@ +var Module = typeof Module != "undefined" ? Module : {}; + +var moduleOverrides = Object.assign({}, Module); + +var arguments_ = []; + +var thisProgram = "./this.program"; + +var quit_ = (status, toThrow) => { + throw toThrow; +}; + +var ENVIRONMENT_IS_WEB = false; + +var ENVIRONMENT_IS_WORKER = false; + +var ENVIRONMENT_IS_NODE = true; + +var scriptDirectory = ""; + +function locateFile(path) { + if (Module["locateFile"]) { + return Module["locateFile"](path, scriptDirectory); + } + return scriptDirectory + path; +} + +var read_, readAsync, readBinary; + +if (ENVIRONMENT_IS_NODE) { + var fs = require("fs"); + var nodePath = require("path"); + if (ENVIRONMENT_IS_WORKER) { + scriptDirectory = nodePath.dirname(scriptDirectory) + "/"; + } else { + scriptDirectory = __dirname + "/"; + } + read_ = (filename, binary) => { + filename = isFileURI(filename) ? new URL(filename) : nodePath.normalize(filename); + return fs.readFileSync(filename, binary ? undefined : "utf8"); + }; + readBinary = filename => { + var ret = read_(filename, true); + if (!ret.buffer) { + ret = new Uint8Array(ret); + } + return ret; + }; + readAsync = (filename, onload, onerror, binary = true) => { + filename = isFileURI(filename) ? new URL(filename) : nodePath.normalize(filename); + fs.readFile(filename, binary ? undefined : "utf8", (err, data) => { + if (err) onerror(err); else onload(binary ? data.buffer : data); + }); + }; + if (!Module["thisProgram"] && process.argv.length > 1) { + thisProgram = process.argv[1].replace(/\\/g, "/"); + } + arguments_ = process.argv.slice(2); + if (typeof module != "undefined") { + module["exports"] = Module; + } + quit_ = (status, toThrow) => { + process.exitCode = status; + throw toThrow; + }; + Module["inspect"] = () => "[Emscripten Module object]"; +} else {} + +var out = Module["print"] || console.log.bind(console); + +var err = Module["printErr"] || console.error.bind(console); + +Object.assign(Module, moduleOverrides); + +moduleOverrides = null; + +if (Module["arguments"]) arguments_ = Module["arguments"]; + +if (Module["thisProgram"]) thisProgram = Module["thisProgram"]; + +if (Module["quit"]) quit_ = Module["quit"]; + +var wasmBinary; + +if (Module["wasmBinary"]) wasmBinary = Module["wasmBinary"]; + +var noExitRuntime = Module["noExitRuntime"] || true; + +if (typeof WebAssembly != "object") { + abort("no native wasm support detected"); +} + +var wasmMemory; + +var ABORT = false; + +var EXITSTATUS; + +var HEAP8, HEAPU8, HEAP16, HEAPU16, HEAP32, HEAPU32, HEAPF32, HEAPF64; + +function updateMemoryViews() { + var b = wasmMemory.buffer; + Module["HEAP8"] = HEAP8 = new Int8Array(b); + Module["HEAP16"] = HEAP16 = new Int16Array(b); + Module["HEAPU8"] = HEAPU8 = new Uint8Array(b); + Module["HEAPU16"] = HEAPU16 = new Uint16Array(b); + Module["HEAP32"] = HEAP32 = new Int32Array(b); + Module["HEAPU32"] = HEAPU32 = new Uint32Array(b); + Module["HEAPF32"] = HEAPF32 = new Float32Array(b); + Module["HEAPF64"] = HEAPF64 = new Float64Array(b); +} + +var wasmTable; + +var __ATPRERUN__ = []; + +var __ATINIT__ = []; + +var __ATPOSTRUN__ = []; + +var runtimeInitialized = false; + +var runtimeKeepaliveCounter = 0; + +function preRun() { + if (Module["preRun"]) { + if (typeof Module["preRun"] == "function") Module["preRun"] = [ Module["preRun"] ]; + while (Module["preRun"].length) { + addOnPreRun(Module["preRun"].shift()); + } + } + callRuntimeCallbacks(__ATPRERUN__); +} + +function initRuntime() { + runtimeInitialized = true; + callRuntimeCallbacks(__ATINIT__); +} + +function postRun() { + if (Module["postRun"]) { + if (typeof Module["postRun"] == "function") Module["postRun"] = [ Module["postRun"] ]; + while (Module["postRun"].length) { + addOnPostRun(Module["postRun"].shift()); + } + } + callRuntimeCallbacks(__ATPOSTRUN__); +} + +function addOnPreRun(cb) { + __ATPRERUN__.unshift(cb); +} + +function addOnInit(cb) { + __ATINIT__.unshift(cb); +} + +function addOnPostRun(cb) { + __ATPOSTRUN__.unshift(cb); +} + +var runDependencies = 0; + +var runDependencyWatcher = null; + +var dependenciesFulfilled = null; + +function addRunDependency(id) { + runDependencies++; + if (Module["monitorRunDependencies"]) { + Module["monitorRunDependencies"](runDependencies); + } +} + +function removeRunDependency(id) { + runDependencies--; + if (Module["monitorRunDependencies"]) { + Module["monitorRunDependencies"](runDependencies); + } + if (runDependencies == 0) { + if (runDependencyWatcher !== null) { + clearInterval(runDependencyWatcher); + runDependencyWatcher = null; + } + if (dependenciesFulfilled) { + var callback = dependenciesFulfilled; + dependenciesFulfilled = null; + callback(); + } + } +} + +function abort(what) { + if (Module["onAbort"]) { + Module["onAbort"](what); + } + what = "Aborted(" + what + ")"; + err(what); + ABORT = true; + EXITSTATUS = 1; + what += ". Build with -sASSERTIONS for more info."; + var e = new WebAssembly.RuntimeError(what); + throw e; +} + +var dataURIPrefix = "data:application/octet-stream;base64,"; + +function isDataURI(filename) { + return filename.startsWith(dataURIPrefix); +} + +function isFileURI(filename) { + return filename.startsWith("file://"); +} + +var wasmBinaryFile; + +wasmBinaryFile = "rlottie-wasm.wasm"; + +if (!isDataURI(wasmBinaryFile)) { + wasmBinaryFile = locateFile(wasmBinaryFile); +} + +function getBinarySync(file) { + if (file == wasmBinaryFile && wasmBinary) { + return new Uint8Array(wasmBinary); + } + if (readBinary) { + return readBinary(file); + } + throw "both async and sync fetching of the wasm failed"; +} + +function getBinaryPromise(binaryFile) { + if (!wasmBinary && (ENVIRONMENT_IS_WEB || ENVIRONMENT_IS_WORKER)) { + if (typeof fetch == "function") { + return fetch(binaryFile, { + credentials: "same-origin" + }).then(response => { + if (!response["ok"]) { + throw "failed to load wasm binary file at '" + binaryFile + "'"; + } + return response["arrayBuffer"](); + }).catch(() => getBinarySync(binaryFile)); + } + } + return Promise.resolve().then(() => getBinarySync(binaryFile)); +} + +function instantiateArrayBuffer(binaryFile, imports, receiver) { + return getBinaryPromise(binaryFile).then(binary => WebAssembly.instantiate(binary, imports)).then(instance => instance).then(receiver, reason => { + err(`failed to asynchronously prepare wasm: ${reason}`); + abort(reason); + }); +} + +function instantiateAsync(binary, binaryFile, imports, callback) { + if (!binary && typeof WebAssembly.instantiateStreaming == "function" && !isDataURI(binaryFile) && !ENVIRONMENT_IS_NODE && typeof fetch == "function") { + return fetch(binaryFile, { + credentials: "same-origin" + }).then(response => { + var result = WebAssembly.instantiateStreaming(response, imports); + return result.then(callback, function(reason) { + err(`wasm streaming compile failed: ${reason}`); + err("falling back to ArrayBuffer instantiation"); + return instantiateArrayBuffer(binaryFile, imports, callback); + }); + }); + } + return instantiateArrayBuffer(binaryFile, imports, callback); +} + +function createWasm() { + var info = { + "env": wasmImports, + "wasi_snapshot_preview1": wasmImports + }; + function receiveInstance(instance, module) { + var exports = instance.exports; + wasmExports = exports; + wasmMemory = wasmExports["memory"]; + updateMemoryViews(); + wasmTable = wasmExports["__indirect_function_table"]; + addOnInit(wasmExports["__wasm_call_ctors"]); + removeRunDependency("wasm-instantiate"); + return exports; + } + addRunDependency("wasm-instantiate"); + function receiveInstantiationResult(result) { + receiveInstance(result["instance"]); + } + if (Module["instantiateWasm"]) { + try { + return Module["instantiateWasm"](info, receiveInstance); + } catch (e) { + err(`Module.instantiateWasm callback failed with error: ${e}`); + return false; + } + } + instantiateAsync(wasmBinary, wasmBinaryFile, info, receiveInstantiationResult); + return {}; +} + +var tempDouble; + +function ExitStatus(status) { + this.name = "ExitStatus"; + this.message = `Program terminated with exit(${status})`; + this.status = status; +} + +var callRuntimeCallbacks = callbacks => { + while (callbacks.length > 0) { + callbacks.shift()(Module); + } +}; + +var UTF8Decoder = typeof TextDecoder != "undefined" ? new TextDecoder("utf8") : undefined; + +var UTF8ArrayToString = (heapOrArray, idx, maxBytesToRead) => { + var endIdx = idx + maxBytesToRead; + var endPtr = idx; + while (heapOrArray[endPtr] && !(endPtr >= endIdx)) ++endPtr; + if (endPtr - idx > 16 && heapOrArray.buffer && UTF8Decoder) { + return UTF8Decoder.decode(heapOrArray.subarray(idx, endPtr)); + } + var str = ""; + while (idx < endPtr) { + var u0 = heapOrArray[idx++]; + if (!(u0 & 128)) { + str += String.fromCharCode(u0); + continue; + } + var u1 = heapOrArray[idx++] & 63; + if ((u0 & 224) == 192) { + str += String.fromCharCode((u0 & 31) << 6 | u1); + continue; + } + var u2 = heapOrArray[idx++] & 63; + if ((u0 & 240) == 224) { + u0 = (u0 & 15) << 12 | u1 << 6 | u2; + } else { + u0 = (u0 & 7) << 18 | u1 << 12 | u2 << 6 | heapOrArray[idx++] & 63; + } + if (u0 < 65536) { + str += String.fromCharCode(u0); + } else { + var ch = u0 - 65536; + str += String.fromCharCode(55296 | ch >> 10, 56320 | ch & 1023); + } + } + return str; +}; + +var UTF8ToString = (ptr, maxBytesToRead) => ptr ? UTF8ArrayToString(HEAPU8, ptr, maxBytesToRead) : ""; + +var ___assert_fail = (condition, filename, line, func) => { + abort(`Assertion failed: ${UTF8ToString(condition)}, at: ` + [ filename ? UTF8ToString(filename) : "unknown filename", line, func ? UTF8ToString(func) : "unknown function" ]); +}; + +function ExceptionInfo(excPtr) { + this.excPtr = excPtr; + this.ptr = excPtr - 24; + this.set_type = function(type) { + HEAPU32[this.ptr + 4 >> 2] = type; + }; + this.get_type = function() { + return HEAPU32[this.ptr + 4 >> 2]; + }; + this.set_destructor = function(destructor) { + HEAPU32[this.ptr + 8 >> 2] = destructor; + }; + this.get_destructor = function() { + return HEAPU32[this.ptr + 8 >> 2]; + }; + this.set_caught = function(caught) { + caught = caught ? 1 : 0; + HEAP8[this.ptr + 12 >> 0] = caught; + }; + this.get_caught = function() { + return HEAP8[this.ptr + 12 >> 0] != 0; + }; + this.set_rethrown = function(rethrown) { + rethrown = rethrown ? 1 : 0; + HEAP8[this.ptr + 13 >> 0] = rethrown; + }; + this.get_rethrown = function() { + return HEAP8[this.ptr + 13 >> 0] != 0; + }; + this.init = function(type, destructor) { + this.set_adjusted_ptr(0); + this.set_type(type); + this.set_destructor(destructor); + }; + this.set_adjusted_ptr = function(adjustedPtr) { + HEAPU32[this.ptr + 16 >> 2] = adjustedPtr; + }; + this.get_adjusted_ptr = function() { + return HEAPU32[this.ptr + 16 >> 2]; + }; + this.get_exception_ptr = function() { + var isPointer = ___cxa_is_pointer_type(this.get_type()); + if (isPointer) { + return HEAPU32[this.excPtr >> 2]; + } + var adjusted = this.get_adjusted_ptr(); + if (adjusted !== 0) return adjusted; + return this.excPtr; + }; +} + +var exceptionLast = 0; + +var uncaughtExceptionCount = 0; + +var ___cxa_throw = (ptr, type, destructor) => { + var info = new ExceptionInfo(ptr); + info.init(type, destructor); + exceptionLast = ptr; + uncaughtExceptionCount++; + throw exceptionLast; +}; + +var SYSCALLS = { + varargs: undefined, + get() { + var ret = HEAP32[SYSCALLS.varargs >> 2]; + SYSCALLS.varargs += 4; + return ret; + }, + getp() { + return SYSCALLS.get(); + }, + getStr(ptr) { + var ret = UTF8ToString(ptr); + return ret; + } +}; + +function ___syscall_ioctl(fd, op, varargs) { + SYSCALLS.varargs = varargs; + return 0; +} + +function ___syscall_openat(dirfd, path, flags, varargs) { + SYSCALLS.varargs = varargs; +} + +var __embind_register_bigint = (primitiveType, name, size, minRange, maxRange) => {}; + +var embind_init_charCodes = () => { + var codes = new Array(256); + for (var i = 0; i < 256; ++i) { + codes[i] = String.fromCharCode(i); + } + embind_charCodes = codes; +}; + +var embind_charCodes = undefined; + +var readLatin1String = ptr => { + var ret = ""; + var c = ptr; + while (HEAPU8[c]) { + ret += embind_charCodes[HEAPU8[c++]]; + } + return ret; +}; + +var awaitingDependencies = {}; + +var registeredTypes = {}; + +var typeDependencies = {}; + +var BindingError = undefined; + +var throwBindingError = message => { + throw new BindingError(message); +}; + +var InternalError = undefined; + +var throwInternalError = message => { + throw new InternalError(message); +}; + +var whenDependentTypesAreResolved = (myTypes, dependentTypes, getTypeConverters) => { + myTypes.forEach(function(type) { + typeDependencies[type] = dependentTypes; + }); + function onComplete(typeConverters) { + var myTypeConverters = getTypeConverters(typeConverters); + if (myTypeConverters.length !== myTypes.length) { + throwInternalError("Mismatched type converter count"); + } + for (var i = 0; i < myTypes.length; ++i) { + registerType(myTypes[i], myTypeConverters[i]); + } + } + var typeConverters = new Array(dependentTypes.length); + var unregisteredTypes = []; + var registered = 0; + dependentTypes.forEach((dt, i) => { + if (registeredTypes.hasOwnProperty(dt)) { + typeConverters[i] = registeredTypes[dt]; + } else { + unregisteredTypes.push(dt); + if (!awaitingDependencies.hasOwnProperty(dt)) { + awaitingDependencies[dt] = []; + } + awaitingDependencies[dt].push(() => { + typeConverters[i] = registeredTypes[dt]; + ++registered; + if (registered === unregisteredTypes.length) { + onComplete(typeConverters); + } + }); + } + }); + if (0 === unregisteredTypes.length) { + onComplete(typeConverters); + } +}; + +function sharedRegisterType(rawType, registeredInstance, options = {}) { + var name = registeredInstance.name; + if (!rawType) { + throwBindingError(`type "${name}" must have a positive integer typeid pointer`); + } + if (registeredTypes.hasOwnProperty(rawType)) { + if (options.ignoreDuplicateRegistrations) { + return; + } else { + throwBindingError(`Cannot register type '${name}' twice`); + } + } + registeredTypes[rawType] = registeredInstance; + delete typeDependencies[rawType]; + if (awaitingDependencies.hasOwnProperty(rawType)) { + var callbacks = awaitingDependencies[rawType]; + delete awaitingDependencies[rawType]; + callbacks.forEach(cb => cb()); + } +} + +function registerType(rawType, registeredInstance, options = {}) { + if (!("argPackAdvance" in registeredInstance)) { + throw new TypeError("registerType registeredInstance requires argPackAdvance"); + } + return sharedRegisterType(rawType, registeredInstance, options); +} + +var GenericWireTypeSize = 8; + +var __embind_register_bool = (rawType, name, trueValue, falseValue) => { + name = readLatin1String(name); + registerType(rawType, { + name: name, + "fromWireType": function(wt) { + return !!wt; + }, + "toWireType": function(destructors, o) { + return o ? trueValue : falseValue; + }, + "argPackAdvance": GenericWireTypeSize, + "readValueFromPointer": function(pointer) { + return this["fromWireType"](HEAPU8[pointer]); + }, + destructorFunction: null + }); +}; + +function ClassHandle_isAliasOf(other) { + if (!(this instanceof ClassHandle)) { + return false; + } + if (!(other instanceof ClassHandle)) { + return false; + } + var leftClass = this.$$.ptrType.registeredClass; + var left = this.$$.ptr; + var rightClass = other.$$.ptrType.registeredClass; + var right = other.$$.ptr; + while (leftClass.baseClass) { + left = leftClass.upcast(left); + leftClass = leftClass.baseClass; + } + while (rightClass.baseClass) { + right = rightClass.upcast(right); + rightClass = rightClass.baseClass; + } + return leftClass === rightClass && left === right; +} + +var shallowCopyInternalPointer = o => ({ + count: o.count, + deleteScheduled: o.deleteScheduled, + preservePointerOnDelete: o.preservePointerOnDelete, + ptr: o.ptr, + ptrType: o.ptrType, + smartPtr: o.smartPtr, + smartPtrType: o.smartPtrType +}); + +var throwInstanceAlreadyDeleted = obj => { + function getInstanceTypeName(handle) { + return handle.$$.ptrType.registeredClass.name; + } + throwBindingError(getInstanceTypeName(obj) + " instance already deleted"); +}; + +var finalizationRegistry = false; + +var detachFinalizer = handle => {}; + +var runDestructor = $$ => { + if ($$.smartPtr) { + $$.smartPtrType.rawDestructor($$.smartPtr); + } else { + $$.ptrType.registeredClass.rawDestructor($$.ptr); + } +}; + +var releaseClassHandle = $$ => { + $$.count.value -= 1; + var toDelete = 0 === $$.count.value; + if (toDelete) { + runDestructor($$); + } +}; + +var downcastPointer = (ptr, ptrClass, desiredClass) => { + if (ptrClass === desiredClass) { + return ptr; + } + if (undefined === desiredClass.baseClass) { + return null; + } + var rv = downcastPointer(ptr, ptrClass, desiredClass.baseClass); + if (rv === null) { + return null; + } + return desiredClass.downcast(rv); +}; + +var registeredPointers = {}; + +var getInheritedInstanceCount = () => Object.keys(registeredInstances).length; + +var getLiveInheritedInstances = () => { + var rv = []; + for (var k in registeredInstances) { + if (registeredInstances.hasOwnProperty(k)) { + rv.push(registeredInstances[k]); + } + } + return rv; +}; + +var deletionQueue = []; + +var flushPendingDeletes = () => { + while (deletionQueue.length) { + var obj = deletionQueue.pop(); + obj.$$.deleteScheduled = false; + obj["delete"](); + } +}; + +var delayFunction = undefined; + +var setDelayFunction = fn => { + delayFunction = fn; + if (deletionQueue.length && delayFunction) { + delayFunction(flushPendingDeletes); + } +}; + +var init_embind = () => { + Module["getInheritedInstanceCount"] = getInheritedInstanceCount; + Module["getLiveInheritedInstances"] = getLiveInheritedInstances; + Module["flushPendingDeletes"] = flushPendingDeletes; + Module["setDelayFunction"] = setDelayFunction; +}; + +var registeredInstances = {}; + +var getBasestPointer = (class_, ptr) => { + if (ptr === undefined) { + throwBindingError("ptr should not be undefined"); + } + while (class_.baseClass) { + ptr = class_.upcast(ptr); + class_ = class_.baseClass; + } + return ptr; +}; + +var getInheritedInstance = (class_, ptr) => { + ptr = getBasestPointer(class_, ptr); + return registeredInstances[ptr]; +}; + +var makeClassHandle = (prototype, record) => { + if (!record.ptrType || !record.ptr) { + throwInternalError("makeClassHandle requires ptr and ptrType"); + } + var hasSmartPtrType = !!record.smartPtrType; + var hasSmartPtr = !!record.smartPtr; + if (hasSmartPtrType !== hasSmartPtr) { + throwInternalError("Both smartPtrType and smartPtr must be specified"); + } + record.count = { + value: 1 + }; + return attachFinalizer(Object.create(prototype, { + $$: { + value: record + } + })); +}; + +function RegisteredPointer_fromWireType(ptr) { + var rawPointer = this.getPointee(ptr); + if (!rawPointer) { + this.destructor(ptr); + return null; + } + var registeredInstance = getInheritedInstance(this.registeredClass, rawPointer); + if (undefined !== registeredInstance) { + if (0 === registeredInstance.$$.count.value) { + registeredInstance.$$.ptr = rawPointer; + registeredInstance.$$.smartPtr = ptr; + return registeredInstance["clone"](); + } else { + var rv = registeredInstance["clone"](); + this.destructor(ptr); + return rv; + } + } + function makeDefaultHandle() { + if (this.isSmartPointer) { + return makeClassHandle(this.registeredClass.instancePrototype, { + ptrType: this.pointeeType, + ptr: rawPointer, + smartPtrType: this, + smartPtr: ptr + }); + } else { + return makeClassHandle(this.registeredClass.instancePrototype, { + ptrType: this, + ptr: ptr + }); + } + } + var actualType = this.registeredClass.getActualType(rawPointer); + var registeredPointerRecord = registeredPointers[actualType]; + if (!registeredPointerRecord) { + return makeDefaultHandle.call(this); + } + var toType; + if (this.isConst) { + toType = registeredPointerRecord.constPointerType; + } else { + toType = registeredPointerRecord.pointerType; + } + var dp = downcastPointer(rawPointer, this.registeredClass, toType.registeredClass); + if (dp === null) { + return makeDefaultHandle.call(this); + } + if (this.isSmartPointer) { + return makeClassHandle(toType.registeredClass.instancePrototype, { + ptrType: toType, + ptr: dp, + smartPtrType: this, + smartPtr: ptr + }); + } else { + return makeClassHandle(toType.registeredClass.instancePrototype, { + ptrType: toType, + ptr: dp + }); + } +} + +var attachFinalizer = handle => { + if ("undefined" === typeof FinalizationRegistry) { + attachFinalizer = handle => handle; + return handle; + } + finalizationRegistry = new FinalizationRegistry(info => { + releaseClassHandle(info.$$); + }); + attachFinalizer = handle => { + var $$ = handle.$$; + var hasSmartPtr = !!$$.smartPtr; + if (hasSmartPtr) { + var info = { + $$: $$ + }; + finalizationRegistry.register(handle, info, handle); + } + return handle; + }; + detachFinalizer = handle => finalizationRegistry.unregister(handle); + return attachFinalizer(handle); +}; + +function ClassHandle_clone() { + if (!this.$$.ptr) { + throwInstanceAlreadyDeleted(this); + } + if (this.$$.preservePointerOnDelete) { + this.$$.count.value += 1; + return this; + } else { + var clone = attachFinalizer(Object.create(Object.getPrototypeOf(this), { + $$: { + value: shallowCopyInternalPointer(this.$$) + } + })); + clone.$$.count.value += 1; + clone.$$.deleteScheduled = false; + return clone; + } +} + +function ClassHandle_delete() { + if (!this.$$.ptr) { + throwInstanceAlreadyDeleted(this); + } + if (this.$$.deleteScheduled && !this.$$.preservePointerOnDelete) { + throwBindingError("Object already scheduled for deletion"); + } + detachFinalizer(this); + releaseClassHandle(this.$$); + if (!this.$$.preservePointerOnDelete) { + this.$$.smartPtr = undefined; + this.$$.ptr = undefined; + } +} + +function ClassHandle_isDeleted() { + return !this.$$.ptr; +} + +function ClassHandle_deleteLater() { + if (!this.$$.ptr) { + throwInstanceAlreadyDeleted(this); + } + if (this.$$.deleteScheduled && !this.$$.preservePointerOnDelete) { + throwBindingError("Object already scheduled for deletion"); + } + deletionQueue.push(this); + if (deletionQueue.length === 1 && delayFunction) { + delayFunction(flushPendingDeletes); + } + this.$$.deleteScheduled = true; + return this; +} + +var init_ClassHandle = () => { + ClassHandle.prototype["isAliasOf"] = ClassHandle_isAliasOf; + ClassHandle.prototype["clone"] = ClassHandle_clone; + ClassHandle.prototype["delete"] = ClassHandle_delete; + ClassHandle.prototype["isDeleted"] = ClassHandle_isDeleted; + ClassHandle.prototype["deleteLater"] = ClassHandle_deleteLater; +}; + +function ClassHandle() {} + +var char_0 = 48; + +var char_9 = 57; + +var makeLegalFunctionName = name => { + if (undefined === name) { + return "_unknown"; + } + name = name.replace(/[^a-zA-Z0-9_]/g, "$"); + var f = name.charCodeAt(0); + if (f >= char_0 && f <= char_9) { + return `_${name}`; + } + return name; +}; + +function createNamedFunction(name, body) { + name = makeLegalFunctionName(name); + return { + [name]: function() { + return body.apply(this, arguments); + } + }[name]; +} + +var ensureOverloadTable = (proto, methodName, humanName) => { + if (undefined === proto[methodName].overloadTable) { + var prevFunc = proto[methodName]; + proto[methodName] = function() { + if (!proto[methodName].overloadTable.hasOwnProperty(arguments.length)) { + throwBindingError(`Function '${humanName}' called with an invalid number of arguments (${arguments.length}) - expects one of (${proto[methodName].overloadTable})!`); + } + return proto[methodName].overloadTable[arguments.length].apply(this, arguments); + }; + proto[methodName].overloadTable = []; + proto[methodName].overloadTable[prevFunc.argCount] = prevFunc; + } +}; + +var exposePublicSymbol = (name, value, numArguments) => { + if (Module.hasOwnProperty(name)) { + if (undefined === numArguments || undefined !== Module[name].overloadTable && undefined !== Module[name].overloadTable[numArguments]) { + throwBindingError(`Cannot register public name '${name}' twice`); + } + ensureOverloadTable(Module, name, name); + if (Module.hasOwnProperty(numArguments)) { + throwBindingError(`Cannot register multiple overloads of a function with the same number of arguments (${numArguments})!`); + } + Module[name].overloadTable[numArguments] = value; + } else { + Module[name] = value; + if (undefined !== numArguments) { + Module[name].numArguments = numArguments; + } + } +}; + +function RegisteredClass(name, constructor, instancePrototype, rawDestructor, baseClass, getActualType, upcast, downcast) { + this.name = name; + this.constructor = constructor; + this.instancePrototype = instancePrototype; + this.rawDestructor = rawDestructor; + this.baseClass = baseClass; + this.getActualType = getActualType; + this.upcast = upcast; + this.downcast = downcast; + this.pureVirtualFunctions = []; +} + +var upcastPointer = (ptr, ptrClass, desiredClass) => { + while (ptrClass !== desiredClass) { + if (!ptrClass.upcast) { + throwBindingError(`Expected null or instance of ${desiredClass.name}, got an instance of ${ptrClass.name}`); + } + ptr = ptrClass.upcast(ptr); + ptrClass = ptrClass.baseClass; + } + return ptr; +}; + +function constNoSmartPtrRawPointerToWireType(destructors, handle) { + if (handle === null) { + if (this.isReference) { + throwBindingError(`null is not a valid ${this.name}`); + } + return 0; + } + if (!handle.$$) { + throwBindingError(`Cannot pass "${embindRepr(handle)}" as a ${this.name}`); + } + if (!handle.$$.ptr) { + throwBindingError(`Cannot pass deleted object as a pointer of type ${this.name}`); + } + var handleClass = handle.$$.ptrType.registeredClass; + var ptr = upcastPointer(handle.$$.ptr, handleClass, this.registeredClass); + return ptr; +} + +function genericPointerToWireType(destructors, handle) { + var ptr; + if (handle === null) { + if (this.isReference) { + throwBindingError(`null is not a valid ${this.name}`); + } + if (this.isSmartPointer) { + ptr = this.rawConstructor(); + if (destructors !== null) { + destructors.push(this.rawDestructor, ptr); + } + return ptr; + } else { + return 0; + } + } + if (!handle.$$) { + throwBindingError(`Cannot pass "${embindRepr(handle)}" as a ${this.name}`); + } + if (!handle.$$.ptr) { + throwBindingError(`Cannot pass deleted object as a pointer of type ${this.name}`); + } + if (!this.isConst && handle.$$.ptrType.isConst) { + throwBindingError(`Cannot convert argument of type ${handle.$$.smartPtrType ? handle.$$.smartPtrType.name : handle.$$.ptrType.name} to parameter type ${this.name}`); + } + var handleClass = handle.$$.ptrType.registeredClass; + ptr = upcastPointer(handle.$$.ptr, handleClass, this.registeredClass); + if (this.isSmartPointer) { + if (undefined === handle.$$.smartPtr) { + throwBindingError("Passing raw pointer to smart pointer is illegal"); + } + switch (this.sharingPolicy) { + case 0: + if (handle.$$.smartPtrType === this) { + ptr = handle.$$.smartPtr; + } else { + throwBindingError(`Cannot convert argument of type ${handle.$$.smartPtrType ? handle.$$.smartPtrType.name : handle.$$.ptrType.name} to parameter type ${this.name}`); + } + break; + + case 1: + ptr = handle.$$.smartPtr; + break; + + case 2: + if (handle.$$.smartPtrType === this) { + ptr = handle.$$.smartPtr; + } else { + var clonedHandle = handle["clone"](); + ptr = this.rawShare(ptr, Emval.toHandle(() => clonedHandle["delete"]())); + if (destructors !== null) { + destructors.push(this.rawDestructor, ptr); + } + } + break; + + default: + throwBindingError("Unsupporting sharing policy"); + } + } + return ptr; +} + +function nonConstNoSmartPtrRawPointerToWireType(destructors, handle) { + if (handle === null) { + if (this.isReference) { + throwBindingError(`null is not a valid ${this.name}`); + } + return 0; + } + if (!handle.$$) { + throwBindingError(`Cannot pass "${embindRepr(handle)}" as a ${this.name}`); + } + if (!handle.$$.ptr) { + throwBindingError(`Cannot pass deleted object as a pointer of type ${this.name}`); + } + if (handle.$$.ptrType.isConst) { + throwBindingError(`Cannot convert argument of type ${handle.$$.ptrType.name} to parameter type ${this.name}`); + } + var handleClass = handle.$$.ptrType.registeredClass; + var ptr = upcastPointer(handle.$$.ptr, handleClass, this.registeredClass); + return ptr; +} + +function readPointer(pointer) { + return this["fromWireType"](HEAPU32[pointer >> 2]); +} + +function RegisteredPointer_getPointee(ptr) { + if (this.rawGetPointee) { + ptr = this.rawGetPointee(ptr); + } + return ptr; +} + +function RegisteredPointer_destructor(ptr) { + if (this.rawDestructor) { + this.rawDestructor(ptr); + } +} + +var RegisteredPointer_deleteObject = handle => { + if (handle !== null) { + handle["delete"](); + } +}; + +var init_RegisteredPointer = () => { + RegisteredPointer.prototype.getPointee = RegisteredPointer_getPointee; + RegisteredPointer.prototype.destructor = RegisteredPointer_destructor; + RegisteredPointer.prototype["argPackAdvance"] = GenericWireTypeSize; + RegisteredPointer.prototype["readValueFromPointer"] = readPointer; + RegisteredPointer.prototype["deleteObject"] = RegisteredPointer_deleteObject; + RegisteredPointer.prototype["fromWireType"] = RegisteredPointer_fromWireType; +}; + +function RegisteredPointer(name, registeredClass, isReference, isConst, isSmartPointer, pointeeType, sharingPolicy, rawGetPointee, rawConstructor, rawShare, rawDestructor) { + this.name = name; + this.registeredClass = registeredClass; + this.isReference = isReference; + this.isConst = isConst; + this.isSmartPointer = isSmartPointer; + this.pointeeType = pointeeType; + this.sharingPolicy = sharingPolicy; + this.rawGetPointee = rawGetPointee; + this.rawConstructor = rawConstructor; + this.rawShare = rawShare; + this.rawDestructor = rawDestructor; + if (!isSmartPointer && registeredClass.baseClass === undefined) { + if (isConst) { + this["toWireType"] = constNoSmartPtrRawPointerToWireType; + this.destructorFunction = null; + } else { + this["toWireType"] = nonConstNoSmartPtrRawPointerToWireType; + this.destructorFunction = null; + } + } else { + this["toWireType"] = genericPointerToWireType; + } +} + +var replacePublicSymbol = (name, value, numArguments) => { + if (!Module.hasOwnProperty(name)) { + throwInternalError("Replacing nonexistant public symbol"); + } + if (undefined !== Module[name].overloadTable && undefined !== numArguments) { + Module[name].overloadTable[numArguments] = value; + } else { + Module[name] = value; + Module[name].argCount = numArguments; + } +}; + +var dynCallLegacy = (sig, ptr, args) => { + var f = Module["dynCall_" + sig]; + return args && args.length ? f.apply(null, [ ptr ].concat(args)) : f.call(null, ptr); +}; + +var wasmTableMirror = []; + +var getWasmTableEntry = funcPtr => { + var func = wasmTableMirror[funcPtr]; + if (!func) { + if (funcPtr >= wasmTableMirror.length) wasmTableMirror.length = funcPtr + 1; + wasmTableMirror[funcPtr] = func = wasmTable.get(funcPtr); + } + return func; +}; + +var dynCall = (sig, ptr, args) => { + if (sig.includes("j")) { + return dynCallLegacy(sig, ptr, args); + } + var rtn = getWasmTableEntry(ptr).apply(null, args); + return rtn; +}; + +var getDynCaller = (sig, ptr) => { + var argCache = []; + return function() { + argCache.length = 0; + Object.assign(argCache, arguments); + return dynCall(sig, ptr, argCache); + }; +}; + +var embind__requireFunction = (signature, rawFunction) => { + signature = readLatin1String(signature); + function makeDynCaller() { + if (signature.includes("j")) { + return getDynCaller(signature, rawFunction); + } + return getWasmTableEntry(rawFunction); + } + var fp = makeDynCaller(); + if (typeof fp != "function") { + throwBindingError(`unknown function pointer with signature ${signature}: ${rawFunction}`); + } + return fp; +}; + +var extendError = (baseErrorType, errorName) => { + var errorClass = createNamedFunction(errorName, function(message) { + this.name = errorName; + this.message = message; + var stack = new Error(message).stack; + if (stack !== undefined) { + this.stack = this.toString() + "\n" + stack.replace(/^Error(:[^\n]*)?\n/, ""); + } + }); + errorClass.prototype = Object.create(baseErrorType.prototype); + errorClass.prototype.constructor = errorClass; + errorClass.prototype.toString = function() { + if (this.message === undefined) { + return this.name; + } else { + return `${this.name}: ${this.message}`; + } + }; + return errorClass; +}; + +var UnboundTypeError = undefined; + +var getTypeName = type => { + var ptr = ___getTypeName(type); + var rv = readLatin1String(ptr); + _free(ptr); + return rv; +}; + +var throwUnboundTypeError = (message, types) => { + var unboundTypes = []; + var seen = {}; + function visit(type) { + if (seen[type]) { + return; + } + if (registeredTypes[type]) { + return; + } + if (typeDependencies[type]) { + typeDependencies[type].forEach(visit); + return; + } + unboundTypes.push(type); + seen[type] = true; + } + types.forEach(visit); + throw new UnboundTypeError(`${message}: ` + unboundTypes.map(getTypeName).join([ ", " ])); +}; + +var __embind_register_class = (rawType, rawPointerType, rawConstPointerType, baseClassRawType, getActualTypeSignature, getActualType, upcastSignature, upcast, downcastSignature, downcast, name, destructorSignature, rawDestructor) => { + name = readLatin1String(name); + getActualType = embind__requireFunction(getActualTypeSignature, getActualType); + if (upcast) { + upcast = embind__requireFunction(upcastSignature, upcast); + } + if (downcast) { + downcast = embind__requireFunction(downcastSignature, downcast); + } + rawDestructor = embind__requireFunction(destructorSignature, rawDestructor); + var legalFunctionName = makeLegalFunctionName(name); + exposePublicSymbol(legalFunctionName, function() { + throwUnboundTypeError(`Cannot construct ${name} due to unbound types`, [ baseClassRawType ]); + }); + whenDependentTypesAreResolved([ rawType, rawPointerType, rawConstPointerType ], baseClassRawType ? [ baseClassRawType ] : [], function(base) { + base = base[0]; + var baseClass; + var basePrototype; + if (baseClassRawType) { + baseClass = base.registeredClass; + basePrototype = baseClass.instancePrototype; + } else { + basePrototype = ClassHandle.prototype; + } + var constructor = createNamedFunction(legalFunctionName, function() { + if (Object.getPrototypeOf(this) !== instancePrototype) { + throw new BindingError("Use 'new' to construct " + name); + } + if (undefined === registeredClass.constructor_body) { + throw new BindingError(name + " has no accessible constructor"); + } + var body = registeredClass.constructor_body[arguments.length]; + if (undefined === body) { + throw new BindingError(`Tried to invoke ctor of ${name} with invalid number of parameters (${arguments.length}) - expected (${Object.keys(registeredClass.constructor_body).toString()}) parameters instead!`); + } + return body.apply(this, arguments); + }); + var instancePrototype = Object.create(basePrototype, { + constructor: { + value: constructor + } + }); + constructor.prototype = instancePrototype; + var registeredClass = new RegisteredClass(name, constructor, instancePrototype, rawDestructor, baseClass, getActualType, upcast, downcast); + if (registeredClass.baseClass) { + if (registeredClass.baseClass.__derivedClasses === undefined) { + registeredClass.baseClass.__derivedClasses = []; + } + registeredClass.baseClass.__derivedClasses.push(registeredClass); + } + var referenceConverter = new RegisteredPointer(name, registeredClass, true, false, false); + var pointerConverter = new RegisteredPointer(name + "*", registeredClass, false, false, false); + var constPointerConverter = new RegisteredPointer(name + " const*", registeredClass, false, true, false); + registeredPointers[rawType] = { + pointerType: pointerConverter, + constPointerType: constPointerConverter + }; + replacePublicSymbol(legalFunctionName, constructor); + return [ referenceConverter, pointerConverter, constPointerConverter ]; + }); +}; + +var heap32VectorToArray = (count, firstElement) => { + var array = []; + for (var i = 0; i < count; i++) { + array.push(HEAPU32[firstElement + i * 4 >> 2]); + } + return array; +}; + +var runDestructors = destructors => { + while (destructors.length) { + var ptr = destructors.pop(); + var del = destructors.pop(); + del(ptr); + } +}; + +function newFunc(constructor, argumentList) { + if (!(constructor instanceof Function)) { + throw new TypeError(`new_ called with constructor type ${typeof constructor} which is not a function`); + } + var dummy = createNamedFunction(constructor.name || "unknownFunctionName", function() {}); + dummy.prototype = constructor.prototype; + var obj = new dummy; + var r = constructor.apply(obj, argumentList); + return r instanceof Object ? r : obj; +} + +function craftInvokerFunction(humanName, argTypes, classType, cppInvokerFunc, cppTargetFunc, isAsync) { + var argCount = argTypes.length; + if (argCount < 2) { + throwBindingError("argTypes array size mismatch! Must at least get return value and 'this' types!"); + } + var isClassMethodFunc = argTypes[1] !== null && classType !== null; + var needsDestructorStack = false; + for (var i = 1; i < argTypes.length; ++i) { + if (argTypes[i] !== null && argTypes[i].destructorFunction === undefined) { + needsDestructorStack = true; + break; + } + } + var returns = argTypes[0].name !== "void"; + var argsList = ""; + var argsListWired = ""; + for (var i = 0; i < argCount - 2; ++i) { + argsList += (i !== 0 ? ", " : "") + "arg" + i; + argsListWired += (i !== 0 ? ", " : "") + "arg" + i + "Wired"; + } + var invokerFnBody = `\n return function ${makeLegalFunctionName(humanName)}(${argsList}) {\n if (arguments.length !== ${argCount - 2}) {\n throwBindingError('function ${humanName} called with ' + arguments.length + ' arguments, expected ${argCount - 2}');\n }`; + if (needsDestructorStack) { + invokerFnBody += "var destructors = [];\n"; + } + var dtorStack = needsDestructorStack ? "destructors" : "null"; + var args1 = [ "throwBindingError", "invoker", "fn", "runDestructors", "retType", "classParam" ]; + var args2 = [ throwBindingError, cppInvokerFunc, cppTargetFunc, runDestructors, argTypes[0], argTypes[1] ]; + if (isClassMethodFunc) { + invokerFnBody += "var thisWired = classParam.toWireType(" + dtorStack + ", this);\n"; + } + for (var i = 0; i < argCount - 2; ++i) { + invokerFnBody += "var arg" + i + "Wired = argType" + i + ".toWireType(" + dtorStack + ", arg" + i + "); // " + argTypes[i + 2].name + "\n"; + args1.push("argType" + i); + args2.push(argTypes[i + 2]); + } + if (isClassMethodFunc) { + argsListWired = "thisWired" + (argsListWired.length > 0 ? ", " : "") + argsListWired; + } + invokerFnBody += (returns || isAsync ? "var rv = " : "") + "invoker(fn" + (argsListWired.length > 0 ? ", " : "") + argsListWired + ");\n"; + if (needsDestructorStack) { + invokerFnBody += "runDestructors(destructors);\n"; + } else { + for (var i = isClassMethodFunc ? 1 : 2; i < argTypes.length; ++i) { + var paramName = i === 1 ? "thisWired" : "arg" + (i - 2) + "Wired"; + if (argTypes[i].destructorFunction !== null) { + invokerFnBody += paramName + "_dtor(" + paramName + "); // " + argTypes[i].name + "\n"; + args1.push(paramName + "_dtor"); + args2.push(argTypes[i].destructorFunction); + } + } + } + if (returns) { + invokerFnBody += "var ret = retType.fromWireType(rv);\n" + "return ret;\n"; + } else {} + invokerFnBody += "}\n"; + args1.push(invokerFnBody); + return newFunc(Function, args1).apply(null, args2); +} + +var __embind_register_class_constructor = (rawClassType, argCount, rawArgTypesAddr, invokerSignature, invoker, rawConstructor) => { + var rawArgTypes = heap32VectorToArray(argCount, rawArgTypesAddr); + invoker = embind__requireFunction(invokerSignature, invoker); + whenDependentTypesAreResolved([], [ rawClassType ], function(classType) { + classType = classType[0]; + var humanName = `constructor ${classType.name}`; + if (undefined === classType.registeredClass.constructor_body) { + classType.registeredClass.constructor_body = []; + } + if (undefined !== classType.registeredClass.constructor_body[argCount - 1]) { + throw new BindingError(`Cannot register multiple constructors with identical number of parameters (${argCount - 1}) for class '${classType.name}'! Overload resolution is currently only performed using the parameter count, not actual type info!`); + } + classType.registeredClass.constructor_body[argCount - 1] = () => { + throwUnboundTypeError(`Cannot construct ${classType.name} due to unbound types`, rawArgTypes); + }; + whenDependentTypesAreResolved([], rawArgTypes, argTypes => { + argTypes.splice(1, 0, null); + classType.registeredClass.constructor_body[argCount - 1] = craftInvokerFunction(humanName, argTypes, null, invoker, rawConstructor); + return []; + }); + return []; + }); +}; + +var __embind_register_class_function = (rawClassType, methodName, argCount, rawArgTypesAddr, invokerSignature, rawInvoker, context, isPureVirtual, isAsync) => { + var rawArgTypes = heap32VectorToArray(argCount, rawArgTypesAddr); + methodName = readLatin1String(methodName); + rawInvoker = embind__requireFunction(invokerSignature, rawInvoker); + whenDependentTypesAreResolved([], [ rawClassType ], function(classType) { + classType = classType[0]; + var humanName = `${classType.name}.${methodName}`; + if (methodName.startsWith("@@")) { + methodName = Symbol[methodName.substring(2)]; + } + if (isPureVirtual) { + classType.registeredClass.pureVirtualFunctions.push(methodName); + } + function unboundTypesHandler() { + throwUnboundTypeError(`Cannot call ${humanName} due to unbound types`, rawArgTypes); + } + var proto = classType.registeredClass.instancePrototype; + var method = proto[methodName]; + if (undefined === method || undefined === method.overloadTable && method.className !== classType.name && method.argCount === argCount - 2) { + unboundTypesHandler.argCount = argCount - 2; + unboundTypesHandler.className = classType.name; + proto[methodName] = unboundTypesHandler; + } else { + ensureOverloadTable(proto, methodName, humanName); + proto[methodName].overloadTable[argCount - 2] = unboundTypesHandler; + } + whenDependentTypesAreResolved([], rawArgTypes, function(argTypes) { + var memberFunction = craftInvokerFunction(humanName, argTypes, classType, rawInvoker, context, isAsync); + if (undefined === proto[methodName].overloadTable) { + memberFunction.argCount = argCount - 2; + proto[methodName] = memberFunction; + } else { + proto[methodName].overloadTable[argCount - 2] = memberFunction; + } + return []; + }); + return []; + }); +}; + +function handleAllocatorInit() { + Object.assign(HandleAllocator.prototype, { + get(id) { + return this.allocated[id]; + }, + has(id) { + return this.allocated[id] !== undefined; + }, + allocate(handle) { + var id = this.freelist.pop() || this.allocated.length; + this.allocated[id] = handle; + return id; + }, + free(id) { + this.allocated[id] = undefined; + this.freelist.push(id); + } + }); +} + +function HandleAllocator() { + this.allocated = [ undefined ]; + this.freelist = []; +} + +var emval_handles = new HandleAllocator; + +var __emval_decref = handle => { + if (handle >= emval_handles.reserved && 0 === --emval_handles.get(handle).refcount) { + emval_handles.free(handle); + } +}; + +var count_emval_handles = () => { + var count = 0; + for (var i = emval_handles.reserved; i < emval_handles.allocated.length; ++i) { + if (emval_handles.allocated[i] !== undefined) { + ++count; + } + } + return count; +}; + +var init_emval = () => { + emval_handles.allocated.push({ + value: undefined + }, { + value: null + }, { + value: true + }, { + value: false + }); + emval_handles.reserved = emval_handles.allocated.length; + Module["count_emval_handles"] = count_emval_handles; +}; + +var Emval = { + toValue: handle => { + if (!handle) { + throwBindingError("Cannot use deleted val. handle = " + handle); + } + return emval_handles.get(handle).value; + }, + toHandle: value => { + switch (value) { + case undefined: + return 1; + + case null: + return 2; + + case true: + return 3; + + case false: + return 4; + + default: + { + return emval_handles.allocate({ + refcount: 1, + value: value + }); + } + } + } +}; + +function simpleReadValueFromPointer(pointer) { + return this["fromWireType"](HEAP32[pointer >> 2]); +} + +var __embind_register_emval = (rawType, name) => { + name = readLatin1String(name); + registerType(rawType, { + name: name, + "fromWireType": handle => { + var rv = Emval.toValue(handle); + __emval_decref(handle); + return rv; + }, + "toWireType": (destructors, value) => Emval.toHandle(value), + "argPackAdvance": GenericWireTypeSize, + "readValueFromPointer": simpleReadValueFromPointer, + destructorFunction: null + }); +}; + +var embindRepr = v => { + if (v === null) { + return "null"; + } + var t = typeof v; + if (t === "object" || t === "array" || t === "function") { + return v.toString(); + } else { + return "" + v; + } +}; + +var floatReadValueFromPointer = (name, width) => { + switch (width) { + case 4: + return function(pointer) { + return this["fromWireType"](HEAPF32[pointer >> 2]); + }; + + case 8: + return function(pointer) { + return this["fromWireType"](HEAPF64[pointer >> 3]); + }; + + default: + throw new TypeError(`invalid float width (${width}): ${name}`); + } +}; + +var __embind_register_float = (rawType, name, size) => { + name = readLatin1String(name); + registerType(rawType, { + name: name, + "fromWireType": value => value, + "toWireType": (destructors, value) => value, + "argPackAdvance": GenericWireTypeSize, + "readValueFromPointer": floatReadValueFromPointer(name, size), + destructorFunction: null + }); +}; + +var integerReadValueFromPointer = (name, width, signed) => { + switch (width) { + case 1: + return signed ? pointer => HEAP8[pointer >> 0] : pointer => HEAPU8[pointer >> 0]; + + case 2: + return signed ? pointer => HEAP16[pointer >> 1] : pointer => HEAPU16[pointer >> 1]; + + case 4: + return signed ? pointer => HEAP32[pointer >> 2] : pointer => HEAPU32[pointer >> 2]; + + default: + throw new TypeError(`invalid integer width (${width}): ${name}`); + } +}; + +var __embind_register_integer = (primitiveType, name, size, minRange, maxRange) => { + name = readLatin1String(name); + if (maxRange === -1) { + maxRange = 4294967295; + } + var fromWireType = value => value; + if (minRange === 0) { + var bitshift = 32 - 8 * size; + fromWireType = value => value << bitshift >>> bitshift; + } + var isUnsignedType = name.includes("unsigned"); + var checkAssertions = (value, toTypeName) => {}; + var toWireType; + if (isUnsignedType) { + toWireType = function(destructors, value) { + checkAssertions(value, this.name); + return value >>> 0; + }; + } else { + toWireType = function(destructors, value) { + checkAssertions(value, this.name); + return value; + }; + } + registerType(primitiveType, { + name: name, + "fromWireType": fromWireType, + "toWireType": toWireType, + "argPackAdvance": GenericWireTypeSize, + "readValueFromPointer": integerReadValueFromPointer(name, size, minRange !== 0), + destructorFunction: null + }); +}; + +var __embind_register_memory_view = (rawType, dataTypeIndex, name) => { + var typeMapping = [ Int8Array, Uint8Array, Int16Array, Uint16Array, Int32Array, Uint32Array, Float32Array, Float64Array ]; + var TA = typeMapping[dataTypeIndex]; + function decodeMemoryView(handle) { + var size = HEAPU32[handle >> 2]; + var data = HEAPU32[handle + 4 >> 2]; + return new TA(HEAP8.buffer, data, size); + } + name = readLatin1String(name); + registerType(rawType, { + name: name, + "fromWireType": decodeMemoryView, + "argPackAdvance": GenericWireTypeSize, + "readValueFromPointer": decodeMemoryView + }, { + ignoreDuplicateRegistrations: true + }); +}; + +var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { + if (!(maxBytesToWrite > 0)) return 0; + var startIdx = outIdx; + var endIdx = outIdx + maxBytesToWrite - 1; + for (var i = 0; i < str.length; ++i) { + var u = str.charCodeAt(i); + if (u >= 55296 && u <= 57343) { + var u1 = str.charCodeAt(++i); + u = 65536 + ((u & 1023) << 10) | u1 & 1023; + } + if (u <= 127) { + if (outIdx >= endIdx) break; + heap[outIdx++] = u; + } else if (u <= 2047) { + if (outIdx + 1 >= endIdx) break; + heap[outIdx++] = 192 | u >> 6; + heap[outIdx++] = 128 | u & 63; + } else if (u <= 65535) { + if (outIdx + 2 >= endIdx) break; + heap[outIdx++] = 224 | u >> 12; + heap[outIdx++] = 128 | u >> 6 & 63; + heap[outIdx++] = 128 | u & 63; + } else { + if (outIdx + 3 >= endIdx) break; + heap[outIdx++] = 240 | u >> 18; + heap[outIdx++] = 128 | u >> 12 & 63; + heap[outIdx++] = 128 | u >> 6 & 63; + heap[outIdx++] = 128 | u & 63; + } + } + heap[outIdx] = 0; + return outIdx - startIdx; +}; + +var stringToUTF8 = (str, outPtr, maxBytesToWrite) => stringToUTF8Array(str, HEAPU8, outPtr, maxBytesToWrite); + +var lengthBytesUTF8 = str => { + var len = 0; + for (var i = 0; i < str.length; ++i) { + var c = str.charCodeAt(i); + if (c <= 127) { + len++; + } else if (c <= 2047) { + len += 2; + } else if (c >= 55296 && c <= 57343) { + len += 4; + ++i; + } else { + len += 3; + } + } + return len; +}; + +var __embind_register_std_string = (rawType, name) => { + name = readLatin1String(name); + var stdStringIsUTF8 = name === "std::string"; + registerType(rawType, { + name: name, + "fromWireType": value => { + var length = HEAPU32[value >> 2]; + var payload = value + 4; + var str; + if (stdStringIsUTF8) { + var decodeStartPtr = payload; + for (var i = 0; i <= length; ++i) { + var currentBytePtr = payload + i; + if (i == length || HEAPU8[currentBytePtr] == 0) { + var maxRead = currentBytePtr - decodeStartPtr; + var stringSegment = UTF8ToString(decodeStartPtr, maxRead); + if (str === undefined) { + str = stringSegment; + } else { + str += String.fromCharCode(0); + str += stringSegment; + } + decodeStartPtr = currentBytePtr + 1; + } + } + } else { + var a = new Array(length); + for (var i = 0; i < length; ++i) { + a[i] = String.fromCharCode(HEAPU8[payload + i]); + } + str = a.join(""); + } + _free(value); + return str; + }, + "toWireType": (destructors, value) => { + if (value instanceof ArrayBuffer) { + value = new Uint8Array(value); + } + var length; + var valueIsOfTypeString = typeof value == "string"; + if (!(valueIsOfTypeString || value instanceof Uint8Array || value instanceof Uint8ClampedArray || value instanceof Int8Array)) { + throwBindingError("Cannot pass non-string to std::string"); + } + if (stdStringIsUTF8 && valueIsOfTypeString) { + length = lengthBytesUTF8(value); + } else { + length = value.length; + } + var base = _malloc(4 + length + 1); + var ptr = base + 4; + HEAPU32[base >> 2] = length; + if (stdStringIsUTF8 && valueIsOfTypeString) { + stringToUTF8(value, ptr, length + 1); + } else { + if (valueIsOfTypeString) { + for (var i = 0; i < length; ++i) { + var charCode = value.charCodeAt(i); + if (charCode > 255) { + _free(ptr); + throwBindingError("String has UTF-16 code units that do not fit in 8 bits"); + } + HEAPU8[ptr + i] = charCode; + } + } else { + for (var i = 0; i < length; ++i) { + HEAPU8[ptr + i] = value[i]; + } + } + } + if (destructors !== null) { + destructors.push(_free, base); + } + return base; + }, + "argPackAdvance": GenericWireTypeSize, + "readValueFromPointer": readPointer, + destructorFunction: ptr => _free(ptr) + }); +}; + +var UTF16Decoder = typeof TextDecoder != "undefined" ? new TextDecoder("utf-16le") : undefined; + +var UTF16ToString = (ptr, maxBytesToRead) => { + var endPtr = ptr; + var idx = endPtr >> 1; + var maxIdx = idx + maxBytesToRead / 2; + while (!(idx >= maxIdx) && HEAPU16[idx]) ++idx; + endPtr = idx << 1; + if (endPtr - ptr > 32 && UTF16Decoder) return UTF16Decoder.decode(HEAPU8.subarray(ptr, endPtr)); + var str = ""; + for (var i = 0; !(i >= maxBytesToRead / 2); ++i) { + var codeUnit = HEAP16[ptr + i * 2 >> 1]; + if (codeUnit == 0) break; + str += String.fromCharCode(codeUnit); + } + return str; +}; + +var stringToUTF16 = (str, outPtr, maxBytesToWrite) => { + if (maxBytesToWrite === undefined) { + maxBytesToWrite = 2147483647; + } + if (maxBytesToWrite < 2) return 0; + maxBytesToWrite -= 2; + var startPtr = outPtr; + var numCharsToWrite = maxBytesToWrite < str.length * 2 ? maxBytesToWrite / 2 : str.length; + for (var i = 0; i < numCharsToWrite; ++i) { + var codeUnit = str.charCodeAt(i); + HEAP16[outPtr >> 1] = codeUnit; + outPtr += 2; + } + HEAP16[outPtr >> 1] = 0; + return outPtr - startPtr; +}; + +var lengthBytesUTF16 = str => str.length * 2; + +var UTF32ToString = (ptr, maxBytesToRead) => { + var i = 0; + var str = ""; + while (!(i >= maxBytesToRead / 4)) { + var utf32 = HEAP32[ptr + i * 4 >> 2]; + if (utf32 == 0) break; + ++i; + if (utf32 >= 65536) { + var ch = utf32 - 65536; + str += String.fromCharCode(55296 | ch >> 10, 56320 | ch & 1023); + } else { + str += String.fromCharCode(utf32); + } + } + return str; +}; + +var stringToUTF32 = (str, outPtr, maxBytesToWrite) => { + if (maxBytesToWrite === undefined) { + maxBytesToWrite = 2147483647; + } + if (maxBytesToWrite < 4) return 0; + var startPtr = outPtr; + var endPtr = startPtr + maxBytesToWrite - 4; + for (var i = 0; i < str.length; ++i) { + var codeUnit = str.charCodeAt(i); + if (codeUnit >= 55296 && codeUnit <= 57343) { + var trailSurrogate = str.charCodeAt(++i); + codeUnit = 65536 + ((codeUnit & 1023) << 10) | trailSurrogate & 1023; + } + HEAP32[outPtr >> 2] = codeUnit; + outPtr += 4; + if (outPtr + 4 > endPtr) break; + } + HEAP32[outPtr >> 2] = 0; + return outPtr - startPtr; +}; + +var lengthBytesUTF32 = str => { + var len = 0; + for (var i = 0; i < str.length; ++i) { + var codeUnit = str.charCodeAt(i); + if (codeUnit >= 55296 && codeUnit <= 57343) ++i; + len += 4; + } + return len; +}; + +var __embind_register_std_wstring = (rawType, charSize, name) => { + name = readLatin1String(name); + var decodeString, encodeString, getHeap, lengthBytesUTF, shift; + if (charSize === 2) { + decodeString = UTF16ToString; + encodeString = stringToUTF16; + lengthBytesUTF = lengthBytesUTF16; + getHeap = () => HEAPU16; + shift = 1; + } else if (charSize === 4) { + decodeString = UTF32ToString; + encodeString = stringToUTF32; + lengthBytesUTF = lengthBytesUTF32; + getHeap = () => HEAPU32; + shift = 2; + } + registerType(rawType, { + name: name, + "fromWireType": value => { + var length = HEAPU32[value >> 2]; + var HEAP = getHeap(); + var str; + var decodeStartPtr = value + 4; + for (var i = 0; i <= length; ++i) { + var currentBytePtr = value + 4 + i * charSize; + if (i == length || HEAP[currentBytePtr >> shift] == 0) { + var maxReadBytes = currentBytePtr - decodeStartPtr; + var stringSegment = decodeString(decodeStartPtr, maxReadBytes); + if (str === undefined) { + str = stringSegment; + } else { + str += String.fromCharCode(0); + str += stringSegment; + } + decodeStartPtr = currentBytePtr + charSize; + } + } + _free(value); + return str; + }, + "toWireType": (destructors, value) => { + if (!(typeof value == "string")) { + throwBindingError(`Cannot pass non-string to C++ string type ${name}`); + } + var length = lengthBytesUTF(value); + var ptr = _malloc(4 + length + charSize); + HEAPU32[ptr >> 2] = length >> shift; + encodeString(value, ptr + 4, length + charSize); + if (destructors !== null) { + destructors.push(_free, ptr); + } + return ptr; + }, + "argPackAdvance": GenericWireTypeSize, + "readValueFromPointer": simpleReadValueFromPointer, + destructorFunction: ptr => _free(ptr) + }); +}; + +var __embind_register_void = (rawType, name) => { + name = readLatin1String(name); + registerType(rawType, { + isVoid: true, + name: name, + "argPackAdvance": 0, + "fromWireType": () => undefined, + "toWireType": (destructors, o) => undefined + }); +}; + +var __emscripten_throw_longjmp = () => { + throw Infinity; +}; + +var __emval_incref = handle => { + if (handle > 4) { + emval_handles.get(handle).refcount += 1; + } +}; + +var requireRegisteredType = (rawType, humanName) => { + var impl = registeredTypes[rawType]; + if (undefined === impl) { + throwBindingError(humanName + " has unknown type " + getTypeName(rawType)); + } + return impl; +}; + +var __emval_take_value = (type, arg) => { + type = requireRegisteredType(type, "_emval_take_value"); + var v = type["readValueFromPointer"](arg); + return Emval.toHandle(v); +}; + +var isLeapYear = year => year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + +var MONTH_DAYS_LEAP_CUMULATIVE = [ 0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335 ]; + +var MONTH_DAYS_REGULAR_CUMULATIVE = [ 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334 ]; + +var ydayFromDate = date => { + var leap = isLeapYear(date.getFullYear()); + var monthDaysCumulative = leap ? MONTH_DAYS_LEAP_CUMULATIVE : MONTH_DAYS_REGULAR_CUMULATIVE; + var yday = monthDaysCumulative[date.getMonth()] + date.getDate() - 1; + return yday; +}; + +var convertI32PairToI53Checked = (lo, hi) => hi + 2097152 >>> 0 < 4194305 - !!lo ? (lo >>> 0) + hi * 4294967296 : NaN; + +function __localtime_js(time_low, time_high, tmPtr) { + var time = convertI32PairToI53Checked(time_low, time_high); + var date = new Date(time * 1e3); + HEAP32[tmPtr >> 2] = date.getSeconds(); + HEAP32[tmPtr + 4 >> 2] = date.getMinutes(); + HEAP32[tmPtr + 8 >> 2] = date.getHours(); + HEAP32[tmPtr + 12 >> 2] = date.getDate(); + HEAP32[tmPtr + 16 >> 2] = date.getMonth(); + HEAP32[tmPtr + 20 >> 2] = date.getFullYear() - 1900; + HEAP32[tmPtr + 24 >> 2] = date.getDay(); + var yday = ydayFromDate(date) | 0; + HEAP32[tmPtr + 28 >> 2] = yday; + HEAP32[tmPtr + 36 >> 2] = -(date.getTimezoneOffset() * 60); + var start = new Date(date.getFullYear(), 0, 1); + var summerOffset = new Date(date.getFullYear(), 6, 1).getTimezoneOffset(); + var winterOffset = start.getTimezoneOffset(); + var dst = (summerOffset != winterOffset && date.getTimezoneOffset() == Math.min(winterOffset, summerOffset)) | 0; + HEAP32[tmPtr + 32 >> 2] = dst; +} + +var __mktime_js = function(tmPtr) { + var ret = (() => { + var date = new Date(HEAP32[tmPtr + 20 >> 2] + 1900, HEAP32[tmPtr + 16 >> 2], HEAP32[tmPtr + 12 >> 2], HEAP32[tmPtr + 8 >> 2], HEAP32[tmPtr + 4 >> 2], HEAP32[tmPtr >> 2], 0); + var dst = HEAP32[tmPtr + 32 >> 2]; + var guessedOffset = date.getTimezoneOffset(); + var start = new Date(date.getFullYear(), 0, 1); + var summerOffset = new Date(date.getFullYear(), 6, 1).getTimezoneOffset(); + var winterOffset = start.getTimezoneOffset(); + var dstOffset = Math.min(winterOffset, summerOffset); + if (dst < 0) { + HEAP32[tmPtr + 32 >> 2] = Number(summerOffset != winterOffset && dstOffset == guessedOffset); + } else if (dst > 0 != (dstOffset == guessedOffset)) { + var nonDstOffset = Math.max(winterOffset, summerOffset); + var trueOffset = dst > 0 ? dstOffset : nonDstOffset; + date.setTime(date.getTime() + (trueOffset - guessedOffset) * 6e4); + } + HEAP32[tmPtr + 24 >> 2] = date.getDay(); + var yday = ydayFromDate(date) | 0; + HEAP32[tmPtr + 28 >> 2] = yday; + HEAP32[tmPtr >> 2] = date.getSeconds(); + HEAP32[tmPtr + 4 >> 2] = date.getMinutes(); + HEAP32[tmPtr + 8 >> 2] = date.getHours(); + HEAP32[tmPtr + 12 >> 2] = date.getDate(); + HEAP32[tmPtr + 16 >> 2] = date.getMonth(); + HEAP32[tmPtr + 20 >> 2] = date.getYear(); + return date.getTime() / 1e3; + })(); + return setTempRet0((tempDouble = ret, +Math.abs(tempDouble) >= 1 ? tempDouble > 0 ? +Math.floor(tempDouble / 4294967296) >>> 0 : ~~+Math.ceil((tempDouble - +(~~tempDouble >>> 0)) / 4294967296) >>> 0 : 0)), + ret >>> 0; +}; + +var stringToNewUTF8 = str => { + var size = lengthBytesUTF8(str) + 1; + var ret = _malloc(size); + if (ret) stringToUTF8(str, ret, size); + return ret; +}; + +var __tzset_js = (timezone, daylight, tzname) => { + var currentYear = (new Date).getFullYear(); + var winter = new Date(currentYear, 0, 1); + var summer = new Date(currentYear, 6, 1); + var winterOffset = winter.getTimezoneOffset(); + var summerOffset = summer.getTimezoneOffset(); + var stdTimezoneOffset = Math.max(winterOffset, summerOffset); + HEAPU32[timezone >> 2] = stdTimezoneOffset * 60; + HEAP32[daylight >> 2] = Number(winterOffset != summerOffset); + function extractZone(date) { + var match = date.toTimeString().match(/\(([A-Za-z ]+)\)$/); + return match ? match[1] : "GMT"; + } + var winterName = extractZone(winter); + var summerName = extractZone(summer); + var winterNamePtr = stringToNewUTF8(winterName); + var summerNamePtr = stringToNewUTF8(summerName); + if (summerOffset < winterOffset) { + HEAPU32[tzname >> 2] = winterNamePtr; + HEAPU32[tzname + 4 >> 2] = summerNamePtr; + } else { + HEAPU32[tzname >> 2] = summerNamePtr; + HEAPU32[tzname + 4 >> 2] = winterNamePtr; + } +}; + +var _abort = () => { + abort(""); +}; + +var _emscripten_memcpy_big = (dest, src, num) => HEAPU8.copyWithin(dest, src, src + num); + +var getHeapMax = () => 2147483648; + +var growMemory = size => { + var b = wasmMemory.buffer; + var pages = (size - b.byteLength + 65535) / 65536; + try { + wasmMemory.grow(pages); + updateMemoryViews(); + return 1; + } catch (e) {} +}; + +var _emscripten_resize_heap = requestedSize => { + var oldSize = HEAPU8.length; + requestedSize >>>= 0; + var maxHeapSize = getHeapMax(); + if (requestedSize > maxHeapSize) { + return false; + } + var alignUp = (x, multiple) => x + (multiple - x % multiple) % multiple; + for (var cutDown = 1; cutDown <= 4; cutDown *= 2) { + var overGrownHeapSize = oldSize * (1 + .2 / cutDown); + overGrownHeapSize = Math.min(overGrownHeapSize, requestedSize + 100663296); + var newSize = Math.min(maxHeapSize, alignUp(Math.max(requestedSize, overGrownHeapSize), 65536)); + var replacement = growMemory(newSize); + if (replacement) { + return true; + } + } + return false; +}; + +var ENV = {}; + +var getExecutableName = () => thisProgram || "./this.program"; + +var getEnvStrings = () => { + if (!getEnvStrings.strings) { + var lang = (typeof navigator == "object" && navigator.languages && navigator.languages[0] || "C").replace("-", "_") + ".UTF-8"; + var env = { + "USER": "web_user", + "LOGNAME": "web_user", + "PATH": "/", + "PWD": "/", + "HOME": "/home/web_user", + "LANG": lang, + "_": getExecutableName() + }; + for (var x in ENV) { + if (ENV[x] === undefined) delete env[x]; else env[x] = ENV[x]; + } + var strings = []; + for (var x in env) { + strings.push(`${x}=${env[x]}`); + } + getEnvStrings.strings = strings; + } + return getEnvStrings.strings; +}; + +var stringToAscii = (str, buffer) => { + for (var i = 0; i < str.length; ++i) { + HEAP8[buffer++ >> 0] = str.charCodeAt(i); + } + HEAP8[buffer >> 0] = 0; +}; + +var _environ_get = (__environ, environ_buf) => { + var bufSize = 0; + getEnvStrings().forEach((string, i) => { + var ptr = environ_buf + bufSize; + HEAPU32[__environ + i * 4 >> 2] = ptr; + stringToAscii(string, ptr); + bufSize += string.length + 1; + }); + return 0; +}; + +var _environ_sizes_get = (penviron_count, penviron_buf_size) => { + var strings = getEnvStrings(); + HEAPU32[penviron_count >> 2] = strings.length; + var bufSize = 0; + strings.forEach(string => bufSize += string.length + 1); + HEAPU32[penviron_buf_size >> 2] = bufSize; + return 0; +}; + +var _fd_close = fd => 52; + +var _fd_read = (fd, iov, iovcnt, pnum) => 52; + +function _fd_seek(fd, offset_low, offset_high, whence, newOffset) { + var offset = convertI32PairToI53Checked(offset_low, offset_high); + return 70; +} + +var printCharBuffers = [ null, [], [] ]; + +var printChar = (stream, curr) => { + var buffer = printCharBuffers[stream]; + if (curr === 0 || curr === 10) { + (stream === 1 ? out : err)(UTF8ArrayToString(buffer, 0)); + buffer.length = 0; + } else { + buffer.push(curr); + } +}; + +var _fd_write = (fd, iov, iovcnt, pnum) => { + var num = 0; + for (var i = 0; i < iovcnt; i++) { + var ptr = HEAPU32[iov >> 2]; + var len = HEAPU32[iov + 4 >> 2]; + iov += 8; + for (var j = 0; j < len; j++) { + printChar(fd, HEAPU8[ptr + j]); + } + num += len; + } + HEAPU32[pnum >> 2] = num; + return 0; +}; + +var arraySum = (array, index) => { + var sum = 0; + for (var i = 0; i <= index; sum += array[i++]) {} + return sum; +}; + +var MONTH_DAYS_LEAP = [ 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ]; + +var MONTH_DAYS_REGULAR = [ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ]; + +var addDays = (date, days) => { + var newDate = new Date(date.getTime()); + while (days > 0) { + var leap = isLeapYear(newDate.getFullYear()); + var currentMonth = newDate.getMonth(); + var daysInCurrentMonth = (leap ? MONTH_DAYS_LEAP : MONTH_DAYS_REGULAR)[currentMonth]; + if (days > daysInCurrentMonth - newDate.getDate()) { + days -= daysInCurrentMonth - newDate.getDate() + 1; + newDate.setDate(1); + if (currentMonth < 11) { + newDate.setMonth(currentMonth + 1); + } else { + newDate.setMonth(0); + newDate.setFullYear(newDate.getFullYear() + 1); + } + } else { + newDate.setDate(newDate.getDate() + days); + return newDate; + } + } + return newDate; +}; + +function intArrayFromString(stringy, dontAddNull, length) { + var len = length > 0 ? length : lengthBytesUTF8(stringy) + 1; + var u8array = new Array(len); + var numBytesWritten = stringToUTF8Array(stringy, u8array, 0, u8array.length); + if (dontAddNull) u8array.length = numBytesWritten; + return u8array; +} + +var writeArrayToMemory = (array, buffer) => { + HEAP8.set(array, buffer); +}; + +var _strftime = (s, maxsize, format, tm) => { + var tm_zone = HEAPU32[tm + 40 >> 2]; + var date = { + tm_sec: HEAP32[tm >> 2], + tm_min: HEAP32[tm + 4 >> 2], + tm_hour: HEAP32[tm + 8 >> 2], + tm_mday: HEAP32[tm + 12 >> 2], + tm_mon: HEAP32[tm + 16 >> 2], + tm_year: HEAP32[tm + 20 >> 2], + tm_wday: HEAP32[tm + 24 >> 2], + tm_yday: HEAP32[tm + 28 >> 2], + tm_isdst: HEAP32[tm + 32 >> 2], + tm_gmtoff: HEAP32[tm + 36 >> 2], + tm_zone: tm_zone ? UTF8ToString(tm_zone) : "" + }; + var pattern = UTF8ToString(format); + var EXPANSION_RULES_1 = { + "%c": "%a %b %d %H:%M:%S %Y", + "%D": "%m/%d/%y", + "%F": "%Y-%m-%d", + "%h": "%b", + "%r": "%I:%M:%S %p", + "%R": "%H:%M", + "%T": "%H:%M:%S", + "%x": "%m/%d/%y", + "%X": "%H:%M:%S", + "%Ec": "%c", + "%EC": "%C", + "%Ex": "%m/%d/%y", + "%EX": "%H:%M:%S", + "%Ey": "%y", + "%EY": "%Y", + "%Od": "%d", + "%Oe": "%e", + "%OH": "%H", + "%OI": "%I", + "%Om": "%m", + "%OM": "%M", + "%OS": "%S", + "%Ou": "%u", + "%OU": "%U", + "%OV": "%V", + "%Ow": "%w", + "%OW": "%W", + "%Oy": "%y" + }; + for (var rule in EXPANSION_RULES_1) { + pattern = pattern.replace(new RegExp(rule, "g"), EXPANSION_RULES_1[rule]); + } + var WEEKDAYS = [ "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" ]; + var MONTHS = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" ]; + function leadingSomething(value, digits, character) { + var str = typeof value == "number" ? value.toString() : value || ""; + while (str.length < digits) { + str = character[0] + str; + } + return str; + } + function leadingNulls(value, digits) { + return leadingSomething(value, digits, "0"); + } + function compareByDay(date1, date2) { + function sgn(value) { + return value < 0 ? -1 : value > 0 ? 1 : 0; + } + var compare; + if ((compare = sgn(date1.getFullYear() - date2.getFullYear())) === 0) { + if ((compare = sgn(date1.getMonth() - date2.getMonth())) === 0) { + compare = sgn(date1.getDate() - date2.getDate()); + } + } + return compare; + } + function getFirstWeekStartDate(janFourth) { + switch (janFourth.getDay()) { + case 0: + return new Date(janFourth.getFullYear() - 1, 11, 29); + + case 1: + return janFourth; + + case 2: + return new Date(janFourth.getFullYear(), 0, 3); + + case 3: + return new Date(janFourth.getFullYear(), 0, 2); + + case 4: + return new Date(janFourth.getFullYear(), 0, 1); + + case 5: + return new Date(janFourth.getFullYear() - 1, 11, 31); + + case 6: + return new Date(janFourth.getFullYear() - 1, 11, 30); + } + } + function getWeekBasedYear(date) { + var thisDate = addDays(new Date(date.tm_year + 1900, 0, 1), date.tm_yday); + var janFourthThisYear = new Date(thisDate.getFullYear(), 0, 4); + var janFourthNextYear = new Date(thisDate.getFullYear() + 1, 0, 4); + var firstWeekStartThisYear = getFirstWeekStartDate(janFourthThisYear); + var firstWeekStartNextYear = getFirstWeekStartDate(janFourthNextYear); + if (compareByDay(firstWeekStartThisYear, thisDate) <= 0) { + if (compareByDay(firstWeekStartNextYear, thisDate) <= 0) { + return thisDate.getFullYear() + 1; + } + return thisDate.getFullYear(); + } + return thisDate.getFullYear() - 1; + } + var EXPANSION_RULES_2 = { + "%a": date => WEEKDAYS[date.tm_wday].substring(0, 3), + "%A": date => WEEKDAYS[date.tm_wday], + "%b": date => MONTHS[date.tm_mon].substring(0, 3), + "%B": date => MONTHS[date.tm_mon], + "%C": date => { + var year = date.tm_year + 1900; + return leadingNulls(year / 100 | 0, 2); + }, + "%d": date => leadingNulls(date.tm_mday, 2), + "%e": date => leadingSomething(date.tm_mday, 2, " "), + "%g": date => getWeekBasedYear(date).toString().substring(2), + "%G": date => getWeekBasedYear(date), + "%H": date => leadingNulls(date.tm_hour, 2), + "%I": date => { + var twelveHour = date.tm_hour; + if (twelveHour == 0) twelveHour = 12; else if (twelveHour > 12) twelveHour -= 12; + return leadingNulls(twelveHour, 2); + }, + "%j": date => leadingNulls(date.tm_mday + arraySum(isLeapYear(date.tm_year + 1900) ? MONTH_DAYS_LEAP : MONTH_DAYS_REGULAR, date.tm_mon - 1), 3), + "%m": date => leadingNulls(date.tm_mon + 1, 2), + "%M": date => leadingNulls(date.tm_min, 2), + "%n": () => "\n", + "%p": date => { + if (date.tm_hour >= 0 && date.tm_hour < 12) { + return "AM"; + } + return "PM"; + }, + "%S": date => leadingNulls(date.tm_sec, 2), + "%t": () => "\t", + "%u": date => date.tm_wday || 7, + "%U": date => { + var days = date.tm_yday + 7 - date.tm_wday; + return leadingNulls(Math.floor(days / 7), 2); + }, + "%V": date => { + var val = Math.floor((date.tm_yday + 7 - (date.tm_wday + 6) % 7) / 7); + if ((date.tm_wday + 371 - date.tm_yday - 2) % 7 <= 2) { + val++; + } + if (!val) { + val = 52; + var dec31 = (date.tm_wday + 7 - date.tm_yday - 1) % 7; + if (dec31 == 4 || dec31 == 5 && isLeapYear(date.tm_year % 400 - 1)) { + val++; + } + } else if (val == 53) { + var jan1 = (date.tm_wday + 371 - date.tm_yday) % 7; + if (jan1 != 4 && (jan1 != 3 || !isLeapYear(date.tm_year))) val = 1; + } + return leadingNulls(val, 2); + }, + "%w": date => date.tm_wday, + "%W": date => { + var days = date.tm_yday + 7 - (date.tm_wday + 6) % 7; + return leadingNulls(Math.floor(days / 7), 2); + }, + "%y": date => (date.tm_year + 1900).toString().substring(2), + "%Y": date => date.tm_year + 1900, + "%z": date => { + var off = date.tm_gmtoff; + var ahead = off >= 0; + off = Math.abs(off) / 60; + off = off / 60 * 100 + off % 60; + return (ahead ? "+" : "-") + String("0000" + off).slice(-4); + }, + "%Z": date => date.tm_zone, + "%%": () => "%" + }; + pattern = pattern.replace(/%%/g, "\0\0"); + for (var rule in EXPANSION_RULES_2) { + if (pattern.includes(rule)) { + pattern = pattern.replace(new RegExp(rule, "g"), EXPANSION_RULES_2[rule](date)); + } + } + pattern = pattern.replace(/\0\0/g, "%"); + var bytes = intArrayFromString(pattern, false); + if (bytes.length > maxsize) { + return 0; + } + writeArrayToMemory(bytes, s); + return bytes.length - 1; +}; + +var _strftime_l = (s, maxsize, format, tm, loc) => _strftime(s, maxsize, format, tm); + +embind_init_charCodes(); + +BindingError = Module["BindingError"] = class BindingError extends Error { + constructor(message) { + super(message); + this.name = "BindingError"; + } +}; + +InternalError = Module["InternalError"] = class InternalError extends Error { + constructor(message) { + super(message); + this.name = "InternalError"; + } +}; + +init_ClassHandle(); + +init_embind(); + +init_RegisteredPointer(); + +UnboundTypeError = Module["UnboundTypeError"] = extendError(Error, "UnboundTypeError"); + +handleAllocatorInit(); + +init_emval(); + +var wasmImports = { + __assert_fail: ___assert_fail, + __cxa_throw: ___cxa_throw, + __syscall_ioctl: ___syscall_ioctl, + __syscall_openat: ___syscall_openat, + _embind_register_bigint: __embind_register_bigint, + _embind_register_bool: __embind_register_bool, + _embind_register_class: __embind_register_class, + _embind_register_class_constructor: __embind_register_class_constructor, + _embind_register_class_function: __embind_register_class_function, + _embind_register_emval: __embind_register_emval, + _embind_register_float: __embind_register_float, + _embind_register_integer: __embind_register_integer, + _embind_register_memory_view: __embind_register_memory_view, + _embind_register_std_string: __embind_register_std_string, + _embind_register_std_wstring: __embind_register_std_wstring, + _embind_register_void: __embind_register_void, + _emscripten_throw_longjmp: __emscripten_throw_longjmp, + _emval_decref: __emval_decref, + _emval_incref: __emval_incref, + _emval_take_value: __emval_take_value, + _localtime_js: __localtime_js, + _mktime_js: __mktime_js, + _tzset_js: __tzset_js, + abort: _abort, + emscripten_memcpy_big: _emscripten_memcpy_big, + emscripten_resize_heap: _emscripten_resize_heap, + environ_get: _environ_get, + environ_sizes_get: _environ_sizes_get, + fd_close: _fd_close, + fd_read: _fd_read, + fd_seek: _fd_seek, + fd_write: _fd_write, + invoke_vi: invoke_vi, + invoke_viii: invoke_viii, + invoke_viiii: invoke_viiii, + invoke_viiiii: invoke_viiiii, + strftime_l: _strftime_l +}; + +var wasmExports = createWasm(); + +var ___wasm_call_ctors = () => (___wasm_call_ctors = wasmExports["__wasm_call_ctors"])(); + +var _free = a0 => (_free = wasmExports["free"])(a0); + +var _malloc = a0 => (_malloc = wasmExports["malloc"])(a0); + +var ___getTypeName = a0 => (___getTypeName = wasmExports["__getTypeName"])(a0); + +var __embind_initialize_bindings = Module["__embind_initialize_bindings"] = () => (__embind_initialize_bindings = Module["__embind_initialize_bindings"] = wasmExports["_embind_initialize_bindings"])(); + +var ___errno_location = () => (___errno_location = wasmExports["__errno_location"])(); + +var _setThrew = (a0, a1) => (_setThrew = wasmExports["setThrew"])(a0, a1); + +var setTempRet0 = a0 => (setTempRet0 = wasmExports["setTempRet0"])(a0); + +var stackSave = () => (stackSave = wasmExports["stackSave"])(); + +var stackRestore = a0 => (stackRestore = wasmExports["stackRestore"])(a0); + +var stackAlloc = a0 => (stackAlloc = wasmExports["stackAlloc"])(a0); + +var ___cxa_increment_exception_refcount = a0 => (___cxa_increment_exception_refcount = wasmExports["__cxa_increment_exception_refcount"])(a0); + +var ___cxa_is_pointer_type = a0 => (___cxa_is_pointer_type = wasmExports["__cxa_is_pointer_type"])(a0); + +var dynCall_iijii = Module["dynCall_iijii"] = (a0, a1, a2, a3, a4, a5) => (dynCall_iijii = Module["dynCall_iijii"] = wasmExports["dynCall_iijii"])(a0, a1, a2, a3, a4, a5); + +var dynCall_jiji = Module["dynCall_jiji"] = (a0, a1, a2, a3, a4) => (dynCall_jiji = Module["dynCall_jiji"] = wasmExports["dynCall_jiji"])(a0, a1, a2, a3, a4); + +var dynCall_viijii = Module["dynCall_viijii"] = (a0, a1, a2, a3, a4, a5, a6) => (dynCall_viijii = Module["dynCall_viijii"] = wasmExports["dynCall_viijii"])(a0, a1, a2, a3, a4, a5, a6); + +var dynCall_iiiiij = Module["dynCall_iiiiij"] = (a0, a1, a2, a3, a4, a5, a6) => (dynCall_iiiiij = Module["dynCall_iiiiij"] = wasmExports["dynCall_iiiiij"])(a0, a1, a2, a3, a4, a5, a6); + +var dynCall_iiiiijj = Module["dynCall_iiiiijj"] = (a0, a1, a2, a3, a4, a5, a6, a7, a8) => (dynCall_iiiiijj = Module["dynCall_iiiiijj"] = wasmExports["dynCall_iiiiijj"])(a0, a1, a2, a3, a4, a5, a6, a7, a8); + +var dynCall_iiiiiijj = Module["dynCall_iiiiiijj"] = (a0, a1, a2, a3, a4, a5, a6, a7, a8, a9) => (dynCall_iiiiiijj = Module["dynCall_iiiiiijj"] = wasmExports["dynCall_iiiiiijj"])(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9); + +function invoke_vi(index, a1) { + var sp = stackSave(); + try { + getWasmTableEntry(index)(a1); + } catch (e) { + stackRestore(sp); + if (e !== e + 0) throw e; + _setThrew(1, 0); + } +} + +function invoke_viii(index, a1, a2, a3) { + var sp = stackSave(); + try { + getWasmTableEntry(index)(a1, a2, a3); + } catch (e) { + stackRestore(sp); + if (e !== e + 0) throw e; + _setThrew(1, 0); + } +} + +function invoke_viiii(index, a1, a2, a3, a4) { + var sp = stackSave(); + try { + getWasmTableEntry(index)(a1, a2, a3, a4); + } catch (e) { + stackRestore(sp); + if (e !== e + 0) throw e; + _setThrew(1, 0); + } +} + +function invoke_viiiii(index, a1, a2, a3, a4, a5) { + var sp = stackSave(); + try { + getWasmTableEntry(index)(a1, a2, a3, a4, a5); + } catch (e) { + stackRestore(sp); + if (e !== e + 0) throw e; + _setThrew(1, 0); + } +} + +var calledRun; + +dependenciesFulfilled = function runCaller() { + if (!calledRun) run(); + if (!calledRun) dependenciesFulfilled = runCaller; +}; + +function run() { + if (runDependencies > 0) { + return; + } + preRun(); + if (runDependencies > 0) { + return; + } + function doRun() { + if (calledRun) return; + calledRun = true; + Module["calledRun"] = true; + if (ABORT) return; + initRuntime(); + if (Module["onRuntimeInitialized"]) Module["onRuntimeInitialized"](); + postRun(); + } + if (Module["setStatus"]) { + Module["setStatus"]("Running..."); + setTimeout(function() { + setTimeout(function() { + Module["setStatus"](""); + }, 1); + doRun(); + }, 1); + } else { + doRun(); + } +} + +if (Module["preInit"]) { + if (typeof Module["preInit"] == "function") Module["preInit"] = [ Module["preInit"] ]; + while (Module["preInit"].length > 0) { + Module["preInit"].pop()(); + } +} + +run(); diff --git a/d2m/converters/rlottie-wasm.license b/src/d2m/converters/rlottie-wasm.license similarity index 100% rename from d2m/converters/rlottie-wasm.license rename to src/d2m/converters/rlottie-wasm.license diff --git a/d2m/converters/rlottie-wasm.wasm b/src/d2m/converters/rlottie-wasm.wasm old mode 100755 new mode 100644 similarity index 82% rename from d2m/converters/rlottie-wasm.wasm rename to src/d2m/converters/rlottie-wasm.wasm index b7778c9..ca65b5b Binary files a/d2m/converters/rlottie-wasm.wasm and b/src/d2m/converters/rlottie-wasm.wasm differ diff --git a/d2m/converters/thread-to-announcement.js b/src/d2m/converters/thread-to-announcement.js similarity index 93% rename from d2m/converters/thread-to-announcement.js rename to src/d2m/converters/thread-to-announcement.js index 9d9302a..11a067f 100644 --- a/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -4,10 +4,9 @@ const assert = require("assert").strict const passthrough = require("../../passthrough") const {discord, sync, db, select} = passthrough -/** @type {import("../../matrix/read-registration")} */ -const reg = sync.require("../../matrix/read-registration.js") /** @type {import("../../m2d/converters/utils")} */ const mxUtils = sync.require("../../m2d/converters/utils") +const {reg} = require("../../matrix/read-registration.js") const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) diff --git a/d2m/converters/thread-to-announcement.test.js b/src/d2m/converters/thread-to-announcement.test.js similarity index 99% rename from d2m/converters/thread-to-announcement.test.js rename to src/d2m/converters/thread-to-announcement.test.js index 3bbca20..471cd94 100644 --- a/d2m/converters/thread-to-announcement.test.js +++ b/src/d2m/converters/thread-to-announcement.test.js @@ -1,6 +1,6 @@ const {test} = require("supertape") const {threadToAnnouncement} = require("./thread-to-announcement") -const data = require("../../test/data") +const data = require("../../../test/data") const Ty = require("../../types") /** diff --git a/d2m/converters/user-to-mxid.js b/src/d2m/converters/user-to-mxid.js similarity index 94% rename from d2m/converters/user-to-mxid.js rename to src/d2m/converters/user-to-mxid.js index 97b655e..e0ab137 100644 --- a/d2m/converters/user-to-mxid.js +++ b/src/d2m/converters/user-to-mxid.js @@ -1,7 +1,7 @@ // @ts-check const assert = require("assert") -const registration = require("../../matrix/read-registration") +const {reg} = require("../../matrix/read-registration") const passthrough = require("../../passthrough") const {select} = passthrough @@ -26,7 +26,7 @@ function downcaseUsername(user) { // remove leading and trailing dashes and underscores... .replace(/(?:^[_-]*|[_-]*$)/g, "") // If requested, also make the Discord user ID part of the username - if (registration.ooye.include_user_id_in_mxid) { + if (reg.ooye.include_user_id_in_mxid) { downcased = user.id + "_" + downcased } // The new length must be at least 2 characters (in other words, it should have some content) @@ -64,7 +64,7 @@ function userToSimName(user) { } // 1. Is sim user already registered? - const existing = select("sim", "sim_name", {user_id: user.id}).pluck().get() + const existing = select("sim", "user_id", {user_id: user.id}).pluck().get() assert.equal(existing, null, "Shouldn't try to create a new name for an existing sim") // 2. Register based on username (could be new or old format) diff --git a/d2m/converters/user-to-mxid.test.js b/src/d2m/converters/user-to-mxid.test.js similarity index 95% rename from d2m/converters/user-to-mxid.test.js rename to src/d2m/converters/user-to-mxid.test.js index 4576205..86f151b 100644 --- a/d2m/converters/user-to-mxid.test.js +++ b/src/d2m/converters/user-to-mxid.test.js @@ -1,7 +1,7 @@ const {test} = require("supertape") const tryToCatch = require("try-to-catch") const assert = require("assert") -const data = require("../../test/data") +const data = require("../../../test/data") const {userToSimName} = require("./user-to-mxid") test("user2name: cannot create user for a webhook", async t => { @@ -46,7 +46,7 @@ test("user2name: works on special user", t => { }) test("user2name: includes ID if requested in config", t => { - const reg = require("../../matrix/read-registration") + const {reg} = require("../../matrix/read-registration") reg.ooye.include_user_id_in_mxid = true t.equal(userToSimName({username: "Harry Styles!", discriminator: "0001", id: "123456"}), "123456_harry_styles") t.equal(userToSimName({username: "f***", discriminator: "0001", id: "123456"}), "123456_f") diff --git a/d2m/discord-client.js b/src/d2m/discord-client.js similarity index 66% rename from d2m/discord-client.js rename to src/d2m/discord-client.js index 80dcbcf..b05d48f 100644 --- a/d2m/discord-client.js +++ b/src/d2m/discord-client.js @@ -1,12 +1,17 @@ // @ts-check -const { SnowTransfer } = require("snowtransfer") -const { Client: CloudStorm } = require("cloudstorm") +const DiscordTypes = require("discord-api-types/v10") +const {Endpoints, SnowTransfer} = require("snowtransfer") +const {reg} = require("../matrix/read-registration") +const {Client: CloudStorm} = require("cloudstorm") + +// @ts-ignore +Endpoints.BASE_HOST = reg.ooye.discord_origin || "https://discord.com"; Endpoints.CDN_URL = reg.ooye.discord_cdn_origin || "https://cdn.discordapp.com" const passthrough = require("../passthrough") -const { sync } = passthrough +const {sync} = passthrough -/** @type {typeof import("./discord-packets")} */ +/** @type {import("./discord-packets")} */ const discordPackets = sync.require("./discord-packets") class DiscordClient { @@ -24,7 +29,7 @@ class DiscordClient { intents: [ "DIRECT_MESSAGES", "DIRECT_MESSAGE_REACTIONS", "DIRECT_MESSAGE_TYPING", "GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS", - "MESSAGE_CONTENT" + "MESSAGE_CONTENT", "GUILD_PRESENCES" ], ws: { compress: false, @@ -32,15 +37,15 @@ class DiscordClient { } }) this.ready = false - /** @type {import("discord-api-types/v10").APIUser} */ + /** @type {DiscordTypes.APIUser} */ // @ts-ignore avoid setting as or null because we know we need to wait for ready anyways this.user = null - /** @type {Pick} */ + /** @type {Pick} */ // @ts-ignore this.application = null - /** @type {Map} */ + /** @type {Map} */ this.channels = new Map() - /** @type {Map} */ + /** @type {Map} */ // we get members from the GUILD_CREATE and we do maintain it this.guilds = new Map() /** @type {Map>} */ this.guildChannelMap = new Map() diff --git a/d2m/discord-packets.js b/src/d2m/discord-packets.js similarity index 58% rename from d2m/discord-packets.js rename to src/d2m/discord-packets.js index 6ba839a..017d50e 100644 --- a/d2m/discord-packets.js +++ b/src/d2m/discord-packets.js @@ -4,7 +4,11 @@ const DiscordTypes = require("discord-api-types/v10") const passthrough = require("../passthrough") -const { sync } = passthrough +const {sync, db} = passthrough + +function populateGuildID(guildID, channelID) { + db.prepare("UPDATE channel_room SET guild_id = ? WHERE channel_id = ?").run(guildID, channelID) +} const utils = { /** @@ -16,6 +20,8 @@ const utils = { // requiring this later so that the client is already constructed by the time event-dispatcher is loaded /** @type {typeof import("./event-dispatcher")} */ const eventDispatcher = sync.require("./event-dispatcher") + /** @type {import("../discord/register-interactions")} */ + const interactions = sync.require("../discord/register-interactions") // Client internals, keep track of the state we need if (message.t === "READY") { @@ -26,6 +32,7 @@ const utils = { console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`) } else if (message.t === "GUILD_CREATE") { + message.d.members = message.d.members.filter(m => m.user.id === client.user.id) // only keep the bot's own member - it's needed to determine private channels on web client.guilds.set(message.d.id, message.d) const arr = [] client.guildChannelMap.set(message.d.id, arr) @@ -34,17 +41,25 @@ const utils = { channel.guild_id = message.d.id arr.push(channel.id) client.channels.set(channel.id, channel) + populateGuildID(message.d.id, channel.id) } for (const thread of message.d.threads || []) { // @ts-ignore thread.guild_id = message.d.id arr.push(thread.id) client.channels.set(thread.id, thread) + populateGuildID(message.d.id, thread.id) } + if (listen === "full") { - eventDispatcher.checkMissedExpressions(message.d) - eventDispatcher.checkMissedPins(client, message.d) - eventDispatcher.checkMissedMessages(client, message.d) + try { + await eventDispatcher.checkMissedExpressions(message.d) + await eventDispatcher.checkMissedPins(client, message.d) + await eventDispatcher.checkMissedMessages(client, message.d) + } catch (e) { + console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:") + console.error(e) + } } } else if (message.t === "GUILD_UPDATE") { @@ -87,9 +102,20 @@ const utils = { } } + } else if (message.t === "GUILD_MEMBER_UPDATE") { + const guild = client.guilds.get(message.d.guild_id) + const member = guild?.members.find(m => m.user.id === message.d.user.id) + if (member) { // only update existing members (i.e. the bot's own member) - don't want to inflate the cache with new irrelevant ones + Object.assign(member, message.d) + } + } else if (message.t === "THREAD_CREATE") { client.channels.set(message.d.id, message.d) - + if (message.d["guild_id"]) { + populateGuildID(message.d["guild_id"], message.d.id) + const channels = client.guildChannelMap.get(message.d["guild_id"]) + if (channels && !channels.includes(message.d.id)) channels.push(message.d.id) + } } else if (message.t === "CHANNEL_UPDATE" || message.t === "THREAD_UPDATE") { client.channels.set(message.d.id, message.d) @@ -111,71 +137,40 @@ const utils = { client.guildChannelMap.delete(message.d.id) - } else if (message.t === "CHANNEL_CREATE" || message.t === "CHANNEL_DELETE") { - if (message.t === "CHANNEL_CREATE") { - client.channels.set(message.d.id, message.d) - if (message.d["guild_id"]) { // obj[prop] notation can be used to access a property without typescript complaining that it doesn't exist on all values something can have - const channels = client.guildChannelMap.get(message.d["guild_id"]) - if (channels && !channels.includes(message.d.id)) channels.push(message.d.id) - } - } else { - client.channels.delete(message.d.id) - if (message.d["guild_id"]) { - const channels = client.guildChannelMap.get(message.d["guild_id"]) - if (channels) { - const previous = channels.indexOf(message.d.id) - if (previous !== -1) channels.splice(previous, 1) - } + } else if (message.t === "CHANNEL_CREATE") { + client.channels.set(message.d.id, message.d) + if (message.d["guild_id"]) { // obj[prop] notation can be used to access a property without typescript complaining that it doesn't exist on all values something can have + populateGuildID(message.d["guild_id"], message.d.id) + const channels = client.guildChannelMap.get(message.d["guild_id"]) + if (channels && !channels.includes(message.d.id)) channels.push(message.d.id) + } + + } else if (message.t === "CHANNEL_DELETE") { + client.channels.delete(message.d.id) + if (message.d["guild_id"]) { + const channels = client.guildChannelMap.get(message.d["guild_id"]) + if (channels) { + const previous = channels.indexOf(message.d.id) + if (previous !== -1) channels.splice(previous, 1) } } } // Event dispatcher for OOYE bridge operations - if (listen === "full") { + if (listen === "full" && message.t) { try { - if (message.t === "GUILD_UPDATE") { - await eventDispatcher.onGuildUpdate(client, message.d) - - } else if (message.t === "GUILD_EMOJIS_UPDATE" || message.t === "GUILD_STICKERS_UPDATE") { - await eventDispatcher.onExpressionsUpdate(client, message.d) - - } else if (message.t === "CHANNEL_UPDATE") { - await eventDispatcher.onChannelOrThreadUpdate(client, message.d, false) - - } else if (message.t === "CHANNEL_PINS_UPDATE") { - await eventDispatcher.onChannelPinsUpdate(client, message.d) - - } else if (message.t === "THREAD_CREATE") { - // @ts-ignore - await eventDispatcher.onThreadCreate(client, message.d) - - } else if (message.t === "THREAD_UPDATE") { - await eventDispatcher.onChannelOrThreadUpdate(client, message.d, true) - - } else if (message.t === "MESSAGE_CREATE") { - await eventDispatcher.onMessageCreate(client, message.d) - - } else if (message.t === "MESSAGE_UPDATE") { - await eventDispatcher.onMessageUpdate(client, message.d) - - } else if (message.t === "MESSAGE_DELETE") { - await eventDispatcher.onMessageDelete(client, message.d) - - } else if (message.t === "MESSAGE_DELETE_BULK") { - await eventDispatcher.onMessageDeleteBulk(client, message.d) - - } else if (message.t === "TYPING_START") { - await eventDispatcher.onTypingStart(client, message.d) - - } else if (message.t === "MESSAGE_REACTION_ADD") { - await eventDispatcher.onReactionAdd(client, message.d) - - } else if (message.t === "MESSAGE_REACTION_REMOVE" || message.t === "MESSAGE_REACTION_REMOVE_EMOJI" || message.t === "MESSAGE_REACTION_REMOVE_ALL") { + if (message.t === "MESSAGE_REACTION_REMOVE" || message.t === "MESSAGE_REACTION_REMOVE_EMOJI" || message.t === "MESSAGE_REACTION_REMOVE_ALL") { await eventDispatcher.onSomeReactionsRemoved(client, message.d) + + } else if (message.t === "INTERACTION_CREATE") { + await interactions.dispatchInteraction(message.d) + + } else if (message.t in eventDispatcher) { + await eventDispatcher[message.t](client, message.d) } } catch (e) { // Let OOYE try to handle errors too - eventDispatcher.onError(client, e, message) + await eventDispatcher.onError(client, e, message) } } } diff --git a/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js similarity index 74% rename from d2m/event-dispatcher.js rename to src/d2m/event-dispatcher.js index 7f27b77..1698317 100644 --- a/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -2,7 +2,6 @@ const assert = require("assert").strict const DiscordTypes = require("discord-api-types/v10") -const util = require("util") const {sync, db, select, from} = require("../passthrough") /** @type {import("./actions/send-message")}) */ @@ -27,21 +26,18 @@ const updatePins = sync.require("./actions/update-pins") const api = sync.require("../matrix/api") /** @type {import("../discord/utils")} */ const dUtils = sync.require("../discord/utils") -/** @type {import("../discord/discord-command-handler")}) */ -const discordCommandHandler = sync.require("../discord/discord-command-handler") -/** @type {import("../m2d/converters/utils")} */ -const mxUtils = require("../m2d/converters/utils") /** @type {import("./actions/speedbump")} */ const speedbump = sync.require("./actions/speedbump") /** @type {import("./actions/retrigger")} */ const retrigger = sync.require("./actions/retrigger") +/** @type {import("./actions/set-presence")} */ +const setPresence = sync.require("./actions/set-presence") +/** @type {import("../m2d/event-dispatcher")} */ +const matrixEventDispatcher = sync.require("../m2d/event-dispatcher") -/** @type {any} */ // @ts-ignore bad types from semaphore -const Semaphore = require("@chriscdn/promise-semaphore") +const {Semaphore} = require("@chriscdn/promise-semaphore") const checkMissedPinsSema = new Semaphore() -let lastReportedEvent = 0 - // Grab Discord events we care about for the bridge, check them, and pass them on module.exports = { @@ -50,49 +46,15 @@ module.exports = { * @param {Error} e * @param {import("cloudstorm").IGatewayMessage} gatewayMessage */ - onError(client, e, gatewayMessage) { - console.error("hit event-dispatcher's error handler with this exception:") - console.error(e) // TODO: also log errors into a file or into the database, maybe use a library for this? or just wing it? definitely need to be able to store the formatted event body to load back in later - console.error(`while handling this ${gatewayMessage.t} gateway event:`) - console.dir(gatewayMessage.d, {depth: null}) - - if (gatewayMessage.t === "TYPING_START") return - - if (Date.now() - lastReportedEvent < 5000) return - lastReportedEvent = Date.now() - + async onError(client, e, gatewayMessage) { const channelID = gatewayMessage.d["channel_id"] if (!channelID) return const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get() if (!roomID) return - let stackLines = null - if (e.stack) { - stackLines = e.stack.split("\n") - let cloudstormLine = stackLines.findIndex(l => l.includes("/node_modules/cloudstorm/")) - if (cloudstormLine !== -1) { - stackLines = stackLines.slice(0, cloudstormLine - 2) - } - } + if (gatewayMessage.t === "TYPING_START") return - const builder = new mxUtils.MatrixStringBuilder() - builder.addLine("\u26a0 Bridged event from Discord not delivered", "\u26a0 Bridged event from Discord not delivered") - builder.addLine(`Gateway event: ${gatewayMessage.t}`) - builder.addLine(e.toString()) - if (stackLines) { - builder.addLine(`Error trace:\n${stackLines.join("\n")}`, `
    Error trace
    ${stackLines.join("\n")}
    `) - } - builder.addLine("", `
    Original payload
    ${util.inspect(gatewayMessage.d, false, 4, false)}
    `) - api.sendEvent(roomID, "m.room.message", { - ...builder.get(), - "moe.cadence.ooye.error": { - source: "discord", - payload: gatewayMessage - }, - "m.mentions": { - user_ids: ["@cadence:cadence.moe"] - } - }) + await matrixEventDispatcher.sendError(roomID, "Discord", gatewayMessage.t, e, gatewayMessage) }, /** @@ -105,13 +67,22 @@ module.exports = { async checkMissedMessages(client, guild) { if (guild.unavailable) return const bridgedChannels = select("channel_room", "channel_id").pluck().all() - const prepared = select("event_message", "event_id", {}, "WHERE message_id = ?").pluck() - for (const channel of guild.channels.concat(guild.threads)) { + const preparedExists = db.prepare("SELECT channel_id FROM message_channel WHERE channel_id = ? LIMIT 1") + const preparedGet = select("event_message", "event_id", {}, "WHERE message_id = ?").pluck() + /** @type {(DiscordTypes.APIChannel & {type: DiscordTypes.GuildChannelType})[]} */ + let channels = [] + channels = channels.concat(guild.channels, guild.threads) + for (const channel of channels) { if (!bridgedChannels.includes(channel.id)) continue if (!("last_message_id" in channel) || !channel.last_message_id) continue - const latestWasBridged = prepared.get(channel.last_message_id) + + // Skip if channel is already up-to-date + const latestWasBridged = preparedGet.get(channel.last_message_id) if (latestWasBridged) continue + // Skip if channel was just added to the bridge (there's no place to resume from if it's brand new) + if (!preparedExists.get(channel.id)) continue + // Permissions check const member = guild.members.find(m => m.user?.id === client.user.id) if (!member) return @@ -133,17 +104,28 @@ module.exports = { } } let latestBridgedMessageIndex = messages.findIndex(m => { - return prepared.get(m.id) + return preparedGet.get(m.id) }) // console.log(`[check missed messages] got ${messages.length} messages; last message that IS bridged is at position ${latestBridgedMessageIndex} in the channel`) if (latestBridgedMessageIndex === -1) latestBridgedMessageIndex = 1 // rather than crawling the ENTIRE channel history, let's just bridge the most recent 1 message to make it up to date. + + // We get member data so that we can accurately update any changes to nickname or permissions that have occurred in the meantime + // The rate limit is lax enough that the backlog will still be pretty quick (at time of writing, 5 per 1 second per guild) + /** @type {Map} id -> member: cache members for the run because people talk to each other */ + const members = new Map() + + // Send in order for (let i = Math.min(messages.length, latestBridgedMessageIndex)-1; i >= 0; i--) { - const simulatedGatewayDispatchData = { + const message = messages[i] + + if (!members.has(message.author.id)) members.set(message.author.id, await client.snow.guild.getGuildMember(guild.id, message.author.id).catch(() => undefined)) + await module.exports.MESSAGE_CREATE(client, { guild_id: guild.id, + member: members.get(message.author.id), + // @ts-ignore backfill: true, - ...messages[i] - } - await module.exports.onMessageCreate(client, simulatedGatewayDispatchData) + ...message + }) } } }, @@ -181,7 +163,7 @@ module.exports = { */ async checkMissedExpressions(guild) { const data = {guild_id: guild.id, ...guild} - createSpace.syncSpaceExpressions(data, true) + await createSpace.syncSpaceExpressions(data, true) }, /** @@ -190,10 +172,10 @@ module.exports = { * @param {import("./discord-client")} client * @param {DiscordTypes.APIThreadChannel} thread */ - async onThreadCreate(client, thread) { + async THREAD_CREATE(client, thread) { const channelID = thread.parent_id || undefined const parentRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get() - if (!parentRoomID) return // Not interested in a thread if we aren't interested in its wider channel + if (!parentRoomID) return // Not interested in a thread if we aren't interested in its wider channel (won't autocreate) const threadRoomID = await createRoom.syncRoom(thread.id) // Create room (will share the same inflight as the initial message to the thread) await announceThread.announceThread(parentRoomID, threadRoomID, thread) }, @@ -202,7 +184,7 @@ module.exports = { * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayGuildUpdateDispatchData} guild */ - async onGuildUpdate(client, guild) { + async GUILD_UPDATE(client, guild) { const spaceID = select("guild_space", "space_id", {guild_id: guild.id}).pluck().get() if (!spaceID) return await createSpace.syncSpace(guild) @@ -211,33 +193,54 @@ module.exports = { /** * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayChannelUpdateDispatchData} channelOrThread - * @param {boolean} isThread */ - async onChannelOrThreadUpdate(client, channelOrThread, isThread) { + async CHANNEL_UPDATE(client, channelOrThread) { const roomID = select("channel_room", "room_id", {channel_id: channelOrThread.id}).pluck().get() if (!roomID) return // No target room to update the data on await createRoom.syncRoom(channelOrThread.id) }, + /** + * @param {import("./discord-client")} client + * @param {DiscordTypes.GatewayChannelUpdateDispatchData} thread + */ + async THREAD_UPDATE(client, thread) { + await module.exports.CHANNEL_UPDATE(client, thread) + }, + /** * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayChannelPinsUpdateDispatchData} data */ - async onChannelPinsUpdate(client, data) { + async CHANNEL_PINS_UPDATE(client, data) { const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get() if (!roomID) return // No target room to update pins in const convertedTimestamp = updatePins.convertTimestamp(data.last_pin_timestamp) await updatePins.updatePins(data.channel_id, roomID, convertedTimestamp) }, + /** + * @param {import("./discord-client")} client + * @param {DiscordTypes.GatewayChannelDeleteDispatchData} channel + */ + async CHANNEL_DELETE(client, channel) { + const guildID = channel["guild_id"] + if (!guildID) return // channel must have been a DM channel or something + const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() + if (!roomID) return // channel wasn't being bridged in the first place + // @ts-ignore + await createRoom.unbridgeDeletedChannel(channel, guildID) + }, + /** * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayMessageCreateDispatchData} message */ - async onMessageCreate(client, message) { + async MESSAGE_CREATE(client, message) { if (message.author.username === "Deleted User") return // Nothing we can do for deleted users. const channel = client.channels.get(message.channel_id) if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages. + const guild = client.guilds.get(channel.guild_id) assert(guild) @@ -248,12 +251,13 @@ module.exports = { if (dUtils.isEphemeralMessage(message)) return // Ephemeral messages are for the eyes of the receiver only! + if (!createRoom.existsOrAutocreatable(channel, guild.id)) return // Check that the sending-to room exists or is autocreatable + const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id) if (affected) return // @ts-ignore - await sendMessage.sendMessage(message, channel, guild, row), - await discordCommandHandler.execute(message, channel, guild) + await sendMessage.sendMessage(message, channel, guild, row) retrigger.messageFinishedBridging(message.id) }, @@ -262,15 +266,12 @@ module.exports = { * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayMessageUpdateDispatchData} data */ - async onMessageUpdate(client, data) { + async MESSAGE_UPDATE(client, data) { // Based on looking at data they've sent me over the gateway, this is the best way to check for meaningful changes. // If the message content is a string then it includes all interesting fields and is meaningful. // Otherwise, if there are embeds, then the system generated URL preview embeds. if (!(typeof data.content === "string" || "embeds" in data)) return - // Deal with Eventual Consistency(TM) - if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.onMessageUpdate, client, data)) return - if (data.webhook_id) { const row = select("webhook", "webhook_id", {webhook_id: data.webhook_id}).pluck().get() if (row) return // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it. @@ -282,25 +283,27 @@ module.exports = { const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id) if (affected) return + // Check that the sending-to room exists, and deal with Eventual Consistency(TM) + if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.MESSAGE_UPDATE, client, data)) return + /** @type {DiscordTypes.GatewayMessageCreateDispatchData} */ // @ts-ignore const message = data - const channel = client.channels.get(message.channel_id) if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages. const guild = client.guilds.get(channel.guild_id) assert(guild) + // @ts-ignore - await editMessage.editMessage(message, guild, row) + await retrigger.pauseChanges(message.id, editMessage.editMessage(message, guild, row)) }, /** * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayMessageReactionAddDispatchData} data */ - async onReactionAdd(client, data) { + async MESSAGE_REACTION_ADD(client, data) { if (data.user_id === client.user.id) return // m2d reactions are added by the discord bot user - do not reflect them back to matrix. - discordCommandHandler.onReactionAdd(data) await addReaction.addReaction(data) }, @@ -316,25 +319,25 @@ module.exports = { * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayMessageDeleteDispatchData} data */ - async onMessageDelete(client, data) { + async MESSAGE_DELETE(client, data) { speedbump.onMessageDelete(data.id) - if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.onMessageDelete, client, data)) return + if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.MESSAGE_DELETE, client, data)) return await deleteMessage.deleteMessage(data) }, - /** + /** * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayMessageDeleteBulkDispatchData} data */ - async onMessageDeleteBulk(client, data) { - await deleteMessage.deleteMessageBulk(data) - }, + async MESSAGE_DELETE_BULK(client, data) { + await deleteMessage.deleteMessageBulk(data) + }, /** * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayTypingStartDispatchData} data */ - async onTypingStart(client, data) { + async TYPING_START(client, data) { const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get() if (!roomID) return const mxid = from("sim").join("sim_member", "mxid").where({user_id: data.user_id, room_id: roomID}).pluck("mxid").get() @@ -347,9 +350,27 @@ module.exports = { /** * @param {import("./discord-client")} client - * @param {DiscordTypes.GatewayGuildEmojisUpdateDispatchData | DiscordTypes.GatewayGuildStickersUpdateDispatchData} data + * @param {DiscordTypes.GatewayGuildEmojisUpdateDispatchData} data */ - async onExpressionsUpdate(client, data) { + async GUILD_EMOJIS_UPDATE(client, data) { await createSpace.syncSpaceExpressions(data, false) + }, + + /** + * @param {import("./discord-client")} client + * @param {DiscordTypes.GatewayGuildStickersUpdateDispatchData} data + */ + async GUILD_STICKERS_UPDATE(client, data) { + await createSpace.syncSpaceExpressions(data, false) + }, + + /** + * @param {import("./discord-client")} client + * @param {DiscordTypes.GatewayPresenceUpdateDispatchData} data + */ + PRESENCE_UPDATE(client, data) { + const status = data.status + if (!status) return + setPresence.presenceTracker.incomingPresence(data.user.id, data.guild_id, status) } } diff --git a/db/migrate.js b/src/db/migrate.js similarity index 90% rename from db/migrate.js rename to src/db/migrate.js index 7c1faf9..46d0c14 100644 --- a/db/migrate.js +++ b/src/db/migrate.js @@ -6,7 +6,8 @@ const {join} = require("path") async function migrate(db) { let files = fs.readdirSync(join(__dirname, "migrations")) files = files.sort() - db.prepare("CREATE TABLE IF NOT EXISTS migration (filename TEXT NOT NULL)").run() + db.prepare("CREATE TABLE IF NOT EXISTS migration (filename TEXT NOT NULL, PRIMARY KEY (filename)) WITHOUT ROWID").run() + /** @type {string} */ let progress = db.prepare("SELECT * FROM migration").pluck().get() if (!progress) { progress = "" @@ -37,6 +38,8 @@ async function migrate(db) { if (migrationRan) { console.log("Database migrations all done.") } + + db.pragma("foreign_keys = on") } module.exports.migrate = migrate diff --git a/db/migrations/.baby b/src/db/migrations/.baby similarity index 100% rename from db/migrations/.baby rename to src/db/migrations/.baby diff --git a/db/migrations/0001-schema.sql b/src/db/migrations/0001-schema.sql similarity index 100% rename from db/migrations/0001-schema.sql rename to src/db/migrations/0001-schema.sql diff --git a/db/migrations/0002-optimise-profile-content.sql b/src/db/migrations/0002-optimise-profile-content.sql similarity index 100% rename from db/migrations/0002-optimise-profile-content.sql rename to src/db/migrations/0002-optimise-profile-content.sql diff --git a/db/migrations/0002-optimise-profile-content.up.js b/src/db/migrations/0002-optimise-profile-content.up.js similarity index 96% rename from db/migrations/0002-optimise-profile-content.up.js rename to src/db/migrations/0002-optimise-profile-content.up.js index a8619cf..5b540cb 100644 --- a/db/migrations/0002-optimise-profile-content.up.js +++ b/src/db/migrations/0002-optimise-profile-content.up.js @@ -3,6 +3,7 @@ module.exports = async function(db) { const contents = db.prepare("SELECT distinct hashed_profile_content FROM sim_member WHERE hashed_profile_content IS NOT NULL").pluck().all() const stmt = db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE hashed_profile_content = ?") db.transaction(() => { + /* c8 ignore next 6 */ for (let s of contents) { let b = Buffer.isBuffer(s) ? Uint8Array.from(s) : Uint8Array.from(Buffer.from(s)) const unsignedHash = hasher.h64Raw(b) diff --git a/db/migrations/0003-distinguish-column-names.sql b/src/db/migrations/0003-distinguish-column-names.sql similarity index 100% rename from db/migrations/0003-distinguish-column-names.sql rename to src/db/migrations/0003-distinguish-column-names.sql diff --git a/db/migrations/0004-auto-emoji-guild.sql b/src/db/migrations/0004-auto-emoji-guild.sql similarity index 100% rename from db/migrations/0004-auto-emoji-guild.sql rename to src/db/migrations/0004-auto-emoji-guild.sql diff --git a/db/migrations/0005-clear-member-cache.sql b/src/db/migrations/0005-clear-member-cache.sql similarity index 100% rename from db/migrations/0005-clear-member-cache.sql rename to src/db/migrations/0005-clear-member-cache.sql diff --git a/db/migrations/0006-add-privacy-to-space.sql b/src/db/migrations/0006-add-privacy-to-space.sql similarity index 100% rename from db/migrations/0006-add-privacy-to-space.sql rename to src/db/migrations/0006-add-privacy-to-space.sql diff --git a/db/migrations/0007-split-part-and-reaction-part.sql b/src/db/migrations/0007-split-part-and-reaction-part.sql similarity index 100% rename from db/migrations/0007-split-part-and-reaction-part.sql rename to src/db/migrations/0007-split-part-and-reaction-part.sql diff --git a/db/migrations/0008-add-last-bridged-pin-timestamp.sql b/src/db/migrations/0008-add-last-bridged-pin-timestamp.sql similarity index 100% rename from db/migrations/0008-add-last-bridged-pin-timestamp.sql rename to src/db/migrations/0008-add-last-bridged-pin-timestamp.sql diff --git a/db/migrations/0009-add-speedbump-id.sql b/src/db/migrations/0009-add-speedbump-id.sql similarity index 100% rename from db/migrations/0009-add-speedbump-id.sql rename to src/db/migrations/0009-add-speedbump-id.sql diff --git a/db/migrations/0010-add-sim-proxy.sql b/src/db/migrations/0010-add-sim-proxy.sql similarity index 100% rename from db/migrations/0010-add-sim-proxy.sql rename to src/db/migrations/0010-add-sim-proxy.sql diff --git a/db/migrations/0011-move-bridge-bot-to-real-id.up.js b/src/db/migrations/0011-move-bridge-bot-to-real-id.up.js similarity index 77% rename from db/migrations/0011-move-bridge-bot-to-real-id.up.js rename to src/db/migrations/0011-move-bridge-bot-to-real-id.up.js index 1808fcd..3fab076 100644 --- a/db/migrations/0011-move-bridge-bot-to-real-id.up.js +++ b/src/db/migrations/0011-move-bridge-bot-to-real-id.up.js @@ -10,7 +10,6 @@ */ module.exports = async function(db) { - const config = require("../../config") - const id = Buffer.from(config.discordToken.split(".")[0], "base64").toString() + const id = require("../../../addbot").id db.prepare("UPDATE OR REPLACE sim SET user_id = ? WHERE user_id = '0'").run(id) } diff --git a/src/db/migrations/0012-add-member-power.sql b/src/db/migrations/0012-add-member-power.sql new file mode 100644 index 0000000..b0bb8d6 --- /dev/null +++ b/src/db/migrations/0012-add-member-power.sql @@ -0,0 +1,14 @@ +BEGIN TRANSACTION; + +-- the power we want them to have +CREATE TABLE IF NOT EXISTS member_power ( + mxid TEXT NOT NULL, + room_id TEXT NOT NULL, + power_level INTEGER NOT NULL, + PRIMARY KEY(mxid, room_id) +) WITHOUT ROWID; + +-- the power they have +ALTER TABLE member_cache ADD COLUMN power_level INTEGER NOT NULL DEFAULT 0; + +COMMIT; diff --git a/src/db/migrations/0013-media-proxy.sql b/src/db/migrations/0013-media-proxy.sql new file mode 100644 index 0000000..d55f059 --- /dev/null +++ b/src/db/migrations/0013-media-proxy.sql @@ -0,0 +1,8 @@ +BEGIN TRANSACTION; + +CREATE TABLE "media_proxy" ( + "permitted_hash" INTEGER NOT NULL, + PRIMARY KEY("permitted_hash") +) WITHOUT ROWID; + +COMMIT; diff --git a/src/db/migrations/0014-add-guild-active.sql b/src/db/migrations/0014-add-guild-active.sql new file mode 100644 index 0000000..01e2f16 --- /dev/null +++ b/src/db/migrations/0014-add-guild-active.sql @@ -0,0 +1,11 @@ +BEGIN TRANSACTION; + +CREATE TABLE "guild_active" ( + "guild_id" TEXT NOT NULL, + "autocreate" INTEGER NOT NULL, + PRIMARY KEY("guild_id") +) WITHOUT ROWID; + +INSERT INTO guild_active (guild_id, autocreate) SELECT guild_id, 1 FROM guild_space; + +COMMIT; diff --git a/src/db/migrations/0015-add-guild-id-to-channel-room.sql b/src/db/migrations/0015-add-guild-id-to-channel-room.sql new file mode 100644 index 0000000..81342e4 --- /dev/null +++ b/src/db/migrations/0015-add-guild-id-to-channel-room.sql @@ -0,0 +1,5 @@ +BEGIN TRANSACTION; + +ALTER TABLE channel_room ADD COLUMN guild_id TEXT; + +COMMIT; diff --git a/src/db/migrations/0016-foreign-keys.sql b/src/db/migrations/0016-foreign-keys.sql new file mode 100644 index 0000000..7a2b26c --- /dev/null +++ b/src/db/migrations/0016-foreign-keys.sql @@ -0,0 +1,147 @@ +-- /docs/foreign-keys.md + +-- 2 +BEGIN TRANSACTION; + +-- *** channel_room *** + +-- 4 +-- adding UNIQUE to room_id here will auto-generate the usable index we wanted +CREATE TABLE "new_channel_room" ( + "channel_id" TEXT NOT NULL, + "room_id" TEXT NOT NULL UNIQUE, + "name" TEXT NOT NULL, + "nick" TEXT, + "thread_parent" TEXT, + "custom_avatar" TEXT, + "last_bridged_pin_timestamp" INTEGER, + "speedbump_id" TEXT, + "speedbump_checked" INTEGER, + "speedbump_webhook_id" TEXT, + "guild_id" TEXT, + PRIMARY KEY("channel_id"), + FOREIGN KEY("guild_id") REFERENCES "guild_active"("guild_id") ON DELETE CASCADE +) WITHOUT ROWID; +-- 5 +INSERT INTO new_channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar, last_bridged_pin_timestamp, speedbump_id, speedbump_checked, speedbump_webhook_id, guild_id) SELECT channel_id, room_id, name, nick, thread_parent, custom_avatar, last_bridged_pin_timestamp, speedbump_id, speedbump_checked, speedbump_webhook_id, guild_id FROM channel_room; +-- 6 +DROP TABLE channel_room; +-- 7 +ALTER TABLE new_channel_room RENAME TO channel_room; + +-- *** message_channel *** + +-- 4 +CREATE TABLE "new_message_channel" ( + "message_id" TEXT NOT NULL, + "channel_id" TEXT NOT NULL, + PRIMARY KEY("message_id"), + FOREIGN KEY("channel_id") REFERENCES "channel_room"("channel_id") ON DELETE CASCADE +) WITHOUT ROWID; +-- 5 +-- don't copy any orphaned messages +INSERT INTO new_message_channel (message_id, channel_id) SELECT message_id, channel_id FROM message_channel WHERE channel_id IN (SELECT channel_id FROM channel_room); +-- 6 +DROP TABLE message_channel; +-- 7 +ALTER TABLE new_message_channel RENAME TO message_channel; + +-- *** event_message *** + +-- clean up any orphaned events +DELETE FROM event_message WHERE message_id NOT IN (SELECT message_id FROM message_channel); +-- 4 +CREATE TABLE "new_event_message" ( + "event_id" TEXT NOT NULL, + "event_type" TEXT, + "event_subtype" TEXT, + "message_id" TEXT NOT NULL, + "part" INTEGER NOT NULL, + "reaction_part" INTEGER NOT NULL, + "source" INTEGER NOT NULL, + PRIMARY KEY("message_id","event_id"), + FOREIGN KEY("message_id") REFERENCES "message_channel"("message_id") ON DELETE CASCADE +) WITHOUT ROWID; +-- 5 +INSERT INTO new_event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) SELECT event_id, event_type, event_subtype, message_id, part, reaction_part, source FROM event_message; +-- 6 +DROP TABLE event_message; +-- 7 +ALTER TABLE new_event_message RENAME TO event_message; + +-- *** guild_space *** + +-- 4 +CREATE TABLE "new_guild_space" ( + "guild_id" TEXT NOT NULL, + "space_id" TEXT NOT NULL, + "privacy_level" INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY("guild_id"), + FOREIGN KEY("guild_id") REFERENCES "guild_active"("guild_id") ON DELETE CASCADE +) WITHOUT ROWID; +-- 5 +INSERT INTO new_guild_space (guild_id, space_id, privacy_level) SELECT guild_id, space_id, privacy_level FROM guild_space; +-- 6 +DROP TABLE guild_space; +-- 7 +ALTER TABLE new_guild_space RENAME TO guild_space; + +-- *** reaction *** + +-- 4 +CREATE TABLE "new_reaction" ( + "hashed_event_id" INTEGER NOT NULL, + "message_id" TEXT NOT NULL, + "encoded_emoji" TEXT NOT NULL, + PRIMARY KEY("hashed_event_id"), + FOREIGN KEY("message_id") REFERENCES "message_channel"("message_id") ON DELETE CASCADE +) WITHOUT ROWID; +-- 5 +INSERT INTO new_reaction (hashed_event_id, message_id, encoded_emoji) SELECT hashed_event_id, message_id, encoded_emoji FROM reaction WHERE message_id IN (SELECT message_id FROM message_channel); +-- 6 +DROP TABLE reaction; +-- 7 +ALTER TABLE new_reaction RENAME TO reaction; + +-- *** webhook *** + +-- 4 +-- using RESTRICT instead of CASCADE as a reminder that the webhooks also need to be deleted using the Discord API, it can't just be entirely automatic +CREATE TABLE "new_webhook" ( + "channel_id" TEXT NOT NULL, + "webhook_id" TEXT NOT NULL, + "webhook_token" TEXT NOT NULL, + PRIMARY KEY("channel_id"), + FOREIGN KEY("channel_id") REFERENCES "channel_room"("channel_id") ON DELETE RESTRICT +) WITHOUT ROWID; +-- 5 +INSERT INTO new_webhook (channel_id, webhook_id, webhook_token) SELECT channel_id, webhook_id, webhook_token FROM webhook WHERE channel_id IN (SELECT channel_id FROM channel_room); +-- 6 +DROP TABLE webhook; +-- 7 +ALTER TABLE new_webhook RENAME TO webhook; + +-- *** sim *** + +-- 4 +-- while we're at it, rebuild this table to give it WITHOUT ROWID, remove UNIQUE, and replace the localpart column with username. no foreign keys needed +CREATE TABLE "new_sim" ( + "user_id" TEXT NOT NULL, + "username" TEXT NOT NULL, + "sim_name" TEXT NOT NULL, + "mxid" TEXT NOT NULL, + PRIMARY KEY("user_id") +) WITHOUT ROWID; +-- 5 +INSERT INTO new_sim (user_id, username, sim_name, mxid) SELECT user_id, sim_name, sim_name, mxid FROM sim; +-- 6 +DROP TABLE sim; +-- 7 +ALTER TABLE new_sim RENAME TO sim; + +-- *** end *** + +-- 10 +PRAGMA foreign_key_check; +-- 11 +COMMIT; diff --git a/src/db/migrations/0017-analyze.sql b/src/db/migrations/0017-analyze.sql new file mode 100644 index 0000000..802fca2 --- /dev/null +++ b/src/db/migrations/0017-analyze.sql @@ -0,0 +1,225 @@ +-- https://www.sqlite.org/lang_analyze.html + +BEGIN TRANSACTION; + +ANALYZE sqlite_schema; + +DELETE FROM "sqlite_stat1"; +INSERT INTO "sqlite_stat1" ("tbl","idx","stat") VALUES ('sim','sim','625 1'), + ('reaction','reaction','3242 1'), + ('channel_room','channel_room','389 1'), + ('channel_room','sqlite_autoindex_channel_room_1','389 1'), + ('media_proxy','media_proxy','5068 1'), + ('sim_proxy','sim_proxy','36 1'), + ('webhook','webhook','155 1'), + ('member_cache','member_cache','784 3 1'), + ('member_power','member_power','1 1 1'), + ('file','file','21862 1'), + ('message_channel','message_channel','366884 1'), + ('lottie','lottie','19 1'), + ('event_message','event_message','382920 1 1'), + ('migration',NULL,'1'), + ('sim_member','sim_member','2871 7 1'), + ('guild_space','guild_space','32 1'), + ('guild_active','guild_active','34 1'), + ('emoji','emoji','2563 1'), + ('auto_emoji','auto_emoji','3 1'); + +DELETE FROM "sqlite_stat4"; +INSERT INTO "sqlite_stat4" ("tbl","idx","neq","nlt","ndlt","sample") VALUES ('sim','sim','1','69','69',X'0231313137363631373038303932333039353039'), + ('sim','sim','1','139','139',X'0231313530383936363934333439373931323332'), + ('sim','sim','1','209','209',X'0231323231383737363334373737323139303732'), + ('sim','sim','1','279','279',X'0231333039313431353735353334313136383636'), + ('sim','sim','1','349','349',X'0231333935343433383235363034313635363434'), + ('sim','sim','1','419','419',X'0231353335363239373830383338353134373030'), + ('sim','sim','1','489','489',X'0231363930333339333730353930363636383034'), + ('sim','sim','1','559','559',X'0231383535353736303637393137323137383133'), + ('reaction','reaction','1','360','360',X'020699d5faceefb5fb4f'), + ('reaction','reaction','1','721','721',X'0206b61095e98b6b2fb1'), + ('reaction','reaction','1','1082','1082',X'0206d1dcb418603a5eaa'), + ('reaction','reaction','1','1443','1443',X'0206ef9fc42b9df746ad'), + ('reaction','reaction','1','1804','1804',X'02060f38c1f98f130605'), + ('reaction','reaction','1','2165','2165',X'02062b53df6dab7b1067'), + ('reaction','reaction','1','2526','2526',X'020645dd7e7f60c4aac7'), + ('reaction','reaction','1','2887','2887',X'0206658d2fe735805979'), + ('channel_room','channel_room','1','43','43',X'023331313434393131333330393139333231363330'), + ('channel_room','channel_room','1','87','87',X'023331313835343033343830303934303335393738'), + ('channel_room','channel_room','1','131','131',X'023331323139353036353836343139303638393839'), + ('channel_room','channel_room','1','175','175',X'023331323336353538333034323331303334393630'), + ('channel_room','channel_room','1','219','219',X'023331323933373932323135333930323234343235'), + ('channel_room','channel_room','1','263','263',X'023331333333323139363936393333323038303937'), + ('channel_room','channel_room','1','307','307',X'0231343835363635393733363433333738363938'), + ('channel_room','channel_room','1','351','351',X'0231373039303432313039353632323234363731'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','6 6','6 6',X'034b3321416a6c4c49464e6248646474424a6d4d73503a636164656e63652e6d6f6531313531333434383735363139343833373233'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','34 34','34 34',X'034b3321474b4a63424a6b527a47634e4855686c50613a636164656e63652e6d6f6531303237393433323532323237323630343637'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','43 43','43 43',X'034b3121484b50534d62736d694673506d6268414f513a636164656e63652e6d6f65313931343837343839393433343034353434'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','58 58','58 58',X'034b33214a4479425a685545706874784f6e6f6569513a636164656e63652e6d6f6531323937323836373434353633313236333532'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','87 87','87 87',X'034b33214e544d724e686e715271695755654d494d523a636164656e63652e6d6f6531323235323434353738393939373031353536'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','108 108','108 108',X'034b332151444e44796656674e7657565345656876713a636164656e63652e6d6f6531313432333134303935353535363435343830'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','131 131','131 131',X'034b3121544171536b575752654b43506f584c6a75483a636164656e63652e6d6f65383737303730363531343733363631393532'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','175 175','175 175',X'034b3321594249486864714e697255585941587845563a636164656e63652e6d6f6531323335303831373939353936373639333730'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','177 177','177 177',X'034b3321594b46454e79716667696951686956496b533a636164656e63652e6d6f6531323934363237303431343530333933373034'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','186 186','186 186',X'034b3321596f54644f55766a53765349767266716c653a636164656e63652e6d6f6531323734313936373733383435333430323933'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','202 202','202 202',X'034b3121625877616673695372655647676470535a463a636164656e63652e6d6f65373339303137363739373936343336393932'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','208 208','208 208',X'034b3321634a4b6843764943795377717a47634551423a636164656e63652e6d6f6531323732363632303331323238373331343834'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','219 219','219 219',X'034b3121656455786a56647a6755765844554951434b3a636164656e63652e6d6f65343937313631333530393334353630373738'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','242 242','242 242',X'034b31216a4d746e6e6f51414e4278466a486458494d3a636164656e63652e6d6f65373634353135323932303539323731313939'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','263 263','263 263',X'034b31216c7a776870666a5a6e59797468656a7453483a636164656e63652e6d6f65383838343831373132383438343030343534'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','264 264','264 264',X'034b33216d454c5846716a426958726d7558796943723a636164656e63652e6d6f6531313936393134373631303430393234373432'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','268 268','268 268',X'034b33216d557765577571546761574a767769576a653a636164656e63652e6d6f6531323936373131393236333032333830303834'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','291 291','291 291',X'034b3321704761494e45534643587a634e42497a724e3a636164656e63652e6d6f6531303237343531333333333533313532353232'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','306 306','306 306',X'034b3321717646656248564f4b6876454e54494563763a636164656e63652e6d6f6531323737373238383139323232303230313436'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','307 307','307 307',X'034b332171767370666d716f476449634a66794c506c3a636164656e63652e6d6f6531323936393138333638393539343633343735'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','351 351','351 351',X'034b3321774e7a7741724a47796f4c5168426f544e4b3a636164656e63652e6d6f6531303238303436373930333435333739383930'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','368 368','368 368',X'034b3321794e6d504c7765654a69756570725a677a733a636164656e63652e6d6f6531323531393631373233373731313632363234'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','376 376','376 376',X'034b3121796a4879795772466f704c66646878564e423a636164656e63652e6d6f65333336313537353037303734353233313336'), + ('channel_room','sqlite_autoindex_channel_room_1','1 1','379 379','379 379',X'034b31217a4c4f6b62766b44587551465948594555673a636164656e63652e6d6f65393933383838313433373030393330363331'), + ('media_proxy','media_proxy','1','563','563',X'02069e6054680b610946'), + ('media_proxy','media_proxy','1','1127','1127',X'0206bb489b717c9320e4'), + ('media_proxy','media_proxy','1','1691','1691',X'0206d75f602775b7a27c'), + ('media_proxy','media_proxy','1','2255','2255',X'0206f2c705ddca4e2b14'), + ('media_proxy','media_proxy','1','2819','2819',X'02061060db7a5151967b'), + ('media_proxy','media_proxy','1','3383','3383',X'02062cc47366f7550d22'), + ('media_proxy','media_proxy','1','3947','3947',X'020647d275ec0d781fc7'), + ('media_proxy','media_proxy','1','4511','4511',X'02066402024a7ea38249'), + ('sim_proxy','sim_proxy','1','4','4',X'025531316564343731342d636635652d346333372d393331382d376136353266383732636634'), + ('sim_proxy','sim_proxy','1','9','9',X'025533346636333932642d323263372d346337382d393063372d326536323734313535613266'), + ('sim_proxy','sim_proxy','1','14','14',X'025535396662363131392d626133392d346565382d393738612d386432376366303631393633'), + ('sim_proxy','sim_proxy','1','19','19',X'025539373066366536332d646234632d346531342d383063362d336639343938643961363665'), + ('sim_proxy','sim_proxy','1','24','24',X'025561636231613335642d313336662d343362332d626365622d326566646634616265306436'), + ('sim_proxy','sim_proxy','1','29','29',X'025563316635623735392d336136342d343633342d623634632d643461656436316539656632'), + ('sim_proxy','sim_proxy','1','34','34',X'025566323230373135632d633436332d343532622d626233612d373662646662306365353537'), + ('webhook','webhook','1','17','17',X'023331313532383834313435343038373230393936'), + ('webhook','webhook','1','35','35',X'023331313939303936333434333830343631313138'), + ('webhook','webhook','1','53','53',X'023331323331383036353337373032353736313938'), + ('webhook','webhook','1','71','71',X'023331323933373836383939343238383036363536'), + ('webhook','webhook','1','89','89',X'023331333132363031353130363535353537373132'), + ('webhook','webhook','1','107','107',X'0231323937323734313733303636333133373339'), + ('webhook','webhook','1','125','125',X'0231353239313736313536333938363832313137'), + ('webhook','webhook','1','143','143',X'0231363837303238373334333232313437333434'), + ('member_cache','member_cache','4 1','73 74','48 74',X'034b3921496f4866536e67625a6762747061747a494e3a636164656e63652e6d6f65406875636b6c65746f6e3a636164656e63652e6d6f65'), + ('member_cache','member_cache','2 1','86 87','57 87',X'034b2d214b5169714663546e764f6f4f424475746a7a3a636164656e63652e6d6f6540726e6c3a636164656e63652e6d6f65'), + ('member_cache','member_cache','4 1','101 104','68 104',X'034b43214e446249714e704a795076664b526e4e63723a636164656e63652e6d6f6540776f756e6465645f77617272696f723a6d61747269782e6f7267'), + ('member_cache','member_cache','4 1','110 113','73 113',X'034b3b214f485844457370624d485348716c4445614f3a636164656e63652e6d6f6540717561647261646963616c3a6d61747269782e6f7267'), + ('member_cache','member_cache','5 1','171 175','111 175',X'034b3b215450616f6a5454444446444847776c7276743a636164656e63652e6d6f6540766962656973766572796f3a6d61747269782e6f7267'), + ('member_cache','member_cache','39 1','180 208','116 208',X'034b4d2154716c79516d69667847556767456d64424e3a636164656e63652e6d6f6540726f626c6b796f6772653a6372616674696e67636f6d72616465732e6e6574'), + ('member_cache','member_cache','4 1','231 231','126 231',X'034b3b2156624f77675559777146614e4c5345644e413a636164656e63652e6d6f654061666c6f7765723a73796e646963617465642e676179'), + ('member_cache','member_cache','9 1','262 263','141 263',X'034b3b21594b46454e79716667696951686956496b533a636164656e63652e6d6f654062656e6d61633a636861742e62656e6d61632e78797a'), + ('member_cache','member_cache','3 1','283 283','149 283',X'034b35215a615a4d78456f52724d6d4e49554d79446c3a636164656e63652e6d6f6540636164656e63653a636164656e63652e6d6f65'), + ('member_cache','member_cache','88 1','307 351','166 351',X'034b3b2163427874565278446c5a765356684a58564b3a636164656e63652e6d6f65406a61736b617274683a736c656570696e672e746f776e'), + ('member_cache','member_cache','11 1','408 415','177 415',X'034b5121654856655270706e6c6f57587177704a6e553a636164656e63652e6d6f65406a61636b736f6e6368656e3636363a6a61636b736f6e6368656e3636362e636f6d'), + ('member_cache','member_cache','7 1','423 424','181 424',X'034b4b2165724f7079584e465a486a48724568784e583a636164656e63652e6d6f6540616d796973636f6f6c7a3a6d61747269782e6174697573616d792e636f6d'), + ('member_cache','member_cache','96 1','436 439','187 439',X'034b4b21676865544b5a7451666c444e7070684c49673a636164656e63652e6d6f6540616c65783a73706163652e67616d65727374617665726e2e6f6e6c696e65'), + ('member_cache','member_cache','96 1','436 527','187 527',X'034b3121676865544b5a7451666c444e7070684c49673a636164656e63652e6d6f654078796c6f626f6c3a616d6265722e74656c'), + ('member_cache','member_cache','10 1','546 555','197 555',X'0351312169537958674e7851634575586f587073536e3a707573737468656361742e6f726740797562697175653a6e6f70652e63686174'), + ('member_cache','member_cache','13 1','594 601','224 601',X'034b2b216c7570486a715444537a774f744d59476d493a636164656e63652e6d6f6540656c6c69753a68617368692e7265'), + ('member_cache','member_cache','2 1','614 615','229 615',X'034b2f216d584978494644676c4861734e53427371773a636164656e63652e6d6f654077696e673a666561746865722e6f6e6c'), + ('member_cache','member_cache','4 1','616 619','230 619',X'034b2f216d616767455367755a427147425a74536e723a636164656e63652e6d6f654077696e673a666561746865722e6f6e6c'), + ('member_cache','member_cache','4 1','659 660','259 660',X'034b332172454f73706e5971644f414c4149466e69563a636164656e63652e6d6f6540656c797369613a636164656e63652e6d6f65'), + ('member_cache','member_cache','4 1','699 701','284 701',X'034b3521766e717a56767678534a586c5a504f5276533a636164656e63652e6d6f6540636164656e63653a636164656e63652e6d6f65'), + ('member_cache','member_cache','1 1','703 703','285 703',X'034b3521767165714c474851616842464a56566779483a636164656e63652e6d6f654063696465723a6361746769726c2e636c6f7564'), + ('member_cache','member_cache','4 1','705 705','287 705',X'034b35217750454472596b77497a6f744e66706e57503a636164656e63652e6d6f6540636164656e63653a636164656e63652e6d6f65'), + ('member_cache','member_cache','35 1','709 709','288 709',X'034b2d2177574f667376757356486f4e4e567242585a3a636164656e63652e6d6f654061613a6361747669626572732e6d65'), + ('member_cache','member_cache','14 1','747 749','291 749',X'034b3721776c534544496a44676c486d42474b7254703a636164656e63652e6d6f654062616461746e616d65733a62616461742e646576'), + ('member_power','member_power','1 1','0 0','0 0',X'03350f40636164656e63653a636164656e63652e6d6f652a'), + ('file','file','1','2429','2429',X'03815f68747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f313039393033313838373530303033343038382f313333313336303134333238333036303833372f50584c5f32303235303132315f3230323934323137372e6a7067'), + ('file','file','1','4859','4859',X'03817568747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f313134353832313533383832323637323436362f313330323232393131303834373936373331352f53637265656e73686f745f32303234313130325f3034313332365f5265646469742e6a7067'), + ('file','file','1','7289','7289',X'03815968747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f313231393439383932363436363636323433302f313239373634363930353038353636313234362f494d475f32303234313032305f3135323230302e6a7067'), + ('file','file','1','9719','9719',X'03814168747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3135393136353731343139343735393638302f313236383537333933363531343337313635392f494d475f353433362e6a7067'), + ('file','file','1','12149','12149',X'03813b68747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3236363736373539303634313233383032372f313237323430333931313939383730313630392f696d6167652e706e67'), + ('file','file','1','14579','14579',X'03816b68747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3539383730363933323736303434343936392f313237373532343532343330383632373437372f45585445524e414c5f454449545f323032345f4d5f64726166745f322e646f6378'), + ('file','file','1','17009','17009',X'03815768747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3635353231363137333639363238363734362f313333333630333132383634313036303938342f323032352d30312d32375f31372e30312e31352e706e67'), + ('file','file','1','19439','19439',X'027f68747470733a2f2f63646e2e646973636f72646170702e636f6d2f656d6f6a69732f313230323936303730343936313931323836322e706e67'), + ('message_channel','message_channel','1','40764','40764',X'023331313630333434353733303030383232383735'), + ('message_channel','message_channel','1','81529','81529',X'023331313830323437393130343238393837343333'), + ('message_channel','message_channel','1','122294','122294',X'023331313938303533383732383337363131363230'), + ('message_channel','message_channel','1','163059','163059',X'023331323237373739373330333839303738303537'), + ('message_channel','message_channel','1','203824','203824',X'023331323437303438333039303031313538373137'), + ('message_channel','message_channel','1','244589','244589',X'023331323635363939353734333034303830303034'), + ('message_channel','message_channel','1','285354','285354',X'023331323835343637363238323434313732383234'), + ('message_channel','message_channel','1','326119','326119',X'023331333038373932333935313031333736353732'), + ('lottie','lottie','1','2','2',X'0231373439303534363630373639323138363331'), + ('lottie','lottie','1','5','5',X'0231373534313037353339323030363731373635'), + ('lottie','lottie','1','8','8',X'0231373936313430363338303933343433303932'), + ('lottie','lottie','1','11','11',X'0231373936313431373032363935343835353030'), + ('lottie','lottie','1','14','14',X'0231383136303837373932323931323832393434'), + ('lottie','lottie','1','17','17',X'0231383233393736313032393736323930383636'), + ('event_message','event_message','11 1','14788 14796','14356 14796',X'03336531313532303033373639343137303839303434246d44714a474b3530424a715170394273684e534d64365f3768494354776e70362d793130786d6669766563'), + ('event_message','event_message','11 1','33806 33815','32914 33815',X'033365313135373630333638373035373836363736322459526f7139484b376e55677a397668796f6e3053424a49497978497a5750734e4f6e39756f765644664d45'), + ('event_message','event_message','1 1','42546 42546','41335 42546',X'03336531313630363236383737353835363938393237245033436e4f6d6a35462d6939454e4e79586b70796f5679306b74324a654464764276326e746d33566a4355'), + ('event_message','event_message','2 1','85093 85093','81999 85093',X'0333653131383039393939363531323930343831303424496748666562784533746e623047412d534f7176594a354e4e55385735706c68523159676854636a554734'), + ('event_message','event_message','11 1','116157 116165','111525 116165',X'03336531313933363837333730363137333237363536245a32305734766c737079566e387a6e2d526a6f64694a51745f5a7851644f5f33744e415169453755356477'), + ('event_message','event_message','1 1','127640 127640','122328 127640',X'0333653132303132353637313733373633303332323624702d626f415672476a4b45327a7158664f3738387a5a597a376a42624648717431334d386464705467476f'), + ('event_message','event_message','16 1','140270 140281','134379 140281',X'03336531323039333735363534373138343830343235246c374a4f7543526b7756306d627a69356e5843496b4538416a6951374f67473455456c2d7053445a516649'), + ('event_message','event_message','11 1','162065 162071','154933 162071',X'0333653132323434383135393033313937373537373424674c77513179796e4b6d5859496b5a597a4a55627a66557a55552d714c4b5f524f454e4250325f6e44766b'), + ('event_message','event_message','1 1','170187 170187','162530 170187',X'0333653132323937373533363839343532373038333424656c6c76416a544a5847627936767249767470677a555572787231716f5a75536e50525f474b4e35455945'), + ('event_message','event_message','11 1','178736 178736','170762 178736',X'03336531323333353238303533323338333337353537242d39304668552d36455373594b6435484d7237666d6a414a5f6a576149616e356c4776384e655436564959'), + ('event_message','event_message','10 1','180317 180325','172228 180325',X'03336531323334363237303030393333383130323637246a4679416449665a4f54432d2d735971715472473735374c7a50504f34386439657963485477686d797751'), + ('event_message','event_message','1 1','212734 212734','203278 212734',X'03336531323438303131373930303633373637363734246557493950456f4d576b614b4d33416a7030782d6d455f6c4b4a6c495f6d6d44686f6379464b5170534f59'), + ('event_message','event_message','11 1','228100 228103','217856 228103',X'033365313235333734353736363733373132313336322447326a7a746e5977716a3676304a7a4168576e30725950596470667a5f496f76426771574a4876434c516b'), + ('event_message','event_message','11 1','240172 240181','229123 240181',X'033365313235393239383538323233303630313735382472635679454c5a76647453547a37366f3669434a4f614a316a756f683835535778494d546c5072517a676f'), + ('event_message','event_message','10 1','240259 240264','229132 240264',X'0333653132353933303030343035333535373235323024535849376a465f696e71424d714c4f564b347659746852644b31724747502d435a5f355a354f6b774e3751'), + ('event_message','event_message','2 1','255280 255281','243230 255281',X'03336531323636373837343338353137343234313738246e6e4e576f526a54495757723441463770625454746b7a73784c4d336b312d6d6645665031496b43586e59'), + ('event_message','event_message','1 1','297828 297828','283436 297828',X'0333653132383637303937353334363834323032313024544342465970356f39767a2d4f6b2d33654f4e433772426d354966615934476b48536e58445257474f4767'), + ('event_message','event_message','1 1','340375 340375','323489 340375',X'033365313330393336363936343530353934303030392431664536386d2d50546e786d5474345458584c35754847594139353779396c76582d50797150496d395f30'), + ('event_message','event_message','11 1','343791 343799','326730 343799',X'03336531333131353731333337393331363537323737246731767241315269725951592d3933304f6973587142372d6961686e34684e492d6462374952714176616b'), + ('event_message','event_message','10 1','363207 363216','344868 363216',X'0333653133323434393732323633393438393834393524784f78636e4749364269545941734d7a4f7557316f526e68356b675378544e30466442386a3037695f4f67'), + ('event_message','event_message','10 1','363219 363219','344871 363219',X'033365313332343439373532363733323039393732352432586f7938567937643843375a47767472657966326b4c4f4159397546386b4755364c3642435f446b6d77'), + ('event_message','event_message','10 1','363340 363343','344918 363343',X'03336531333234353037313732333634373530393830244f46645731396649534176706d34646c36367a2d5543337236432d436354506e34752d63444f7036733345'), + ('event_message','event_message','11 1','369452 369456','350712 369456',X'0333653133323736353831313733343439323336383024574d774330644574417277375f4562554a534465546532577174506d3747584347774570646c4f79326d30'), + ('event_message','event_message','10 1','372353 372356','353425 372356',X'0333653133323933313737353039313234353035373224366259364d313472667163486d5854476349716d4a4366467471796839794472375a6a487463715a6d4f34'), + ('sim_member','sim_member','225 1','0 12','0 12',X'034b4721414956694e775a64636b4652764c4f4567433a636164656e63652e6d6f65405f6f6f79655f616b6972615f6e6965723a636164656e63652e6d6f65'), + ('sim_member','sim_member','2 1','319 319','14 319',X'034b43214456706f6e54524d56456570486378744c423a636164656e63652e6d6f65405f6f6f79655f656e746f6c6f6d613a636164656e63652e6d6f65'), + ('sim_member','sim_member','68 1','391 440','26 440',X'034b4921457a54624a496c496d45534f746b4e644e4a3a636164656e63652e6d6f65405f6f6f79655f73617475726461797465643a636164656e63652e6d6f65'), + ('sim_member','sim_member','8 1','638 639','59 639',X'034b3f21497a4f675169446e757346516977796d614c3a636164656e63652e6d6f65405f6f6f79655f636f6f6b69653a636164656e63652e6d6f65'), + ('sim_member','sim_member','31 1','743 771','86 771',X'034b49214d5071594e414a62576b72474f544a7461703a636164656e63652e6d6f65405f6f6f79655f746865666f6f6c323239343a636164656e63652e6d6f65'), + ('sim_member','sim_member','26 1','774 787','87 787',X'034b49214d687950614b4250506f496c7365794d6d743a636164656e63652e6d6f65405f6f6f79655f6b79757567727970686f6e3a636164656e63652e6d6f65'), + ('sim_member','sim_member','27 1','877 881','104 881',X'034b45215063734371724f466a48476f41424270414c3a636164656e63652e6d6f65405f6f6f79655f62696c6c795f626f623a636164656e63652e6d6f65'), + ('sim_member','sim_member','16 1','956 959','117 959',X'034b43215158526f4a777a63506d5047546d454b454d3a636164656e63652e6d6f65405f6f6f79655f626f7472616334723a636164656e63652e6d6f65'), + ('sim_member','sim_member','32 1','997 1012','121 1012',X'034b3f215170676c734e587a4c7751594d4c6c734f503a636164656e63652e6d6f65405f6f6f79655f6a75746f6d693a636164656e63652e6d6f65'), + ('sim_member','sim_member','7 1','1274 1279','157 1279',X'034b4121554d6f6e68556765644d47585a78466658753a636164656e63652e6d6f65405f6f6f79655f6d696e696d75733a636164656e63652e6d6f65'), + ('sim_member','sim_member','27 1','1415 1439','188 1439',X'034b3d21595868717249786d586e47736961796a59783a636164656e63652e6d6f65405f6f6f79655f73746161663a636164656e63652e6d6f65'), + ('sim_member','sim_member','16 1','1597 1599','217 1599',X'034b512163466a4479477274466d48796d794c6652453a636164656e63652e6d6f65405f6f6f79655f626f6a61636b5f686f7273656d616e3a636164656e63652e6d6f65'), + ('sim_member','sim_member','27 1','1758 1761','248 1761',X'034b3d2168665a74624d656f5355564e424850736a743a636164656e63652e6d6f65405f6f6f79655f617a7572653a636164656e63652e6d6f65'), + ('sim_member','sim_member','25 1','1865 1886','270 1886',X'034b3f216b4c52714b4b555158636962494d744f706c3a636164656e63652e6d6f65405f6f6f79655f7361796f72693a636164656e63652e6d6f65'), + ('sim_member','sim_member','19 1','1918 1919','276 1919',X'034b3d216b68497350756c465369736d43646c596e493a636164656e63652e6d6f65405f6f6f79655f617a7572653a636164656e63652e6d6f65'), + ('sim_member','sim_member','33 1','1986 2015','286 2015',X'034b3d216d5451744d736a534c4f646c576f7265594d3a636164656e63652e6d6f65405f6f6f79655f73746161663a636164656e63652e6d6f65'), + ('sim_member','sim_member','37 1','2027 2028','289 2028',X'034b4d216d616767455367755a427147425a74536e723a636164656e63652e6d6f65405f6f6f79655f2e7265616c2e706572736f6e2e3a636164656e63652e6d6f65'), + ('sim_member','sim_member','28 1','2117 2130','297 2130',X'034b3f216e4e595a794b6f4e70797859417a50466f733a636164656e63652e6d6f65405f6f6f79655f6a75746f6d693a636164656e63652e6d6f65'), + ('sim_member','sim_member','20 1','2230 2239','310 2239',X'034b41217046504c7270594879487a784e4c69594b413a636164656e63652e6d6f65405f6f6f79655f686578676f61743a636164656e63652e6d6f65'), + ('sim_member','sim_member','30 1','2381 2398','332 2398',X'034b3b21717a44626c4b6c69444c577a52524f6e465a3a636164656e63652e6d6f65405f6f6f79655f6d6e696b3a636164656e63652e6d6f65'), + ('sim_member','sim_member','38 1','2490 2518','344 2518',X'034b3d2173445250714549546e4f4e57474176496b423a636164656e63652e6d6f65405f6f6f79655f727974686d3a636164656e63652e6d6f65'), + ('sim_member','sim_member','11 1','2555 2559','358 2559',X'034b4321746751436d526b426e6474516362687150583a636164656e63652e6d6f65405f6f6f79655f6a6f7365707065793a636164656e63652e6d6f65'), + ('sim_member','sim_member','47 1','2633 2666','377 2666',X'034b4b217750454472596b77497a6f744e66706e57503a636164656e63652e6d6f65405f6f6f79655f6e61706f6c656f6e333038393a636164656e63652e6d6f65'), + ('sim_member','sim_member','52 1','2817 2837','414 2837',X'034b43217a66654e574d744b4f764f48766f727979563a636164656e63652e6d6f65405f6f6f79655f696e736f676e69613a636164656e63652e6d6f65'), + ('guild_space','guild_space','1','3','3',X'0231313132373630363639313738323431303234'), + ('guild_space','guild_space','1','7','7',X'023331313534383638343234373234343633363837'), + ('guild_space','guild_space','1','11','11',X'023331323139303338323637383430393235383138'), + ('guild_space','guild_space','1','15','15',X'023331323839353939363232353930383930313335'), + ('guild_space','guild_space','1','19','19',X'0231323733383737363437323234393935383431'), + ('guild_space','guild_space','1','23','23',X'0231353239313736313536333938363832313135'), + ('guild_space','guild_space','1','27','27',X'0231373535303134333534373334313533383138'), + ('guild_space','guild_space','1','31','31',X'0231393933383838313432343535323130303834'), + ('guild_active','guild_active','1','3','3',X'0231313132373630363639313738323431303234'), + ('guild_active','guild_active','1','7','7',X'023331313534383638343234373234343633363837'), + ('guild_active','guild_active','1','11','11',X'023331323139303338323637383430393235383138'), + ('guild_active','guild_active','1','15','15',X'023331323839353939363232353930383930313335'), + ('guild_active','guild_active','1','19','19',X'023331333333323139363936393333323038303934'), + ('guild_active','guild_active','1','23','23',X'0231343735353939303338353336373434393630'), + ('guild_active','guild_active','1','27','27',X'022f3636313932393535373737343836383438'), + ('guild_active','guild_active','1','31','31',X'0231383737303635303431393930353136373637'), + ('emoji','emoji','1','284','284',X'023331313132323031303430303637303339333332'), + ('emoji','emoji','1','569','569',X'023331323334393032303131393436383630363638'), + ('emoji','emoji','1','854','854',X'0231323735313734373438353034313935303732'), + ('emoji','emoji','1','1139','1139',X'0231333837343730383630303134393737303235'), + ('emoji','emoji','1','1424','1424',X'0231353435363639393734303130383838313932'), + ('emoji','emoji','1','1709','1709',X'0231363339313034333030393139393437323634'), + ('emoji','emoji','1','1994','1994',X'0231373532363932363230303638373832313231'), + ('emoji','emoji','1','2279','2279',X'0231383935343737353331303434363138323430'), + ('auto_emoji','auto_emoji','1','0','0',X'02114c31'), + ('auto_emoji','auto_emoji','1','1','1',X'02114c32'), + ('auto_emoji','auto_emoji','1','2','2',X'020f5f'); + +ANALYZE sqlite_schema; + +COMMIT; diff --git a/src/db/migrations/0018-add-custom-topic-to-channel-room.sql b/src/db/migrations/0018-add-custom-topic-to-channel-room.sql new file mode 100644 index 0000000..c33d21c --- /dev/null +++ b/src/db/migrations/0018-add-custom-topic-to-channel-room.sql @@ -0,0 +1,5 @@ +BEGIN TRANSACTION; + +ALTER TABLE channel_room ADD COLUMN custom_topic INTEGER DEFAULT 0; + +COMMIT; diff --git a/src/db/migrations/0019-add-invite.sql b/src/db/migrations/0019-add-invite.sql new file mode 100644 index 0000000..6ad03f9 --- /dev/null +++ b/src/db/migrations/0019-add-invite.sql @@ -0,0 +1,13 @@ +BEGIN TRANSACTION; + +CREATE TABLE "invite" ( + "mxid" TEXT NOT NULL, + "room_id" TEXT NOT NULL, + "type" TEXT, + "name" TEXT, + "topic" TEXT, + "avatar" TEXT, + PRIMARY KEY("mxid","room_id") +) WITHOUT ROWID; + +COMMIT; diff --git a/src/db/migrations/0020-add-presence-to-guild-space.sql b/src/db/migrations/0020-add-presence-to-guild-space.sql new file mode 100644 index 0000000..ea4c908 --- /dev/null +++ b/src/db/migrations/0020-add-presence-to-guild-space.sql @@ -0,0 +1,5 @@ +BEGIN TRANSACTION; + +ALTER TABLE guild_space ADD COLUMN presence INTEGER NOT NULL DEFAULT 1; + +COMMIT; diff --git a/src/db/migrations/0021-add-url-preview-to-guild-space.sql b/src/db/migrations/0021-add-url-preview-to-guild-space.sql new file mode 100644 index 0000000..64bc0dd --- /dev/null +++ b/src/db/migrations/0021-add-url-preview-to-guild-space.sql @@ -0,0 +1,5 @@ +BEGIN TRANSACTION; + +ALTER TABLE guild_space ADD COLUMN url_preview INTEGER NOT NULL DEFAULT 1; + +COMMIT; diff --git a/src/db/migrations/0022-auto-emoji-without-guild.sql b/src/db/migrations/0022-auto-emoji-without-guild.sql new file mode 100644 index 0000000..1d23c0d --- /dev/null +++ b/src/db/migrations/0022-auto-emoji-without-guild.sql @@ -0,0 +1,11 @@ +BEGIN TRANSACTION; + +DROP TABLE auto_emoji; + +CREATE TABLE auto_emoji ( + name TEXT NOT NULL, + emoji_id TEXT NOT NULL, + PRIMARY KEY (name) +) WITHOUT ROWID; + +COMMIT; diff --git a/src/db/migrations/0023-add-original-encoding-to-reaction.sql b/src/db/migrations/0023-add-original-encoding-to-reaction.sql new file mode 100644 index 0000000..e42e4e1 --- /dev/null +++ b/src/db/migrations/0023-add-original-encoding-to-reaction.sql @@ -0,0 +1,5 @@ +BEGIN TRANSACTION; + +ALTER TABLE reaction ADD COLUMN original_encoding TEXT; + +COMMIT; diff --git a/src/db/migrations/0024-add-direct.sql b/src/db/migrations/0024-add-direct.sql new file mode 100644 index 0000000..94dc4ae --- /dev/null +++ b/src/db/migrations/0024-add-direct.sql @@ -0,0 +1,9 @@ +BEGIN TRANSACTION; + +CREATE TABLE direct ( + mxid TEXT NOT NULL, + room_id TEXT NOT NULL, + PRIMARY KEY (mxid) +) WITHOUT ROWID; + +COMMIT; diff --git a/db/orm-defs.d.ts b/src/db/orm-defs.d.ts similarity index 75% rename from db/orm-defs.d.ts rename to src/db/orm-defs.d.ts index 0223058..79fd501 100644 --- a/db/orm-defs.d.ts +++ b/src/db/orm-defs.d.ts @@ -1,4 +1,9 @@ export type Models = { + auto_emoji: { + name: string + emoji_id: string + } + channel_room: { channel_id: string room_id: string @@ -10,6 +15,20 @@ export type Models = { speedbump_id: string | null speedbump_webhook_id: string | null speedbump_checked: number | null + guild_id: string | null + custom_topic: number + } + + direct: { + mxid: string + room_id: string + } + + emoji: { + emoji_id: string + name: string + animated: number + mxc_url: string } event_message: { @@ -31,6 +50,21 @@ export type Models = { guild_id: string space_id: string privacy_level: number + presence: 0 | 1 + url_preview: 0 | 1 + } + + guild_active: { + guild_id: string + autocreate: 0 | 1 + } + + invite: { + mxid: string + room_id: string + type: string | null + name: string | null + avatar: string | null } lottie: { @@ -38,11 +72,22 @@ export type Models = { mxc_url: string } + media_proxy: { + permitted_hash: number + } + member_cache: { room_id: string mxid: string displayname: string | null - avatar_url: string | null + avatar_url: string | null, + power_level: number + } + + member_power: { + mxid: string + room_id: string + power_level: number } message_channel: { @@ -75,23 +120,11 @@ export type Models = { webhook_token: string } - emoji: { - emoji_id: string - name: string - animated: number - mxc_url: string - } - reaction: { hashed_event_id: number message_id: string encoded_emoji: string - } - - auto_emoji: { - name: string - emoji_id: string - guild_id: string + original_encoding: string | null } } @@ -107,3 +140,5 @@ export type AllKeys = U extends any ? keyof U : never export type PickTypeOf> = T extends { [k in K]?: any } ? T[K] : never export type Merge = {[x in AllKeys]: PickTypeOf} export type Nullable = {[k in keyof T]: T[k] | null} +export type Numberish = {[k in keyof T]: T[k] extends number ? (number | bigint) : T[k]} +export type ValueOrArray = {[k in keyof T]: T[k][] | T[k]} diff --git a/db/orm-defs.js b/src/db/orm-defs.js similarity index 100% rename from db/orm-defs.js rename to src/db/orm-defs.js diff --git a/db/orm.js b/src/db/orm.js similarity index 86% rename from db/orm.js rename to src/db/orm.js index c6cab96..4d9b6f1 100644 --- a/db/orm.js +++ b/src/db/orm.js @@ -8,15 +8,20 @@ const U = require("./orm-defs") * @template {keyof U.Models[Table]} Col * @param {Table} table * @param {Col[] | Col} cols - * @param {Partial} where + * @param {Partial>>} where * @param {string} [e] */ function select(table, cols, where = {}, e = "") { if (!Array.isArray(cols)) cols = [cols] const parameters = [] const wheres = Object.entries(where).map(([col, value]) => { - parameters.push(value) - return `"${col}" = ?` + if (Array.isArray(value)) { + parameters.push(...value) + return `"${col}" IN (` + Array(value.length).fill("?").join(", ") + ")" + } else { + parameters.push(value) + return `"${col}" = ?` + } }) const whereString = wheres.length ? " WHERE " + wheres.join(" AND ") : "" /** @type {U.Prepared>} */ @@ -44,6 +49,8 @@ class From { /** @private */ this.cols = [] /** @private */ + this.makeColsSafe = true + /** @private */ this.using = [] /** @private */ this.isPluck = false @@ -78,6 +85,12 @@ class From { return r } + selectUnsafe(...cols) { + this.cols = cols + this.makeColsSafe = false + return this + } + /** * @template {Col} Select * @param {Select} col @@ -100,7 +113,7 @@ class From { } /** - * @param {Partial} conditions + * @param {Partial>} conditions */ where(conditions) { const wheres = Object.entries(conditions).map(([col, value]) => { @@ -112,7 +125,8 @@ class From { } prepare() { - let sql = `SELECT ${this.cols.map(k => `"${k}"`).join(", ")} FROM ${this.tables[0]} ` + if (this.makeColsSafe) this.cols = this.cols.map(k => `"${k}"`) + let sql = `SELECT ${this.cols.join(", ")} FROM ${this.tables[0]} ` for (let i = 1; i < this.tables.length; i++) { const table = this.tables[i] const col = this.using[i-1] diff --git a/db/orm.test.js b/src/db/orm.test.js similarity index 72% rename from db/orm.test.js rename to src/db/orm.test.js index 3d4e8ef..4549b9e 100644 --- a/db/orm.test.js +++ b/src/db/orm.test.js @@ -1,22 +1,22 @@ // @ts-check const {test} = require("supertape") -const data = require("../test/data") +const data = require("../../test/data") const {db, select, from} = require("../passthrough") test("orm: select: get works", t => { - const row = select("guild_space", "guild_id", {}, "WHERE space_id = ?").get("!jjWAGMeQdNrVZSSfvz:cadence.moe") + const row = select("guild_space", "guild_id", {}, "WHERE space_id = ?").get("!jjmvBegULiLucuWEHU:cadence.moe") t.equal(row?.guild_id, data.guild.general.id) }) test("orm: from: get works", t => { - const row = from("guild_space").select("guild_id").and("WHERE space_id = ?").get("!jjWAGMeQdNrVZSSfvz:cadence.moe") + const row = from("guild_space").select("guild_id").and("WHERE space_id = ?").get("!jjmvBegULiLucuWEHU:cadence.moe") t.equal(row?.guild_id, data.guild.general.id) }) test("orm: select: get pluck works", t => { - const guildID = select("guild_space", "guild_id", {}, "WHERE space_id = ?").pluck().get("!jjWAGMeQdNrVZSSfvz:cadence.moe") + const guildID = select("guild_space", "guild_id", {}, "WHERE space_id = ?").pluck().get("!jjmvBegULiLucuWEHU:cadence.moe") t.equal(guildID, data.guild.general.id) }) @@ -30,8 +30,13 @@ test("orm: select: all, where and pluck works on multiple columns", t => { t.deepEqual(names, ["cadence [they]"]) }) +test("orm: select: in array works", t => { + const ids = select("emoji", "emoji_id", {name: ["online", "upstinky"]}).pluck().all() + t.deepEqual(ids, ["288858540888686602", "606664341298872324"]) +}) + test("orm: from: get pluck works", t => { - const guildID = from("guild_space").pluck("guild_id").and("WHERE space_id = ?").get("!jjWAGMeQdNrVZSSfvz:cadence.moe") + const guildID = from("guild_space").pluck("guild_id").and("WHERE space_id = ?").get("!jjmvBegULiLucuWEHU:cadence.moe") t.equal(guildID, data.guild.general.id) }) @@ -53,3 +58,13 @@ test("orm: from: join direction works", t => { const hasNoOwnerInner = from("sim").join("sim_proxy", "user_id", "inner").select("user_id", "proxy_owner_id").where({sim_name: "crunch_god"}).get() t.deepEqual(hasNoOwnerInner, undefined) }) + +test("orm: select unsafe works (to select complex column names that can't be type verified)", t => { + const results = from("member_cache") + .join("member_power", "mxid") + .join("channel_room", "room_id") // only include rooms that are bridged + .and("where member_power.room_id = '*' and member_cache.power_level != member_power.power_level") + .selectUnsafe("mxid", "member_cache.room_id", "member_power.power_level") + .all() + t.equal(results[0].power_level, 100) +}) diff --git a/src/discord/interactions/invite.js b/src/discord/interactions/invite.js new file mode 100644 index 0000000..8940363 --- /dev/null +++ b/src/discord/interactions/invite.js @@ -0,0 +1,156 @@ +// @ts-check + +const DiscordTypes = require("discord-api-types/v10") +const assert = require("assert/strict") +const {InteractionMethods} = require("snowtransfer") +const {id: botID} = require("../../../addbot") +const {discord, sync, db, select} = require("../../passthrough") + +/** @type {import("../../d2m/actions/create-room")} */ +const createRoom = sync.require("../../d2m/actions/create-room") +/** @type {import("../../d2m/actions/create-space")} */ +const createSpace = sync.require("../../d2m/actions/create-space") +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") +/** @type {import("../../matrix/read-registration")} */ +const {reg} = sync.require("../../matrix/read-registration") + +/** + * @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction & {channel: DiscordTypes.APIGuildTextChannel}} interaction + * @param {{api: typeof api}} di + * @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters[2]}>} + */ +async function* _interact({data, channel, guild_id}, {api}) { + // Check guild exists - it might not exist if the application was added with applications.commands scope and not bot scope + const guild = discord.guilds.get(guild_id) + if (!guild) return yield {createInteractionResponse: { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: `I can't perform actions in this server because there is no bot presence in the server. You should try re-adding this bot to the server, making sure that it has bot scope (not just commands).\nIf you add the bot from ${reg.ooye.bridge_origin} this should work automatically.`, + flags: DiscordTypes.MessageFlags.Ephemeral + } + }} + + // Get named MXID + /** @type {DiscordTypes.APIApplicationCommandInteractionDataStringOption[] | undefined} */ // @ts-ignore + const options = data.options + const input = options?.[0]?.value || "" + const mxid = input.match(/@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)/)?.[0] + if (!mxid) return yield {createInteractionResponse: { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`", + flags: DiscordTypes.MessageFlags.Ephemeral + } + }} + + // Ensure guild and room are bridged + db.prepare("INSERT OR IGNORE INTO guild_active (guild_id, autocreate) VALUES (?, 1)").run(guild_id) + const existing = createRoom.existsOrAutocreatable(channel, guild_id) + if (existing === 0) return yield {createInteractionResponse: { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: "This channel isn't bridged, so you can't invite Matrix users yet. Try turning on automatic room-creation or link a Matrix room in the website.", + flags: DiscordTypes.MessageFlags.Ephemeral + } + }} + assert(existing) // can't be null or undefined as we just inserted the guild_active row + + yield {createInteractionResponse: { + type: DiscordTypes.InteractionResponseType.DeferredChannelMessageWithSource, + data: { + flags: DiscordTypes.MessageFlags.Ephemeral + } + }} + + const spaceID = await createSpace.ensureSpace(guild) + const roomID = await createRoom.ensureRoom(channel.id) + + // Check for existing invite to the space + let spaceMember + try { + spaceMember = await api.getStateEvent(spaceID, "m.room.member", mxid) + } catch (e) {} + if (spaceMember && spaceMember.membership === "invite") { + return yield {editOriginalInteractionResponse: { + content: `\`${mxid}\` already has an invite, which they haven't accepted yet.`, + }} + } + + // Invite Matrix user if not in space + if (!spaceMember || spaceMember.membership !== "join") { + await api.inviteToRoom(spaceID, mxid) + return yield {editOriginalInteractionResponse: { + content: `You invited \`${mxid}\` to the server.` + }} + } + + // The Matrix user *is* in the space, maybe we want to invite them to this channel? + let roomMember + try { + roomMember = await api.getStateEvent(roomID, "m.room.member", mxid) + } catch (e) {} + if (!roomMember || (roomMember.membership !== "join" && roomMember.membership !== "invite")) { + return yield {editOriginalInteractionResponse: { + content: `\`${mxid}\` is already in this server. Would you like to additionally invite them to this specific channel?`, + components: [{ + type: DiscordTypes.ComponentType.ActionRow, + components: [{ + type: DiscordTypes.ComponentType.Button, + custom_id: "invite_channel", + style: DiscordTypes.ButtonStyle.Primary, + label: "Sure", + }] + }] + }} + } + + // The Matrix user *is* in the space and in the channel. + return yield {editOriginalInteractionResponse: { + content: `\`${mxid}\` is already in this server and this channel.`, + }} +} + +/** + * @param {DiscordTypes.APIMessageComponentGuildInteraction} interaction + * @param {{api: typeof api}} di + * @returns {Promise} + */ +async function _interactButton({channel, message}, {api}) { + const mxid = message.content.match(/`(@(?:[^:]+):(?:[a-z0-9:-]+\.[a-z0-9.:-]+))`/)?.[1] + assert(mxid) + const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() + await api.inviteToRoom(roomID, mxid) + return { + type: DiscordTypes.InteractionResponseType.UpdateMessage, + data: { + content: `You invited \`${mxid}\` to the channel.`, + flags: DiscordTypes.MessageFlags.Ephemeral, + components: [] + } + } +} + +/* c8 ignore start */ + +/** @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction & {channel: DiscordTypes.APIGuildTextChannel}} interaction */ +async function interact(interaction) { + for await (const response of _interact(interaction, {api})) { + if (response.createInteractionResponse) { + // TODO: Test if it is reasonable to remove `await` from these calls. Or zip these calls with the next interaction iteration and use Promise.all. + await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse) + } else if (response.editOriginalInteractionResponse) { + await discord.snow.interaction.editOriginalInteractionResponse(botID, interaction.token, response.editOriginalInteractionResponse) + } + } +} + +/** @param {DiscordTypes.APIMessageComponentGuildInteraction} interaction */ +async function interactButton(interaction) { + await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interactButton(interaction, {api})) +} + +module.exports.interact = interact +module.exports.interactButton = interactButton +module.exports._interact = _interact +module.exports._interactButton = _interactButton diff --git a/src/discord/interactions/invite.test.js b/src/discord/interactions/invite.test.js new file mode 100644 index 0000000..a2393e5 --- /dev/null +++ b/src/discord/interactions/invite.test.js @@ -0,0 +1,256 @@ +const {test} = require("supertape") +const DiscordTypes = require("discord-api-types/v10") +const {db, discord} = require("../../passthrough") +const {MatrixServerError} = require("../../matrix/mreq") +const {_interact, _interactButton} = require("./invite") + +/** + * @template T + * @param {AsyncIterable} ai + * @returns {Promise} + */ +async function fromAsync(ai) { + const result = [] + for await (const value of ai) { + result.push(value) + } + return result +} + +test("invite: checks for missing matrix ID", async t => { + const msgs = await fromAsync(_interact({ + data: { + options: [] + }, + channel: discord.channels.get("0"), + guild_id: "112760669178241024" + }, {})) + t.equal(msgs[0].createInteractionResponse.data.content, "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`") +}) + +test("invite: checks for invalid matrix ID", async t => { + const msgs = await fromAsync(_interact({ + data: { + options: [{ + name: "user", + type: DiscordTypes.ApplicationCommandOptionType.String, + value: "@cadence" + }] + }, + channel: discord.channels.get("0"), + guild_id: "112760669178241024" + }, {})) + t.equal(msgs[0].createInteractionResponse.data.content, "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`") +}) + +test("invite: checks if guild exists", async t => { // it might not exist if the application was added with applications.commands scope and not bot scope + const msgs = await fromAsync(_interact({ + data: { + options: [{ + name: "user", + type: DiscordTypes.ApplicationCommandOptionType.String, + value: "@cadence:cadence.moe" + }] + }, + channel: discord.channels.get("0"), + guild_id: "0" + }, {})) + t.match(msgs[0].createInteractionResponse.data.content, /there is no bot presence in the server/) +}) + +test("invite: checks if channel exists or is autocreatable", async t => { + db.prepare("UPDATE guild_active SET autocreate = 0 WHERE guild_id = '112760669178241024'").run() + const msgs = await fromAsync(_interact({ + data: { + options: [{ + name: "user", + type: DiscordTypes.ApplicationCommandOptionType.String, + value: "@cadence:cadence.moe" + }] + }, + channel: discord.channels.get("498323546729086986"), + guild_id: "112760669178241024" + }, {})) + t.equal(msgs[0].createInteractionResponse.data.content, "This channel isn't bridged, so you can't invite Matrix users yet. Try turning on automatic room-creation or link a Matrix room in the website.") + db.prepare("UPDATE guild_active SET autocreate = 1 WHERE guild_id = '112760669178241024'").run() +}) + +test("invite: checks if user is already invited to space", async t => { + let called = 0 + const msgs = await fromAsync(_interact({ + data: { + options: [{ + name: "user", + type: DiscordTypes.ApplicationCommandOptionType.String, + value: "@cadence:cadence.moe" + }] + }, + channel: discord.channels.get("112760669178241024"), + guild_id: "112760669178241024" + }, { + api: { + getStateEvent: async (roomID, type, stateKey) => { + called++ + t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID + t.equal(type, "m.room.member") + t.equal(stateKey, "@cadence:cadence.moe") + return { + displayname: "cadence", + membership: "invite" + } + } + } + })) + t.equal(msgs[1].editOriginalInteractionResponse.content, "`@cadence:cadence.moe` already has an invite, which they haven't accepted yet.") + t.equal(called, 1) +}) + +test("invite: invites if user is not in space", async t => { + let called = 0 + const msgs = await fromAsync(_interact({ + data: { + options: [{ + name: "user", + type: DiscordTypes.ApplicationCommandOptionType.String, + value: "@cadence:cadence.moe" + }] + }, + channel: discord.channels.get("112760669178241024"), + guild_id: "112760669178241024" + }, { + api: { + getStateEvent: async (roomID, type, stateKey) => { + called++ + t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID + t.equal(type, "m.room.member") + t.equal(stateKey, "@cadence:cadence.moe") + throw new MatrixServerError("State event doesn't exist or something") + }, + inviteToRoom: async (roomID, mxid) => { + called++ + t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID + t.equal(mxid, "@cadence:cadence.moe") + } + } + })) + t.equal(msgs[1].editOriginalInteractionResponse.content, "You invited `@cadence:cadence.moe` to the server.") + t.equal(called, 2) +}) + +test("invite: prompts to invite to room (if never joined)", async t => { + let called = 0 + const msgs = await fromAsync(_interact({ + data: { + options: [{ + name: "user", + type: DiscordTypes.ApplicationCommandOptionType.String, + value: "@cadence:cadence.moe" + }] + }, + channel: discord.channels.get("112760669178241024"), + guild_id: "112760669178241024" + }, { + api: { + getStateEvent: async (roomID, type, stateKey) => { + called++ + t.equal(type, "m.room.member") + t.equal(stateKey, "@cadence:cadence.moe") + if (roomID === "!jjmvBegULiLucuWEHU:cadence.moe") { // space ID + return { + displayname: "cadence", + membership: "join" + } + } else { + throw new MatrixServerError("State event doesn't exist or something") + } + } + } + })) + t.equal(msgs[1].editOriginalInteractionResponse.content, "`@cadence:cadence.moe` is already in this server. Would you like to additionally invite them to this specific channel?") + t.equal(called, 2) +}) + +test("invite: prompts to invite to room (if left)", async t => { + let called = 0 + const msgs = await fromAsync(_interact({ + data: { + options: [{ + name: "user", + type: DiscordTypes.ApplicationCommandOptionType.String, + value: "@cadence:cadence.moe" + }] + }, + channel: discord.channels.get("112760669178241024"), + guild_id: "112760669178241024" + }, { + api: { + getStateEvent: async (roomID, type, stateKey) => { + called++ + t.equal(type, "m.room.member") + t.equal(stateKey, "@cadence:cadence.moe") + if (roomID === "!jjmvBegULiLucuWEHU:cadence.moe") { // space ID + return { + displayname: "cadence", + membership: "join" + } + } else { + return { + displayname: "cadence", + membership: "leave" + } + } + } + } + })) + t.equal(msgs[1].editOriginalInteractionResponse.content, "`@cadence:cadence.moe` is already in this server. Would you like to additionally invite them to this specific channel?") + t.equal(called, 2) +}) + +test("invite button: invites to room when button clicked", async t => { + let called = 0 + const msg = await _interactButton({ + channel: discord.channels.get("112760669178241024"), + message: { + content: "`@cadence:cadence.moe` is already in this server. Would you like to additionally invite them to this specific channel?" + } + }, { + api: { + inviteToRoom: async (roomID, mxid) => { + called++ + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") // room ID + t.equal(mxid, "@cadence:cadence.moe") + } + } + }) + t.equal(msg.data.content, "You invited `@cadence:cadence.moe` to the channel.") + t.equal(called, 1) +}) + +test("invite: no-op if in room and space", async t => { + let called = 0 + const msgs = await fromAsync(_interact({ + data: { + options: [{ + name: "user", + type: DiscordTypes.ApplicationCommandOptionType.String, + value: "@cadence:cadence.moe" + }] + }, + channel: discord.channels.get("112760669178241024"), + guild_id: "112760669178241024" + }, { + api: { + getStateEvent: async (roomID, type, stateKey) => { + called++ + t.equal(type, "m.room.member") + t.equal(stateKey, "@cadence:cadence.moe") + return { + displayname: "cadence", + membership: "join" + } + } + } + })) + t.equal(msgs[1].editOriginalInteractionResponse.content, "`@cadence:cadence.moe` is already in this server and this channel.") + t.equal(called, 2) +}) diff --git a/src/discord/interactions/matrix-info.js b/src/discord/interactions/matrix-info.js new file mode 100644 index 0000000..b7551f1 --- /dev/null +++ b/src/discord/interactions/matrix-info.js @@ -0,0 +1,63 @@ +// @ts-check + +const DiscordTypes = require("discord-api-types/v10") +const {discord, sync, from} = require("../../passthrough") + +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") + +/** + * @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction + * @param {{api: typeof api}} di + * @returns {Promise} + */ +async function _interact({guild_id, data}, {api}) { + const message = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id") + .select("name", "nick", "source", "channel_id", "room_id", "event_id").where({message_id: data.target_id, part: 0}).get() + + if (!message) { + return { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: "This message hasn't been bridged to Matrix.", + flags: DiscordTypes.MessageFlags.Ephemeral + } + } + } + + const idInfo = `\n-# Room ID: \`${message.room_id}\`\n-# Event ID: \`${message.event_id}\`` + const roomName = message.nick || message.name + + if (message.source === 1) { // from Discord + const userID = data.resolved.messages[data.target_id].author.id + return { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: `Bridged <@${userID}> https://discord.com/channels/${guild_id}/${message.channel_id}/${data.target_id} on Discord to [${roomName}]() on Matrix.` + + idInfo, + flags: DiscordTypes.MessageFlags.Ephemeral + } + } + } + + // from Matrix + const event = await api.getEvent(message.room_id, message.event_id) + return { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: `Bridged [${event.sender}]()'s message in [${roomName}]() on Matrix to https://discord.com/channels/${guild_id}/${message.channel_id}/${data.target_id} on Discord.` + + idInfo, + flags: DiscordTypes.MessageFlags.Ephemeral + } + } +} + +/* c8 ignore start */ + +/** @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction */ +async function interact(interaction) { + await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interact(interaction, {api})) +} + +module.exports.interact = interact +module.exports._interact = _interact diff --git a/src/discord/interactions/matrix-info.test.js b/src/discord/interactions/matrix-info.test.js new file mode 100644 index 0000000..5d0206e --- /dev/null +++ b/src/discord/interactions/matrix-info.test.js @@ -0,0 +1,73 @@ +const {test} = require("supertape") +const data = require("../../../test/data") +const {_interact} = require("./matrix-info") + +test("matrix info: checks if message is bridged", async t => { + const msg = await _interact({ + data: { + target_id: "0" + }, + guild_id: "112760669178241024" + }, {}) + t.equal(msg.data.content, "This message hasn't been bridged to Matrix.") +}) + +test("matrix info: shows info for discord source message", async t => { + const msg = await _interact({ + data: { + target_id: "1141619794500649020", + resolved: { + messages: { + "1141619794500649020": data.message_update.edit_by_webhook + } + } + }, + guild_id: "497159726455455754" + }, {}) + t.equal( + msg.data.content, + "Bridged <@700285844094845050> https://discord.com/channels/497159726455455754/497161350934560778/1141619794500649020 on Discord to [amanda-spam]() on Matrix." + + "\n-# Room ID: `!CzvdIdUQXgUjDVKxeU:cadence.moe`" + + "\n-# Event ID: `$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10`" + ) +}) + +test("matrix info: shows info for matrix source message", async t => { + let called = 0 + const msg = await _interact({ + data: { + target_id: "1128118177155526666", + resolved: { + messages: { + "1141501302736695316": data.message.simple_reply_to_matrix_user + } + } + }, + guild_id: "112760669178241024" + }, { + api: { + async getEvent(roomID, eventID) { + called++ + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") + t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4") + return { + event_id: eventID, + room_id: roomID, + type: "m.room.message", + content: { + msgtype: "m.text", + body: "so can you reply to my webhook uwu" + }, + sender: "@cadence:cadence.moe" + } + } + } + }) + t.equal( + msg.data.content, + "Bridged [@cadence:cadence.moe]()'s message in [main]() on Matrix to https://discord.com/channels/112760669178241024/112760669178241024/1128118177155526666 on Discord." + + "\n-# Room ID: `!kLRqKKUQXcibIMtOpl:cadence.moe`" + + "\n-# Event ID: `$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4`" + ) + t.equal(called, 1) +}) diff --git a/src/discord/interactions/permissions.js b/src/discord/interactions/permissions.js new file mode 100644 index 0000000..0a573e3 --- /dev/null +++ b/src/discord/interactions/permissions.js @@ -0,0 +1,167 @@ +// @ts-check + +const DiscordTypes = require("discord-api-types/v10") +const Ty = require("../../types") +const {discord, sync, select, from} = require("../../passthrough") +const assert = require("assert/strict") +const {id: botID} = require("../../../addbot") +const {InteractionMethods} = require("snowtransfer") + +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") + +/** + * @param {DiscordTypes.APIContextMenuGuildInteraction} interaction + * @param {{api: typeof api}} di + * @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters[2]}>} + */ +async function* _interact({data, guild_id}, {api}) { + // Get message info + const row = from("event_message") + .join("message_channel", "message_id") + .select("event_id", "source", "channel_id") + .where({message_id: data.target_id}) + .get() + + // Can't operate on Discord users + if (!row || row.source === 1) { // not bridged or sent by a discord user + return yield {createInteractionResponse: { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: `The permissions command can only be used on Matrix users.`, + flags: DiscordTypes.MessageFlags.Ephemeral + } + }} + } + + // Get the message sender, the person that will be inspected/edited + const eventID = row.event_id + const roomID = select("channel_room", "room_id", {channel_id: row.channel_id}).pluck().get() + assert(roomID) + const event = await api.getEvent(roomID, eventID) + const sender = event.sender + + // Get the space, where the power levels will be inspected/edited + const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get() + assert(spaceID) + + // Get the power level + /** @type {Ty.Event.M_Power_Levels} */ + const powerLevelsContent = await api.getStateEvent(spaceID, "m.room.power_levels", "") + const userPower = powerLevelsContent.users?.[event.sender] || 0 + + // Administrators equal to the bot cannot be demoted + if (userPower >= 100) { + return yield {createInteractionResponse: { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: `\`${sender}\` has administrator permissions. This cannot be edited.`, + flags: DiscordTypes.MessageFlags.Ephemeral + } + }} + } + + yield {createInteractionResponse: { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: `Showing permissions for \`${sender}\`. Click to edit.`, + flags: DiscordTypes.MessageFlags.Ephemeral, + components: [ + { + type: DiscordTypes.ComponentType.ActionRow, + components: [ + { + type: DiscordTypes.ComponentType.StringSelect, + custom_id: "permissions_edit", + options: [ + { + label: "Default", + value: "default", + default: userPower < 50 + }, { + label: "Moderator", + value: "moderator", + default: userPower >= 50 && userPower < 100 + }, { + label: "Admin (you cannot undo this!)", + value: "admin", + default: userPower === 100 + } + ] + } + ] + } + ] + } + }} +} + +/** + * @param {DiscordTypes.APIMessageComponentSelectMenuInteraction} interaction + * @param {{api: typeof api}} di + * @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters[2]}>} + */ +async function* _interactEdit({data, guild_id, message}, {api}) { + // Get the person that will be inspected/edited + const mxid = message.content.match(/`(@(?:[^:]+):(?:[a-z0-9:-]+\.[a-z0-9.:-]+))`/)?.[1] + assert(mxid) + + const permission = data.values[0] + const power = + ( permission === "admin" ? 100 + : permission === "moderator" ? 50 + : 0) + + yield {createInteractionResponse: { + type: DiscordTypes.InteractionResponseType.UpdateMessage, + data: { + content: `Updating \`${mxid}\` to **${permission}**, please wait...`, + components: [] + } + }} + + // Get the space, where the power levels will be inspected/edited + const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get() + assert(spaceID) + + // Do it + await api.setUserPowerCascade(spaceID, mxid, power) + + // ACK + yield {editOriginalInteractionResponse: { + content: `Updated \`${mxid}\` to **${permission}**.`, + components: [] + }} +} + + +/* c8 ignore start */ + +/** @param {DiscordTypes.APIContextMenuGuildInteraction} interaction */ +async function interact(interaction) { + for await (const response of _interact(interaction, {api})) { + if (response.createInteractionResponse) { + // TODO: Test if it is reasonable to remove `await` from these calls. Or zip these calls with the next interaction iteration and use Promise.all. + await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse) + } else if (response.editOriginalInteractionResponse) { + await discord.snow.interaction.editOriginalInteractionResponse(botID, interaction.token, response.editOriginalInteractionResponse) + } + } +} + +/** @param {DiscordTypes.APIMessageComponentSelectMenuInteraction} interaction */ +async function interactEdit(interaction) { + for await (const response of _interactEdit(interaction, {api})) { + if (response.createInteractionResponse) { + // TODO: Test if it is reasonable to remove `await` from these calls. Or zip these calls with the next interaction iteration and use Promise.all. + await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse) + } else if (response.editOriginalInteractionResponse) { + await discord.snow.interaction.editOriginalInteractionResponse(botID, interaction.token, response.editOriginalInteractionResponse) + } + } +} + +module.exports.interact = interact +module.exports.interactEdit = interactEdit +module.exports._interact = _interact +module.exports._interactEdit = _interactEdit diff --git a/src/discord/interactions/permissions.test.js b/src/discord/interactions/permissions.test.js new file mode 100644 index 0000000..a7da859 --- /dev/null +++ b/src/discord/interactions/permissions.test.js @@ -0,0 +1,199 @@ +const {test} = require("supertape") +const DiscordTypes = require("discord-api-types/v10") +const {select, db} = require("../../passthrough") +const {_interact, _interactEdit} = require("./permissions") + +/** + * @template T + * @param {AsyncIterable} ai + * @returns {Promise} + */ +async function fromAsync(ai) { + const result = [] + for await (const value of ai) { + result.push(value) + } + return result +} + +test("permissions: checks if message is bridged", async t => { + const msgs = await fromAsync(_interact({ + data: { + target_id: "0" + }, + guild_id: "0" + }, {})) + t.equal(msgs.length, 1) + t.equal(msgs[0].createInteractionResponse.data.content, "The permissions command can only be used on Matrix users.") +}) + +test("permissions: checks if message is sent by a matrix user", async t => { + const msgs = await fromAsync(_interact({ + data: { + target_id: "1126786462646550579" + }, + guild_id: "112760669178241024" + }, {})) + t.equal(msgs.length, 1) + t.equal(msgs[0].createInteractionResponse.data.content, "The permissions command can only be used on Matrix users.") +}) + +test("permissions: reports permissions of selected matrix user (implicit default)", async t => { + let called = 0 + const msgs = await fromAsync(_interact({ + data: { + target_id: "1128118177155526666" + }, + guild_id: "112760669178241024" + }, { + api: { + async getEvent(roomID, eventID) { + called++ + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") // room ID + t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4") + return { + sender: "@cadence:cadence.moe" + } + }, + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return { + users: {} + } + } + } + })) + t.equal(msgs.length, 1) + t.equal(msgs[0].createInteractionResponse.data.content, "Showing permissions for `@cadence:cadence.moe`. Click to edit.") + t.deepEqual(msgs[0].createInteractionResponse.data.components[0].components[0].options[0], {label: "Default", value: "default", default: true}) + t.equal(called, 2) +}) + +test("permissions: reports permissions of selected matrix user (moderator)", async t => { + let called = 0 + const msgs = await fromAsync(_interact({ + data: { + target_id: "1128118177155526666" + }, + guild_id: "112760669178241024" + }, { + api: { + async getEvent(roomID, eventID) { + called++ + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") // room ID + t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4") + return { + sender: "@cadence:cadence.moe" + } + }, + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return { + users: { + "@cadence:cadence.moe": 50 + } + } + } + } + })) + t.equal(msgs.length, 1) + t.equal(msgs[0].createInteractionResponse.data.content, "Showing permissions for `@cadence:cadence.moe`. Click to edit.") + t.deepEqual(msgs[0].createInteractionResponse.data.components[0].components[0].options[1], {label: "Moderator", value: "moderator", default: true}) + t.equal(called, 2) +}) + +test("permissions: reports permissions of selected matrix user (admin)", async t => { + let called = 0 + const msgs = await fromAsync(_interact({ + data: { + target_id: "1128118177155526666" + }, + guild_id: "112760669178241024" + }, { + api: { + async getEvent(roomID, eventID) { + called++ + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") // room ID + t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4") + return { + sender: "@cadence:cadence.moe" + } + }, + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return { + users: { + "@cadence:cadence.moe": 100 + } + } + } + } + })) + t.equal(msgs.length, 1) + t.equal(msgs[0].createInteractionResponse.data.content, "`@cadence:cadence.moe` has administrator permissions. This cannot be edited.") + t.notOk(msgs[0].createInteractionResponse.data.components) + t.equal(called, 2) +}) + +test("permissions: can update user to moderator", async t => { + let called = 0 + const msgs = await fromAsync(_interactEdit({ + data: { + target_id: "1128118177155526666", + values: ["moderator"] + }, + message: { + content: "Showing permissions for `@cadence:cadence.moe`. Click to edit." + }, + guild_id: "112760669178241024" + }, { + api: { + async setUserPowerCascade(roomID, mxid, power) { + called++ + t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID + t.equal(mxid, "@cadence:cadence.moe") + t.equal(power, 50) + } + } + })) + t.equal(msgs.length, 2) + t.equal(msgs[0].createInteractionResponse.data.content, "Updating `@cadence:cadence.moe` to **moderator**, please wait...") + t.equal(msgs[1].editOriginalInteractionResponse.content, "Updated `@cadence:cadence.moe` to **moderator**.") + t.equal(called, 1) +}) + +test("permissions: can update user to default", async t => { + let called = 0 + const msgs = await fromAsync(_interactEdit({ + data: { + target_id: "1128118177155526666", + values: ["default"] + }, + message: { + content: "Showing permissions for `@cadence:cadence.moe`. Click to edit." + }, + guild_id: "112760669178241024" + }, { + api: { + async setUserPowerCascade(roomID, mxid, power) { + called++ + t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID + t.equal(mxid, "@cadence:cadence.moe") + t.equal(power, 0) + } + } + })) + t.equal(msgs.length, 2) + t.equal(msgs[0].createInteractionResponse.data.content, "Updating `@cadence:cadence.moe` to **default**, please wait...") + t.equal(msgs[1].editOriginalInteractionResponse.content, "Updated `@cadence:cadence.moe` to **default**.") + t.equal(called, 1) +}) diff --git a/src/discord/interactions/privacy.js b/src/discord/interactions/privacy.js new file mode 100644 index 0000000..841167e --- /dev/null +++ b/src/discord/interactions/privacy.js @@ -0,0 +1,79 @@ +// @ts-check + +const DiscordTypes = require("discord-api-types/v10") +const {discord, sync, db, select} = require("../../passthrough") +const {id: botID} = require("../../../addbot") +const {InteractionMethods} = require("snowtransfer") + +/** @type {import("../../d2m/actions/create-space")} */ +const createSpace = sync.require("../../d2m/actions/create-space") + +/** + * @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction + * @param {{createSpace: typeof createSpace}} di + * @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters[2]}>} + */ +async function* _interact({data, guild_id}, {createSpace}) { + // Check guild is bridged + const current = select("guild_space", "privacy_level", {guild_id}).pluck().get() + if (current == null) { + return yield {createInteractionResponse: { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: "This server isn't bridged to Matrix, so you can't set the Matrix privacy level.", + flags: DiscordTypes.MessageFlags.Ephemeral + } + }} + } + + // Get input level + /** @type {DiscordTypes.APIApplicationCommandInteractionDataStringOption[] | undefined} */ // @ts-ignore + const options = data.options + const input = options?.[0]?.value || "" + const levels = ["invite", "link", "directory"] + const level = levels.findIndex(x => input === x) + if (level === -1) { + return yield {createInteractionResponse: { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: "**Usage: `/privacy `**. This will set who can join the space on Matrix-side. There are three levels:" + + "\n`invite`: Can only join with a direct in-app invite from another user. No shareable invite links." + + "\n`link`: Matrix links can be created and shared like Discord's invite links. In-app invites still work." + + "\n`directory`: Publicly visible in the Matrix space directory, like Server Discovery. Invites and links still work." + + `\n**Current privacy level: \`${levels[current]}\`**`, + flags: DiscordTypes.MessageFlags.Ephemeral + } + }} + } + + yield {createInteractionResponse: { + type: DiscordTypes.InteractionResponseType.DeferredChannelMessageWithSource, + data: { + flags: DiscordTypes.MessageFlags.Ephemeral + } + }} + + db.prepare("UPDATE guild_space SET privacy_level = ? WHERE guild_id = ?").run(level, guild_id) + await createSpace.syncSpaceFully(guild_id) // this is inefficient but OK to call infrequently on user request + + yield {editOriginalInteractionResponse: { + content: `Privacy level updated to \`${levels[level]}\`.` + }} +} + +/* c8 ignore start */ + +/** @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction */ +async function interact(interaction) { + for await (const response of _interact(interaction, {createSpace})) { + if (response.createInteractionResponse) { + // TODO: Test if it is reasonable to remove `await` from these calls. Or zip these calls with the next interaction iteration and use Promise.all. + await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse) + } else if (response.editOriginalInteractionResponse) { + await discord.snow.interaction.editOriginalInteractionResponse(botID, interaction.token, response.editOriginalInteractionResponse) + } + } +} + +module.exports.interact = interact +module.exports._interact = _interact diff --git a/src/discord/interactions/privacy.test.js b/src/discord/interactions/privacy.test.js new file mode 100644 index 0000000..a94bbc7 --- /dev/null +++ b/src/discord/interactions/privacy.test.js @@ -0,0 +1,86 @@ +const {test} = require("supertape") +const DiscordTypes = require("discord-api-types/v10") +const {select, db} = require("../../passthrough") +const {_interact} = require("./privacy") + +/** + * @template T + * @param {AsyncIterable} ai + * @returns {Promise} + */ +async function fromAsync(ai) { + const result = [] + for await (const value of ai) { + result.push(value) + } + return result +} + +test("privacy: checks if guild is bridged", async t => { + const msgs = await fromAsync(_interact({ + data: { + options: [] + }, + guild_id: "0" + }, {})) + t.equal(msgs.length, 1) + t.equal(msgs[0].createInteractionResponse.data.content, "This server isn't bridged to Matrix, so you can't set the Matrix privacy level.") +}) + +test("privacy: reports usage if there is no parameter", async t => { + const msgs = await fromAsync(_interact({ + data: { + options: [] + }, + guild_id: "112760669178241024" + }, {})) + t.equal(msgs.length, 1) + t.match(msgs[0].createInteractionResponse.data.content, /Usage: `\/privacy/) +}) + +test("privacy: reports usage for invalid parameter", async t => { + const msgs = await fromAsync(_interact({ + data: { + options: [ + { + name: "level", + type: DiscordTypes.ApplicationCommandOptionType.String, + value: "info" + } + ] + }, + guild_id: "112760669178241024" + }, {})) + t.equal(msgs.length, 1) + t.match(msgs[0].createInteractionResponse.data.content, /Usage: `\/privacy/) +}) + +test("privacy: updates setting and calls syncSpace for valid parameter", async t => { + let called = 0 + const msgs = await fromAsync(_interact({ + data: { + options: [ + { + name: "level", + type: DiscordTypes.ApplicationCommandOptionType.String, + value: "directory" + } + ] + }, + guild_id: "112760669178241024" + }, { + createSpace: { + async syncSpaceFully(guildID) { + called++ + t.equal(guildID, "112760669178241024") + } + } + })) + t.equal(msgs.length, 2) + t.equal(msgs[0].createInteractionResponse.type, DiscordTypes.InteractionResponseType.DeferredChannelMessageWithSource) + t.equal(msgs[1].editOriginalInteractionResponse.content, "Privacy level updated to `directory`.") + t.equal(called, 1) + t.equal(select("guild_space", "privacy_level", {guild_id: "112760669178241024"}).pluck().get(), 2) + // Undo database changes + db.prepare("UPDATE guild_space SET privacy_level = 0 WHERE guild_id = ?").run("112760669178241024") +}) diff --git a/src/discord/interactions/reactions.js b/src/discord/interactions/reactions.js new file mode 100644 index 0000000..13dbd69 --- /dev/null +++ b/src/discord/interactions/reactions.js @@ -0,0 +1,76 @@ +// @ts-check + +const DiscordTypes = require("discord-api-types/v10") +const {discord, sync, select, from} = require("../../passthrough") +const {id: botID} = require("../../../addbot") +const {InteractionMethods} = require("snowtransfer") + +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") +/** @type {import("../../m2d/converters/utils")} */ +const utils = sync.require("../../m2d/converters/utils") + +/** + * @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction + * @param {{api: typeof api}} di + * @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters[2]}>} + */ +async function* _interact({data}, {api}) { + const row = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id") + .select("event_id", "room_id").where({message_id: data.target_id}).get() + if (!row) { + return yield {createInteractionResponse: { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: "This message hasn't been bridged to Matrix.", + flags: DiscordTypes.MessageFlags.Ephemeral + } + }} + } + + yield {createInteractionResponse: { + type: DiscordTypes.InteractionResponseType.DeferredChannelMessageWithSource, + data: { + flags: DiscordTypes.MessageFlags.Ephemeral + } + }} + + const reactions = await api.getFullRelations(row.room_id, row.event_id, "m.annotation") + + /** @type {Map} */ + const inverted = new Map() + for (const reaction of reactions) { + if (utils.eventSenderIsFromDiscord(reaction.sender)) continue + const key = reaction.content["m.relates_to"].key + const displayname = select("member_cache", "displayname", {mxid: reaction.sender, room_id: row.room_id}).pluck().get() || reaction.sender + if (!inverted.has(key)) inverted.set(key, []) + // @ts-ignore + inverted.get(key).push(displayname) + } + + if (inverted.size === 0) { + return yield {editOriginalInteractionResponse: { + content: "Nobody from Matrix reacted to this message.", + }} + } + + return yield {editOriginalInteractionResponse: { + content: [...inverted.entries()].map(([key, value]) => `${key} ⮞ ${value.join(" ⬩ ")}`).join("\n"), + }} +} + +/* c8 ignore start */ + +/** @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction */ +async function interact(interaction) { + for await (const response of _interact(interaction, {api})) { + if (response.createInteractionResponse) { + await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse) + } else if (response.editOriginalInteractionResponse) { + await discord.snow.interaction.editOriginalInteractionResponse(botID, interaction.token, response.editOriginalInteractionResponse) + } + } +} + +module.exports.interact = interact +module.exports._interact = _interact diff --git a/src/discord/interactions/reactions.test.js b/src/discord/interactions/reactions.test.js new file mode 100644 index 0000000..50ddeca --- /dev/null +++ b/src/discord/interactions/reactions.test.js @@ -0,0 +1,99 @@ +const {test} = require("supertape") +const {_interact} = require("./reactions") + +/** + * @template T + * @param {AsyncIterable} ai + * @returns {Promise} + */ +async function fromAsync(ai) { + const result = [] + for await (const value of ai) { + result.push(value) + } + return result +} + +test("reactions: checks if message is bridged", async t => { + const msgs = await fromAsync(_interact({ + data: { + target_id: "0" + } + }, {})) + t.equal(msgs.length, 1) + t.equal(msgs[0].createInteractionResponse.data.content, "This message hasn't been bridged to Matrix.") +}) + +test("reactions: different response if nobody reacted", async t => { + const msgs = await fromAsync(_interact({ + data: { + target_id: "1126786462646550579" + } + }, { + api: { + async getFullRelations(roomID, eventID) { + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") + t.equal(eventID, "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg") + return [] + } + } + })) + t.equal(msgs.length, 2) + t.equal(msgs[1].editOriginalInteractionResponse.content, "Nobody from Matrix reacted to this message.") +}) + +test("reactions: shows reactions if there are some, ignoring discord users", async t => { + let called = 1 + const msgs = await fromAsync(_interact({ + data: { + target_id: "1126786462646550579" + } + }, { + api: { + async getFullRelations(roomID, eventID) { + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") + t.equal(eventID, "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg") + return [{ + sender: "@cadence:cadence.moe", + content: { + "m.relates_to": { + key: "🐈", + rel_type: "m.annotation" + } + } + }, { + sender: "@rnl:cadence.moe", + content: { + "m.relates_to": { + key: "🐈", + rel_type: "m.annotation" + } + } + }, { + sender: "@cadence:cadence.moe", + content: { + "m.relates_to": { + key: "🐈‍⬛", + rel_type: "m.annotation" + } + } + }, { + sender: "@_ooye_rnl:cadence.moe", + content: { + "m.relates_to": { + key: "🐈", + rel_type: "m.annotation" + } + } + }] + } + } + })) + t.equal(msgs.length, 2) + t.equal( + msgs[1].editOriginalInteractionResponse.content, + "🐈 ⮞ cadence [they] ⬩ @rnl:cadence.moe" + + "\n🐈‍⬛ ⮞ cadence [they]" + ) + t.equal(called, 1) +}) diff --git a/src/discord/register-interactions.js b/src/discord/register-interactions.js new file mode 100644 index 0000000..0c5a0b0 --- /dev/null +++ b/src/discord/register-interactions.js @@ -0,0 +1,109 @@ +// @ts-check + +const DiscordTypes = require("discord-api-types/v10") +const {discord, sync, db, select} = require("../passthrough") +const {id} = require("../../addbot") + +const matrixInfo = sync.require("./interactions/matrix-info.js") +const invite = sync.require("./interactions/invite.js") +const permissions = sync.require("./interactions/permissions.js") +const reactions = sync.require("./interactions/reactions.js") +const privacy = sync.require("./interactions/privacy.js") + +// User must have EVERY permission in default_member_permissions to be able to use the command + +discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{ + name: "Matrix info", + contexts: [DiscordTypes.InteractionContextType.Guild], + type: DiscordTypes.ApplicationCommandType.Message, +}, { + name: "Permissions", + contexts: [DiscordTypes.InteractionContextType.Guild], + type: DiscordTypes.ApplicationCommandType.Message, + default_member_permissions: String(DiscordTypes.PermissionFlagsBits.KickMembers | DiscordTypes.PermissionFlagsBits.ManageRoles) +}, { + name: "Reactions", + contexts: [DiscordTypes.InteractionContextType.Guild], + type: DiscordTypes.ApplicationCommandType.Message +}, { + name: "invite", + contexts: [DiscordTypes.InteractionContextType.Guild], + type: DiscordTypes.ApplicationCommandType.ChatInput, + description: "Invite a Matrix user to this Discord server", + default_member_permissions: String(DiscordTypes.PermissionFlagsBits.CreateInstantInvite), + options: [ + { + type: DiscordTypes.ApplicationCommandOptionType.String, + description: "The Matrix user to invite, e.g. @username:example.org", + name: "user" + } + ] +}, { + name: "privacy", + contexts: [DiscordTypes.InteractionContextType.Guild], + type: DiscordTypes.ApplicationCommandType.ChatInput, + description: "Change whether Matrix users can join through direct invites, links, or the public directory.", + default_member_permissions: String(DiscordTypes.PermissionFlagsBits.ManageGuild), + options: [ + { + type: DiscordTypes.ApplicationCommandOptionType.String, + description: "Check or set the new privacy level", + name: "level", + choices: [{ + name: "❓️ Check the current privacy level and get more information.", + value: "info" + }, { + name: "🤝 Only allow joining with a direct in-app invite from another user. No shareable invite links.", + value: "invite" + }, { + name: "🔗 Matrix links can be created and shared like Discord's invite links. In-app invites still work.", + value: "link", + }, { + name: "🌏️ Publicly visible in the Matrix directory, like Server Discovery. Invites and links still work.", + value: "directory" + }] + } + ] +}]).catch(e => { + console.error(e) +}) + +async function dispatchInteraction(interaction) { + const interactionId = interaction.data.custom_id || interaction.data.name + try { + if (interactionId === "Matrix info") { + await matrixInfo.interact(interaction) + } else if (interactionId === "invite") { + await invite.interact(interaction) + } else if (interactionId === "invite_channel") { + await invite.interactButton(interaction) + } else if (interactionId === "Permissions") { + await permissions.interact(interaction) + } else if (interactionId === "permissions_edit") { + await permissions.interactEdit(interaction) + } else if (interactionId === "Reactions") { + await reactions.interact(interaction) + } else if (interactionId === "privacy") { + await privacy.interact(interaction) + } else { + throw new Error(`Unknown interaction ${interactionId}`) + } + } catch (e) { + let stackLines = null + if (e.stack) { + stackLines = e.stack.split("\n") + let cloudstormLine = stackLines.findIndex(l => l.includes("/node_modules/cloudstorm/")) + if (cloudstormLine !== -1) { + stackLines = stackLines.slice(0, cloudstormLine - 2) + } + } + await discord.snow.interaction.createFollowupMessage(id, interaction.token, { + content: `Interaction failed: **${interactionId}**` + + `\nError trace:\n\`\`\`\n${stackLines.join("\n")}\`\`\`` + + `Interaction data:\n\`\`\`\n${JSON.stringify(interaction.data, null, 2)}\`\`\``, + flags: DiscordTypes.MessageFlags.Ephemeral + }) + } +} + +module.exports.dispatchInteraction = dispatchInteraction diff --git a/discord/utils.js b/src/discord/utils.js similarity index 72% rename from discord/utils.js rename to src/discord/utils.js index 6e95d17..963f0b8 100644 --- a/discord/utils.js +++ b/src/discord/utils.js @@ -3,6 +3,15 @@ const DiscordTypes = require("discord-api-types/v10") const assert = require("assert").strict +const {reg} = require("../matrix/read-registration") + +const {db} = require("../passthrough") + +/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore +let hasher = null +// @ts-ignore +require("xxhash-wasm")().then(h => hasher = h) + const EPOCH = 1420070400000 /** @@ -97,16 +106,14 @@ function hasAllPermissions(resolvedPermissions, permissionsToCheckFor) { * @param {DiscordTypes.APIMessage} message */ function isWebhookMessage(message) { - const isInteractionResponse = message.type === 20 - return message.webhook_id && !isInteractionResponse + return message.webhook_id && message.type !== DiscordTypes.MessageType.ChatInputCommand } /** - * Ephemeral messages can be generated if a slash command is attached to the same bot that OOYE is running on * @param {Pick} message */ function isEphemeralMessage(message) { - return message.flags && (message.flags & (1 << 6)); + return Boolean(message.flags && (message.flags & DiscordTypes.MessageFlags.Ephemeral)) } /** @param {string} snowflake */ @@ -119,6 +126,34 @@ function timestampToSnowflakeInexact(timestamp) { return String((timestamp - EPOCH) * 2**22) } +/** @param {string} url */ +function getPublicUrlForCdn(url) { + const match = url.match(/https:\/\/(cdn|media)\.discordapp\.(?:com|net)\/attachments\/([0-9]+)\/([0-9]+)\/([-A-Za-z0-9_.,]+)/) + if (!match) return url + const unsignedHash = hasher.h64(match[3]) // attachment ID + const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range + db.prepare("INSERT OR IGNORE INTO media_proxy (permitted_hash) VALUES (?)").run(signedHash) + return `${reg.ooye.bridge_origin}/download/discord${match[1]}/${match[2]}/${match[3]}/${match[4]}` +} + +/** + * @param {string} oldTimestamp + * @param {string} newTimestamp + * @returns {string} "a x-day-old unbridged message" + */ +function howOldUnbridgedMessage(oldTimestamp, newTimestamp) { + const dateDifference = new Date(newTimestamp).getTime() - new Date(oldTimestamp).getTime() + const oneHour = 60 * 60 * 1000 + if (dateDifference < oneHour) { + return "an unbridged message" + } else if (dateDifference < 25 * oneHour) { + var dateDisplay = `a ${Math.floor(dateDifference / oneHour)}-hour-old unbridged message` + } else { + var dateDisplay = `a ${Math.round(dateDifference / (24 * oneHour))}-day-old unbridged message` + } + return dateDisplay +} + module.exports.getPermissions = getPermissions module.exports.hasPermission = hasPermission module.exports.hasSomePermissions = hasSomePermissions @@ -127,3 +162,5 @@ module.exports.isWebhookMessage = isWebhookMessage module.exports.isEphemeralMessage = isEphemeralMessage module.exports.snowflakeToTimestampExact = snowflakeToTimestampExact module.exports.timestampToSnowflakeInexact = timestampToSnowflakeInexact +module.exports.getPublicUrlForCdn = getPublicUrlForCdn +module.exports.howOldUnbridgedMessage = howOldUnbridgedMessage diff --git a/discord/utils.test.js b/src/discord/utils.test.js similarity index 66% rename from discord/utils.test.js rename to src/discord/utils.test.js index 815c0a1..7900440 100644 --- a/discord/utils.test.js +++ b/src/discord/utils.test.js @@ -1,6 +1,6 @@ const DiscordTypes = require("discord-api-types/v10") const {test} = require("supertape") -const data = require("../test/data") +const data = require("../../test/data") const utils = require("./utils") test("is webhook message: identifies bot interaction response as not a message", t => { @@ -84,6 +84,67 @@ test("getPermissions: channel overwrite to allow role works", t => { t.equal((permissions & want), want) }) +test("getPermissions: channel overwrite to allow user works", t => { + const guildRoles = [ + { + version: 1695412489043, + unicode_emoji: null, + tags: {}, + position: 0, + permissions: "559623605571137", + name: "@everyone", + mentionable: false, + managed: false, + id: "1154868424724463687", + icon: null, + hoist: false, + flags: 0, + color: 0 + }, + { + version: 1695412604262, + unicode_emoji: null, + tags: { bot_id: "466378653216014359" }, + position: 1, + permissions: "536995904", + name: "PluralKit", + mentionable: false, + managed: true, + id: "1154868908336099444", + icon: null, + hoist: false, + flags: 0, + color: 0 + }, + { + version: 1698778936921, + unicode_emoji: null, + tags: {}, + position: 1, + permissions: "536870912", + name: "web hookers", + mentionable: false, + managed: false, + id: "1168988246680801360", + icon: null, + hoist: false, + flags: 0, + color: 0 + } + ] + const userRoles = [] + const userID = "353373325575323648" + const overwrites = [ + { type: 0, id: "1154868908336099444", deny: "0", allow: "1024" }, + { type: 0, id: "1154868424724463687", deny: "1024", allow: "0" }, + { type: 0, id: "1168988246680801360", deny: "0", allow: "1024" }, + { type: 1, id: "353373325575323648", deny: "0", allow: "1024" } + ] + const permissions = utils.getPermissions(userRoles, guildRoles, userID, overwrites) + const want = BigInt(1 << 10 | 1 << 16) + t.equal((permissions & want), want) +}) + test("hasSomePermissions: detects the permission", t => { const userPermissions = DiscordTypes.PermissionFlagsBits.MentionEveryone | DiscordTypes.PermissionFlagsBits.BanMembers const canRemoveMembers = utils.hasSomePermissions(userPermissions, ["KickMembers", "BanMembers"]) @@ -107,3 +168,15 @@ test("hasAllPermissions: doesn't detect not the permissions", t => { const canRemoveMembers = utils.hasAllPermissions(userPermissions, ["KickMembers", "BanMembers"]) t.equal(canRemoveMembers, false) }) + +test("isEphemeralMessage: detects ephemeral message", t => { + t.equal(utils.isEphemeralMessage(data.special_message.ephemeral_message), true) +}) + +test("isEphemeralMessage: doesn't detect normal message", t => { + t.equal(utils.isEphemeralMessage(data.message.simple_plaintext), false) +}) + +test("getPublicUrlForCdn: no-op on non-discord URL", t => { + t.equal(utils.getPublicUrlForCdn("https://cadence.moe"), "https://cadence.moe") +}) diff --git a/m2d/actions/add-reaction.js b/src/m2d/actions/add-reaction.js similarity index 58% rename from m2d/actions/add-reaction.js rename to src/m2d/actions/add-reaction.js index cfd471b..277c475 100644 --- a/m2d/actions/add-reaction.js +++ b/src/m2d/actions/add-reaction.js @@ -20,12 +20,25 @@ async function addReaction(event) { if (!messageID) return // Nothing can be done if the parent message was never bridged. const key = event.content["m.relates_to"].key - const discordPreferredEncoding = emoji.encodeEmoji(key, event.content.shortcode) + const discordPreferredEncoding = await emoji.encodeEmoji(key, event.content.shortcode) if (!discordPreferredEncoding) return - await discord.snow.channel.createReaction(channelID, messageID, discordPreferredEncoding) // acting as the discord bot itself + try { + await discord.snow.channel.createReaction(channelID, messageID, discordPreferredEncoding) // acting as the discord bot itself + } catch (e) { + if (e.message?.includes("Maximum number of reactions reached")) { + // we'll silence this particular error to avoid spamming the chat + // not adding it to the database otherwise a m->d removal would try calling the API + return + } + if (e.message?.includes("Unknown Emoji")) { + // happens if a matrix user tries to add on to a super reaction + return + } + throw e + } - db.prepare("REPLACE INTO reaction (hashed_event_id, message_id, encoded_emoji) VALUES (?, ?, ?)").run(utils.getEventIDHash(event.event_id), messageID, discordPreferredEncoding) + db.prepare("REPLACE INTO reaction (hashed_event_id, message_id, encoded_emoji, original_encoding) VALUES (?, ?, ?, ?)").run(utils.getEventIDHash(event.event_id), messageID, discordPreferredEncoding, key) } module.exports.addReaction = addReaction diff --git a/m2d/actions/channel-webhook.js b/src/m2d/actions/channel-webhook.js similarity index 95% rename from m2d/actions/channel-webhook.js rename to src/m2d/actions/channel-webhook.js index 13c3ab1..09c642b 100644 --- a/m2d/actions/channel-webhook.js +++ b/src/m2d/actions/channel-webhook.js @@ -2,7 +2,7 @@ const assert = require("assert").strict const DiscordTypes = require("discord-api-types/v10") -const {Readable} = require("stream") +const stream = require("stream") const passthrough = require("../../passthrough") const {discord, db, select} = passthrough @@ -57,7 +57,7 @@ async function withWebhook(channelID, callback) { /** * @param {string} channelID - * @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]}} data + * @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]}} data * @param {string} [threadID] */ async function sendMessageWithWebhook(channelID, data, threadID) { @@ -70,7 +70,7 @@ async function sendMessageWithWebhook(channelID, data, threadID) { /** * @param {string} channelID * @param {string} messageID - * @param {DiscordTypes.RESTPatchAPIWebhookWithTokenMessageJSONBody & {files?: {name: string, file: Buffer | Readable}[]}} data + * @param {DiscordTypes.RESTPatchAPIWebhookWithTokenMessageJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]}} data * @param {string} [threadID] */ async function editMessageWithWebhook(channelID, messageID, data, threadID) { diff --git a/m2d/actions/emoji-sheet.js b/src/m2d/actions/emoji-sheet.js similarity index 63% rename from m2d/actions/emoji-sheet.js rename to src/m2d/actions/emoji-sheet.js index 5f96297..a63f0b0 100644 --- a/m2d/actions/emoji-sheet.js +++ b/src/m2d/actions/emoji-sheet.js @@ -1,13 +1,12 @@ // @ts-check -const assert = require("assert") -const fetch = require("node-fetch").default - -const utils = require("../converters/utils") +const stream = require("stream") const {sync} = require("../../passthrough") /** @type {import("../converters/emoji-sheet")} */ const emojiSheetConverter = sync.require("../converters/emoji-sheet") +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") /** * Downloads the emoji from the web and converts to uncompressed PNG data. @@ -16,20 +15,15 @@ const emojiSheetConverter = sync.require("../converters/emoji-sheet") */ async function getAndConvertEmoji(mxc) { const abortController = new AbortController() - - const url = utils.getPublicUrlForMxc(mxc) - assert(url) - - /** @type {import("node-fetch").Response} */ // If it turns out to be a GIF, we want to abandon the connection without downloading the whole thing. // If we were using connection pooling, we would be forced to download the entire GIF. // So we set no agent to ensure we are not connection pooling. - // @ts-ignore the signal is slightly different from the type it wants (still works fine) - const res = await fetch(url, {agent: false, signal: abortController.signal}) - return emojiSheetConverter.convertImageStream(res.body, () => { + const res = await api.getMedia(mxc, {signal: abortController.signal}) + const readable = stream.Readable.fromWeb(res.body) + return emojiSheetConverter.convertImageStream(readable, () => { abortController.abort() - res.body.pause() - res.body.emit("end") + readable.emit("end") + readable.on("error", () => {}) // DOMException [AbortError]: This operation was aborted }) } diff --git a/m2d/actions/redact.js b/src/m2d/actions/redact.js similarity index 92% rename from m2d/actions/redact.js rename to src/m2d/actions/redact.js index 7569df4..1f6cef8 100644 --- a/m2d/actions/redact.js +++ b/src/m2d/actions/redact.js @@ -13,7 +13,6 @@ const utils = sync.require("../converters/utils") */ async function deleteMessage(event) { const rows = from("event_message").join("message_channel", "message_id").select("channel_id", "message_id").where({event_id: event.redacts}).all() - db.prepare("DELETE FROM event_message WHERE event_id = ?").run(event.event_id) for (const row of rows) { db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(row.message_id) await discord.snow.channel.deleteMessage(row.channel_id, row.message_id, event.content.reason) @@ -25,7 +24,6 @@ async function deleteMessage(event) { */ async function removeReaction(event) { const hash = utils.getEventIDHash(event.redacts) - // TODO: this works but fix the type const row = from("reaction").join("message_channel", "message_id").select("channel_id", "message_id", "encoded_emoji").where({hashed_event_id: hash}).get() if (!row) return await discord.snow.channel.deleteReactionSelf(row.channel_id, row.message_id, row.encoded_emoji) diff --git a/m2d/actions/send-event.js b/src/m2d/actions/send-event.js similarity index 84% rename from m2d/actions/send-event.js rename to src/m2d/actions/send-event.js index 4422ad3..6b4eb26 100644 --- a/m2d/actions/send-event.js +++ b/src/m2d/actions/send-event.js @@ -2,10 +2,9 @@ const Ty = require("../../types") const DiscordTypes = require("discord-api-types/v10") -const {Readable} = require("stream") +const stream = require("stream") const assert = require("assert").strict const crypto = require("crypto") -const fetch = require("node-fetch").default const passthrough = require("../../passthrough") const {sync, discord, db, select} = passthrough @@ -23,8 +22,8 @@ const editMessage = sync.require("../../d2m/actions/edit-message") const emojiSheet = sync.require("../actions/emoji-sheet") /** - * @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[], pendingFiles?: ({name: string, url: string} | {name: string, url: string, key: string, iv: string} | {name: string, buffer: Buffer | Readable})[]}} message - * @returns {Promise} + * @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[], pendingFiles?: ({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer | stream.Readable})[]}} message + * @returns {Promise} */ async function resolvePendingFiles(message) { if (!message.pendingFiles) return message @@ -38,16 +37,14 @@ async function resolvePendingFiles(message) { if ("key" in p) { // Encrypted file const d = crypto.createDecipheriv("aes-256-ctr", Buffer.from(p.key, "base64url"), Buffer.from(p.iv, "base64url")) - // @ts-ignore - fetch(p.url).then(res => res.body.pipe(d)) + await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb(res.body).pipe(d)) return { name: p.name, file: d } } else { // Unencrypted file - /** @type {Readable} */ // @ts-ignore - const body = await fetch(p.url).then(res => res.body) + const body = await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb(res.body)) return { name: p.name, file: body @@ -65,7 +62,7 @@ async function resolvePendingFiles(message) { /** @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker} event */ async function sendEvent(event) { const row = select("channel_room", ["channel_id", "thread_parent"], {room_id: event.room_id}).get() - if (!row) return // allow the bot to exist in unbridged rooms, just don't do anything with it + if (!row) return [] // allow the bot to exist in unbridged rooms, just don't do anything with it let channelID = row.channel_id let threadID = undefined if (row.thread_parent) { @@ -79,7 +76,7 @@ async function sendEvent(event) { // no need to sync the matrix member to the other side. but if I did need to, this is where I'd do it - let {messagesToEdit, messagesToSend, messagesToDelete, ensureJoined} = await eventToMessage.eventToMessage(event, guild, {api, snow: discord.snow, fetch, mxcDownloader: emojiSheet.getAndConvertEmoji}) + let {messagesToEdit, messagesToSend, messagesToDelete, ensureJoined} = await eventToMessage.eventToMessage(event, guild, {api, snow: discord.snow, mxcDownloader: emojiSheet.getAndConvertEmoji}) messagesToEdit = await Promise.all(messagesToEdit.map(async e => { e.message = await resolvePendingFiles(e.message) @@ -102,14 +99,13 @@ async function sendEvent(event) { for (const id of messagesToDelete) { db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(id) - db.prepare("DELETE FROM event_message WHERE message_id = ?").run(id) await channelWebhook.deleteMessageWithWebhook(channelID, id, threadID) } for (const message of messagesToSend) { const reactionPart = messagesToEdit.length === 0 && message === messagesToSend[messagesToSend.length - 1] ? 0 : 1 const messageResponse = await channelWebhook.sendMessageWithWebhook(channelID, message, threadID) - db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(messageResponse.id, threadID || channelID) + db.prepare("INSERT INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(messageResponse.id, threadID || channelID) db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 0)").run(event.event_id, event.type, event.content["msgtype"] || null, messageResponse.id, eventPart, reactionPart) // source 0 = matrix eventPart = 1 diff --git a/src/m2d/actions/setup-emojis.js b/src/m2d/actions/setup-emojis.js new file mode 100644 index 0000000..ba2c045 --- /dev/null +++ b/src/m2d/actions/setup-emojis.js @@ -0,0 +1,26 @@ +// @ts-check + +const fs = require("fs") +const {join} = require("path") + +const passthrough = require("../../passthrough") + +const {id} = require("../../../addbot") + +async function setupEmojis() { + const {discord, db} = passthrough + const emojis = await discord.snow.assets.getAppEmojis(id) + for (const name of ["L1", "L2"]) { + const existing = emojis.items.find(e => e.name === name) + if (existing) { + db.prepare("REPLACE INTO auto_emoji (name, emoji_id) VALUES (?, ?)").run(existing.name, existing.id) + } else { + const filename = join(__dirname, "../../../docs/img", `${name}.png`) + const data = fs.readFileSync(filename, null) + const uploaded = await discord.snow.assets.createAppEmoji(id, {name, image: "data:image/png;base64," + data.toString("base64")}) + db.prepare("REPLACE INTO auto_emoji (name, emoji_id) VALUES (?, ?)").run(uploaded.name, uploaded.id) + } + } +} + +module.exports.setupEmojis = setupEmojis diff --git a/src/m2d/actions/update-pins.js b/src/m2d/actions/update-pins.js new file mode 100644 index 0000000..46074c6 --- /dev/null +++ b/src/m2d/actions/update-pins.js @@ -0,0 +1,25 @@ +// @ts-check + +const {sync, from, discord} = require("../../passthrough") + +/** @type {import("../converters/diff-pins")} */ +const diffPins = sync.require("../converters/diff-pins") + +/** + * @param {string[]} pins + * @param {string[]} prev + */ +async function updatePins(pins, prev) { + const diff = diffPins.diffPins(pins, prev) + for (const [event_id, added] of diff) { + const row = from("event_message").join("message_channel", "message_id").where({event_id, part: 0}).select("channel_id", "message_id").get() + if (!row) continue + if (added) { + discord.snow.channel.addChannelPinnedMessage(row.channel_id, row.message_id, "Message pinned on Matrix") + } else { + discord.snow.channel.removeChannelPinnedMessage(row.channel_id, row.message_id, "Message unpinned on Matrix") + } + } +} + +module.exports.updatePins = updatePins diff --git a/src/m2d/converters/diff-pins.js b/src/m2d/converters/diff-pins.js new file mode 100644 index 0000000..e6e038b --- /dev/null +++ b/src/m2d/converters/diff-pins.js @@ -0,0 +1,17 @@ +// @ts-check + +/** + * @param {string[]} pins + * @param {string[]} prev + * @returns {[string, boolean][]} + */ +function diffPins(pins, prev) { + /** @type {[string, boolean][]} */ + const result = [] + return result.concat( + prev.filter(id => !pins.includes(id)).map(id => [id, false]), // removed + pins.filter(id => !prev.includes(id)).map(id => [id, true]) // added + ) +} + +module.exports.diffPins = diffPins diff --git a/src/m2d/converters/diff-pins.test.js b/src/m2d/converters/diff-pins.test.js new file mode 100644 index 0000000..edb9c47 --- /dev/null +++ b/src/m2d/converters/diff-pins.test.js @@ -0,0 +1,11 @@ +// @ts-check + +const {test} = require("supertape") +const diffPins = require("./diff-pins") + +test("diff pins: diff is as expected", t => { + t.deepEqual( + diffPins.diffPins(["same", "new"], ["same", "old"]), + [["old", false], ["new", true]] + ) +}) diff --git a/m2d/converters/emoji-sheet.js b/src/m2d/converters/emoji-sheet.js similarity index 94% rename from m2d/converters/emoji-sheet.js rename to src/m2d/converters/emoji-sheet.js index 4d9cc07..16d5065 100644 --- a/m2d/converters/emoji-sheet.js +++ b/src/m2d/converters/emoji-sheet.js @@ -1,6 +1,7 @@ // @ts-check const assert = require("assert").strict +const stream = require("stream") const {pipeline} = require("stream").promises const sharp = require("sharp") const {GIFrame} = require("@cloudrac3r/giframe") @@ -48,7 +49,7 @@ async function compositeMatrixEmojis(mxcs, mxcDownloader) { } /** - * @param {import("node-fetch").Response["body"]} streamIn + * @param {stream.Readable} streamIn * @param {() => any} stopStream * @returns {Promise} Uncompressed PNG image */ @@ -81,9 +82,10 @@ async function convertImageStream(streamIn, stopStream) { giframe.feed(chunk) }) const frame = await giframe.getFrame() + const pixels = Uint8Array.from(frame.pixels) stopStream() - const buffer = await sharp(frame.pixels, {raw: {width: frame.width, height: frame.height, channels: 4}}) + const buffer = await sharp(pixels, {raw: {width: frame.width, height: frame.height, channels: 4}}) .resize(SIZE, SIZE, {fit: "contain", background: {r: 0, g: 0, b: 0, alpha: 0}}) .png({compressionLevel: 0}) .toBuffer({resolveWithObject: true}) diff --git a/m2d/converters/emoji-sheet.test.js b/src/m2d/converters/emoji-sheet.test.js similarity index 100% rename from m2d/converters/emoji-sheet.test.js rename to src/m2d/converters/emoji-sheet.test.js diff --git a/src/m2d/converters/emoji.js b/src/m2d/converters/emoji.js new file mode 100644 index 0000000..63e53a9 --- /dev/null +++ b/src/m2d/converters/emoji.js @@ -0,0 +1,98 @@ +// @ts-check + +const fsp = require("fs").promises +const {join} = require("path") +const emojisp = fsp.readFile(join(__dirname, "emojis.txt"), "utf8").then(content => content.split("\n")) + +const passthrough = require("../../passthrough") +const {select} = passthrough + + +/** + * @param {string} input + * @param {string | null | undefined} shortcode + * @returns {string?} + */ +function encodeCustomEmoji(input, shortcode) { + // Custom emoji + let row = select("emoji", ["emoji_id", "name"], {mxc_url: input}).get() + if (!row && shortcode) { + // Use the name to try to find a known emoji with the same name. + const name = shortcode.replace(/^:|:$/g, "") + row = select("emoji", ["emoji_id", "name"], {name: name}).get() + } + if (!row) { + // We don't have this emoji and there's no realistic way to just-in-time upload a new emoji somewhere. Sucks! + return null + } + return encodeURIComponent(`${row.name}:${row.emoji_id}`) +} + +/** + * @param {string} input + * @returns {Promise} URL encoded! + */ +async function encodeDefaultEmoji(input) { + // Default emoji + + // Shortcut: If there are ASCII letters then it's not an emoji, it's a freeform Matrix text reaction. + // (Regional indicator letters are not ASCII. ASCII digits might be part of an emoji.) + if (input.match(/[A-Za-z]/)) return null + + // Check against the dataset + const emojis = await emojisp + const encoded = encodeURIComponent(input) + + // Best case scenario: they reacted with an exact replica of a valid emoji. + if (emojis.includes(input)) return encoded + + // Maybe it has some extraneous \ufe0f or \ufe0e (at the end or in the middle), and it'll be valid if they're removed. + const trimmed = input.replace(/\ufe0e|\ufe0f/g, "") + const trimmedEncoded = encodeURIComponent(trimmed) + if (trimmed !== input) { + if (emojis.includes(trimmed)) return trimmedEncoded + } + + // Okay, well, maybe it was already missing one and it actually needs an extra \ufe0f, and it'll be valid if that's added. + else { + const appended = input + "\ufe0f" + const appendedEncoded = encodeURIComponent(appended) + if (emojis.includes(appended)) return appendedEncoded + } + + // Hmm, so adding or removing that from the end didn't help, but maybe there needs to be one in the middle? We can try some heuristics. + // These heuristics come from executing scripts/emoji-surrogates-statistics.js. + if (trimmedEncoded.length <= 21 && trimmed.match(/^[*#0-9]/)) { // ->19: Keycap digit? 0️⃣ 1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣ 6️⃣ 7️⃣ 8️⃣ 9️⃣ *️⃣ #️⃣ + const keycap = trimmed[0] + "\ufe0f" + trimmed.slice(1) + if (emojis.includes(keycap)) return encodeURIComponent(keycap) + } else if (trimmedEncoded.length === 27 && trimmed[0] === "⛹") { // ->45: ⛹️‍♀️ ⛹️‍♂️ + const balling = trimmed[0] + "\ufe0f" + trimmed.slice(1) + "\ufe0f" + if (emojis.includes(balling)) return encodeURIComponent(balling) + } else if (trimmedEncoded.length === 30) { // ->39: ⛓️‍💥 ❤️‍🩹 ❤️‍🔥 or ->48: 🏳️‍⚧️ 🏌️‍♀️ 🕵️‍♀️ 🏋️‍♀️ and gender variants + const thriving = trimmed[0] + "\ufe0f" + trimmed.slice(1) + if (emojis.includes(thriving)) return encodeURIComponent(thriving) + const powerful = trimmed.slice(0, 2) + "\ufe0f" + trimmed.slice(2) + "\ufe0f" + if (emojis.includes(powerful)) return encodeURIComponent(powerful) + } else if (trimmedEncoded.length === 51 && trimmed[3] === "❤") { // ->60: 👩‍❤️‍👨 👩‍❤️‍👩 👨‍❤️‍👨 + const yellowRomance = trimmed.slice(0, 3) + "❤\ufe0f" + trimmed.slice(4) + if (emojis.includes(yellowRomance)) return encodeURIComponent(yellowRomance) + } + + // there are a few more longer ones but I got bored + return null +} + +/** + * @param {string} input + * @param {string | null | undefined} shortcode + * @returns {Promise} + */ +async function encodeEmoji(input, shortcode) { + if (input.startsWith("mxc://")) { + return encodeCustomEmoji(input, shortcode) + } else { + return encodeDefaultEmoji(input) + } +} + +module.exports.encodeEmoji = encodeEmoji diff --git a/src/m2d/converters/emoji.test.js b/src/m2d/converters/emoji.test.js new file mode 100644 index 0000000..ad9846b --- /dev/null +++ b/src/m2d/converters/emoji.test.js @@ -0,0 +1,52 @@ +// @ts-check + +const {test} = require("supertape") +const {encodeEmoji} = require("./emoji") + +test("emoji: valid", async t => { + t.equal(await encodeEmoji("🦄", null), "%F0%9F%A6%84") +}) + +test("emoji: freeform text", async t => { + t.equal(await encodeEmoji("ha", null), null) +}) + +test("emoji: suspicious unicode", async t => { + t.equal(await encodeEmoji("Ⓐ", null), null) +}) + +test("emoji: needs u+fe0f added", async t => { + t.equal(await encodeEmoji("☺", null), "%E2%98%BA%EF%B8%8F") +}) + +test("emoji: needs u+fe0f removed", async t => { + t.equal(await encodeEmoji("⭐️", null), "%E2%AD%90") +}) + +test("emoji: number key needs u+fe0f in the middle", async t => { + t.equal(await encodeEmoji("3⃣", null), "3%EF%B8%8F%E2%83%A3") +}) + +test("emoji: hash key needs u+fe0f in the middle", async t => { + t.equal(await encodeEmoji("#⃣", null), "%23%EF%B8%8F%E2%83%A3") +}) + +test("emoji: broken chains needs u+fe0f in the middle", async t => { + t.equal(await encodeEmoji("⛓‍💥", null), "%E2%9B%93%EF%B8%8F%E2%80%8D%F0%9F%92%A5") +}) + +test("emoji: balling needs u+fe0f in the middle", async t => { + t.equal(await encodeEmoji("⛹‍♀", null), "%E2%9B%B9%EF%B8%8F%E2%80%8D%E2%99%80%EF%B8%8F") +}) + +test("emoji: trans flag needs u+fe0f in the middle", async t => { + t.equal(await encodeEmoji("🏳‍⚧", null), "%F0%9F%8F%B3%EF%B8%8F%E2%80%8D%E2%9A%A7%EF%B8%8F") +}) + +test("emoji: spy needs u+fe0f in the middle", async t => { + t.equal(await encodeEmoji("🕵‍♀", null), "%F0%9F%95%B5%EF%B8%8F%E2%80%8D%E2%99%80%EF%B8%8F") +}) + +test("emoji: couple needs u+fe0f in the middle", async t => { + t.equal(await encodeEmoji("👩‍❤‍👩", null), "%F0%9F%91%A9%E2%80%8D%E2%9D%A4%EF%B8%8F%E2%80%8D%F0%9F%91%A9") +}) diff --git a/src/m2d/converters/emojis.txt b/src/m2d/converters/emojis.txt new file mode 100644 index 0000000..2ac8997 --- /dev/null +++ b/src/m2d/converters/emojis.txt @@ -0,0 +1,3799 @@ +😀 +😃 +😄 +😁 +😆 +🥹 +😅 +😂 +🤣 +🥲 +☺️ +😊 +😇 +🙂 +🙃 +😉 +😌 +😍 +🥰 +😘 +😗 +😙 +😚 +😋 +😛 +😝 +😜 +🤪 +🤨 +🧐 +🤓 +😎 +🥸 +🤩 +🥳 +😏 +😒 +😞 +😔 +😟 +😕 +🙁 +☹️ +😣 +😖 +😫 +😩 +🥺 +😢 +😭 +😤 +😠 +😡 +🤬 +🤯 +😳 +🥵 +🥶 +😶‍🌫️ +😱 +😨 +😰 +😥 +😓 +🤗 +🤔 +🫣 +🤭 +🫢 +🫡 +🤫 +🫠 +🤥 +😶 +🫥 +😐 +🫤 +😑 +🫨 +🙂‍↔️ +🙂‍↕️ +😬 +🙄 +😯 +😦 +😧 +😮 +😲 +🥱 +😴 +🤤 +😪 +😮‍💨 +😵 +😵‍💫 +🤐 +🥴 +🤢 +🤮 +🤧 +😷 +🤒 +🤕 +🤑 +🤠 +😈 +👿 +👹 +👺 +🤡 +💩 +👻 +💀 +☠️ +👽 +👾 +🤖 +🎃 +😺 +😸 +😹 +😻 +😼 +😽 +🙀 +😿 +😾 +🤝🏻 +🫱🏻‍🫲🏼 +🫱🏻‍🫲🏽 +🫱🏻‍🫲🏾 +🫱🏻‍🫲🏿 +🫱🏼‍🫲🏻 +🤝🏼 +🫱🏼‍🫲🏽 +🫱🏼‍🫲🏾 +🫱🏼‍🫲🏿 +🫱🏽‍🫲🏻 +🫱🏽‍🫲🏼 +🤝🏽 +🫱🏽‍🫲🏾 +🫱🏽‍🫲🏿 +🫱🏾‍🫲🏻 +🫱🏾‍🫲🏼 +🫱🏾‍🫲🏽 +🤝🏾 +🫱🏾‍🫲🏿 +🫱🏿‍🫲🏻 +🫱🏿‍🫲🏼 +🫱🏿‍🫲🏽 +🫱🏿‍🫲🏾 +🤝🏿 +🤝 +🫶🏻 +🫶🏼 +🫶🏽 +🫶🏾 +🫶🏿 +🫶 +🤲🏻 +🤲🏼 +🤲🏽 +🤲🏾 +🤲🏿 +🤲 +👐🏻 +👐🏼 +👐🏽 +👐🏾 +👐🏿 +👐 +🙌🏻 +🙌🏼 +🙌🏽 +🙌🏾 +🙌🏿 +🙌 +👏🏻 +👏🏼 +👏🏽 +👏🏾 +👏🏿 +👏 +👍🏻 +👍🏼 +👍🏽 +👍🏾 +👍🏿 +👍 +👎🏻 +👎🏼 +👎🏽 +👎🏾 +👎🏿 +👎 +👊🏻 +👊🏼 +👊🏽 +👊🏾 +👊🏿 +👊 +✊🏻 +✊🏼 +✊🏽 +✊🏾 +✊🏿 +✊ +🤛🏻 +🤛🏼 +🤛🏽 +🤛🏾 +🤛🏿 +🤛 +🤜🏻 +🤜🏼 +🤜🏽 +🤜🏾 +🤜🏿 +🤜 +🫷🏻 +🫷🏼 +🫷🏽 +🫷🏾 +🫷🏿 +🫷 +🫸🏻 +🫸🏼 +🫸🏽 +🫸🏾 +🫸🏿 +🫸 +🤞🏻 +🤞🏼 +🤞🏽 +🤞🏾 +🤞🏿 +🤞 +✌🏻 +✌🏼 +✌🏽 +✌🏾 +✌🏿 +✌️ +🫰🏻 +🫰🏼 +🫰🏽 +🫰🏾 +🫰🏿 +🫰 +🤟🏻 +🤟🏼 +🤟🏽 +🤟🏾 +🤟🏿 +🤟 +🤘🏻 +🤘🏼 +🤘🏽 +🤘🏾 +🤘🏿 +🤘 +👌🏻 +👌🏼 +👌🏽 +👌🏾 +👌🏿 +👌 +🤌🏼 +🤌🏻 +🤌🏽 +🤌🏾 +🤌🏿 +🤌 +🤏🏻 +🤏🏼 +🤏🏽 +🤏🏾 +🤏🏿 +🤏 +🫳🏻 +🫳🏼 +🫳🏽 +🫳🏾 +🫳🏿 +🫳 +🫴🏻 +🫴🏼 +🫴🏽 +🫴🏾 +🫴🏿 +🫴 +👈🏻 +👈🏼 +👈🏽 +👈🏾 +👈🏿 +👈 +👉🏻 +👉🏼 +👉🏽 +👉🏾 +👉🏿 +👉 +👆🏻 +👆🏼 +👆🏽 +👆🏾 +👆🏿 +👆 +👇🏻 +👇🏼 +👇🏽 +👇🏾 +👇🏿 +👇 +☝🏻 +☝🏼 +☝🏽 +☝🏾 +☝🏿 +☝️ +✋🏻 +✋🏼 +✋🏽 +✋🏾 +✋🏿 +✋ +🤚🏻 +🤚🏼 +🤚🏽 +🤚🏾 +🤚🏿 +🤚 +🖐🏻 +🖐🏼 +🖐🏽 +🖐🏾 +🖐🏿 +🖐️ +🖖🏻 +🖖🏼 +🖖🏽 +🖖🏾 +🖖🏿 +🖖 +👋🏻 +👋🏼 +👋🏽 +👋🏾 +👋🏿 +👋 +🤙🏻 +🤙🏼 +🤙🏽 +🤙🏾 +🤙🏿 +🤙 +🫲🏻 +🫲🏼 +🫲🏽 +🫲🏾 +🫲🏿 +🫲 +🫱🏻 +🫱🏼 +🫱🏽 +🫱🏾 +🫱🏿 +🫱 +💪🏻 +💪🏼 +💪🏽 +💪🏾 +💪🏿 +💪 +🦾 +🖕🏻 +🖕🏼 +🖕🏽 +🖕🏾 +🖕🏿 +🖕 +✍🏻 +✍🏼 +✍🏽 +✍🏾 +✍🏿 +✍️ +🙏🏻 +🙏🏼 +🙏🏽 +🙏🏾 +🙏🏿 +🙏 +🫵🏻 +🫵🏼 +🫵🏽 +🫵🏾 +🫵🏿 +🫵 +🦶🏻 +🦶🏼 +🦶🏽 +🦶🏾 +🦶🏿 +🦶 +🦵🏻 +🦵🏼 +🦵🏽 +🦵🏾 +🦵🏿 +🦵 +🦿 +💄 +💋 +👄 +🫦 +🦷 +👅 +👂🏻 +👂🏼 +👂🏽 +👂🏾 +👂🏿 +👂 +🦻🏻 +🦻🏼 +🦻🏽 +🦻🏾 +🦻🏿 +🦻 +👃🏻 +👃🏼 +👃🏽 +👃🏾 +👃🏿 +👃 +👣 +👁️ +👀 +🫀 +🫁 +🧠 +🗣️ +👤 +👥 +🫂 +👶🏻 +👶🏼 +👶🏽 +👶🏾 +👶🏿 +👶 +🧒🏻 +🧒🏼 +🧒🏽 +🧒🏾 +🧒🏿 +🧒 +👧🏻 +👧🏼 +👧🏽 +👧🏾 +👧🏿 +👧 +👦🏻 +👦🏼 +👦🏽 +👦🏾 +👦🏿 +👦 +🧑🏻 +🧑🏼 +🧑🏽 +🧑🏾 +🧑🏿 +🧑 +👩🏻 +👩🏼 +👩🏽 +👩🏾 +👩🏿 +👩 +👨🏻 +👨🏼 +👨🏽 +👨🏾 +👨🏿 +👨 +🧑🏻‍🦱 +🧑🏼‍🦱 +🧑🏽‍🦱 +🧑🏾‍🦱 +🧑🏿‍🦱 +🧑‍🦱 +👩🏻‍🦱 +👩🏼‍🦱 +👩🏽‍🦱 +👩🏾‍🦱 +👩🏿‍🦱 +👩‍🦱 +👨🏻‍🦱 +👨🏼‍🦱 +👨🏽‍🦱 +👨🏾‍🦱 +👨🏿‍🦱 +👨‍🦱 +🧑🏻‍🦰 +🧑🏼‍🦰 +🧑🏽‍🦰 +🧑🏾‍🦰 +🧑🏿‍🦰 +🧑‍🦰 +👩🏻‍🦰 +👩🏼‍🦰 +👩🏽‍🦰 +👩🏾‍🦰 +👩🏿‍🦰 +👩‍🦰 +👨🏻‍🦰 +👨🏼‍🦰 +👨🏽‍🦰 +👨🏾‍🦰 +👨🏿‍🦰 +👨‍🦰 +👱🏻 +👱🏼 +👱🏽 +👱🏾 +👱🏿 +👱 +👱🏻‍♀️ +👱🏼‍♀️ +👱🏽‍♀️ +👱🏾‍♀️ +👱🏿‍♀️ +👱‍♀️ +👱🏻‍♂️ +👱🏼‍♂️ +👱🏽‍♂️ +👱🏾‍♂️ +👱🏿‍♂️ +👱‍♂️ +🧑🏻‍🦳 +🧑🏼‍🦳 +🧑🏽‍🦳 +🧑🏾‍🦳 +🧑🏿‍🦳 +🧑‍🦳 +👩🏻‍🦳 +👩🏼‍🦳 +👩🏽‍🦳 +👩🏾‍🦳 +👩🏿‍🦳 +👩‍🦳 +👨🏻‍🦳 +👨🏼‍🦳 +👨🏽‍🦳 +👨🏾‍🦳 +👨🏿‍🦳 +👨‍🦳 +🧑🏻‍🦲 +🧑🏼‍🦲 +🧑🏽‍🦲 +🧑🏾‍🦲 +🧑🏿‍🦲 +🧑‍🦲 +👩🏻‍🦲 +👩🏼‍🦲 +👩🏽‍🦲 +👩🏾‍🦲 +👩🏿‍🦲 +👩‍🦲 +👨🏻‍🦲 +👨🏼‍🦲 +👨🏽‍🦲 +👨🏾‍🦲 +👨🏿‍🦲 +👨‍🦲 +🧔🏻 +🧔🏼 +🧔🏽 +🧔🏾 +🧔🏿 +🧔 +🧔🏻‍♀️ +🧔🏼‍♀️ +🧔🏽‍♀️ +🧔🏾‍♀️ +🧔🏿‍♀️ +🧔‍♀️ +🧔🏻‍♂️ +🧔🏼‍♂️ +🧔🏽‍♂️ +🧔🏾‍♂️ +🧔🏿‍♂️ +🧔‍♂️ +🧓🏻 +🧓🏼 +🧓🏽 +🧓🏾 +🧓🏿 +🧓 +👵🏻 +👵🏼 +👵🏽 +👵🏾 +👵🏿 +👵 +👴🏻 +👴🏼 +👴🏽 +👴🏾 +👴🏿 +👴 +👲🏻 +👲🏼 +👲🏽 +👲🏾 +👲🏿 +👲 +👳🏻 +👳🏼 +👳🏽 +👳🏾 +👳🏿 +👳 +👳🏻‍♀️ +👳🏼‍♀️ +👳🏽‍♀️ +👳🏾‍♀️ +👳🏿‍♀️ +👳‍♀️ +👳🏻‍♂️ +👳🏼‍♂️ +👳🏽‍♂️ +👳🏾‍♂️ +👳🏿‍♂️ +👳‍♂️ +🧕🏻 +🧕🏼 +🧕🏽 +🧕🏾 +🧕🏿 +🧕 +👮🏻 +👮🏼 +👮🏽 +👮🏾 +👮🏿 +👮 +👮🏻‍♀️ +👮🏼‍♀️ +👮🏽‍♀️ +👮🏾‍♀️ +👮🏿‍♀️ +👮‍♀️ +👮🏻‍♂️ +👮🏼‍♂️ +👮🏽‍♂️ +👮🏾‍♂️ +👮🏿‍♂️ +👮‍♂️ +👷🏻 +👷🏼 +👷🏽 +👷🏾 +👷🏿 +👷 +👷🏻‍♀️ +👷🏼‍♀️ +👷🏽‍♀️ +👷🏾‍♀️ +👷🏿‍♀️ +👷‍♀️ +👷🏻‍♂️ +👷🏼‍♂️ +👷🏽‍♂️ +👷🏾‍♂️ +👷🏿‍♂️ +👷‍♂️ +💂🏻 +💂🏼 +💂🏽 +💂🏾 +💂🏿 +💂 +💂🏻‍♀️ +💂🏼‍♀️ +💂🏽‍♀️ +💂🏾‍♀️ +💂🏿‍♀️ +💂‍♀️ +💂🏻‍♂️ +💂🏼‍♂️ +💂🏽‍♂️ +💂🏾‍♂️ +💂🏿‍♂️ +💂‍♂️ +🕵🏻 +🕵🏼 +🕵🏽 +🕵🏾 +🕵🏿 +🕵️ +🕵🏻‍♀️ +🕵🏼‍♀️ +🕵🏽‍♀️ +🕵🏾‍♀️ +🕵🏿‍♀️ +🕵️‍♀️ +🕵🏻‍♂️ +🕵🏼‍♂️ +🕵🏽‍♂️ +🕵🏾‍♂️ +🕵🏿‍♂️ +🕵️‍♂️ +🧑🏻‍⚕️ +🧑🏼‍⚕️ +🧑🏽‍⚕️ +🧑🏾‍⚕️ +🧑🏿‍⚕️ +🧑‍⚕️ +👩🏻‍⚕️ +👩🏼‍⚕️ +👩🏽‍⚕️ +👩🏾‍⚕️ +👩🏿‍⚕️ +👩‍⚕️ +👨🏻‍⚕️ +👨🏼‍⚕️ +👨🏽‍⚕️ +👨🏾‍⚕️ +👨🏿‍⚕️ +👨‍⚕️ +🧑🏻‍🌾 +🧑🏼‍🌾 +🧑🏽‍🌾 +🧑🏾‍🌾 +🧑🏿‍🌾 +🧑‍🌾 +👩🏻‍🌾 +👩🏼‍🌾 +👩🏽‍🌾 +👩🏾‍🌾 +👩🏿‍🌾 +👩‍🌾 +👨🏻‍🌾 +👨🏼‍🌾 +👨🏽‍🌾 +👨🏾‍🌾 +👨🏿‍🌾 +👨‍🌾 +🧑🏻‍🍳 +🧑🏼‍🍳 +🧑🏽‍🍳 +🧑🏾‍🍳 +🧑🏿‍🍳 +🧑‍🍳 +👩🏻‍🍳 +👩🏼‍🍳 +👩🏽‍🍳 +👩🏾‍🍳 +👩🏿‍🍳 +👩‍🍳 +👨🏻‍🍳 +👨🏼‍🍳 +👨🏽‍🍳 +👨🏾‍🍳 +👨🏿‍🍳 +👨‍🍳 +🧑🏻‍🎓 +🧑🏼‍🎓 +🧑🏽‍🎓 +🧑🏾‍🎓 +🧑🏿‍🎓 +🧑‍🎓 +👩🏻‍🎓 +👩🏼‍🎓 +👩🏽‍🎓 +👩🏾‍🎓 +👩🏿‍🎓 +👩‍🎓 +👨🏻‍🎓 +👨🏼‍🎓 +👨🏽‍🎓 +👨🏾‍🎓 +👨🏿‍🎓 +👨‍🎓 +🧑🏻‍🎤 +🧑🏼‍🎤 +🧑🏽‍🎤 +🧑🏾‍🎤 +🧑🏿‍🎤 +🧑‍🎤 +👩🏻‍🎤 +👩🏼‍🎤 +👩🏽‍🎤 +👩🏾‍🎤 +👩🏿‍🎤 +👩‍🎤 +👨🏻‍🎤 +👨🏼‍🎤 +👨🏽‍🎤 +👨🏾‍🎤 +👨🏿‍🎤 +👨‍🎤 +🧑🏻‍🏫 +🧑🏼‍🏫 +🧑🏽‍🏫 +🧑🏾‍🏫 +🧑🏿‍🏫 +🧑‍🏫 +👩🏻‍🏫 +👩🏼‍🏫 +👩🏽‍🏫 +👩🏾‍🏫 +👩🏿‍🏫 +👩‍🏫 +👨🏻‍🏫 +👨🏼‍🏫 +👨🏽‍🏫 +👨🏾‍🏫 +👨🏿‍🏫 +👨‍🏫 +🧑🏻‍🏭 +🧑🏼‍🏭 +🧑🏽‍🏭 +🧑🏾‍🏭 +🧑🏿‍🏭 +🧑‍🏭 +👩🏻‍🏭 +👩🏼‍🏭 +👩🏽‍🏭 +👩🏾‍🏭 +👩🏿‍🏭 +👩‍🏭 +👨🏻‍🏭 +👨🏼‍🏭 +👨🏽‍🏭 +👨🏾‍🏭 +👨🏿‍🏭 +👨‍🏭 +🧑🏻‍💻 +🧑🏼‍💻 +🧑🏽‍💻 +🧑🏾‍💻 +🧑🏿‍💻 +🧑‍💻 +👩🏻‍💻 +👩🏼‍💻 +👩🏽‍💻 +👩🏾‍💻 +👩🏿‍💻 +👩‍💻 +👨🏻‍💻 +👨🏼‍💻 +👨🏽‍💻 +👨🏾‍💻 +👨🏿‍💻 +👨‍💻 +🧑🏻‍💼 +🧑🏼‍💼 +🧑🏽‍💼 +🧑🏾‍💼 +🧑🏿‍💼 +🧑‍💼 +👩🏻‍💼 +👩🏼‍💼 +👩🏽‍💼 +👩🏾‍💼 +👩🏿‍💼 +👩‍💼 +👨🏻‍💼 +👨🏼‍💼 +👨🏽‍💼 +👨🏾‍💼 +👨🏿‍💼 +👨‍💼 +🧑🏻‍🔧 +🧑🏼‍🔧 +🧑🏽‍🔧 +🧑🏾‍🔧 +🧑🏿‍🔧 +🧑‍🔧 +👩🏻‍🔧 +👩🏼‍🔧 +👩🏽‍🔧 +👩🏾‍🔧 +👩🏿‍🔧 +👩‍🔧 +👨🏻‍🔧 +👨🏼‍🔧 +👨🏽‍🔧 +👨🏾‍🔧 +👨🏿‍🔧 +👨‍🔧 +🧑🏻‍🔬 +🧑🏼‍🔬 +🧑🏽‍🔬 +🧑🏾‍🔬 +🧑🏿‍🔬 +🧑‍🔬 +👩🏻‍🔬 +👩🏼‍🔬 +👩🏽‍🔬 +👩🏾‍🔬 +👩🏿‍🔬 +👩‍🔬 +👨🏻‍🔬 +👨🏼‍🔬 +👨🏽‍🔬 +👨🏾‍🔬 +👨🏿‍🔬 +👨‍🔬 +🧑🏻‍🎨 +🧑🏼‍🎨 +🧑🏽‍🎨 +🧑🏾‍🎨 +🧑🏿‍🎨 +🧑‍🎨 +👩🏻‍🎨 +👩🏼‍🎨 +👩🏽‍🎨 +👩🏾‍🎨 +👩🏿‍🎨 +👩‍🎨 +👨🏻‍🎨 +👨🏼‍🎨 +👨🏽‍🎨 +👨🏾‍🎨 +👨🏿‍🎨 +👨‍🎨 +🧑🏻‍🚒 +🧑🏼‍🚒 +🧑🏽‍🚒 +🧑🏾‍🚒 +🧑🏿‍🚒 +🧑‍🚒 +👩🏻‍🚒 +👩🏼‍🚒 +👩🏽‍🚒 +👩🏾‍🚒 +👩🏿‍🚒 +👩‍🚒 +👨🏻‍🚒 +👨🏼‍🚒 +👨🏽‍🚒 +👨🏾‍🚒 +👨🏿‍🚒 +👨‍🚒 +🧑🏻‍✈️ +🧑🏼‍✈️ +🧑🏽‍✈️ +🧑🏾‍✈️ +🧑🏿‍✈️ +🧑‍✈️ +👩🏻‍✈️ +👩🏼‍✈️ +👩🏽‍✈️ +👩🏾‍✈️ +👩🏿‍✈️ +👩‍✈️ +👨🏻‍✈️ +👨🏼‍✈️ +👨🏽‍✈️ +👨🏾‍✈️ +👨🏿‍✈️ +👨‍✈️ +🧑🏻‍🚀 +🧑🏼‍🚀 +🧑🏽‍🚀 +🧑🏾‍🚀 +🧑🏿‍🚀 +🧑‍🚀 +👩🏻‍🚀 +👩🏼‍🚀 +👩🏽‍🚀 +👩🏾‍🚀 +👩🏿‍🚀 +👩‍🚀 +👨🏻‍🚀 +👨🏼‍🚀 +👨🏽‍🚀 +👨🏾‍🚀 +👨🏿‍🚀 +👨‍🚀 +🧑🏻‍⚖️ +🧑🏼‍⚖️ +🧑🏽‍⚖️ +🧑🏾‍⚖️ +🧑🏿‍⚖️ +🧑‍⚖️ +👩🏻‍⚖️ +👩🏼‍⚖️ +👩🏽‍⚖️ +👩🏾‍⚖️ +👩🏿‍⚖️ +👩‍⚖️ +👨🏻‍⚖️ +👨🏼‍⚖️ +👨🏽‍⚖️ +👨🏾‍⚖️ +👨🏿‍⚖️ +👨‍⚖️ +👰🏻 +👰🏼 +👰🏽 +👰🏾 +👰🏿 +👰 +👰🏻‍♀️ +👰🏼‍♀️ +👰🏽‍♀️ +👰🏾‍♀️ +👰🏿‍♀️ +👰‍♀️ +👰🏻‍♂️ +👰🏼‍♂️ +👰🏽‍♂️ +👰🏾‍♂️ +👰🏿‍♂️ +👰‍♂️ +🤵🏻 +🤵🏼 +🤵🏽 +🤵🏾 +🤵🏿 +🤵 +🤵🏻‍♀️ +🤵🏼‍♀️ +🤵🏽‍♀️ +🤵🏾‍♀️ +🤵🏿‍♀️ +🤵‍♀️ +🤵🏻‍♂️ +🤵🏼‍♂️ +🤵🏽‍♂️ +🤵🏾‍♂️ +🤵🏿‍♂️ +🤵‍♂️ +🫅🏻 +🫅🏼 +🫅🏽 +🫅🏾 +🫅🏿 +🫅 +👸🏻 +👸🏼 +👸🏽 +👸🏾 +👸🏿 +👸 +🤴🏻 +🤴🏼 +🤴🏽 +🤴🏾 +🤴🏿 +🤴 +🦸🏻 +🦸🏼 +🦸🏽 +🦸🏾 +🦸🏿 +🦸 +🦸🏻‍♀️ +🦸🏼‍♀️ +🦸🏽‍♀️ +🦸🏾‍♀️ +🦸🏿‍♀️ +🦸‍♀️ +🦸🏻‍♂️ +🦸🏼‍♂️ +🦸🏽‍♂️ +🦸🏾‍♂️ +🦸🏿‍♂️ +🦸‍♂️ +🦹🏻 +🦹🏼 +🦹🏽 +🦹🏾 +🦹🏿 +🦹 +🦹🏻‍♀️ +🦹🏼‍♀️ +🦹🏽‍♀️ +🦹🏾‍♀️ +🦹🏿‍♀️ +🦹‍♀️ +🦹🏻‍♂️ +🦹🏼‍♂️ +🦹🏽‍♂️ +🦹🏾‍♂️ +🦹🏿‍♂️ +🦹‍♂️ +🥷🏻 +🥷🏼 +🥷🏽 +🥷🏾 +🥷🏿 +🥷 +🧑🏻‍🎄 +🧑🏼‍🎄 +🧑🏽‍🎄 +🧑🏾‍🎄 +🧑🏿‍🎄 +🧑‍🎄 +🤶🏻 +🤶🏼 +🤶🏽 +🤶🏾 +🤶🏿 +🤶 +🎅🏻 +🎅🏼 +🎅🏽 +🎅🏾 +🎅🏿 +🎅 +🧙🏻 +🧙🏼 +🧙🏽 +🧙🏾 +🧙🏿 +🧙 +🧙🏻‍♀️ +🧙🏼‍♀️ +🧙🏽‍♀️ +🧙🏾‍♀️ +🧙🏿‍♀️ +🧙‍♀️ +🧙🏻‍♂️ +🧙🏼‍♂️ +🧙🏽‍♂️ +🧙🏾‍♂️ +🧙🏿‍♂️ +🧙‍♂️ +🧝🏻 +🧝🏼 +🧝🏽 +🧝🏾 +🧝🏿 +🧝 +🧝🏻‍♀️ +🧝🏼‍♀️ +🧝🏽‍♀️ +🧝🏾‍♀️ +🧝🏿‍♀️ +🧝‍♀️ +🧝🏻‍♂️ +🧝🏼‍♂️ +🧝🏽‍♂️ +🧝🏾‍♂️ +🧝🏿‍♂️ +🧝‍♂️ +🧌 +🧛🏻 +🧛🏼 +🧛🏽 +🧛🏾 +🧛🏿 +🧛 +🧛🏻‍♀️ +🧛🏼‍♀️ +🧛🏽‍♀️ +🧛🏾‍♀️ +🧛🏿‍♀️ +🧛‍♀️ +🧛🏻‍♂️ +🧛🏼‍♂️ +🧛🏽‍♂️ +🧛🏾‍♂️ +🧛🏿‍♂️ +🧛‍♂️ +🧟 +🧟‍♀️ +🧟‍♂️ +🧞 +🧞‍♀️ +🧞‍♂️ +🧜🏻 +🧜🏼 +🧜🏽 +🧜🏾 +🧜🏿 +🧜 +🧜🏻‍♀️ +🧜🏼‍♀️ +🧜🏽‍♀️ +🧜🏾‍♀️ +🧜🏿‍♀️ +🧜‍♀️ +🧜🏻‍♂️ +🧜🏼‍♂️ +🧜🏽‍♂️ +🧜🏾‍♂️ +🧜🏿‍♂️ +🧜‍♂️ +🧚🏻 +🧚🏼 +🧚🏽 +🧚🏾 +🧚🏿 +🧚 +🧚🏻‍♀️ +🧚🏼‍♀️ +🧚🏽‍♀️ +🧚🏾‍♀️ +🧚🏿‍♀️ +🧚‍♀️ +🧚🏻‍♂️ +🧚🏼‍♂️ +🧚🏽‍♂️ +🧚🏾‍♂️ +🧚🏿‍♂️ +🧚‍♂️ +👼🏻 +👼🏼 +👼🏽 +👼🏾 +👼🏿 +👼 +🫄🏻 +🫄🏼 +🫄🏽 +🫄🏾 +🫄🏿 +🫄 +🤰🏻 +🤰🏼 +🤰🏽 +🤰🏾 +🤰🏿 +🤰 +🫃🏻 +🫃🏼 +🫃🏽 +🫃🏾 +🫃🏿 +🫃 +🤱🏻 +🤱🏼 +🤱🏽 +🤱🏾 +🤱🏿 +🤱 +🧑🏻‍🍼 +🧑🏼‍🍼 +🧑🏽‍🍼 +🧑🏾‍🍼 +🧑🏿‍🍼 +🧑‍🍼 +👩🏻‍🍼 +👩🏼‍🍼 +👩🏽‍🍼 +👩🏾‍🍼 +👩🏿‍🍼 +👩‍🍼 +👨🏻‍🍼 +👨🏼‍🍼 +👨🏽‍🍼 +👨🏾‍🍼 +👨🏿‍🍼 +👨‍🍼 +🙇🏻 +🙇🏼 +🙇🏽 +🙇🏾 +🙇🏿 +🙇 +🙇🏻‍♀️ +🙇🏼‍♀️ +🙇🏽‍♀️ +🙇🏾‍♀️ +🙇🏿‍♀️ +🙇‍♀️ +🙇🏻‍♂️ +🙇🏼‍♂️ +🙇🏽‍♂️ +🙇🏾‍♂️ +🙇🏿‍♂️ +🙇‍♂️ +💁🏻 +💁🏼 +💁🏽 +💁🏾 +💁🏿 +💁 +💁🏻‍♀️ +💁🏼‍♀️ +💁🏽‍♀️ +💁🏾‍♀️ +💁🏿‍♀️ +💁‍♀️ +💁🏻‍♂️ +💁🏼‍♂️ +💁🏽‍♂️ +💁🏾‍♂️ +💁🏿‍♂️ +💁‍♂️ +🙅🏻 +🙅🏼 +🙅🏽 +🙅🏾 +🙅🏿 +🙅 +🙅🏻‍♀️ +🙅🏼‍♀️ +🙅🏽‍♀️ +🙅🏾‍♀️ +🙅🏿‍♀️ +🙅‍♀️ +🙅🏻‍♂️ +🙅🏼‍♂️ +🙅🏽‍♂️ +🙅🏾‍♂️ +🙅🏿‍♂️ +🙅‍♂️ +🙆🏻 +🙆🏼 +🙆🏽 +🙆🏾 +🙆🏿 +🙆 +🙆🏻‍♀️ +🙆🏼‍♀️ +🙆🏽‍♀️ +🙆🏾‍♀️ +🙆🏿‍♀️ +🙆‍♀️ +🙆🏻‍♂️ +🙆🏼‍♂️ +🙆🏽‍♂️ +🙆🏾‍♂️ +🙆🏿‍♂️ +🙆‍♂️ +🙋🏻 +🙋🏼 +🙋🏽 +🙋🏾 +🙋🏿 +🙋 +🙋🏻‍♀️ +🙋🏼‍♀️ +🙋🏽‍♀️ +🙋🏾‍♀️ +🙋🏿‍♀️ +🙋‍♀️ +🙋🏻‍♂️ +🙋🏼‍♂️ +🙋🏽‍♂️ +🙋🏾‍♂️ +🙋🏿‍♂️ +🙋‍♂️ +🧏🏻 +🧏🏼 +🧏🏽 +🧏🏾 +🧏🏿 +🧏 +🧏🏻‍♀️ +🧏🏼‍♀️ +🧏🏽‍♀️ +🧏🏾‍♀️ +🧏🏿‍♀️ +🧏‍♀️ +🧏🏻‍♂️ +🧏🏼‍♂️ +🧏🏽‍♂️ +🧏🏾‍♂️ +🧏🏿‍♂️ +🧏‍♂️ +🤦🏻 +🤦🏼 +🤦🏽 +🤦🏾 +🤦🏿 +🤦 +🤦🏻‍♀️ +🤦🏼‍♀️ +🤦🏽‍♀️ +🤦🏾‍♀️ +🤦🏿‍♀️ +🤦‍♀️ +🤦🏻‍♂️ +🤦🏼‍♂️ +🤦🏽‍♂️ +🤦🏾‍♂️ +🤦🏿‍♂️ +🤦‍♂️ +🤷🏻 +🤷🏼 +🤷🏽 +🤷🏾 +🤷🏿 +🤷 +🤷🏻‍♀️ +🤷🏼‍♀️ +🤷🏽‍♀️ +🤷🏾‍♀️ +🤷🏿‍♀️ +🤷‍♀️ +🤷🏻‍♂️ +🤷🏼‍♂️ +🤷🏽‍♂️ +🤷🏾‍♂️ +🤷🏿‍♂️ +🤷‍♂️ +🙎🏻 +🙎🏼 +🙎🏽 +🙎🏾 +🙎🏿 +🙎 +🙎🏻‍♀️ +🙎🏼‍♀️ +🙎🏽‍♀️ +🙎🏾‍♀️ +🙎🏿‍♀️ +🙎‍♀️ +🙎🏻‍♂️ +🙎🏼‍♂️ +🙎🏽‍♂️ +🙎🏾‍♂️ +🙎🏿‍♂️ +🙎‍♂️ +🙍🏻 +🙍🏼 +🙍🏽 +🙍🏾 +🙍🏿 +🙍 +🙍🏻‍♀️ +🙍🏼‍♀️ +🙍🏽‍♀️ +🙍🏾‍♀️ +🙍🏿‍♀️ +🙍‍♀️ +🙍🏻‍♂️ +🙍🏼‍♂️ +🙍🏽‍♂️ +🙍🏾‍♂️ +🙍🏿‍♂️ +🙍‍♂️ +💇🏻 +💇🏼 +💇🏽 +💇🏾 +💇🏿 +💇 +💇🏻‍♀️ +💇🏼‍♀️ +💇🏽‍♀️ +💇🏾‍♀️ +💇🏿‍♀️ +💇‍♀️ +💇🏻‍♂️ +💇🏼‍♂️ +💇🏽‍♂️ +💇🏾‍♂️ +💇🏿‍♂️ +💇‍♂️ +💆🏻 +💆🏼 +💆🏽 +💆🏾 +💆🏿 +💆 +💆🏻‍♀️ +💆🏼‍♀️ +💆🏽‍♀️ +💆🏾‍♀️ +💆🏿‍♀️ +💆‍♀️ +💆🏻‍♂️ +💆🏼‍♂️ +💆🏽‍♂️ +💆🏾‍♂️ +💆🏿‍♂️ +💆‍♂️ +🧖🏻 +🧖🏼 +🧖🏽 +🧖🏾 +🧖🏿 +🧖 +🧖🏻‍♀️ +🧖🏼‍♀️ +🧖🏽‍♀️ +🧖🏾‍♀️ +🧖🏿‍♀️ +🧖‍♀️ +🧖🏻‍♂️ +🧖🏼‍♂️ +🧖🏽‍♂️ +🧖🏾‍♂️ +🧖🏿‍♂️ +🧖‍♂️ +💅🏻 +💅🏼 +💅🏽 +💅🏾 +💅🏿 +💅 +🤳🏻 +🤳🏼 +🤳🏽 +🤳🏾 +🤳🏿 +🤳 +💃🏻 +💃🏼 +💃🏽 +💃🏾 +💃🏿 +💃 +🕺🏻 +🕺🏼 +🕺🏽 +🕺🏿 +🕺🏾 +🕺 +👯 +👯‍♀️ +👯‍♂️ +🕴🏻 +🕴🏼 +🕴🏽 +🕴🏾 +🕴🏿 +🕴️ +🧑🏻‍🦽 +🧑🏼‍🦽 +🧑🏽‍🦽 +🧑🏾‍🦽 +🧑🏿‍🦽 +🧑‍🦽 +👩🏻‍🦽 +👩🏼‍🦽 +👩🏽‍🦽 +👩🏾‍🦽 +👩🏿‍🦽 +👩‍🦽 +👨🏻‍🦽 +👨🏼‍🦽 +👨🏽‍🦽 +👨🏾‍🦽 +👨🏿‍🦽 +👨‍🦽 +🧑🏻‍🦽‍➡️ +🧑🏼‍🦽‍➡️ +🧑🏽‍🦽‍➡️ +🧑🏾‍🦽‍➡️ +🧑🏿‍🦽‍➡️ +🧑‍🦽‍➡️ +👨🏼‍🦽‍➡️ +👨🏻‍🦽‍➡️ +👨🏽‍🦽‍➡️ +👨🏾‍🦽‍➡️ +👨🏿‍🦽‍➡️ +👨‍🦽‍➡️ +👩🏻‍🦽‍➡️ +👩🏼‍🦽‍➡️ +👩🏽‍🦽‍➡️ +👩🏾‍🦽‍➡️ +👩🏿‍🦽‍➡️ +👩‍🦽‍➡️ +🧑🏻‍🦼 +🧑🏼‍🦼 +🧑🏽‍🦼 +🧑🏾‍🦼 +🧑🏿‍🦼 +🧑‍🦼 +👩🏻‍🦼 +👩🏼‍🦼 +👩🏽‍🦼 +👩🏾‍🦼 +👩🏿‍🦼 +👩‍🦼 +👨🏻‍🦼 +👨🏼‍🦼 +👨🏽‍🦼 +👨🏾‍🦼 +👨🏿‍🦼 +👨‍🦼 +🧑🏻‍🦼‍➡️ +🧑🏼‍🦼‍➡️ +🧑🏽‍🦼‍➡️ +🧑🏾‍🦼‍➡️ +🧑🏿‍🦼‍➡️ +🧑‍🦼‍➡️ +👨🏻‍🦼‍➡️ +👨🏼‍🦼‍➡️ +👨🏽‍🦼‍➡️ +👨🏾‍🦼‍➡️ +👨🏿‍🦼‍➡️ +👨‍🦼‍➡️ +👩🏻‍🦼‍➡️ +👩🏼‍🦼‍➡️ +👩🏽‍🦼‍➡️ +👩🏾‍🦼‍➡️ +👩🏿‍🦼‍➡️ +👩‍🦼‍➡️ +🚶🏻 +🚶🏼 +🚶🏽 +🚶🏾 +🚶🏿 +🚶 +🚶🏻‍♀️ +🚶🏼‍♀️ +🚶🏽‍♀️ +🚶🏾‍♀️ +🚶🏿‍♀️ +🚶‍♀️ +🚶🏻‍♂️ +🚶🏼‍♂️ +🚶🏽‍♂️ +🚶🏾‍♂️ +🚶🏿‍♂️ +🚶‍♂️ +🚶🏻‍➡️ +🚶🏼‍➡️ +🚶🏽‍➡️ +🚶🏾‍➡️ +🚶🏿‍➡️ +🚶‍➡️ +🚶🏻‍♀️‍➡️ +🚶🏼‍♀️‍➡️ +🚶🏽‍♀️‍➡️ +🚶🏾‍♀️‍➡️ +🚶🏿‍♀️‍➡️ +🚶‍♀️‍➡️ +🚶🏻‍♂️‍➡️ +🚶🏼‍♂️‍➡️ +🚶🏽‍♂️‍➡️ +🚶🏾‍♂️‍➡️ +🚶🏿‍♂️‍➡️ +🚶‍♂️‍➡️ +🧑🏻‍🦯 +🧑🏼‍🦯 +🧑🏽‍🦯 +🧑🏾‍🦯 +🧑🏿‍🦯 +🧑‍🦯 +👩🏻‍🦯 +👩🏼‍🦯 +👩🏽‍🦯 +👩🏾‍🦯 +👩🏿‍🦯 +👩‍🦯 +👨🏻‍🦯 +👨🏼‍🦯 +👨🏽‍🦯 +👨🏾‍🦯 +👨🏿‍🦯 +👨‍🦯 +🧑🏻‍🦯‍➡️ +🧑🏼‍🦯‍➡️ +🧑🏽‍🦯‍➡️ +🧑🏾‍🦯‍➡️ +🧑🏿‍🦯‍➡️ +🧑‍🦯‍➡️ +👨🏻‍🦯‍➡️ +👨🏼‍🦯‍➡️ +👨🏽‍🦯‍➡️ +👨🏾‍🦯‍➡️ +👨🏿‍🦯‍➡️ +👨‍🦯‍➡️ +👩🏻‍🦯‍➡️ +👩🏼‍🦯‍➡️ +👩🏽‍🦯‍➡️ +👩🏾‍🦯‍➡️ +👩🏿‍🦯‍➡️ +👩‍🦯‍➡️ +🧎🏻 +🧎🏼 +🧎🏽 +🧎🏾 +🧎🏿 +🧎 +🧎🏻‍♀️ +🧎🏼‍♀️ +🧎🏽‍♀️ +🧎🏾‍♀️ +🧎🏿‍♀️ +🧎‍♀️ +🧎🏻‍♂️ +🧎🏼‍♂️ +🧎🏽‍♂️ +🧎🏾‍♂️ +🧎🏿‍♂️ +🧎‍♂️ +🧎🏻‍➡️ +🧎🏼‍➡️ +🧎🏽‍➡️ +🧎🏾‍➡️ +🧎🏿‍➡️ +🧎‍➡️ +🧎🏻‍♀️‍➡️ +🧎🏼‍♀️‍➡️ +🧎🏽‍♀️‍➡️ +🧎🏾‍♀️‍➡️ +🧎🏿‍♀️‍➡️ +🧎‍♀️‍➡️ +🧎🏻‍♂️‍➡️ +🧎🏼‍♂️‍➡️ +🧎🏽‍♂️‍➡️ +🧎🏾‍♂️‍➡️ +🧎🏿‍♂️‍➡️ +🧎‍♂️‍➡️ +🏃🏻 +🏃🏼 +🏃🏽 +🏃🏾 +🏃🏿 +🏃 +🏃🏻‍♀️ +🏃🏼‍♀️ +🏃🏽‍♀️ +🏃🏾‍♀️ +🏃🏿‍♀️ +🏃‍♀️ +🏃🏻‍♂️ +🏃🏼‍♂️ +🏃🏽‍♂️ +🏃🏾‍♂️ +🏃🏿‍♂️ +🏃‍♂️ +🏃🏻‍➡️ +🏃🏼‍➡️ +🏃🏽‍➡️ +🏃🏾‍➡️ +🏃🏿‍➡️ +🏃‍➡️ +🏃🏻‍♀️‍➡️ +🏃🏼‍♀️‍➡️ +🏃🏽‍♀️‍➡️ +🏃🏾‍♀️‍➡️ +🏃🏿‍♀️‍➡️ +🏃‍♀️‍➡️ +🏃🏻‍♂️‍➡️ +🏃🏼‍♂️‍➡️ +🏃🏽‍♂️‍➡️ +🏃🏾‍♂️‍➡️ +🏃🏿‍♂️‍➡️ +🏃‍♂️‍➡️ +🧍🏻 +🧍🏼 +🧍🏽 +🧍🏾 +🧍🏿 +🧍 +🧍🏻‍♀️ +🧍🏼‍♀️ +🧍🏽‍♀️ +🧍🏾‍♀️ +🧍🏿‍♀️ +🧍‍♀️ +🧍🏻‍♂️ +🧍🏼‍♂️ +🧍🏽‍♂️ +🧍🏾‍♂️ +🧍🏿‍♂️ +🧍‍♂️ +🧑🏻‍🤝‍🧑🏻 +🧑🏻‍🤝‍🧑🏼 +🧑🏻‍🤝‍🧑🏽 +🧑🏻‍🤝‍🧑🏾 +🧑🏻‍🤝‍🧑🏿 +🧑🏼‍🤝‍🧑🏻 +🧑🏼‍🤝‍🧑🏼 +🧑🏼‍🤝‍🧑🏽 +🧑🏼‍🤝‍🧑🏾 +🧑🏼‍🤝‍🧑🏿 +🧑🏽‍🤝‍🧑🏻 +🧑🏽‍🤝‍🧑🏼 +🧑🏽‍🤝‍🧑🏽 +🧑🏽‍🤝‍🧑🏾 +🧑🏽‍🤝‍🧑🏿 +🧑🏾‍🤝‍🧑🏻 +🧑🏾‍🤝‍🧑🏼 +🧑🏾‍🤝‍🧑🏽 +🧑🏾‍🤝‍🧑🏾 +🧑🏾‍🤝‍🧑🏿 +🧑🏿‍🤝‍🧑🏻 +🧑🏿‍🤝‍🧑🏼 +🧑🏿‍🤝‍🧑🏽 +🧑🏿‍🤝‍🧑🏾 +🧑🏿‍🤝‍🧑🏿 +🧑‍🤝‍🧑 +👫🏻 +👩🏻‍🤝‍👨🏼 +👩🏻‍🤝‍👨🏽 +👩🏻‍🤝‍👨🏾 +👩🏻‍🤝‍👨🏿 +👩🏼‍🤝‍👨🏻 +👫🏼 +👩🏼‍🤝‍👨🏽 +👩🏼‍🤝‍👨🏾 +👩🏼‍🤝‍👨🏿 +👩🏽‍🤝‍👨🏻 +👩🏽‍🤝‍👨🏼 +👫🏽 +👩🏽‍🤝‍👨🏾 +👩🏽‍🤝‍👨🏿 +👩🏾‍🤝‍👨🏻 +👩🏾‍🤝‍👨🏼 +👩🏾‍🤝‍👨🏽 +👫🏾 +👩🏾‍🤝‍👨🏿 +👩🏿‍🤝‍👨🏻 +👩🏿‍🤝‍👨🏼 +👩🏿‍🤝‍👨🏽 +👩🏿‍🤝‍👨🏾 +👫🏿 +👫 +👭🏻 +👩🏻‍🤝‍👩🏼 +👩🏻‍🤝‍👩🏽 +👩🏻‍🤝‍👩🏾 +👩🏻‍🤝‍👩🏿 +👩🏼‍🤝‍👩🏻 +👭🏼 +👩🏼‍🤝‍👩🏽 +👩🏼‍🤝‍👩🏾 +👩🏼‍🤝‍👩🏿 +👩🏽‍🤝‍👩🏻 +👩🏽‍🤝‍👩🏼 +👭🏽 +👩🏽‍🤝‍👩🏾 +👩🏽‍🤝‍👩🏿 +👩🏾‍🤝‍👩🏻 +👩🏾‍🤝‍👩🏼 +👩🏾‍🤝‍👩🏽 +👭🏾 +👩🏾‍🤝‍👩🏿 +👩🏿‍🤝‍👩🏻 +👩🏿‍🤝‍👩🏼 +👩🏿‍🤝‍👩🏽 +👩🏿‍🤝‍👩🏾 +👭🏿 +👭 +👬🏻 +👨🏻‍🤝‍👨🏼 +👨🏻‍🤝‍👨🏽 +👨🏻‍🤝‍👨🏾 +👨🏻‍🤝‍👨🏿 +👨🏼‍🤝‍👨🏻 +👬🏼 +👨🏼‍🤝‍👨🏽 +👨🏼‍🤝‍👨🏾 +👨🏼‍🤝‍👨🏿 +👨🏽‍🤝‍👨🏻 +👨🏽‍🤝‍👨🏼 +👬🏽 +👨🏽‍🤝‍👨🏾 +👨🏽‍🤝‍👨🏿 +👨🏾‍🤝‍👨🏻 +👨🏾‍🤝‍👨🏼 +👨🏾‍🤝‍👨🏽 +👬🏾 +👨🏾‍🤝‍👨🏿 +👨🏿‍🤝‍👨🏻 +👨🏿‍🤝‍👨🏼 +👨🏿‍🤝‍👨🏽 +👨🏿‍🤝‍👨🏾 +👬🏿 +👬 +💑🏻 +🧑🏻‍❤️‍🧑🏼 +🧑🏻‍❤️‍🧑🏽 +🧑🏻‍❤️‍🧑🏾 +🧑🏻‍❤️‍🧑🏿 +🧑🏼‍❤️‍🧑🏻 +💑🏼 +🧑🏼‍❤️‍🧑🏽 +🧑🏼‍❤️‍🧑🏾 +🧑🏼‍❤️‍🧑🏿 +🧑🏽‍❤️‍🧑🏻 +🧑🏽‍❤️‍🧑🏼 +💑🏽 +🧑🏽‍❤️‍🧑🏾 +🧑🏽‍❤️‍🧑🏿 +🧑🏾‍❤️‍🧑🏻 +🧑🏾‍❤️‍🧑🏼 +🧑🏾‍❤️‍🧑🏽 +💑🏾 +🧑🏾‍❤️‍🧑🏿 +🧑🏿‍❤️‍🧑🏻 +🧑🏿‍❤️‍🧑🏼 +🧑🏿‍❤️‍🧑🏽 +🧑🏿‍❤️‍🧑🏾 +💑🏿 +💑 +👩🏻‍❤️‍👨🏻 +👩🏻‍❤️‍👨🏼 +👩🏻‍❤️‍👨🏽 +👩🏻‍❤️‍👨🏾 +👩🏻‍❤️‍👨🏿 +👩🏼‍❤️‍👨🏻 +👩🏼‍❤️‍👨🏼 +👩🏼‍❤️‍👨🏽 +👩🏼‍❤️‍👨🏾 +👩🏼‍❤️‍👨🏿 +👩🏽‍❤️‍👨🏻 +👩🏽‍❤️‍👨🏼 +👩🏽‍❤️‍👨🏽 +👩🏽‍❤️‍👨🏾 +👩🏽‍❤️‍👨🏿 +👩🏾‍❤️‍👨🏻 +👩🏾‍❤️‍👨🏼 +👩🏾‍❤️‍👨🏽 +👩🏾‍❤️‍👨🏾 +👩🏾‍❤️‍👨🏿 +👩🏿‍❤️‍👨🏻 +👩🏿‍❤️‍👨🏼 +👩🏿‍❤️‍👨🏽 +👩🏿‍❤️‍👨🏾 +👩🏿‍❤️‍👨🏿 +👩‍❤️‍👨 +👩🏻‍❤️‍👩🏻 +👩🏻‍❤️‍👩🏼 +👩🏻‍❤️‍👩🏽 +👩🏻‍❤️‍👩🏾 +👩🏻‍❤️‍👩🏿 +👩🏼‍❤️‍👩🏻 +👩🏼‍❤️‍👩🏼 +👩🏼‍❤️‍👩🏽 +👩🏼‍❤️‍👩🏾 +👩🏼‍❤️‍👩🏿 +👩🏽‍❤️‍👩🏻 +👩🏽‍❤️‍👩🏼 +👩🏽‍❤️‍👩🏽 +👩🏽‍❤️‍👩🏾 +👩🏽‍❤️‍👩🏿 +👩🏾‍❤️‍👩🏻 +👩🏾‍❤️‍👩🏼 +👩🏾‍❤️‍👩🏽 +👩🏾‍❤️‍👩🏾 +👩🏾‍❤️‍👩🏿 +👩🏿‍❤️‍👩🏻 +👩🏿‍❤️‍👩🏼 +👩🏿‍❤️‍👩🏽 +👩🏿‍❤️‍👩🏾 +👩🏿‍❤️‍👩🏿 +👩‍❤️‍👩 +👨🏻‍❤️‍👨🏻 +👨🏻‍❤️‍👨🏼 +👨🏻‍❤️‍👨🏽 +👨🏻‍❤️‍👨🏾 +👨🏻‍❤️‍👨🏿 +👨🏼‍❤️‍👨🏻 +👨🏼‍❤️‍👨🏼 +👨🏼‍❤️‍👨🏽 +👨🏼‍❤️‍👨🏾 +👨🏼‍❤️‍👨🏿 +👨🏽‍❤️‍👨🏻 +👨🏽‍❤️‍👨🏼 +👨🏽‍❤️‍👨🏽 +👨🏽‍❤️‍👨🏾 +👨🏽‍❤️‍👨🏿 +👨🏾‍❤️‍👨🏻 +👨🏾‍❤️‍👨🏼 +👨🏾‍❤️‍👨🏽 +👨🏾‍❤️‍👨🏾 +👨🏾‍❤️‍👨🏿 +👨🏿‍❤️‍👨🏻 +👨🏿‍❤️‍👨🏼 +👨🏿‍❤️‍👨🏽 +👨🏿‍❤️‍👨🏾 +👨🏿‍❤️‍👨🏿 +👨‍❤️‍👨 +💏🏻 +🧑🏻‍❤️‍💋‍🧑🏼 +🧑🏻‍❤️‍💋‍🧑🏽 +🧑🏻‍❤️‍💋‍🧑🏾 +🧑🏻‍❤️‍💋‍🧑🏿 +🧑🏼‍❤️‍💋‍🧑🏻 +💏🏼 +🧑🏼‍❤️‍💋‍🧑🏽 +🧑🏼‍❤️‍💋‍🧑🏾 +🧑🏼‍❤️‍💋‍🧑🏿 +🧑🏽‍❤️‍💋‍🧑🏻 +🧑🏽‍❤️‍💋‍🧑🏼 +💏🏽 +🧑🏽‍❤️‍💋‍🧑🏾 +🧑🏽‍❤️‍💋‍🧑🏿 +🧑🏾‍❤️‍💋‍🧑🏻 +🧑🏾‍❤️‍💋‍🧑🏼 +🧑🏾‍❤️‍💋‍🧑🏽 +💏🏾 +🧑🏾‍❤️‍💋‍🧑🏿 +🧑🏿‍❤️‍💋‍🧑🏻 +🧑🏿‍❤️‍💋‍🧑🏼 +🧑🏿‍❤️‍💋‍🧑🏽 +🧑🏿‍❤️‍💋‍🧑🏾 +💏🏿 +💏 +👩🏻‍❤️‍💋‍👨🏻 +👩🏻‍❤️‍💋‍👨🏼 +👩🏻‍❤️‍💋‍👨🏽 +👩🏻‍❤️‍💋‍👨🏾 +👩🏻‍❤️‍💋‍👨🏿 +👩🏼‍❤️‍💋‍👨🏻 +👩🏼‍❤️‍💋‍👨🏼 +👩🏼‍❤️‍💋‍👨🏽 +👩🏼‍❤️‍💋‍👨🏾 +👩🏼‍❤️‍💋‍👨🏿 +👩🏽‍❤️‍💋‍👨🏻 +👩🏽‍❤️‍💋‍👨🏼 +👩🏽‍❤️‍💋‍👨🏽 +👩🏽‍❤️‍💋‍👨🏾 +👩🏽‍❤️‍💋‍👨🏿 +👩🏾‍❤️‍💋‍👨🏻 +👩🏾‍❤️‍💋‍👨🏼 +👩🏾‍❤️‍💋‍👨🏽 +👩🏾‍❤️‍💋‍👨🏾 +👩🏾‍❤️‍💋‍👨🏿 +👩🏿‍❤️‍💋‍👨🏻 +👩🏿‍❤️‍💋‍👨🏼 +👩🏿‍❤️‍💋‍👨🏽 +👩🏿‍❤️‍💋‍👨🏾 +👩🏿‍❤️‍💋‍👨🏿 +👩‍❤️‍💋‍👨 +👩🏻‍❤️‍💋‍👩🏻 +👩🏻‍❤️‍💋‍👩🏼 +👩🏻‍❤️‍💋‍👩🏽 +👩🏻‍❤️‍💋‍👩🏾 +👩🏻‍❤️‍💋‍👩🏿 +👩🏼‍❤️‍💋‍👩🏻 +👩🏼‍❤️‍💋‍👩🏼 +👩🏼‍❤️‍💋‍👩🏽 +👩🏼‍❤️‍💋‍👩🏾 +👩🏼‍❤️‍💋‍👩🏿 +👩🏽‍❤️‍💋‍👩🏻 +👩🏽‍❤️‍💋‍👩🏼 +👩🏽‍❤️‍💋‍👩🏽 +👩🏽‍❤️‍💋‍👩🏾 +👩🏽‍❤️‍💋‍👩🏿 +👩🏾‍❤️‍💋‍👩🏻 +👩🏾‍❤️‍💋‍👩🏼 +👩🏾‍❤️‍💋‍👩🏽 +👩🏾‍❤️‍💋‍👩🏾 +👩🏾‍❤️‍💋‍👩🏿 +👩🏿‍❤️‍💋‍👩🏻 +👩🏿‍❤️‍💋‍👩🏼 +👩🏿‍❤️‍💋‍👩🏽 +👩🏿‍❤️‍💋‍👩🏾 +👩🏿‍❤️‍💋‍👩🏿 +👩‍❤️‍💋‍👩 +👨🏻‍❤️‍💋‍👨🏻 +👨🏻‍❤️‍💋‍👨🏼 +👨🏻‍❤️‍💋‍👨🏽 +👨🏻‍❤️‍💋‍👨🏾 +👨🏻‍❤️‍💋‍👨🏿 +👨🏼‍❤️‍💋‍👨🏻 +👨🏼‍❤️‍💋‍👨🏼 +👨🏼‍❤️‍💋‍👨🏽 +👨🏼‍❤️‍💋‍👨🏾 +👨🏼‍❤️‍💋‍👨🏿 +👨🏽‍❤️‍💋‍👨🏻 +👨🏽‍❤️‍💋‍👨🏼 +👨🏽‍❤️‍💋‍👨🏽 +👨🏽‍❤️‍💋‍👨🏾 +👨🏽‍❤️‍💋‍👨🏿 +👨🏾‍❤️‍💋‍👨🏻 +👨🏾‍❤️‍💋‍👨🏼 +👨🏾‍❤️‍💋‍👨🏽 +👨🏾‍❤️‍💋‍👨🏾 +👨🏾‍❤️‍💋‍👨🏿 +👨🏿‍❤️‍💋‍👨🏻 +👨🏿‍❤️‍💋‍👨🏼 +👨🏿‍❤️‍💋‍👨🏽 +👨🏿‍❤️‍💋‍👨🏾 +👨🏿‍❤️‍💋‍👨🏿 +👨‍❤️‍💋‍👨 +🧑‍🧑‍🧒‍🧒 +🧑‍🧑‍🧒 +🧑‍🧒‍🧒 +🧑‍🧒 +👪 +👨‍👩‍👦 +👨‍👩‍👧 +👨‍👩‍👧‍👦 +👨‍👩‍👦‍👦 +👨‍👩‍👧‍👧 +👩‍👩‍👦 +👩‍👩‍👧 +👩‍👩‍👧‍👦 +👩‍👩‍👦‍👦 +👩‍👩‍👧‍👧 +👨‍👨‍👦 +👨‍👨‍👧 +👨‍👨‍👧‍👦 +👨‍👨‍👦‍👦 +👨‍👨‍👧‍👧 +👩‍👦 +👩‍👧 +👩‍👧‍👦 +👩‍👦‍👦 +👩‍👧‍👧 +👨‍👦 +👨‍👧 +👨‍👧‍👦 +👨‍👦‍👦 +👨‍👧‍👧 +🪢 +🧶 +🧵 +🪡 +🧥 +🥼 +🦺 +👚 +👕 +👖 +🩲 +🩳 +👔 +👗 +👙 +🩱 +👘 +🥻 +🩴 +🥿 +👠 +👡 +👢 +👞 +👟 +🥾 +🧦 +🧤 +🧣 +🎩 +🧢 +👒 +🎓 +⛑️ +🪖 +👑 +💍 +👝 +👛 +👜 +💼 +🎒 +🧳 +👓 +🕶️ +🥽 +🌂 +🐶 +🐱 +🐭 +🐹 +🐰 +🦊 +🐻 +🐼 +🐻‍❄️ +🐨 +🐯 +🦁 +🐮 +🐷 +🐽 +🐸 +🐵 +🙈 +🙉 +🙊 +🐒 +🐔 +🐧 +🐦 +🐤 +🐣 +🐥 +🪿 +🦆 +🐦‍⬛ +🦅 +🦉 +🦇 +🐺 +🐗 +🐴 +🦄 +🫎 +🐝 +🪱 +🐛 +🦋 +🐌 +🐞 +🐜 +🪰 +🪲 +🪳 +🦟 +🦗 +🕷️ +🕸️ +🦂 +🐢 +🐍 +🦎 +🦖 +🦕 +🐙 +🦑 +🪼 +🦐 +🦞 +🦀 +🐡 +🐠 +🐟 +🐬 +🐳 +🐋 +🦈 +🦭 +🐊 +🐅 +🐆 +🦓 +🦍 +🦧 +🦣 +🐘 +🦛 +🦏 +🐪 +🐫 +🦒 +🦘 +🦬 +🐃 +🐂 +🐄 +🫏 +🐎 +🐖 +🐏 +🐑 +🦙 +🐐 +🦌 +🐕 +🐩 +🦮 +🐕‍🦺 +🐈 +🐈‍⬛ +🪶 +🪽 +🐓 +🦃 +🦤 +🦚 +🦜 +🦢 +🦩 +🕊️ +🐇 +🦝 +🦨 +🦡 +🦫 +🦦 +🦥 +🐁 +🐀 +🐿️ +🦔 +🐾 +🐉 +🐲 +🐦‍🔥 +🌵 +🎄 +🌲 +🌳 +🌴 +🪵 +🌱 +🌿 +☘️ +🍀 +🎍 +🪴 +🎋 +🍃 +🍂 +🍁 +🪺 +🪹 +🍄 +🍄‍🟫 +🐚 +🪸 +🪨 +🌾 +💐 +🌷 +🌹 +🥀 +🪻 +🪷 +🌺 +🌸 +🌼 +🌻 +🌞 +🌝 +🌛 +🌜 +🌚 +🌕 +🌖 +🌗 +🌘 +🌑 +🌒 +🌓 +🌔 +🌙 +🌎 +🌍 +🌏 +🪐 +💫 +⭐ +🌟 +✨ +⚡ +☄️ +💥 +🔥 +🌪️ +🌈 +☀️ +🌤️ +⛅ +🌥️ +☁️ +🌦️ +🌧️ +⛈️ +🌩️ +🌨️ +❄️ +☃️ +⛄ +🌬️ +💨 +💧 +💦 +🫧 +☔ +☂️ +🌊 +🌫️ +🍏 +🍎 +🍐 +🍊 +🍋 +🍋‍🟩 +🍌 +🍉 +🍇 +🍓 +🫐 +🍈 +🍒 +🍑 +🥭 +🍍 +🥥 +🥝 +🍅 +🍆 +🥑 +🫛 +🥦 +🥬 +🥒 +🌶️ +🫑 +🌽 +🥕 +🫒 +🧄 +🧅 +🥔 +🍠 +🫚 +🥐 +🥯 +🍞 +🥖 +🥨 +🧀 +🥚 +🍳 +🧈 +🥞 +🧇 +🥓 +🥩 +🍗 +🍖 +🦴 +🌭 +🍔 +🍟 +🍕 +🫓 +🥪 +🥙 +🧆 +🌮 +🌯 +🫔 +🥗 +🥘 +🫕 +🥫 +🫙 +🍝 +🍜 +🍲 +🍛 +🍣 +🍱 +🥟 +🦪 +🍤 +🍙 +🍚 +🍘 +🍥 +🥠 +🥮 +🍢 +🍡 +🍧 +🍨 +🍦 +🥧 +🧁 +🍰 +🎂 +🍮 +🍭 +🍬 +🍫 +🍿 +🍩 +🍪 +🌰 +🥜 +🫘 +🍯 +🥛 +🫗 +🍼 +🫖 +☕ +🍵 +🧉 +🧃 +🥤 +🧋 +🍶 +🍺 +🍻 +🥂 +🍷 +🥃 +🍸 +🍹 +🍾 +🧊 +🥄 +🍴 +🍽️ +🥣 +🥡 +🥢 +🧂 +⚽ +🏀 +🏈 +⚾ +🥎 +🎾 +🏐 +🏉 +🥏 +🎱 +🪀 +🏓 +🏸 +🏒 +🏑 +🥍 +🏏 +🪃 +🥅 +⛳ +🪁 +🛝 +🏹 +🎣 +🤿 +🥊 +🥋 +🎽 +🛹 +🛼 +🛷 +⛸️ +🥌 +🎿 +⛷️ +🏂🏻 +🏂🏼 +🏂🏽 +🏂🏾 +🏂🏿 +🏂 +🪂 +🏋🏻 +🏋🏼 +🏋🏽 +🏋🏾 +🏋🏿 +🏋️ +🏋🏻‍♀️ +🏋🏼‍♀️ +🏋🏽‍♀️ +🏋🏾‍♀️ +🏋🏿‍♀️ +🏋️‍♀️ +🏋🏻‍♂️ +🏋🏼‍♂️ +🏋🏽‍♂️ +🏋🏾‍♂️ +🏋🏿‍♂️ +🏋️‍♂️ +🤼 +🤼‍♀️ +🤼‍♂️ +🤸🏻 +🤸🏼 +🤸🏽 +🤸🏾 +🤸🏿 +🤸 +🤸🏻‍♀️ +🤸🏼‍♀️ +🤸🏽‍♀️ +🤸🏾‍♀️ +🤸🏿‍♀️ +🤸‍♀️ +🤸🏻‍♂️ +🤸🏼‍♂️ +🤸🏽‍♂️ +🤸🏾‍♂️ +🤸🏿‍♂️ +🤸‍♂️ +⛹🏻 +⛹🏼 +⛹🏽 +⛹🏾 +⛹🏿 +⛹️ +⛹🏻‍♀️ +⛹🏼‍♀️ +⛹🏽‍♀️ +⛹🏾‍♀️ +⛹🏿‍♀️ +⛹️‍♀️ +⛹🏻‍♂️ +⛹🏼‍♂️ +⛹🏽‍♂️ +⛹🏾‍♂️ +⛹🏿‍♂️ +⛹️‍♂️ +🤺 +🤾🏻 +🤾🏼 +🤾🏽 +🤾🏾 +🤾🏿 +🤾 +🤾🏻‍♀️ +🤾🏼‍♀️ +🤾🏽‍♀️ +🤾🏾‍♀️ +🤾🏿‍♀️ +🤾‍♀️ +🤾🏻‍♂️ +🤾🏼‍♂️ +🤾🏽‍♂️ +🤾🏾‍♂️ +🤾🏿‍♂️ +🤾‍♂️ +🏌🏻 +🏌🏼 +🏌🏽 +🏌🏾 +🏌🏿 +🏌️ +🏌🏻‍♀️ +🏌🏼‍♀️ +🏌🏽‍♀️ +🏌🏾‍♀️ +🏌🏿‍♀️ +🏌️‍♀️ +🏌🏻‍♂️ +🏌🏼‍♂️ +🏌🏽‍♂️ +🏌🏾‍♂️ +🏌🏿‍♂️ +🏌️‍♂️ +🏇🏻 +🏇🏼 +🏇🏽 +🏇🏾 +🏇🏿 +🏇 +🧘🏻 +🧘🏼 +🧘🏽 +🧘🏾 +🧘🏿 +🧘 +🧘🏻‍♀️ +🧘🏼‍♀️ +🧘🏽‍♀️ +🧘🏾‍♀️ +🧘🏿‍♀️ +🧘‍♀️ +🧘🏻‍♂️ +🧘🏼‍♂️ +🧘🏽‍♂️ +🧘🏾‍♂️ +🧘🏿‍♂️ +🧘‍♂️ +🏄🏻 +🏄🏼 +🏄🏽 +🏄🏾 +🏄🏿 +🏄 +🏄🏻‍♀️ +🏄🏼‍♀️ +🏄🏽‍♀️ +🏄🏾‍♀️ +🏄🏿‍♀️ +🏄‍♀️ +🏄🏻‍♂️ +🏄🏼‍♂️ +🏄🏽‍♂️ +🏄🏾‍♂️ +🏄🏿‍♂️ +🏄‍♂️ +🏊🏻 +🏊🏼 +🏊🏽 +🏊🏾 +🏊🏿 +🏊 +🏊🏻‍♀️ +🏊🏼‍♀️ +🏊🏽‍♀️ +🏊🏾‍♀️ +🏊🏿‍♀️ +🏊‍♀️ +🏊🏻‍♂️ +🏊🏼‍♂️ +🏊🏽‍♂️ +🏊🏾‍♂️ +🏊🏿‍♂️ +🏊‍♂️ +🤽🏻 +🤽🏼 +🤽🏽 +🤽🏾 +🤽🏿 +🤽 +🤽🏻‍♀️ +🤽🏼‍♀️ +🤽🏽‍♀️ +🤽🏾‍♀️ +🤽🏿‍♀️ +🤽‍♀️ +🤽🏻‍♂️ +🤽🏼‍♂️ +🤽🏽‍♂️ +🤽🏾‍♂️ +🤽🏿‍♂️ +🤽‍♂️ +🚣🏻 +🚣🏼 +🚣🏽 +🚣🏾 +🚣🏿 +🚣 +🚣🏻‍♀️ +🚣🏼‍♀️ +🚣🏽‍♀️ +🚣🏾‍♀️ +🚣🏿‍♀️ +🚣‍♀️ +🚣🏻‍♂️ +🚣🏼‍♂️ +🚣🏽‍♂️ +🚣🏾‍♂️ +🚣🏿‍♂️ +🚣‍♂️ +🧗🏻 +🧗🏼 +🧗🏽 +🧗🏾 +🧗🏿 +🧗 +🧗🏻‍♀️ +🧗🏼‍♀️ +🧗🏽‍♀️ +🧗🏾‍♀️ +🧗🏿‍♀️ +🧗‍♀️ +🧗🏻‍♂️ +🧗🏼‍♂️ +🧗🏽‍♂️ +🧗🏾‍♂️ +🧗🏿‍♂️ +🧗‍♂️ +🚵🏻 +🚵🏼 +🚵🏽 +🚵🏾 +🚵🏿 +🚵 +🚵🏻‍♀️ +🚵🏼‍♀️ +🚵🏽‍♀️ +🚵🏾‍♀️ +🚵🏿‍♀️ +🚵‍♀️ +🚵🏻‍♂️ +🚵🏼‍♂️ +🚵🏽‍♂️ +🚵🏾‍♂️ +🚵🏿‍♂️ +🚵‍♂️ +🚴🏻 +🚴🏼 +🚴🏽 +🚴🏾 +🚴🏿 +🚴 +🚴🏻‍♀️ +🚴🏼‍♀️ +🚴🏽‍♀️ +🚴🏾‍♀️ +🚴🏿‍♀️ +🚴‍♀️ +🚴🏻‍♂️ +🚴🏼‍♂️ +🚴🏽‍♂️ +🚴🏾‍♂️ +🚴🏿‍♂️ +🚴‍♂️ +🏆 +🥇 +🥈 +🥉 +🏅 +🎖️ +🏵️ +🎗️ +🎫 +🎟️ +🎪 +🤹🏻 +🤹🏼 +🤹🏽 +🤹🏾 +🤹🏿 +🤹 +🤹🏻‍♀️ +🤹🏼‍♀️ +🤹🏽‍♀️ +🤹🏾‍♀️ +🤹🏿‍♀️ +🤹‍♀️ +🤹🏻‍♂️ +🤹🏼‍♂️ +🤹🏽‍♂️ +🤹🏾‍♂️ +🤹🏿‍♂️ +🤹‍♂️ +🎭 +🩰 +🎨 +🎬 +🎤 +🎧 +🎼 +🎹 +🪇 +🥁 +🪘 +🎷 +🎺 +🪗 +🎸 +🪕 +🎻 +🪈 +🎲 +♟️ +🎯 +🎳 +🎮 +🎰 +🧩 +🚗 +🚕 +🚙 +🛻 +🚐 +🚌 +🚎 +🏎️ +🚓 +🚑 +🚒 +🚚 +🚛 +🚜 +🦯 +🦽 +🦼 +🩼 +🛴 +🚲 +🛵 +🏍️ +🛺 +🛞 +🚨 +🚔 +🚍 +🚘 +🚖 +🚡 +🚠 +🚟 +🚃 +🚋 +🚞 +🚝 +🚄 +🚅 +🚈 +🚂 +🚆 +🚇 +🚊 +🚉 +✈️ +🛫 +🛬 +🛩️ +💺 +🛰️ +🚀 +🛸 +🚁 +🛶 +⛵ +🚤 +🛥️ +🛳️ +⛴️ +🚢 +🛟 +⚓ +🪝 +⛽ +🚧 +🚦 +🚥 +🚏 +🗺️ +🗿 +🗽 +🗼 +🏰 +🏯 +🏟️ +🎡 +🎢 +🎠 +⛲ +⛱️ +🏖️ +🏝️ +🏜️ +🌋 +⛰️ +🏔️ +🗻 +🏕️ +⛺ +🏠 +🏡 +🏘️ +🏚️ +🛖 +🏗️ +🏭 +🏢 +🏬 +🏣 +🏤 +🏥 +🏦 +🏨 +🏪 +🏫 +🏩 +💒 +🏛️ +⛪ +🕌 +🕍 +🛕 +🕋 +⛩️ +🛤️ +🛣️ +🗾 +🎑 +🏞️ +🌅 +🌄 +🌠 +🎇 +🎆 +🌇 +🌆 +🏙️ +🌃 +🌌 +🌉 +🌁 +⌚ +📱 +📲 +💻 +⌨️ +🖥️ +🖨️ +🖱️ +🖲️ +🕹️ +🗜️ +💽 +💾 +💿 +📀 +📼 +📷 +📸 +📹 +🎥 +📽️ +🎞️ +📞 +☎️ +📟 +📠 +📺 +📻 +🎙️ +🎚️ +🎛️ +🧭 +⏱️ +⏲️ +⏰ +🕰️ +⌛ +⏳ +📡 +🔋 +🪫 +🔌 +💡 +🔦 +🕯️ +🪔 +🧯 +🛢️ +💸 +💵 +💴 +💶 +💷 +🪙 +💰 +💳 +🪪 +💎 +⚖️ +🪜 +🧰 +🪛 +🔧 +🔨 +⚒️ +🛠️ +⛏️ +🪚 +🔩 +⚙️ +🪤 +🧱 +⛓️ +🔗 +⛓️‍💥 +🧲 +🔫 +💣 +🧨 +🪓 +🔪 +🗡️ +⚔️ +🛡️ +🚬 +⚰️ +🪦 +⚱️ +🏺 +🔮 +📿 +🧿 +🪬 +💈 +⚗️ +🔭 +🔬 +🕳️ +🩻 +🩹 +🩺 +💊 +💉 +🩸 +🧬 +🦠 +🧫 +🧪 +🌡️ +🧹 +🪠 +🧺 +🧻 +🚽 +🚰 +🚿 +🛁 +🛀🏻 +🛀🏼 +🛀🏽 +🛀🏾 +🛀🏿 +🛀 +🧼 +🪥 +🪒 +🪮 +🧽 +🪣 +🧴 +🛎️ +🔑 +🗝️ +🚪 +🪑 +🛋️ +🛏️ +🛌🏻 +🛌🏼 +🛌🏽 +🛌🏾 +🛌🏿 +🛌 +🧸 +🪆 +🖼️ +🪞 +🪟 +🛍️ +🛒 +🎁 +🎈 +🎏 +🎀 +🪄 +🪅 +🎊 +🎉 +🎎 +🪭 +🏮 +🎐 +🪩 +🧧 +✉️ +📩 +📨 +📧 +💌 +📥 +📤 +📦 +🏷️ +🪧 +📪 +📫 +📬 +📭 +📮 +📯 +📜 +📃 +📄 +📑 +🧾 +📊 +📈 +📉 +🗒️ +🗓️ +📆 +📅 +🗑️ +📇 +🗃️ +🗳️ +🗄️ +📋 +📁 +📂 +🗂️ +🗞️ +📰 +📓 +📔 +📒 +📕 +📗 +📘 +📙 +📚 +📖 +🔖 +🧷 +📎 +🖇️ +📐 +📏 +🧮 +📌 +📍 +✂️ +🖊️ +🖋️ +✒️ +🖌️ +🖍️ +📝 +✏️ +🔍 +🔎 +🔏 +🔐 +🔒 +🔓 +🩷 +❤️ +🧡 +💛 +💚 +🩵 +💙 +💜 +🖤 +🩶 +🤍 +🤎 +💔 +❣️ +💕 +💞 +💓 +💗 +💖 +💘 +💝 +❤️‍🩹 +❤️‍🔥 +💟 +☮️ +✝️ +☪️ +🕉️ +☸️ +🪯 +✡️ +🔯 +🕎 +☯️ +☦️ +🛐 +⛎ +♈ +♉ +♊ +♋ +♌ +♍ +♎ +♏ +♐ +♑ +♒ +♓ +🆔 +⚛️ +🉑 +☢️ +☣️ +📴 +📳 +🈶 +🈚 +🈸 +🈺 +🈷️ +✴️ +🆚 +💮 +🉐 +㊙️ +㊗️ +🈴 +🈵 +🈹 +🈲 +🅰️ +🅱️ +🆎 +🆑 +🅾️ +🆘 +❌ +⭕ +🛑 +⛔ +📛 +🚫 +💯 +💢 +♨️ +🚷 +🚯 +🚳 +🚱 +🔞 +📵 +🚭 +❗ +❕ +❓ +❔ +‼️ +⁉️ +🔅 +🔆 +〽️ +⚠️ +🚸 +🔱 +⚜️ +🔰 +♻️ +✅ +🈯 +💹 +❇️ +✳️ +❎ +🌐 +💠 +Ⓜ️ +🌀 +💤 +🏧 +🚾 +♿ +🅿️ +🛗 +🈳 +🈂️ +🛂 +🛃 +🛄 +🛅 +🛜 +🚹 +🚺 +🚼 +🚻 +🚮 +🎦 +📶 +🈁 +🔣 +ℹ️ +🔤 +🔡 +🔠 +🆖 +🆗 +🆙 +🆒 +🆕 +🆓 +0️⃣ +1️⃣ +2️⃣ +3️⃣ +4️⃣ +5️⃣ +6️⃣ +7️⃣ +8️⃣ +9️⃣ +🔟 +🔢 +#️⃣ +*️⃣ +⏏️ +▶️ +⏸️ +⏯️ +⏹️ +⏺️ +⏭️ +⏮️ +⏩ +⏪ +⏫ +⏬ +◀️ +🔼 +🔽 +➡️ +⬅️ +⬆️ +⬇️ +↗️ +↘️ +↙️ +↖️ +↕️ +↔️ +↪️ +↩️ +⤴️ +⤵️ +🔀 +🔁 +🔂 +🔄 +🔃 +🎵 +🎶 +➕ +➖ +➗ +✖️ +🟰 +♾️ +💲 +💱 +™️ +©️ +®️ +〰️ +➰ +➿ +🔚 +🔙 +🔛 +🔝 +🔜 +✔️ +☑️ +🔘 +⚪ +⚫ +🔴 +🔵 +🟤 +🟣 +🟢 +🟡 +🟠 +🔺 +🔻 +🔸 +🔹 +🔶 +🔷 +🔳 +🔲 +▪️ +▫️ +◾ +◽ +◼️ +◻️ +⬛ +⬜ +🟧 +🟦 +🟥 +🟫 +🟪 +🟩 +🟨 +🔈 +🔇 +🔉 +🔊 +🔔 +🔕 +📣 +📢 +🗨️ +👁‍🗨 +💬 +💭 +🗯️ +♠️ +♣️ +♥️ +♦️ +🃏 +🎴 +🀄 +🕐 +🕑 +🕒 +🕓 +🕔 +🕕 +🕖 +🕗 +🕘 +🕙 +🕚 +🕛 +🕜 +🕝 +🕞 +🕟 +🕠 +🕡 +🕢 +🕣 +🕤 +🕥 +🕦 +🕧 +♀️ +♂️ +⚧ +⚕️ +🇿 +🇾 +🇽 +🇼 +🇻 +🇺 +🇹 +🇸 +🇷 +🇶 +🇵 +🇴 +🇳 +🇲 +🇱 +🇰 +🇯 +🇮 +🇭 +🇬 +🇫 +🇪 +🇩 +🇨 +🇧 +🇦 +🏳️ +🏴 +🏴‍☠️ +🏁 +🚩 +🏳️‍🌈 +🏳️‍⚧️ +🇺🇳 +🇦🇫 +🇦🇽 +🇦🇱 +🇩🇿 +🇦🇸 +🇦🇩 +🇦🇴 +🇦🇮 +🇦🇶 +🇦🇬 +🇦🇷 +🇦🇲 +🇦🇼 +🇦🇺 +🇦🇹 +🇦🇿 +🇧🇸 +🇧🇭 +🇧🇩 +🇧🇧 +🇧🇾 +🇧🇪 +🇧🇿 +🇧🇯 +🇧🇲 +🇧🇹 +🇧🇴 +🇧🇦 +🇧🇼 +🇧🇷 +🇮🇴 +🇻🇬 +🇧🇳 +🇧🇬 +🇧🇫 +🇧🇮 +🇰🇭 +🇨🇲 +🇨🇦 +🇮🇨 +🇨🇻 +🇧🇶 +🇰🇾 +🇨🇫 +🇹🇩 +🇨🇱 +🇨🇳 +🇨🇽 +🇨🇨 +🇨🇴 +🇰🇲 +🇨🇬 +🇨🇩 +🇨🇰 +🇨🇷 +🇨🇮 +🇭🇷 +🇨🇺 +🇨🇼 +🇨🇾 +🇨🇿 +🇩🇰 +🇩🇯 +🇩🇲 +🇩🇴 +🇪🇨 +🇪🇬 +🇸🇻 +🇬🇶 +🇪🇷 +🇪🇪 +🇪🇹 +🇪🇺 +🇫🇰 +🇫🇴 +🇫🇯 +🇫🇮 +🇫🇷 +🇬🇫 +🇵🇫 +🇹🇫 +🇬🇦 +🇬🇲 +🇬🇪 +🇩🇪 +🇬🇭 +🇬🇮 +🇬🇷 +🇬🇱 +🇬🇩 +🇬🇵 +🇬🇺 +🇬🇹 +🇬🇬 +🇬🇳 +🇬🇼 +🇬🇾 +🇭🇹 +🇭🇳 +🇭🇰 +🇭🇺 +🇮🇸 +🇮🇳 +🇮🇩 +🇮🇷 +🇮🇶 +🇮🇪 +🇮🇲 +🇮🇱 +🇮🇹 +🇯🇲 +🇯🇵 +🎌 +🇯🇪 +🇯🇴 +🇰🇿 +🇰🇪 +🇰🇮 +🇽🇰 +🇰🇼 +🇰🇬 +🇱🇦 +🇱🇻 +🇱🇧 +🇱🇸 +🇱🇷 +🇱🇾 +🇱🇮 +🇱🇹 +🇱🇺 +🇲🇴 +🇲🇰 +🇲🇬 +🇲🇼 +🇲🇾 +🇲🇻 +🇲🇱 +🇲🇹 +🇲🇭 +🇲🇶 +🇲🇷 +🇲🇺 +🇾🇹 +🇲🇽 +🇫🇲 +🇲🇩 +🇲🇨 +🇲🇳 +🇲🇪 +🇲🇸 +🇲🇦 +🇲🇿 +🇲🇲 +🇳🇦 +🇳🇷 +🇳🇵 +🇳🇱 +🇳🇨 +🇳🇿 +🇳🇮 +🇳🇪 +🇳🇬 +🇳🇺 +🇳🇫 +🇰🇵 +🇲🇵 +🇳🇴 +🇴🇲 +🇵🇰 +🇵🇼 +🇵🇸 +🇵🇦 +🇵🇬 +🇵🇾 +🇵🇪 +🇵🇭 +🇵🇳 +🇵🇱 +🇵🇹 +🇵🇷 +🇶🇦 +🇷🇪 +🇷🇴 +🇷🇺 +🇷🇼 +🇼🇸 +🇸🇲 +🇸🇹 +🇸🇦 +🇸🇳 +🇷🇸 +🇸🇨 +🇸🇱 +🇸🇬 +🇸🇽 +🇸🇰 +🇸🇮 +🇬🇸 +🇸🇧 +🇸🇴 +🇿🇦 +🇰🇷 +🇸🇸 +🇪🇸 +🇱🇰 +🇧🇱 +🇸🇭 +🇰🇳 +🇱🇨 +🇵🇲 +🇻🇨 +🇸🇩 +🇸🇷 +🇸🇿 +🇸🇪 +🇨🇭 +🇸🇾 +🇹🇼 +🇹🇯 +🇹🇿 +🇹🇭 +🇹🇱 +🇹🇬 +🇹🇰 +🇹🇴 +🇹🇹 +🇹🇳 +🇹🇷 +🇹🇲 +🇹🇨 +🇻🇮 +🇹🇻 +🇺🇬 +🇺🇦 +🇦🇪 +🇬🇧 +🏴󠁧󠁢󠁥󠁮󠁧󠁿 +🏴󠁧󠁢󠁳󠁣󠁴󠁿 +🏴󠁧󠁢󠁷󠁬󠁳󠁿 +🇺🇸 +🇺🇾 +🇺🇿 +🇻🇺 +🇻🇦 +🇻🇪 +🇻🇳 +🇼🇫 +🇪🇭 +🇾🇪 +🇿🇲 +🇿🇼 +🇦🇨 +🇧🇻 +🇨🇵 +🇪🇦 +🇩🇬 +🇭🇲 +🇲🇫 +🇸🇯 +🇹🇦 +🇺🇲 diff --git a/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js similarity index 83% rename from m2d/converters/event-to-message.js rename to src/m2d/converters/event-to-message.js index c22e1a8..3cf08cf 100644 --- a/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -2,9 +2,9 @@ const Ty = require("../../types") const DiscordTypes = require("discord-api-types/v10") -const {Readable} = require("stream") +const stream = require("stream") const chunk = require("chunk-text") -const TurndownService = require("turndown") +const TurndownService = require("@cloudrac3r/turndown") const domino = require("domino") const assert = require("assert").strict const entities = require("entities") @@ -15,8 +15,12 @@ const {sync, db, discord, select, from} = passthrough const mxUtils = sync.require("../converters/utils") /** @type {import("../../discord/utils")} */ const dUtils = sync.require("../../discord/utils") +/** @type {import("../../matrix/file")} */ +const file = sync.require("../../matrix/file") /** @type {import("./emoji-sheet")} */ const emojiSheet = sync.require("./emoji-sheet") +/** @type {import("../actions/setup-emojis")} */ +const setupEmojis = sync.require("../actions/setup-emojis") /** @type {[RegExp, string][]} */ const markdownEscapes = [ @@ -129,7 +133,7 @@ turndownService.addRule("inlineLink", { const href = node.getAttribute("href") content = content.replace(/ @.*/, "") if (href === content) return href - if (href.startsWith("https://matrix.to/#/@") && content[0] !== "@") content = "@" + content + if (decodeURIComponent(href).startsWith("https://matrix.to/#/@") && content[0] !== "@") content = "@" + content return "[" + content + "](" + href + ")" } }) @@ -152,6 +156,27 @@ turndownService.addRule("listItem", { } }) +turndownService.addRule("table", { + filter: "table", + replacement: function (content, node, options) { + const trs = node.querySelectorAll("tr").cache + /** @type {{text: string, tag: string}[][]} */ + const tableText = trs.map(tr => [...tr.querySelectorAll("th, td")].map(cell => ({text: cell.textContent, tag: cell.tagName}))) + const tableTextByColumn = tableText[0].map((col, i) => tableText.map(row => row[i])) + const columnWidths = tableTextByColumn.map(col => Math.max(...col.map(cell => cell.text.length))) + const resultRows = tableText.map((row, rowIndex) => + row.map((cell, colIndex) => + cell.text.padEnd(columnWidths[colIndex]) + ).join(" ") + ) + const tableHasHeader = tableText[0].slice(1).some(cell => cell.tag === "TH") + if (tableHasHeader) { + resultRows.splice(1, 0, "-".repeat(columnWidths.reduce((a, c) => a + c + 2))) + } + return "```\n" + resultRows.join("\n") + "```" + } +}) + /** @type {string[]} SPRITE SHEET EMOJIS FEATURE: mxc urls for the currently processing message */ let endOfMessageEmojis = [] turndownService.addRule("emoji", { @@ -206,11 +231,10 @@ function getCodeContent(preCode) { */ function convertEmoji(mxcUrl, nameForGuess, allowSpriteSheetIndicator, allowLink) { // Get the known emoji from the database. - let row - if (mxcUrl) row = select("emoji", ["emoji_id", "name", "animated"], {mxc_url: mxcUrl}).get() + if (mxcUrl) var row = select("emoji", ["emoji_id", "name", "animated"], {mxc_url: mxcUrl}).get() // Now we have to search all servers to see if we're able to send this emoji. if (row) { - const found = [...discord.guilds.values()].find(g => g.emojis.find(e => e.id === row.id)) + const found = [...discord.guilds.values()].find(g => g.emojis.find(e => e.id === row?.emoji_id)) if (!found) row = null } // Or, if we don't have an emoji right now, we search for the name instead. @@ -220,7 +244,7 @@ function convertEmoji(mxcUrl, nameForGuess, allowSpriteSheetIndicator, allowLink /** @type {{name: string, id: string, animated: number}[]} */ // @ts-ignore const emojis = guild.emojis - const found = emojis.find(e => e.id === row?.id || e.name?.toLowerCase() === nameForGuessLower) + const found = emojis.find(e => e.name?.toLowerCase() === nameForGuessLower) if (found) { row = { animated: found.animated, @@ -257,7 +281,18 @@ async function getMemberFromCacheOrHomeserver(roomID, mxid, api) { const row = select("member_cache", ["displayname", "avatar_url"], {room_id: roomID, mxid}).get() if (row) return row return api.getStateEvent(roomID, "m.room.member", mxid).then(event => { - db.prepare("REPLACE INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?)").run(roomID, mxid, event?.displayname || null, event?.avatar_url || null) + const room = select("channel_room", "room_id", {room_id: roomID}).get() + if (room) { + // save the member to the cache so we don't have to check with the homeserver next time + // the cache will be kept in sync by the `m.room.member` event listener + const displayname = event?.displayname || null + const avatar_url = event?.avatar_url || null + db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?").run( + roomID, mxid, + displayname, avatar_url, + displayname, avatar_url + ) + } return event }).catch(() => { return {displayname: null, avatar_url: null} @@ -303,8 +338,8 @@ function getUserOrProxyOwnerID(mxid) { * At the time of this executing, we know what the end of message emojis are, and we know that at least one of them is unknown. * This function will strip them from the content and generate the correct pending file of the sprite sheet. * @param {string} content - * @param {{id: string, name: string}[]} attachments - * @param {({name: string, url: string} | {name: string, url: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} pendingFiles + * @param {{id: string, filename: string}[]} attachments + * @param {({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} pendingFiles * @param {(mxc: string) => Promise} mxcDownloader function that will download the mxc URLs and convert to uncompressed PNG data. use `getAndConvertEmoji` or a mock. */ async function uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles, mxcDownloader) { @@ -317,9 +352,9 @@ async function uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles, // Create a sprite sheet of known and unknown emojis from the end of the message const buffer = await emojiSheet.compositeMatrixEmojis(endOfMessageEmojis, mxcDownloader) // Attach it - const name = "emojis.png" - attachments.push({id: String(attachments.length), name}) - pendingFiles.push({name, buffer}) + const filename = "emojis.png" + attachments.push({id: String(attachments.length), filename}) + pendingFiles.push({name: filename, buffer}) return content } @@ -329,9 +364,9 @@ async function uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles, */ async function handleRoomOrMessageLinks(input, di) { let offset = 0 - for (const match of [...input.matchAll(/("?https:\/\/matrix.to\/#\/(![^"/, ?)]+)(?:\/(\$[^"/ ?)]+))?(?:\?[^",:!? )]*)?)(">|[, )]|$)/g)]) { + for (const match of [...input.matchAll(/("?https:\/\/matrix.to\/#\/((?:#|%23|!)[^"/, ?)]+)(?:\/(\$[^"/ ?)]+))?(?:\?[^",:!? )]*?)?)(">|[,<\n )]|$)/g)]) { assert(typeof match.index === "number") - const [_, attributeValue, roomID, eventID, endMarker] = match + let [_, attributeValue, roomID, eventID, endMarker] = match let result const resultType = endMarker === '">' ? "html" : "plain" @@ -349,6 +384,16 @@ async function handleRoomOrMessageLinks(input, di) { // Don't process links that are part of the reply fallback, they'll be removed entirely by turndown if (input.slice(match.index + match[0].length + offset).startsWith("In reply to")) continue + // Resolve room alias to room ID if needed + roomID = decodeURIComponent(roomID) + if (roomID[0] === "#") { + try { + roomID = await di.api.getAlias(roomID) + } catch (e) { + continue // Room alias is unresolvable, so it can't be converted + } + } + const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get() if (!channelID) continue if (!eventID) { @@ -388,7 +433,7 @@ async function handleRoomOrMessageLinks(input, di) { * @param {string} senderMxid * @param {string} roomID * @param {DiscordTypes.APIGuild} guild - * @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, fetch: import("node-fetch")["default"]}} di + * @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer}} di */ async function checkWrittenMentions(content, senderMxid, roomID, guild, di) { let writtenMentionMatch = content.match(/(?:^|[^"[<>/A-Za-z0-9])@([A-Za-z][A-Za-z0-9._\[\]\(\)-]+):?/d) // /d flag for indices requires node.js 16+ @@ -404,7 +449,7 @@ async function checkWrittenMentions(content, senderMxid, roomID, guild, di) { allowedMentionsParse: ["everyone"] } } - } else { + } else if (writtenMentionMatch[1].length < 40) { // the API supports up to 100 characters, but really if you're searching more than 40, something messed up const results = await di.snow.guild.searchGuildMembers(guild.id, {query: writtenMentionMatch[1]}) if (results[0]) { assert(results[0].user) @@ -436,10 +481,27 @@ const attachmentEmojis = new Map([ ["m.file", "📄"] ]) +async function getL1L2ReplyLine(called = false) { + // @ts-ignore + const autoEmoji = new Map(select("auto_emoji", ["name", "emoji_id"], {}, "WHERE name = 'L1' OR name = 'L2'").raw().all()) + if (autoEmoji.size === 2) { + return `<:L1:${autoEmoji.get("L1")}><:L2:${autoEmoji.get("L2")}>` + } + /* c8 ignore start */ + if (called) { + // Don't know how this could happen, but just making sure we don't enter an infinite loop. + console.warn("Warning: OOYE is missing data to format replies. To fix this: `npm run setup`") + return "" + } + await setupEmojis.setupEmojis() + return getL1L2ReplyLine(true) + /* c8 ignore stop */ +} + /** * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker | Ty.Event.Outer_M_Room_Message_Encrypted_File} event * @param {import("discord-api-types/v10").APIGuild} guild - * @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, fetch: import("node-fetch")["default"], mxcDownloader: (mxc: string) => Promise}} di simple-as-nails dependency injection for the matrix API + * @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, mxcDownloader: (mxc: string) => Promise}} di simple-as-nails dependency injection for the matrix API */ async function eventToMessage(event, guild, di) { let displayName = event.sender @@ -454,7 +516,7 @@ async function eventToMessage(event, guild, di) { // Try to extract an accurate display name and avatar URL from the member event const member = await getMemberFromCacheOrHomeserver(event.room_id, event.sender, di?.api) if (member.displayname) displayName = member.displayname - if (member.avatar_url) avatarURL = mxUtils.getPublicUrlForMxc(member.avatar_url) || undefined + if (member.avatar_url) avatarURL = mxUtils.getPublicUrlForMxc(member.avatar_url) // If the display name is too long to be put into the webhook (80 characters is the maximum), // put the excess characters into displayNameRunoff, later to be put at the top of the message let [displayNameShortened, displayNameRunoff] = splitDisplayName(displayName) @@ -464,14 +526,53 @@ async function eventToMessage(event, guild, di) { } let content = event.content.body // ultimate fallback + /** @type {{id: string, filename: string}[]} */ const attachments = [] - /** @type {({name: string, url: string} | {name: string, url: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} */ + /** @type {({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} */ const pendingFiles = [] /** @type {DiscordTypes.APIUser[]} */ const ensureJoined = [] // Convert content depending on what the message is - if (event.type === "m.room.message" && (event.content.msgtype === "m.text" || event.content.msgtype === "m.emote")) { + // Handle images first - might need to handle their `body`/`formatted_body` as well, which will fall through to the text processor + let shouldProcessTextEvent = event.type === "m.room.message" && (event.content.msgtype === "m.text" || event.content.msgtype === "m.emote") + if (event.type === "m.room.message" && (event.content.msgtype === "m.file" || event.content.msgtype === "m.video" || event.content.msgtype === "m.audio" || event.content.msgtype === "m.image")) { + content = "" + const filename = event.content.filename || event.content.body + if ("file" in event.content) { + // Encrypted + assert.equal(event.content.file.key.alg, "A256CTR") + attachments.push({id: "0", filename}) + pendingFiles.push({name: filename, mxc: event.content.file.url, key: event.content.file.key.k, iv: event.content.file.iv}) + } else { + // Unencrypted + attachments.push({id: "0", filename}) + pendingFiles.push({name: filename, mxc: event.content.url}) + } + // Check if we also need to process a text event for this image - if it has a caption that's different from its filename + if ((event.content.body && event.content.filename && event.content.body !== event.content.filename) || event.content.formatted_body) { + shouldProcessTextEvent = true + } + } + if (event.type === "m.sticker") { + content = "" + let filename = event.content.body + if (event.type === "m.sticker") { + let mimetype + if (event.content.info?.mimetype?.includes("/")) { + mimetype = event.content.info.mimetype + } else { + const res = await di.api.getMedia(event.content.url, {method: "HEAD"}) + if (res.status === 200) { + mimetype = res.headers.get("content-type") + } + if (!mimetype) throw new Error(`Server error ${res.status} or missing content-type while detecting sticker mimetype`) + } + filename += "." + mimetype.split("/")[1] + } + attachments.push({id: "0", filename}) + pendingFiles.push({name: filename, mxc: event.content.url}) + } else if (shouldProcessTextEvent) { // Handling edits. If the edit was an edit of a reply, edits do not include the reply reference, so we need to fetch up to 2 more events. // this event ---is an edit of--> original event ---is a reply to--> past event await (async () => { @@ -485,13 +586,12 @@ async function eventToMessage(event, guild, di) { if (!messageIDsToEdit.length) return // Ok, it's an edit. - event.content = event.content["m.new_content"] + event = {...event, content: event.content["m.new_content"]} // Is it editing a reply? We need special handling if it is. // Get the original event, then check if it was a reply const originalEvent = await di.api.getEvent(event.room_id, originalEventId) - if (!originalEvent) return - const repliedToEventId = originalEvent.content["m.relates_to"]?.["m.in_reply_to"]?.event_id + const repliedToEventId = originalEvent?.content?.["m.relates_to"]?.["m.in_reply_to"]?.event_id if (!repliedToEventId) return // After all that, it's an edit of a reply. @@ -541,47 +641,38 @@ async function eventToMessage(event, guild, di) { .replace(/]*data-mx-spoiler\b[^>]*>.*?<\/span>/g, "[spoiler]") // Good enough method of removing spoiler content. (I don't want to break out the HTML parser unless I have to.) .replace(/<[^>]+>/g, "") // Completely strip all HTML tags and formatting. ), 50) - replyLine = "> " + contentPreviewChunks[0] + replyLine = "-# > " + contentPreviewChunks[0] if (contentPreviewChunks.length > 1) replyLine = replyLine.replace(/[,.']$/, "") + "..." replyLine += "\n" return } - // @ts-ignore - const autoEmoji = new Map(select("auto_emoji", ["name", "emoji_id"], {}, "WHERE name = 'L1' OR name = 'L2'").raw().all()) - replyLine = `<:L1:${autoEmoji.get("L1")}><:L2:${autoEmoji.get("L2")}>` + replyLine = await getL1L2ReplyLine() const row = from("event_message").join("message_channel", "message_id").select("channel_id", "message_id").where({event_id: repliedToEventId}).and("ORDER BY part").get() if (row) { replyLine += `https://discord.com/channels/${guild.id}/${row.channel_id}/${row.message_id} ` } - const sender = repliedToEvent.sender - const authorID = getUserOrProxyOwnerID(sender) - if (authorID) { - replyLine += `<@${authorID}>` - } else { - let senderName = select("member_cache", "displayname", {mxid: sender}).pluck().get() - if (!senderName) { - const match = sender.match(/@([^:]*)/) - assert(match) - senderName = match[1] - } - replyLine += `Ⓜ️**${senderName}**` - } // If the event has been edited, the homeserver will include the relation in `unsigned`. if (repliedToEvent.unsigned?.["m.relations"]?.["m.replace"]?.content?.["m.new_content"]) { repliedToEvent = repliedToEvent.unsigned["m.relations"]["m.replace"] // Note: this changes which event_id is in repliedToEvent. repliedToEvent.content = repliedToEvent.content["m.new_content"] } - let contentPreview + /** @type {string} */ + let repliedToContent = repliedToEvent.content.formatted_body || repliedToEvent.content.body const fileReplyContentAlternative = attachmentEmojis.get(repliedToEvent.content.msgtype) + let contentPreview if (fileReplyContentAlternative) { contentPreview = " " + fileReplyContentAlternative } else if (repliedToEvent.unsigned?.redacted_because) { contentPreview = " (in reply to a deleted message)" + } else if (typeof repliedToContent !== "string") { + // in reply to a weird metadata event like m.room.name, m.room.member... + // I'm not implementing text fallbacks for arbitrary room events. this should cover most cases + // this has never ever happened in the wild anyway + repliedToEvent.sender = "" + contentPreview = " (channel details edited)" } else { // Generate a reply preview for a standard message - /** @type {string} */ - let repliedToContent = repliedToEvent.content.formatted_body || repliedToEvent.content.body repliedToContent = repliedToContent.replace(/.*<\/mx-reply>/s, "") // Remove everything before replies, so just use the actual message body repliedToContent = repliedToContent.replace(/^\s*
    .*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards repliedToContent = repliedToContent.replace(/(?:\n|
    )+/g, " ") // Should all be on one line @@ -592,17 +683,26 @@ async function eventToMessage(event, guild, di) { return convertEmoji(mxcUrlMatch?.[1], titleTextMatch?.[1], false, false) }) repliedToContent = repliedToContent.replace(/<[^:>][^>]*>/g, "") // Completely strip all HTML tags and formatting. + repliedToContent = repliedToContent.replace(/\bhttps?:\/\/[^ )]*/g, "<$&>") repliedToContent = entities.decodeHTML5Strict(repliedToContent) // Remove entities like & " const contentPreviewChunks = chunk(repliedToContent, 50) if (contentPreviewChunks.length) { - contentPreview = ":\n> " + contentPreviewChunks[0] + contentPreview = ": " + contentPreviewChunks[0] if (contentPreviewChunks.length > 1) contentPreview = contentPreview.replace(/[,.']$/, "") + "..." } else { - console.log("Unable to generate reply preview for this replied-to event because we stripped all of it:", repliedToEvent) contentPreview = "" } } - replyLine = `> ${replyLine}${contentPreview}\n` + const sender = repliedToEvent.sender + const authorID = getUserOrProxyOwnerID(sender) + if (authorID) { + replyLine += `<@${authorID}>` + } else { + let senderName = select("member_cache", "displayname", {mxid: sender}).pluck().get() + if (!senderName) senderName = sender.match(/@([^:]*)/)?.[1] + if (senderName) replyLine += `**Ⓜ${senderName}**` + } + replyLine = `-# > ${replyLine}${contentPreview}\n` })() if (event.content.format === "org.matrix.custom.html" && event.content.formatted_body) { @@ -685,7 +785,7 @@ async function eventToMessage(event, guild, di) { let preNode if (node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"]))) { if (preNode.firstChild?.nodeName === "CODE") { - const ext = (preNode.firstChild.className.match(/language-(\S+)/) || [null, "txt"])[1] + const ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1] || "txt" const filename = `inline_code.${ext}` // Build the replacement node const replacementCode = doc.createElement("code") @@ -725,7 +825,7 @@ async function eventToMessage(event, guild, di) { content = turndownService.turndown(root) // Put < > around any surviving matrix.to links to hide the URL previews - content = content.replace(/\bhttps?:\/\/matrix\.to\/[^ )]*/g, "<$&>") + content = content.replace(/\bhttps?:\/\/matrix\.to\/[^<>\n )]*/g, "<$&>") // It's designed for commonmark, we need to replace the space-space-newline with just newline content = content.replace(/ \n/g, "\n") @@ -744,7 +844,7 @@ async function eventToMessage(event, guild, di) { } content = await handleRoomOrMessageLinks(content, di) // Replace matrix.to links with discord.com equivalents where possible - content = content.replace(/\bhttps?:\/\/matrix\.to\/[^ )]*/, "<$&>") // Put < > around any surviving matrix.to links to hide the URL previews + content = content.replace(/\bhttps?:\/\/matrix\.to\/[^<>\n )]*/, "<$&>") // Put < > around any surviving matrix.to links to hide the URL previews const result = await checkWrittenMentions(content, event.sender, event.room_id, guild, di) if (result) { @@ -757,53 +857,13 @@ async function eventToMessage(event, guild, di) { // @ts-ignore bad type from turndown content = turndownService.escape(content) } - } else if (event.type === "m.room.message" && (event.content.msgtype === "m.file" || event.content.msgtype === "m.video" || event.content.msgtype === "m.audio" || event.content.msgtype === "m.image")) { - content = "" - const filename = event.content.filename || event.content.body - // A written `event.content.body` will be bridged to Discord's image `description` which is like alt text. - // Bridging as description rather than message content in order to match Matrix clients (Element, Neochat) which treat this as alt text or title text. - const description = (event.content.body !== event.content.filename && event.content.filename && event.content.body) || undefined - if ("url" in event.content) { - // Unencrypted - const url = mxUtils.getPublicUrlForMxc(event.content.url) - assert(url) - attachments.push({id: "0", description, filename}) - pendingFiles.push({name: filename, url}) - } else { - // Encrypted - const url = mxUtils.getPublicUrlForMxc(event.content.file.url) - assert(url) - assert.equal(event.content.file.key.alg, "A256CTR") - attachments.push({id: "0", description, filename}) - pendingFiles.push({name: filename, url, key: event.content.file.key.k, iv: event.content.file.iv}) - } - } else if (event.type === "m.sticker") { - content = "" - const url = mxUtils.getPublicUrlForMxc(event.content.url) - assert(url) - let filename = event.content.body - if (event.type === "m.sticker") { - let mimetype - if (event.content.info?.mimetype?.includes("/")) { - mimetype = event.content.info.mimetype - } else { - const res = await di.fetch(url, {method: "HEAD"}) - if (res.status === 200) { - mimetype = res.headers.get("content-type") - } - if (!mimetype) throw new Error(`Server error ${res.status} or missing content-type while detecting sticker mimetype`) - } - filename += "." + mimetype.split("/")[1] - } - attachments.push({id: "0", filename}) - pendingFiles.push({name: filename, url}) } content = displayNameRunoff + replyLine + content // Split into 2000 character chunks const chunks = chunk(content, 2000) - /** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]})[]} */ + /** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]})[]} */ const messages = chunks.map(content => ({ content, allowed_mentions: { diff --git a/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js similarity index 83% rename from m2d/converters/event-to-message.test.js rename to src/m2d/converters/event-to-message.test.js index 6d750a5..3d1c918 100644 --- a/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -3,7 +3,7 @@ const fs = require("fs") const {test} = require("supertape") const {eventToMessage} = require("./event-to-message") const {convertImageStream} = require("./emoji-sheet") -const data = require("../../test/data") +const data = require("../../../test/data") const {MatrixServerError} = require("../../matrix/mreq") const {select, discord} = require("../../passthrough") @@ -263,6 +263,45 @@ test("event2message: links in formatted body where the text & href are the same, ) }) +test("event2message: markdown in link text does not attempt to be escaped because that doesn't work", async t => { + t.deepEqual( + await eventToMessage({ + content: { + body: "hey mario sports mix [she/her], is it possible to listen on a unix socket?", + format: "org.matrix.custom.html", + formatted_body: "hey mario sports mix [she/her], is it possible to listen on a unix socket?", + "m.mentions": { + "user_ids": [ + "@cadence:cadence.moe" + ] + }, + msgtype: "m.text" + }, + event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", + origin_server_ts: 1688301929913, + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", + sender: "@cadence:cadence.moe", + type: "m.room.message", + unsigned: { + age: 405299 + } + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "cadence [they]", + content: "hey [@mario sports mix [she/her]](), is it possible to listen on a unix socket?", + avatar_url: undefined, + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) +}) + test("event2message: basic html is converted to markdown", async t => { t.deepEqual( await eventToMessage({ @@ -520,7 +559,7 @@ test("event2message: lists are bridged correctly", async t => { "transaction_id": "m1692967313951.441" }, "event_id": "$l-xQPY5vNJo3SNxU9d8aOWNVD1glMslMyrp4M_JEF70", - "room_id": "!BpMdOUkWWhFxmTrENV:cadence.moe" + "room_id": "!kLRqKKUQXcibIMtOpl:cadence.moe" }), { ensureJoined: [], @@ -623,7 +662,7 @@ test("event2message: code block contents are formatted correctly and not escaped formatted_body: "
    input = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,\n_input_ = input = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,\n
    \n

    input = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,

    \n" }, event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", - room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe" + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" }), { ensureJoined: [], @@ -653,7 +692,7 @@ test("event2message: code blocks use double backtick as delimiter when necessary formatted_body: "backtick in ` the middle, backtick at the edge`" }, event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", - room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe" + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" }), { ensureJoined: [], @@ -683,7 +722,7 @@ test("event2message: inline code is converted to code block if it contains both formatted_body: "` one two ``" }, event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", - room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe" + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" }), { ensureJoined: [], @@ -713,7 +752,7 @@ test("event2message: code blocks are uploaded as attachments instead if they con formatted_body: 'So if you run code like this
    System.out.println("```");
    it should print a markdown formatted code block' }, event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", - room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe" + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" }), { ensureJoined: [], @@ -733,6 +772,38 @@ test("event2message: code blocks are uploaded as attachments instead if they con ) }) +test("event2message: code blocks are uploaded as attachments instead if they contain incompatible backticks (default to txt file extension)", async t => { + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "wrong body", + format: "org.matrix.custom.html", + formatted_body: 'So if you run code like this
    System.out.println("```");
    it should print a markdown formatted code block' + }, + event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "cadence [they]", + content: "So if you run code like this `[inline_code.txt]` it should print a markdown formatted code block", + attachments: [{id: "0", filename: "inline_code.txt"}], + pendingFiles: [{name: "inline_code.txt", buffer: Buffer.from('System.out.println("```");')}], + avatar_url: undefined, + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) +}) + test("event2message: characters are encoded properly in code blocks", async t => { t.deepEqual( await eventToMessage({ @@ -750,7 +821,7 @@ test("event2message: characters are encoded properly in code blocks", async t => + '\n
    ' }, event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", - room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe" + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" }), { ensureJoined: [], @@ -831,7 +902,7 @@ test("event2message: lists have appropriate line breaks", async t => { 'm.mentions': {}, msgtype: 'm.text' }, - room_id: '!cBxtVRxDlZvSVhJXVK:cadence.moe', + room_id: '!TqlyQmifxGUggEmdBN:cadence.moe', sender: '@Milan:tchncs.de', type: 'm.room.message', }), @@ -872,7 +943,7 @@ test("event2message: ordered list start attribute works", async t => { 'm.mentions': {}, msgtype: 'm.text' }, - room_id: '!cBxtVRxDlZvSVhJXVK:cadence.moe', + room_id: '!TqlyQmifxGUggEmdBN:cadence.moe', sender: '@Milan:tchncs.de', type: 'm.room.message', }), @@ -999,10 +1070,10 @@ test("event2message: rich reply to a sim user", async t => { messagesToEdit: [], messagesToSend: [{ username: "cadence [they]", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:" - + "\n> Slow news day." + content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:" + + " Slow news day." + "\nTesting this reply, ignore", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", allowed_mentions: { parse: ["users", "roles"] } @@ -1017,7 +1088,7 @@ test("event2message: rich reply to a rich reply to a multi-line message should c content: { body: "> <@cadence:cadence.moe> I just checked in a fix that will probably work, can you try reproducing this on the latest `main` branch and see if I fixed it?\n\nwill try later (tomorrow if I don't forgor)", format: "org.matrix.custom.html", - formatted_body: "
    In reply to @cadence:cadence.moe
    I just checked in a fix that will probably work, can you try reproducing this on the latest main branch and see if I fixed it?
    will try later (tomorrow if I don't forgor)", + formatted_body: "
    In reply to @cadence:cadence.moe
    I just checked in a fix that will probably work, can you try reproducing this on the latest main branch and see if I fixed it?
    will try later (tomorrow if I don't forgor)", "m.relates_to": { "m.in_reply_to": { event_id: "$A0Rj559NKOh2VndCZSTJXcvgi42gZWVfVQt73wA2Hn0" @@ -1040,7 +1111,7 @@ test("event2message: rich reply to a rich reply to a multi-line message should c "msgtype": "m.text", "body": "> <@solonovamax:matrix.org> multipart messages will be deleted if the message is edited to require less space\n> \n> \n> steps to reproduce:\n> \n> 1. send a message that is longer than 2000 characters (discord character limit)\n> - bot will split message into two messages on discord\n> 2. edit message to be under 2000 characters (discord character limit)\n> - bot will delete one of the messages on discord, and then edit the other one to include the edited content\n> - the bot will *then* delete the message on matrix (presumably) because one of the messages on discord was deleted (by \n\nI just checked in a fix that will probably work, can you try reproducing this on the latest `main` branch and see if I fixed it?", "format": "org.matrix.custom.html", - "formatted_body": "
    In reply to @solonovamax:matrix.org

    multipart messages will be deleted if the message is edited to require less space

    \n

    steps to reproduce:

    \n
      \n
    1. send a message that is longer than 2000 characters (discord character limit)
    2. \n
    \n
      \n
    • bot will split message into two messages on discord
    • \n
    \n
      \n
    1. edit message to be under 2000 characters (discord character limit)
    2. \n
    \n
      \n
    • bot will delete one of the messages on discord, and then edit the other one to include the edited content
    • \n
    • the bot will then delete the message on matrix (presumably) because one of the messages on discord was deleted (by
    • \n
    \n
    I just checked in a fix that will probably work, can you try reproducing this on the latest main branch and see if I fixed it?", + "formatted_body": "
    In reply to @solonovamax:matrix.org

    multipart messages will be deleted if the message is edited to require less space

    \n

    steps to reproduce:

    \n
      \n
    1. send a message that is longer than 2000 characters (discord character limit)
    2. \n
    \n
      \n
    • bot will split message into two messages on discord
    • \n
    \n
      \n
    1. edit message to be under 2000 characters (discord character limit)
    2. \n
    \n
      \n
    • bot will delete one of the messages on discord, and then edit the other one to include the edited content
    • \n
    • the bot will then delete the message on matrix (presumably) because one of the messages on discord was deleted (by
    • \n
    \n
    I just checked in a fix that will probably work, can you try reproducing this on the latest main branch and see if I fixed it?", "m.relates_to": { "m.in_reply_to": { "event_id": "$u4OD19vd2GETkOyhgFVla92oDKI4ojwBf2-JeVCG7EI" @@ -1052,7 +1123,7 @@ test("event2message: rich reply to a rich reply to a multi-line message should c "age": 19069564 }, "event_id": "$A0Rj559NKOh2VndCZSTJXcvgi42gZWVfVQt73wA2Hn0", - "room_id": "!cBxtVRxDlZvSVhJXVK:cadence.moe" + "room_id": "!TqlyQmifxGUggEmdBN:cadence.moe" }) }, snow: { @@ -1071,8 +1142,8 @@ test("event2message: rich reply to a rich reply to a multi-line message should c messagesToEdit: [], messagesToSend: [{ username: "cadence [they]", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647>Ⓜ️**cadence [they]**:" - + "\n> I just checked in a fix that will probably work..." + content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**Ⓜcadence [they]**:" + + " I just checked in a fix that will probably work..." + "\nwill try later (tomorrow if I don't forgor)", avatar_url: undefined, allowed_mentions: { @@ -1151,10 +1222,10 @@ test("event2message: rich reply to an already-edited message will quote the new messagesToEdit: [], messagesToSend: [{ username: "cadence [they]", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647><@111604486476181504>:" - + "\n> this is the new content. heya!" + content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647><@111604486476181504>:" + + " this is the new content. heya!" + "\nhiiiii....", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", allowed_mentions: { parse: ["users", "roles"] } @@ -1205,9 +1276,9 @@ test("event2message: rich reply to a missing event will quote from formatted_bod messagesToEdit: [], messagesToSend: [{ username: "cadence [they]", - content: "> But who sees the seashells she sells sitting..." + content: "-# > But who sees the seashells she sells sitting..." + "\nWhat a tongue-bender...", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", allowed_mentions: { parse: ["users", "roles"] } @@ -1256,7 +1327,7 @@ test("event2message: rich reply to a missing event without formatted_body will u messagesToSend: [{ username: "cadence [they]", content: "Testing this reply, ignore", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", allowed_mentions: { parse: ["users", "roles"] } @@ -1307,7 +1378,7 @@ test("event2message: rich reply to a missing event and no reply fallback will no messagesToSend: [{ username: "cadence [they]", content: "Testing this reply, ignore.", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", allowed_mentions: { parse: ["users", "roles"] } @@ -1356,10 +1427,59 @@ test("event2message: should avoid using blockquote contents as reply preview in messagesToEdit: [], messagesToSend: [{ username: "cadence [they]", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:" - + "\n> that can't be true! there's no way :o" + content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:" + + " that can't be true! there's no way :o" + "\nI agree!", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) +}) + +test("event2message: should suppress embeds for links in reply preview", async t => { + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@rnl:cadence.moe", + content: { + msgtype: "m.text", + body: `> <@cadence:cadence.moe> https://www.youtube.com/watch?v=uX32idb1jMw\n\nEveryone in the comments is like "you're so ahead of the curve" and "crazy that this came out before the scandal" but I thought Mr Beast sucking was a common sentiment`, + format: "org.matrix.custom.html", + formatted_body: `
    In reply to @cadence:cadence.moe
    https://www.youtube.com/watch?v=uX32idb1jMw
    Everyone in the comments is like "you're so ahead of the curve" and "crazy that this came out before the scandal" but I thought Mr Beast sucking was a common sentiment`, + "m.relates_to": { + "m.in_reply_to": { + event_id: "$qmyjr-ISJtnOM5WTWLI0fT7uSlqRLgpyin2d2NCglCU" + } + } + }, + event_id: "$0Bs3rbsXaeZmSztGMx1NIsqvOrkXOpIWebN-dqr09i4", + room_id: "!fGgIymcYWOqjbSRUdV:cadence.moe" + }, data.guild.general, { + api: { + getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$qmyjr-ISJtnOM5WTWLI0fT7uSlqRLgpyin2d2NCglCU", { + "type": "m.room.message", + "sender": "@cadence:cadence.moe", + "content": { + "m.mentions": {}, + "msgtype": "m.text", + "body": "https://www.youtube.com/watch?v=uX32idb1jMw" + } + }) + } + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "RNL", + content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1273204543739396116 **Ⓜcadence [they]**:" + + " " + + `\nEveryone in the comments is like "you're so ahead of the curve" and "crazy that this came out before the scandal" but I thought Mr Beast sucking was a common sentiment`, + avatar_url: undefined, allowed_mentions: { parse: ["users", "roles"] } @@ -1445,10 +1565,10 @@ test("event2message: should include a reply preview when message ends with a blo messagesToEdit: [], messagesToSend: [{ username: "cadence [they]", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647>Ⓜ️**_ooye_cookie**:" - + "\n> https://tootsuite.net/Warp-Gate2.gif tanget: @..." + content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**Ⓜ_ooye_cookie**:" + + " tanget: @..." + "\naichmophobia", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", allowed_mentions: { parse: ["users", "roles"] } @@ -1529,10 +1649,10 @@ test("event2message: should include a reply preview when replying to a descripti messagesToEdit: [], messagesToSend: [{ username: "cadence [they]", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/497161350934560778/1162625810109317170 <@1109360903096369153>:" - + "\n> It looks like this queue has ended." + content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/497161350934560778/1162625810109317170 <@1109360903096369153>:" + + " It looks like this queue has ended." + `\nso you're saying on matrix side I would have to edit ^this^ to add "Timed out" before the blockquote?`, - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", allowed_mentions: { parse: ["users", "roles"] } @@ -1582,10 +1702,10 @@ test("event2message: entities are not escaped in main message or reply preview", messagesToEdit: [], messagesToSend: [{ username: "cadence [they]", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647>Ⓜ️**cadence [they]**:" - + "\n> Testing? \"':.`[]&things" + content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**Ⓜcadence [they]**:" + + " Testing? \"':.`[]&things" + "\n_Testing?_ \"':.\\`\\[\\]&things", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", allowed_mentions: { parse: ["users", "roles"] } @@ -1632,8 +1752,8 @@ test("event2message: reply preview converts emoji formatting when replying to a messagesToEdit: [], messagesToSend: [{ username: "cadence [they]", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647>Ⓜ️**cadence [they]**:" - + "\n> <:hippo:230201364309868544>" + content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**Ⓜcadence [they]**:" + + " <:hippo:230201364309868544>" + "\nreply", avatar_url: undefined, allowed_mentions: { @@ -1682,8 +1802,8 @@ test("event2message: reply preview can guess custom emoji based on the name if i messagesToEdit: [], messagesToSend: [{ username: "cadence [they]", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647>Ⓜ️**cadence [they]**:" - + "\n> <:hippo:230201364309868544>" + content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**Ⓜcadence [they]**:" + + " <:hippo:230201364309868544>" + "\nreply", avatar_url: undefined, allowed_mentions: { @@ -1732,8 +1852,8 @@ test("event2message: reply preview uses emoji title text when replying to an unk messagesToEdit: [], messagesToSend: [{ username: "cadence [they]", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647>Ⓜ️**cadence [they]**:" - + "\n> :svkftngur_gkdne:" + content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**Ⓜcadence [they]**:" + + " :svkftngur_gkdne:" + "\nreply", avatar_url: undefined, allowed_mentions: { @@ -1782,8 +1902,8 @@ test("event2message: reply preview ignores garbage image", async t => { messagesToEdit: [], messagesToSend: [{ username: "cadence [they]", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647>Ⓜ️**cadence [they]**:" - + "\n> I am having a nice day" + content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**Ⓜcadence [they]**:" + + " I am having a nice day" + "\nreply", avatar_url: undefined, allowed_mentions: { @@ -1832,7 +1952,7 @@ test("event2message: reply to empty message doesn't show an extra line or anythi messagesToEdit: [], messagesToSend: [{ username: "cadence [they]", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647>Ⓜ️**cadence [they]**" + content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**Ⓜcadence [they]**" + "\nreply", avatar_url: undefined, allowed_mentions: { @@ -1914,10 +2034,10 @@ test("event2message: editing a rich reply to a sim user", async t => { id: "1144874214311067708", message: { username: "cadence [they]", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:" - + "\n> Slow news day." + content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:" + + " Slow news day." + "\nEditing this reply, which is also a test", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", allowed_mentions: { parse: ["users", "roles"] } @@ -1973,7 +2093,7 @@ test("event2message: editing a plaintext body message", async t => { message: { username: "cadence [they]", content: "well, I guess it's no longer brand new... it's existed for mere seconds...", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", allowed_mentions: { parse: ["users", "roles"] } @@ -2028,7 +2148,7 @@ test("event2message: editing a plaintext message to be longer", async t => { message: { content: "aaaaaaaaa ".repeat(198) + "well, I guess it's", username: "cadence [they]", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", allowed_mentions: { parse: ["users", "roles"] } @@ -2037,7 +2157,7 @@ test("event2message: editing a plaintext message to be longer", async t => { messagesToSend: [{ content: "no longer brand new... it's existed for mere seconds..." + ("aaaaaaaaa ".repeat(20)).slice(0, -1), username: "cadence [they]", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", allowed_mentions: { parse: ["users", "roles"] } @@ -2090,7 +2210,7 @@ test("event2message: editing a plaintext message to be shorter", async t => { message: { username: "cadence [they]", content: "well, I guess it's no longer brand new... it's existed for mere seconds...", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", allowed_mentions: { parse: ["users", "roles"] } @@ -2151,7 +2271,7 @@ test("event2message: editing a formatted body message", async t => { message: { username: "cadence [they]", content: "**well, I guess it's no longer brand new... it's existed for mere seconds...**", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", allowed_mentions: { parse: ["users", "roles"] } @@ -2205,10 +2325,10 @@ test("event2message: rich reply to a matrix user's long message with formatting" messagesToEdit: [], messagesToSend: [{ username: "cadence [they]", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 Ⓜ️**cadence [they]**:" - + "\n> i should have a little happy test list bold em..." + content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 **Ⓜcadence [they]**:" + + " i should have a little happy test list bold em..." + "\n**no you can't!!!**", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", allowed_mentions: { parse: ["users", "roles"] } @@ -2268,9 +2388,9 @@ test("event2message: rich reply to an image", async t => { messagesToEdit: [], messagesToSend: [{ username: "cadence [they]", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504> 🖼️" + content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504> 🖼️" + "\nCaught in 8K UHD VR QLED Epic Edition", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", allowed_mentions: { parse: ["users", "roles"] } @@ -2323,10 +2443,10 @@ test("event2message: rich reply to a spoiler should ensure the spoiler is hidden messagesToEdit: [], messagesToSend: [{ username: "cadence [they]", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:" - + "\n> [spoiler] cw crossword spoilers you'll never..." + content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:" + + " [spoiler] cw crossword spoilers you'll never..." + "\nomg NO WAY!!", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", allowed_mentions: { parse: ["users", "roles"] } @@ -2378,10 +2498,10 @@ test("event2message: with layered rich replies, the preview should only be the r messagesToEdit: [], messagesToSend: [{ username: "cadence [they]", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 Ⓜ️**cadence [they]**:" - + "\n> two" + content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 **Ⓜcadence [they]**:" + + " two" + "\nthree", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", allowed_mentions: { parse: ["users", "roles"] } @@ -2434,11 +2554,11 @@ test("event2message: if event is a reply and starts with a quote, they should be messagesToEdit: [], messagesToSend: [{ username: "Rose", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647>Ⓜ️**Rose**:" - + "\n> i have a feeling that clients are meant to strip..." + content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**ⓂRose**:" + + " i have a feeling that clients are meant to strip..." + "\n" + "\n> To strip the fallback on the `body`, the client should iterate over each line of the string, removing any lines that start with the fallback prefix (\"> “, including the space, without quotes) and stopping when a line is encountered without the prefix. This prefix is known as the “fallback prefix sequence”.", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/syndicated.gay/ZkBUPXCiXTjdJvONpLJmcbKP", + avatar_url: "https://bridge.example.org/download/matrix/syndicated.gay/ZkBUPXCiXTjdJvONpLJmcbKP", allowed_mentions: { parse: ["users", "roles"] } @@ -2494,9 +2614,55 @@ test("event2message: rich reply to a deleted event", async t => { messagesToEdit: [], messagesToSend: [{ username: "Ampflower 🌺", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647>Ⓜ️**Ampflower 🌺** (in reply to a deleted message)" + content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**ⓂAmpflower 🌺** (in reply to a deleted message)" + "\nHuh it did the same thing here too", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/PRfhXYBTOalvgQYtmCLeUXko", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/PRfhXYBTOalvgQYtmCLeUXko", + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) +}) + +test("event2message: rich reply to a state event with no body", async t => { + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@ampflower:matrix.org", + content: { + msgtype: "m.text", + body: "> <@ampflower:matrix.org> changed the room topic\n\nnice room topic", + format: "org.matrix.custom.html", + formatted_body: "
    In reply to @ampflower:matrix.org changed the room topic
    nice room topic", + "m.relates_to": { + "m.in_reply_to": { + event_id: "$f-noT-d-Eo_Xgpc05Ww89ErUXku4NwKWYGHLzWKo1kU" + } + } + }, + event_id: "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8", + room_id: "!TqlyQmifxGUggEmdBN:cadence.moe" + }, data.guild.general, { + api: { + getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$f-noT-d-Eo_Xgpc05Ww89ErUXku4NwKWYGHLzWKo1kU", { + type: "m.room.topic", + sender: "@ampflower:matrix.org", + content: { + topic: "you're cute" + }, + user_id: "@ampflower:matrix.org" + }) + } + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "Ampflower 🌺", + content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647> (channel details edited)\nnice room topic", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/PRfhXYBTOalvgQYtmCLeUXko", allowed_mentions: { parse: ["users", "roles"] } @@ -2837,6 +3003,133 @@ test("event2message: mentioning bridged rooms works (plaintext body)", async t = ) }) +test("event2message: mentioning bridged rooms by alias works", async t => { + let called = 0 + t.deepEqual( + await eventToMessage({ + content: { + msgtype: "m.text", + body: "wrong body", + format: "org.matrix.custom.html", + formatted_body: `I'm just worm-farm testing channel mentions` + }, + event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", + origin_server_ts: 1688301929913, + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", + sender: "@cadence:cadence.moe", + type: "m.room.message", + unsigned: { + age: 405299 + } + }, {}, { + api: { + async getAlias(alias) { + called++ + t.equal(alias, "#worm-farm:cadence.moe") + return "!BnKuBPCvyfOkhcUjEu:cadence.moe" + } + } + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "cadence [they]", + content: "I'm just <#1100319550446252084> testing channel mentions", + avatar_url: undefined, + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) + t.equal(called, 1) +}) + +test("event2message: mentioning bridged rooms by alias works (plaintext body)", async t => { + let called = 0 + t.deepEqual( + await eventToMessage({ + content: { + msgtype: "m.text", + body: `I'm just https://matrix.to/#/#worm-farm:cadence.moe?via=cadence.moe testing channel mentions` + }, + event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", + origin_server_ts: 1688301929913, + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", + sender: "@cadence:cadence.moe", + type: "m.room.message", + unsigned: { + age: 405299 + } + }, {}, { + api: { + async getAlias(alias) { + called++ + t.equal(alias, "#worm-farm:cadence.moe") + return "!BnKuBPCvyfOkhcUjEu:cadence.moe" + } + } + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "cadence [they]", + content: "I'm just <#1100319550446252084> testing channel mentions", + avatar_url: undefined, + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) + t.equal(called, 1) +}) + +test("event2message: mentioning bridged rooms by alias skips the link when alias is unresolvable", async t => { + let called = 0 + t.deepEqual( + await eventToMessage({ + content: { + msgtype: "m.text", + body: `I'm just https://matrix.to/#/#worm-farm:cadence.moe?via=cadence.moe and https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe?via=cadence.moe testing channel mentions` + }, + event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", + origin_server_ts: 1688301929913, + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", + sender: "@cadence:cadence.moe", + type: "m.room.message", + unsigned: { + age: 405299 + } + }, {}, { + api: { + async getAlias(alias) { + called++ + throw new MatrixServerError("Alias doesn't exist or something") + } + } + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "cadence [they]", + content: "I'm just and <#1100319550446252084> testing channel mentions", + avatar_url: undefined, + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) + t.equal(called, 1) +}) + test("event2message: mentioning known bridged events works (plaintext body)", async t => { t.deepEqual( await eventToMessage({ @@ -2937,6 +3230,40 @@ test("event2message: mentioning known bridged events works (formatted body)", as ) }) +test("event2message: mentioning known bridged events followed by line break and user mention works (partially formatted body)", async t => { + t.deepEqual( + await eventToMessage({ + content: { + msgtype: "m.text", + body: "wrong body", + format: "org.matrix.custom.html", + formatted_body: `https://matrix.to/#/!CzvdIdUQXgUjDVKxeU:cadence.moe/$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10?via=cadence.moeextremity` + }, + event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", + origin_server_ts: 1688301929913, + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", + sender: "@cadence:cadence.moe", + type: "m.room.message", + unsigned: { + age: 405299 + } + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "cadence [they]", + content: "https://discord.com/channels/497159726455455754/497161350934560778/1141619794500649020<@114147806469554185>", + avatar_url: undefined, + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) +}) + test("event2message: mentioning unknown bridged events can approximate with timestamps", async t => { let called = 0 t.deepEqual( @@ -3204,7 +3531,7 @@ test("event2message: caches the member if the member is not known", async t => { }, event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", origin_server_ts: 1688301929913, - room_id: "!should_be_newly_cached:cadence.moe", + room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe", sender: "@should_be_newly_cached:cadence.moe", type: "m.room.message", unsigned: { @@ -3214,7 +3541,7 @@ test("event2message: caches the member if the member is not known", async t => { api: { getStateEvent: async (roomID, type, stateKey) => { called++ - t.equal(roomID, "!should_be_newly_cached:cadence.moe") + t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") t.equal(type, "m.room.member") t.equal(stateKey, "@should_be_newly_cached:cadence.moe") return { @@ -3230,7 +3557,7 @@ test("event2message: caches the member if the member is not known", async t => { messagesToSend: [{ username: "should_be_newly_cached", content: "testing the member state cache", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/this_is_the_avatar", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/this_is_the_avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -3238,12 +3565,60 @@ test("event2message: caches the member if the member is not known", async t => { } ) - t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!should_be_newly_cached:cadence.moe"}).all(), [ + t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe"}).all(), [ {avatar_url: "mxc://cadence.moe/this_is_the_avatar", displayname: null, mxid: "@should_be_newly_cached:cadence.moe"} ]) t.equal(called, 1, "getStateEvent should be called once") }) +test("event2message: does not cache the member if the room is not known", async t => { + let called = 0 + t.deepEqual( + await eventToMessage({ + content: { + body: "testing the member state cache", + msgtype: "m.text" + }, + event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", + origin_server_ts: 1688301929913, + room_id: "!not_real:cadence.moe", + sender: "@should_not_be_cached:cadence.moe", + type: "m.room.message", + unsigned: { + age: 405299 + } + }, {}, { + api: { + getStateEvent: async (roomID, type, stateKey) => { + called++ + t.equal(roomID, "!not_real:cadence.moe") + t.equal(type, "m.room.member") + t.equal(stateKey, "@should_not_be_cached:cadence.moe") + return { + avatar_url: "mxc://cadence.moe/this_is_the_avatar" + } + } + } + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "should_not_be_cached", + content: "testing the member state cache", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/this_is_the_avatar", + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) + + t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!not_real:cadence.moe"}).all(), []) + t.equal(called, 1, "getStateEvent should be called once") +}) + test("event2message: skips caching the member if the member does not exist, somehow", async t => { let called = 0 t.deepEqual( @@ -3299,7 +3674,7 @@ test("event2message: overly long usernames are shifted into the message content" }, event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", origin_server_ts: 1688301929913, - room_id: "!should_be_newly_cached_2:cadence.moe", + room_id: "!cqeGDbPiMFAhLsqqqq:cadence.moe", sender: "@should_be_newly_cached_2:cadence.moe", type: "m.room.message", unsigned: { @@ -3309,7 +3684,7 @@ test("event2message: overly long usernames are shifted into the message content" api: { getStateEvent: async (roomID, type, stateKey) => { called++ - t.equal(roomID, "!should_be_newly_cached_2:cadence.moe") + t.equal(roomID, "!cqeGDbPiMFAhLsqqqq:cadence.moe") t.equal(type, "m.room.member") t.equal(stateKey, "@should_be_newly_cached_2:cadence.moe") return { @@ -3332,7 +3707,7 @@ test("event2message: overly long usernames are shifted into the message content" }] } ) - t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!should_be_newly_cached_2:cadence.moe"}).all(), [ + t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!cqeGDbPiMFAhLsqqqq:cadence.moe"}).all(), [ {avatar_url: null, displayname: "I am BLACK I am WHITE I am SHORT I am LONG I am EVERYTHING YOU THINK IS IMPORTANT and I DON'T MATTER", mxid: "@should_be_newly_cached_2:cadence.moe"} ]) t.equal(called, 1, "getStateEvent should be called once") @@ -3347,7 +3722,7 @@ test("event2message: overly long usernames are not treated specially when the ms }, event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", origin_server_ts: 1688301929913, - room_id: "!should_be_newly_cached_2:cadence.moe", + room_id: "!cqeGDbPiMFAhLsqqqq:cadence.moe", sender: "@should_be_newly_cached_2:cadence.moe", type: "m.room.message", unsigned: { @@ -3394,9 +3769,9 @@ test("event2message: text attachments work", async t => { messagesToSend: [{ username: "cadence [they]", content: "", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", - attachments: [{id: "0", description: undefined, filename: "chiki-powerups.txt"}], - pendingFiles: [{name: "chiki-powerups.txt", url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/zyThGlYQxvlvBVbVgKDDbiHH"}] + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + attachments: [{id: "0", filename: "chiki-powerups.txt"}], + pendingFiles: [{name: "chiki-powerups.txt", mxc: "mxc://cadence.moe/zyThGlYQxvlvBVbVgKDDbiHH"}] }] } ) @@ -3430,15 +3805,15 @@ test("event2message: image attachments work", async t => { messagesToSend: [{ username: "cadence [they]", content: "", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", - attachments: [{id: "0", description: undefined, filename: "cool cat.png"}], - pendingFiles: [{name: "cool cat.png", url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}] + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + attachments: [{id: "0", filename: "cool cat.png"}], + pendingFiles: [{name: "cool cat.png", mxc: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}] }] } ) }) -test("event2message: image attachments can have a custom description", async t => { +test("event2message: image attachments can have a plaintext caption", async t => { t.deepEqual( await eventToMessage({ type: "m.room.message", @@ -3465,10 +3840,62 @@ test("event2message: image attachments can have a custom description", async t = messagesToEdit: [], messagesToSend: [{ username: "cadence [they]", - content: "", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", - attachments: [{id: "0", description: "Cat emoji surrounded by pink hearts", filename: "cool cat.png"}], - pendingFiles: [{name: "cool cat.png", url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}] + content: "Cat emoji surrounded by pink hearts", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + attachments: [{id: "0", filename: "cool cat.png"}], + pendingFiles: [{name: "cool cat.png", mxc: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}], + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) +}) + +test("event2message: image attachments can have a formatted caption", async t => { + t.deepEqual( + await eventToMessage({ + content: { + body: "this event has `formatting`", + filename: "5740.jpg", + format: "org.matrix.custom.html", + formatted_body: "this event has formatting", + info: { + h: 1340, + mimetype: "image/jpeg", + size: 226689, + thumbnail_info: { + h: 670, + mimetype: "image/jpeg", + size: 80157, + w: 540 + }, + thumbnail_url: "mxc://thomcat.rocks/XhLsOCDBYyearsLQgUUrbAvw", + w: 1080, + "xyz.amorgan.blurhash": "KHJQG*55ic-.}?0M58J.9v" + }, + msgtype: "m.image", + url: "mxc://thomcat.rocks/RTHsXmcMPXmuHqVNsnbKtRbh" + }, + origin_server_ts: 1740607766895, + sender: "@cadence:cadence.moe", + type: "m.room.message", + event_id: "$NqNqVgukiQm1nynm9vIr9FIq31hZpQ3udOd7cBIW46U", + room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe" + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "cadence [they]", + content: "this event has `formatting`", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + attachments: [{id: "0", filename: "5740.jpg"}], + pendingFiles: [{name: "5740.jpg", mxc: "mxc://thomcat.rocks/RTHsXmcMPXmuHqVNsnbKtRbh"}], + allowed_mentions: { + parse: ["users", "roles"] + } }] } ) @@ -3516,11 +3943,11 @@ test("event2message: encrypted image attachments work", async t => { messagesToSend: [{ username: "cadence [they]", content: "", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", - attachments: [{id: "0", description: undefined, filename: "image.png"}], + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + attachments: [{id: "0", filename: "image.png"}], pendingFiles: [{ name: "image.png", - url: "https://matrix.cadence.moe/_matrix/media/r0/download/heyquark.com/LOGkUTlVFrqfiExlGZNgCJJX", + mxc: "mxc://heyquark.com/LOGkUTlVFrqfiExlGZNgCJJX", key: "QTo-oMPnN1Rbc7vBFg9WXMgoctscdyxdFEIYm8NYceo", iv: "Va9SHZpIn5kAAAAAAAAAAA" }] @@ -3529,6 +3956,91 @@ test("event2message: encrypted image attachments work", async t => { ) }) +test("event2message: evil encrypted image attachment works", async t => { + t.deepEqual( + await eventToMessage({ + sender: "@austin:tchncs.de", + type: "m.room.message", + content: { + body: "Screenshot 2025-06-29 at 13.36.46.png", + file: { + hashes: { + sha256: "Vh1apd8wSFu/BpUdQbIrKUzFB0Uu+l1octgZL+aVGTQ" + }, + iv: "sd33K7pSZNMAAAAAAAAAAA", + key: { + alg: "A256CTR", + ext: true, + k: "-nyqk1eqI-g-ND59P9qHp310-Qyc2A5gSAYm1BxopSg", + key_ops: [ + "encrypt", + "decrypt" + ], + kty: "oct" + }, + url: "mxc://tchncs.de/eac5f83fa97cd74062daf75dfa04d6e5356897281939377544214085632", + v: "v2" + }, + info: { + h: 682, + mimetype: "image/png", + "org.matrix.msc4230.is_animated": false, + size: 1813154, + thumbnail_file: { + hashes: { + sha256: "o3xykQwfsTUf5Y8qP5fjT7qBv5lAT3rtkmPpise5eQw" + }, + iv: "SNxIZsJkju4AAAAAAAAAAA", + key: { + alg: "A256CTR", + ext: true, + k: "CcibYjzzSDexOWBbcBh_kCDiLibg8vUZthz5CnxV0es", + key_ops: [ + "encrypt", + "decrypt" + ], + kty: "oct" + }, + url: "mxc://tchncs.de/ecd811d913ed1b240ebfc81517a5de2c3a1e9d401939377537079574528", + v: "v2" + }, + thumbnail_info: { + h: 600, + mimetype: "image/png", + size: 451773, + w: 507 + }, + thumbnail_url: null, + w: 577, + "xyz.amorgan.blurhash": "TqN1Ais=t1~qRjWFxURiWCM{ofof" + }, + "m.mentions": {}, + msgtype: "m.image", + url: null + }, + event_id: "$UKMbzTlqlyLYN78utVEtiivABFvOe39nx5trHwqNmeQ", + room_id: "!iSyXgNxQcEuXoXpsSn:pussthecat.org" + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "Austin Huang", + content: "", + avatar_url: "https://bridge.example.org/download/matrix/tchncs.de/090a2b5e07eed2f71e84edad5207221e6c8f8b8e", + attachments: [{id: "0", filename: "Screenshot 2025-06-29 at 13.36.46.png"}], + pendingFiles: [{ + name: "Screenshot 2025-06-29 at 13.36.46.png", + mxc: "mxc://tchncs.de/eac5f83fa97cd74062daf75dfa04d6e5356897281939377544214085632", + key: "-nyqk1eqI-g-ND59P9qHp310-Qyc2A5gSAYm1BxopSg", + iv: "sd33K7pSZNMAAAAAAAAAAA" + }] + }] + } + ) +}) + test("event2message: stickers work", async t => { t.deepEqual( await eventToMessage({ @@ -3561,9 +4073,9 @@ test("event2message: stickers work", async t => { messagesToSend: [{ username: "cadence [they]", content: "", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", attachments: [{id: "0", filename: "get_real2.gif"}], - pendingFiles: [{name: "get_real2.gif", url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/NyMXQFAAdniImbHzsygScbmN"}] + pendingFiles: [{name: "get_real2.gif", mxc: "mxc://cadence.moe/NyMXQFAAdniImbHzsygScbmN"}] }] } ) @@ -3582,15 +4094,17 @@ test("event2message: stickers fetch mimetype from server when mimetype not provi event_id: "$mL-eEVWCwOvFtoOiivDP7gepvf-fTYH6_ioK82bWDI0", room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" }, {}, { - async fetch(url, options) { - called++ - t.equal(url, "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/ybOWQCaXysnyUGuUCaQlTGJf") - t.equal(options.method, "HEAD") - return { - status: 200, - headers: new Map([ - ["content-type", "image/gif"] - ]) + api: { + async getMedia(mxc, options) { + called++ + t.equal(mxc, "mxc://cadence.moe/ybOWQCaXysnyUGuUCaQlTGJf") + t.equal(options.method, "HEAD") + return { + status: 200, + headers: new Map([ + ["content-type", "image/gif"] + ]) + } } } }), @@ -3603,7 +4117,7 @@ test("event2message: stickers fetch mimetype from server when mimetype not provi content: "", avatar_url: undefined, attachments: [{id: "0", filename: "YESYESYES.gif"}], - pendingFiles: [{name: "YESYESYES.gif", url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/ybOWQCaXysnyUGuUCaQlTGJf"}] + pendingFiles: [{name: "YESYESYES.gif", mxc: "mxc://cadence.moe/ybOWQCaXysnyUGuUCaQlTGJf"}] }] } ) @@ -3623,15 +4137,17 @@ test("event2message: stickers with unknown mimetype are not allowed", async t => event_id: "$mL-eEVWCwOvFtoOiivDP7gepvf-fTYH6_ioK82bWDI0", room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" }, {}, { - async fetch(url, options) { - called++ - t.equal(url, "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/ybOWQCaXysnyUGuUCaQlTGJe") - t.equal(options.method, "HEAD") - return { - status: 404, - headers: new Map([ - ["content-type", "application/json"] - ]) + api: { + async getMedia(mxc, options) { + called++ + t.equal(mxc, "mxc://cadence.moe/ybOWQCaXysnyUGuUCaQlTGJe") + t.equal(options.method, "HEAD") + return { + status: 404, + headers: new Map([ + ["content-type", "application/json"] + ]) + } } } }) @@ -3672,6 +4188,36 @@ test("event2message: static emojis work", async t => { ) }) +test("event2message: emojis in other servers are reused if they have the same title text", async t => { + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: ":hippo:", + format: "org.matrix.custom.html", + formatted_body: '\":hippo:\"' + }, + event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", + room_id: "!CzvdIdUQXgUjDVKxeU:cadence.moe" + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "cadence [they]", + content: "<:hippo:230201364309868544>", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) +}) + test("event2message: animated emojis work", async t => { t.deepEqual( await eventToMessage({ @@ -3722,7 +4268,7 @@ test("event2message: unknown emojis in the middle are linked", async t => { messagesToEdit: [], messagesToSend: [{ username: "cadence [they]", - content: "a [:ms_robot_grin:](https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/RLMgJGfgTPjIQtvvWZsYjhjy) b", + content: "a [:ms_robot_grin:](https://bridge.example.org/download/matrix/cadence.moe/RLMgJGfgTPjIQtvvWZsYjhjy) b", avatar_url: undefined, allowed_mentions: { parse: ["users", "roles"] @@ -4076,6 +4622,42 @@ test("event2message: @room in the middle of a link is not converted", async t => ) }) +test("event2message: table", async t => { + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "wrong body", + format: "org.matrix.custom.html", + formatted_body: "content
    Col 1Col 2Col 3
    AppleBananaCherry
    AardvarkBeeCrocodile
    ArgonBoronCarbon
    more content" + }, + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", + event_id: "$SiXetU9h9Dg-M9Frcw_C6ahnoXZ3QPZe3MVJR5tcB9A" + }), + { + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "cadence [they]", + content: "content```" + + "\nCol 1 Col 2 Col 3 " + + "\n---------------------------" + + "\nApple Banana Cherry " + + "\nAardvark Bee Crocodile" + + "\nArgon Boron Carbon ```" + + "more content", + avatar_url: undefined, + allowed_mentions: { + parse: ["users", "roles"] + } + }], + ensureJoined: [] + } + ) +}) + slow()("event2message: unknown emoji at the end is reuploaded as a sprite sheet", async t => { const messages = await eventToMessage({ type: "m.room.message", @@ -4097,7 +4679,7 @@ slow()("event2message: unknown emoji at the end is reuploaded as a sprite sheet" t.deepEqual(testResult, { content: "a b", fileName: "emojis.png", - fileContentStart: "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAPoAAAD6AG1e1JrAAALkklEQVR4nM1ZeWyUxxV/azAGwn0JMJUppPhce++1Oc1i" + fileContentStart: "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAPoAAAD6AG1e1JrAAALoklEQVR4nM1ZaVBU2RU+LZSIGnAvFUtcRkSk6abpbkDH" }) }) @@ -4122,7 +4704,7 @@ slow()("event2message: known emoji from an unreachable server at the end is reup t.deepEqual(testResult, { content: "a b", fileName: "emojis.png", - fileContentStart: "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAOnUlEQVR4nM1aCXCb1Z3/kig+5NjWaeu+LOuWLFnHJ8l2" + fileContentStart: "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAOoUlEQVR4nM1aCXBbx3l+Eu8bN0CAuO+TAHGTFAmAJHgT" }) }) @@ -4145,9 +4727,9 @@ slow()("event2message: known and unknown emojis in the end are reuploaded as a s fileContentStart: messages.messagesToSend[0].pendingFiles[0].buffer.subarray(0, 90).toString("base64") } t.deepEqual(testResult, { - content: "known unknown: <:hippo:230201364309868544> [:ms_robot_dress:](https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/wcouHVjbKJJYajkhJLsyeJAA) and known unknown:", + content: "known unknown: <:hippo:230201364309868544> [:ms_robot_dress:](https://bridge.example.org/download/matrix/cadence.moe/wcouHVjbKJJYajkhJLsyeJAA) and known unknown:", fileName: "emojis.png", - fileContentStart: "iVBORw0KGgoAAAANSUhEUgAAAGAAAAAwCAYAAADuFn/PAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAT5UlEQVR4nOVbCXSVRZauR9gMsoYlvKwvARKSkPUlJOyL" + fileContentStart: "iVBORw0KGgoAAAANSUhEUgAAAGAAAAAwCAYAAADuFn/PAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAeXRFWHRSYXcACklQVEMgcHJvZmlsZQogICAgICA0Ngoz" }) }) @@ -4162,7 +4744,7 @@ slow()("event2message: all unknown chess emojis are reuploaded as a sprite sheet formatted_body: "testing \":chess_good_move:\"\":chess_incorrect:\"\":chess_blund:\"\":chess_brilliant_move:\"\":chess_blundest:\"\":chess_draw_black:\"\":chess_good_move:\"\":chess_incorrect:\"\":chess_blund:\"\":chess_brilliant_move:\"\":chess_blundest:\"\":chess_draw_black:\"" }, event_id: "$Me6iE8C8CZyrDEOYYrXKSYRuuh_25Jj9kZaNrf7LKr4", - room_id: "!maggESguZBqGBZtSnr:cadence.moe" + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" }, {}, {mxcDownloader: mockGetAndConvertEmoji}) const testResult = { content: messages.messagesToSend[0].content, @@ -4172,6 +4754,6 @@ slow()("event2message: all unknown chess emojis are reuploaded as a sprite sheet t.deepEqual(testResult, { content: "testing", fileName: "emojis.png", - fileContentStart: "iVBORw0KGgoAAAANSUhEUgAAAYAAAABgCAYAAAAU9KWJAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAgAElEQVR4nOx9B3xT1/W/UkImYKZtLdt4a0uWMaQkzS9t" + fileContentStart: "iVBORw0KGgoAAAANSUhEUgAAAYAAAABgCAYAAAAU9KWJAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48" }) }) diff --git a/m2d/converters/utils.js b/src/m2d/converters/utils.js similarity index 82% rename from m2d/converters/utils.js rename to src/m2d/converters/utils.js index 9d7a5e2..41cb0af 100644 --- a/m2d/converters/utils.js +++ b/src/m2d/converters/utils.js @@ -1,8 +1,13 @@ // @ts-check -const reg = require("../../matrix/read-registration") -const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) const assert = require("assert").strict + +const passthrough = require("../../passthrough") +const {db} = passthrough + +const {reg} = require("../../matrix/read-registration") +const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) + /** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore let hasher = null // @ts-ignore @@ -25,9 +30,7 @@ const NEWLINE_ELEMENTS = BLOCK_ELEMENTS.concat(["BR"]) */ function eventSenderIsFromDiscord(sender) { // If it's from a user in the bridge's namespace, then it originated from discord - // This includes messages sent by the appservice's bot user, because that is what's used for webhooks - // TODO: It would be nice if bridge system messages wouldn't trigger this check and could be bridged from matrix to discord, while webhook reflections would remain ignored... - // TODO that only applies to the above todo: But you'd have to watch out for the /icon command, where the bridge bot would set the room avatar, and that shouldn't be reflected into the room a second time. + // This could include messages sent by the appservice's bot user, because that is what's used for webhooks if (userRegex.some(x => sender.match(x))) { return true } @@ -35,16 +38,6 @@ function eventSenderIsFromDiscord(sender) { return false } -/** - * @param {string} mxc - * @returns {string?} - */ -function getPublicUrlForMxc(mxc) { - const avatarURLParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/) - if (avatarURLParts) return `${reg.ooye.server_origin}/_matrix/media/r0/download/${avatarURLParts[1]}/${avatarURLParts[2]}` - else return null -} - /** * Event IDs are really big and have more entropy than we need. * If we want to store the event ID in the database, we can store a more compact version by hashing it with this. @@ -213,6 +206,32 @@ async function getViaServersQuery(roomID, api) { return qs } +/** + * Since the introduction of authenticated media, this can no longer just be the /_matrix/media/r0/download URL + * because Discord and Discord users cannot use those URLs. Media now has to be proxied through the bridge. + * To avoid the bridge acting as a proxy for *any* media, there is a list of permitted media stored in the database. + * (The other approach would be signing the URLs with a MAC (or similar) and adding the signature, but I'm not a + * cryptographer, so I don't want to.) To reduce database disk space usage, instead of storing each permitted URL, + * we just store its xxhash as a signed (as in +/-, not signature) 64-bit integer, which fits in an SQLite integer field. + * @see https://matrix.org/blog/2024/06/26/sunsetting-unauthenticated-media/ background + * @see https://matrix.org/blog/2024/06/20/matrix-v1.11-release/ implementation details + * @see https://www.sqlite.org/fileformat2.html#record_format SQLite integer field size + * @param {string} mxc + * @returns {string | undefined} + */ +function getPublicUrlForMxc(mxc) { + assert(hasher, "xxhash is not ready yet") + const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/) + if (!mediaParts) return undefined + + const serverAndMediaID = `${mediaParts[1]}/${mediaParts[2]}` + const unsignedHash = hasher.h64(serverAndMediaID) + const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range + db.prepare("INSERT OR IGNORE INTO media_proxy (permitted_hash) VALUES (?)").run(signedHash) + + return `${reg.ooye.bridge_origin}/download/matrix/${serverAndMediaID}` +} + module.exports.BLOCK_ELEMENTS = BLOCK_ELEMENTS module.exports.eventSenderIsFromDiscord = eventSenderIsFromDiscord module.exports.getPublicUrlForMxc = getPublicUrlForMxc diff --git a/m2d/converters/utils.test.js b/src/m2d/converters/utils.test.js similarity index 100% rename from m2d/converters/utils.test.js rename to src/m2d/converters/utils.test.js diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js new file mode 100644 index 0000000..ce3638c --- /dev/null +++ b/src/m2d/event-dispatcher.js @@ -0,0 +1,384 @@ +// @ts-check + +/* + * Grab Matrix events we care about, check them, and bridge them. + */ + +const util = require("util") +const Ty = require("../types") +const {discord, db, sync, as, select} = require("../passthrough") + +/** @type {import("./actions/send-event")} */ +const sendEvent = sync.require("./actions/send-event") +/** @type {import("./actions/add-reaction")} */ +const addReaction = sync.require("./actions/add-reaction") +/** @type {import("./actions/redact")} */ +const redact = sync.require("./actions/redact") +/** @type {import("./actions/update-pins")}) */ +const updatePins = sync.require("./actions/update-pins") +/** @type {import("../matrix/matrix-command-handler")} */ +const matrixCommandHandler = sync.require("../matrix/matrix-command-handler") +/** @type {import("./converters/utils")} */ +const utils = sync.require("./converters/utils") +/** @type {import("../matrix/api")}) */ +const api = sync.require("../matrix/api") +/** @type {import("../d2m/actions/create-room")} */ +const createRoom = sync.require("../d2m/actions/create-room") +const {reg} = require("../matrix/read-registration") + +let lastReportedEvent = 0 + +/** + * This function is adapted from Evan Kaufman's fantastic work. + * The original function and my adapted function are both MIT licensed. + * @url https://github.com/EvanK/npm-loggable-error/ + * @param {number} [depth] + * @returns {string} +*/ +function stringifyErrorStack(err, depth = 0) { + let collapsed = " ".repeat(depth); + if (!(err instanceof Error)) { + return collapsed + err + } + + // add full stack trace if one exists, otherwise convert to string + let stackLines = String(err?.stack ?? err).replace(/^/gm, " ".repeat(depth)).trim().split("\n") + let cloudstormLine = stackLines.findIndex(l => l.includes("/node_modules/cloudstorm/")) + if (cloudstormLine !== -1) { + stackLines = stackLines.slice(0, cloudstormLine - 2) + } + collapsed += stackLines.join("\n") + + const props = Object.getOwnPropertyNames(err).filter(p => !["message", "stack"].includes(p)) + + // only break into object notation if we have additional props to dump + if (props.length) { + const dedent = " ".repeat(depth); + const indent = " ".repeat(depth + 2); + + collapsed += " {\n"; + + // loop and print each (indented) prop name + for (let property of props) { + collapsed += `${indent}[${property}]: `; + + // if another error object, stringify it too + if (err[property] instanceof Error) { + collapsed += stringifyErrorStack(err[property], depth + 2).trimStart(); + } + // otherwise stringify as JSON + else { + collapsed += JSON.stringify(err[property]); + } + + collapsed += "\n"; + } + + collapsed += `${dedent}}\n`; + } + + return collapsed; +} + +/** + * @param {string} roomID + * @param {"Discord" | "Matrix"} source + * @param {any} type + * @param {any} e + * @param {any} payload + */ +async function sendError(roomID, source, type, e, payload) { + console.error(`Error while processing a ${type} ${source} event:`) + console.error(e) + console.dir(payload, {depth: null}) + + if (Date.now() - lastReportedEvent < 5000) return null + lastReportedEvent = Date.now() + + let errorIntroLine = e.toString() + if (e.cause) { + errorIntroLine += ` (cause: ${e.cause})` + } + + const builder = new utils.MatrixStringBuilder() + + const cloudflareErrorTitle = errorIntroLine.match(/.*?discord\.com \| ([^<]*)<\/title>/s)?.[1] + if (cloudflareErrorTitle) { + builder.addLine( + `\u26a0 Matrix event not delivered to Discord. Discord might be down right now. Cloudflare error: ${cloudflareErrorTitle}`, + `\u26a0 <strong>Matrix event not delivered to Discord</strong><br>Discord might be down right now. Cloudflare error: ${cloudflareErrorTitle}` + ) + } else { + // What + const what = source === "Discord" ? "Bridged event from Discord not delivered" : "Matrix event not delivered to Discord" + builder.addLine(`\u26a0 ${what}`, `\u26a0 <strong>${what}</strong>`) + + // Who + builder.addLine(`Event type: ${type}`) + + // Why + builder.addLine(errorIntroLine) + + // Where + const stack = stringifyErrorStack(e) + builder.addLine(`Error trace:\n${stack}`, `<details><summary>Error trace</summary><pre>${stack}</pre></details>`) + + // How + builder.addLine("", `<details><summary>Original payload</summary><pre>${util.inspect(payload, false, 4, false)}</pre></details>`) + } + + // Send + try { + await api.sendEvent(roomID, "m.room.message", { + ...builder.get(), + "moe.cadence.ooye.error": { + source: source.toLowerCase(), + payload + }, + "m.mentions": { + user_ids: ["@cadence:cadence.moe"] + } + }) + } catch (e) {} +} + +function guard(type, fn) { + return async function(event, ...args) { + try { + return await fn(event, ...args) + } catch (e) { + await sendError(event.room_id, "Matrix", type, e, event) + } + } +} + +/** + * @param {Ty.Event.Outer<Ty.Event.M_Reaction>} reactionEvent + */ +async function onRetryReactionAdd(reactionEvent) { + const roomID = reactionEvent.room_id + const event = await api.getEvent(roomID, reactionEvent.content["m.relates_to"]?.event_id) + + // Check that it's a real error from OOYE + const error = event.content["moe.cadence.ooye.error"] + if (event.sender !== `@${reg.sender_localpart}:${reg.ooye.server_name}` || !error) return + + // To stop people injecting misleading messages, the reaction needs to come from either the original sender or a room moderator + if (reactionEvent.sender !== event.sender) { + // Check if it's a room moderator + const powerLevelsStateContent = await api.getStateEvent(roomID, "m.room.power_levels", "") + const powerLevel = powerLevelsStateContent.users?.[reactionEvent.sender] || 0 + if (powerLevel < 50) return + } + + // Retry + if (error.source === "matrix") { + as.emit(`type:${error.payload.type}`, error.payload) + } else if (error.source === "discord") { + discord.cloud.emit("event", error.payload) + } + + // Redact the error to stop people from executing multiple retries + await api.redactEvent(roomID, event.event_id) +} + +sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message", +/** + * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} event it is a m.room.message because that's what this listener is filtering for + */ +async event => { + if (utils.eventSenderIsFromDiscord(event.sender)) return + const messageResponses = await sendEvent.sendEvent(event) + if (!messageResponses.length) return + if (event.type === "m.room.message" && event.content.msgtype === "m.text") { + // @ts-ignore + await matrixCommandHandler.execute(event) + } + await api.ackEvent(event) +})) + +sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker", +/** + * @param {Ty.Event.Outer_M_Sticker} event it is a m.sticker because that's what this listener is filtering for + */ +async event => { + if (utils.eventSenderIsFromDiscord(event.sender)) return + const messageResponses = await sendEvent.sendEvent(event) + await api.ackEvent(event) +})) + +sync.addTemporaryListener(as, "type:m.reaction", guard("m.reaction", +/** + * @param {Ty.Event.Outer<Ty.Event.M_Reaction>} event it is a m.reaction because that's what this listener is filtering for + */ +async event => { + if (utils.eventSenderIsFromDiscord(event.sender)) return + if (event.content["m.relates_to"].key === "🔁") { + // Try to bridge a failed event again? + await onRetryReactionAdd(event) + } else { + matrixCommandHandler.onReactionAdd(event) + await addReaction.addReaction(event) + } +})) + +sync.addTemporaryListener(as, "type:m.room.redaction", guard("m.room.redaction", +/** + * @param {Ty.Event.Outer_M_Room_Redaction} event it is a m.room.redaction because that's what this listener is filtering for + */ +async event => { + if (utils.eventSenderIsFromDiscord(event.sender)) return + await redact.handle(event) + await api.ackEvent(event) +})) + +sync.addTemporaryListener(as, "type:m.room.avatar", guard("m.room.avatar", +/** + * @param {Ty.Event.StateOuter<Ty.Event.M_Room_Avatar>} event + */ +async event => { + if (event.state_key !== "") return + if (utils.eventSenderIsFromDiscord(event.sender)) return + const url = event.content.url || null + db.prepare("UPDATE channel_room SET custom_avatar = ? WHERE room_id = ?").run(url, event.room_id) +})) + +sync.addTemporaryListener(as, "type:m.room.name", guard("m.room.name", +/** + * @param {Ty.Event.StateOuter<Ty.Event.M_Room_Name>} event + */ +async event => { + if (event.state_key !== "") return + if (utils.eventSenderIsFromDiscord(event.sender)) return + const name = event.content.name || null + db.prepare("UPDATE channel_room SET nick = ? WHERE room_id = ?").run(name, event.room_id) +})) + +sync.addTemporaryListener(as, "type:m.room.topic", guard("m.room.topic", +/** + * @param {Ty.Event.StateOuter<Ty.Event.M_Room_Topic>} event + */ +async event => { + if (event.state_key !== "") return + if (utils.eventSenderIsFromDiscord(event.sender)) return + const customTopic = +!!event.content.topic + const row = select("channel_room", ["channel_id", "custom_topic"], {room_id: event.room_id}).get() + if (!row) return + if (customTopic !== row.custom_topic) db.prepare("UPDATE channel_room SET custom_topic = ? WHERE channel_id = ?").run(customTopic, row.channel_id) + if (!customTopic) await createRoom.syncRoom(row.channel_id) // if it's cleared we should reset it to whatever's on discord +})) + +sync.addTemporaryListener(as, "type:m.room.pinned_events", guard("m.room.pinned_events", +/** + * @param {Ty.Event.StateOuter<Ty.Event.M_Room_PinnedEvents>} event + */ +async event => { + if (event.state_key !== "") return + if (utils.eventSenderIsFromDiscord(event.sender)) return + const pins = event.content.pinned + if (!Array.isArray(pins)) return + let prev = event.unsigned?.prev_content?.pinned + if (!Array.isArray(prev)) { + if (pins.length === 1) { + /* + In edge cases, prev_content isn't guaranteed to be provided by the server. + If prev_content is missing, we can't diff. Better safe than sorry: we'd like to ignore the change rather than wiping the whole channel's pins on Discord. + However, that would mean if the first ever pin came from Matrix-side, it would be ignored, because there would be no prev_content (it's the first pinned event!) + So to handle that edge case, we assume that if there's exactly 1 entry in `pinned`, this is the first ever pin and it should go through. + */ + prev = [] + } else { + return + } + } + + await updatePins.updatePins(pins, prev) + await api.ackEvent(event) +})) + +function getFromInviteRoomState(inviteRoomState, nskey, key) { + if (!Array.isArray(inviteRoomState)) return null + for (const event of inviteRoomState) { + if (event.type === nskey && event.state_key === "") { + return event.content[key] + } + } + return null +} + +sync.addTemporaryListener(as, "type:m.space.child", guard("m.space.child", +/** + * @param {Ty.Event.StateOuter<Ty.Event.M_Space_Child>} event + */ +async event => { + if (Array.isArray(event.content.via) && event.content.via.length) { // space child is being added + await api.joinRoom(event.state_key).catch(() => {}) // try to join if able, it's okay if it doesn't want, bot will still respond to invites + } +})) + +sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member", +/** + * @param {Ty.Event.StateOuter<Ty.Event.M_Room_Member>} event + */ +async event => { + if (event.state_key[0] !== "@") return + + if (event.content.membership === "invite" && event.state_key === `@${reg.sender_localpart}:${reg.ooye.server_name}`) { + // We were invited to a room. We should join, and register the invite details for future reference in web. + const name = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.name", "name") + const topic = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.topic", "topic") + const avatar = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.avatar", "url") + const creationType = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.create", "type") + if (!name) return await api.leaveRoomWithReason(event.room_id, "Please only invite me to rooms that have a name/avatar set. Update the room details and reinvite!") + await api.joinRoom(event.room_id) + db.prepare("INSERT OR IGNORE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, creationType, name, topic, avatar) + if (avatar) utils.getPublicUrlForMxc(avatar) // make sure it's available in the media_proxy allowed URLs + } + + if (utils.eventSenderIsFromDiscord(event.state_key)) return + + if (event.content.membership === "leave" || event.content.membership === "ban") { + // Member is gone + db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key) + + // Unregister room's use as a direct chat if the bot itself left + const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}` + if (event.state_key === bot) { + db.prepare("DELETE FROM direct WHERE room_id = ?").run(event.room_id) + } + } + + const exists = select("channel_room", "room_id", {room_id: event.room_id}) ?? select("guild_space", "space_id", {space_id: event.room_id}) + if (!exists) return // don't cache members in unbridged rooms + + // Member is here + let powerLevel = 0 + try { + /** @type {Ty.Event.M_Power_Levels} */ + const powerLevelsEvent = await api.getStateEvent(event.room_id, "m.room.power_levels", "") + powerLevel = powerLevelsEvent.users?.[event.state_key] ?? powerLevelsEvent.users_default ?? 0 + } catch (e) {} + const displayname = event.content.displayname || null + const avatar_url = event.content.avatar_url + db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url, power_level) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?, power_level = ?").run( + event.room_id, event.state_key, + displayname, avatar_url, powerLevel, + displayname, avatar_url, powerLevel + ) +})) + +sync.addTemporaryListener(as, "type:m.room.power_levels", guard("m.room.power_levels", +/** + * @param {Ty.Event.StateOuter<Ty.Event.M_Power_Levels>} event + */ +async event => { + if (event.state_key !== "") return + const existingPower = select("member_cache", "mxid", {room_id: event.room_id}).pluck().all() + const newPower = event.content.users || {} + for (const mxid of existingPower) { + db.prepare("UPDATE member_cache SET power_level = ? WHERE room_id = ? AND mxid = ?").run(newPower[mxid] || 0, event.room_id, mxid) + } +})) + +module.exports.stringifyErrorStack = stringifyErrorStack +module.exports.sendError = sendError diff --git a/src/m2d/event-dispatcher.test.js b/src/m2d/event-dispatcher.test.js new file mode 100644 index 0000000..de754da --- /dev/null +++ b/src/m2d/event-dispatcher.test.js @@ -0,0 +1,23 @@ +// @ts-check + +const {test} = require("supertape") +const {stringifyErrorStack} = require("./event-dispatcher") + +test("stringify error stack: works", t => { + function a() { + const e = new Error("message", {cause: new Error("inner")}) + // @ts-ignore + e.prop = 2.1 + throw e + } + try { + a() + t.fail("shouldn't get here") + } catch (e) { + const str = stringifyErrorStack(e) + t.match(str, /^Error: message$/m) + t.match(str, /^ at a \(.*event-dispatcher\.test\.js/m) + t.match(str, /^ \[cause\]: Error: inner$/m) + t.match(str, /^ \[prop\]: 2.1$/m) + } +}) diff --git a/src/matrix/api.js b/src/matrix/api.js new file mode 100644 index 0000000..41af63f --- /dev/null +++ b/src/matrix/api.js @@ -0,0 +1,493 @@ +// @ts-check + +const Ty = require("../types") +const assert = require("assert").strict +const streamWeb = require("stream/web") + +const passthrough = require("../passthrough") +const {sync} = passthrough +/** @type {import("./mreq")} */ +const mreq = sync.require("./mreq") +/** @type {import("./txnid")} */ +const makeTxnId = sync.require("./txnid") +const {reg} = require("./read-registration.js") + +/** + * @param {string} p endpoint to access + * @param {string?} [mxid] optional: user to act as, for the ?user_id parameter + * @param {{[x: string]: any}} [otherParams] optional: any other query parameters to add + * @returns {string} the new endpoint + */ +function path(p, mxid, otherParams = {}) { + const u = new URL(p, "http://localhost") + if (mxid) u.searchParams.set("user_id", mxid) + for (const entry of Object.entries(otherParams)) { + if (entry[1] != undefined) { + u.searchParams.set(entry[0], entry[1]) + } + } + let result = u.pathname + const str = u.searchParams.toString() + if (str) result += "?" + str + return result +} + +/** + * @param {string} username + */ +async function register(username) { + console.log(`[api] register: ${username}`) + try { + await mreq.mreq("POST", "/client/v3/register", { + type: "m.login.application_service", + username + }) + } catch (e) { + if (e.errcode === "M_USER_IN_USE" || e.data?.error === "Internal server error") { + // "Internal server error" is the only OK error because older versions of Synapse say this if you try to register the same username twice. + } else { + throw e + } + } +} + +/** + * @returns {Promise<string>} room ID + */ +async function createRoom(content) { + console.log(`[api] create room:`, content) + /** @type {Ty.R.RoomCreated} */ + const root = await mreq.mreq("POST", "/client/v3/createRoom", content) + return root.room_id +} + +/** + * @returns {Promise<string>} room ID + */ +async function joinRoom(roomIDOrAlias, mxid) { + /** @type {Ty.R.RoomJoined} */ + const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid), {}) + return root.room_id +} + +async function inviteToRoom(roomID, mxidToInvite, mxid) { + await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/invite`, mxid), { + user_id: mxidToInvite + }) +} + +async function leaveRoom(roomID, mxid) { + console.log(`[api] leave: ${roomID}: ${mxid}`) + await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, mxid), {}) +} + +/** + * @param {string} roomID + * @param {string} reason + * @param {string} [mxid] + */ +async function leaveRoomWithReason(roomID, reason, mxid) { + console.log(`[api] leave: ${roomID}: ${mxid}, because ${reason}`) + await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, mxid), {reason}) +} + +/** + * @param {string} roomID + * @param {string} eventID + * @template T + */ +async function getEvent(roomID, eventID) { + /** @type {Ty.Event.Outer<T>} */ + const root = await mreq.mreq("GET", `/client/v3/rooms/${roomID}/event/${eventID}`) + return root +} + +/** + * @param {string} roomID + * @param {number} ts unix silliseconds + */ +async function getEventForTimestamp(roomID, ts) { + /** @type {{event_id: string, origin_server_ts: number}} */ + const root = await mreq.mreq("GET", path(`/client/v1/rooms/${roomID}/timestamp_to_event`, null, {ts})) + return root +} + +/** + * @param {string} roomID + * @returns {Promise<Ty.Event.BaseStateEvent[]>} + */ +function getAllState(roomID) { + return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state`) +} + +/** + * @param {string} roomID + * @param {string} type + * @param {string} key + * @returns the *content* of the state event + */ +function getStateEvent(roomID, type, key) { + return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state/${type}/${key}`) +} + +/** + * "Any of the AS's users must be in the room. This API is primarily for Application Services and should be faster to respond than /members as it can be implemented more efficiently on the server." + * @param {string} roomID + * @returns {Promise<{joined: {[mxid: string]: {avatar_url: string?, display_name: string?}}}>} + */ +function getJoinedMembers(roomID) { + return mreq.mreq("GET", `/client/v3/rooms/${roomID}/joined_members`) +} + +/** + * "Get the list of members for this room." This includes joined, invited, knocked, left, and banned members unless a filter is provided. + * The endpoint also supports `at` and `not_membership` URL parameters, but they are not exposed in this wrapper yet. + * @param {string} roomID + * @param {"join" | "invite" | "knock" | "leave" | "ban"} [membership] The kind of membership to filter for. Only one choice allowed. + * @returns {Promise<{chunk: Ty.Event.Outer<Ty.Event.M_Room_Member>[]}>} + */ +function getMembers(roomID, membership) { + return mreq.mreq("GET", `/client/v3/rooms/${roomID}/members`, undefined, {membership}) +} + +/** + * @param {string} roomID + * @param {{from?: string, limit?: any}} pagination + * @returns {Promise<Ty.HierarchyPagination<Ty.R.Hierarchy>>} + */ +function getHierarchy(roomID, pagination) { + let path = `/client/v1/rooms/${roomID}/hierarchy` + if (!pagination.from) delete pagination.from + if (!pagination.limit) pagination.limit = 50 + path += `?${new URLSearchParams(pagination)}` + return mreq.mreq("GET", path) +} + +/** + * Like `getHierarchy` but collects all pages for you. + * @param {string} roomID + */ +async function getFullHierarchy(roomID) { + /** @type {Ty.R.Hierarchy[]} */ + let rooms = [] + /** @type {string | undefined} */ + let nextBatch = undefined + do { + /** @type {Ty.HierarchyPagination<Ty.R.Hierarchy>} */ + const res = await getHierarchy(roomID, {from: nextBatch}) + rooms.push(...res.rooms) + nextBatch = res.next_batch + } while (nextBatch) + return rooms +} + +/** + * Like `getFullHierarchy` but reveals a page at a time through an async iterator. + * @param {string} roomID + */ +async function* generateFullHierarchy(roomID) { + /** @type {string | undefined} */ + let nextBatch = undefined + do { + /** @type {Ty.HierarchyPagination<Ty.R.Hierarchy>} */ + const res = await getHierarchy(roomID, {from: nextBatch}) + for (const room of res.rooms) { + yield room + } + nextBatch = res.next_batch + } while (nextBatch) +} + +/** + * @param {string} roomID + * @param {string} eventID + * @param {{from?: string, limit?: any}} pagination + * @param {string?} [relType] + * @returns {Promise<Ty.Pagination<Ty.Event.Outer<any>>>} + */ +function getRelations(roomID, eventID, pagination, relType) { + let path = `/client/v1/rooms/${roomID}/relations/${eventID}` + if (relType) path += `/${relType}` + if (!pagination.from) delete pagination.from + if (!pagination.limit) pagination.limit = 50 // get a little more consistency between homeservers + path += `?${new URLSearchParams(pagination)}` + return mreq.mreq("GET", path) +} + +/** + * Like `getRelations` but collects and filters all pages for you. + * @param {string} roomID + * @param {string} eventID + * @param {string?} [relType] type of relations to filter, e.g. "m.annotation" for reactions + */ +async function getFullRelations(roomID, eventID, relType) { + /** @type {Ty.Event.Outer<Ty.Event.M_Reaction>[]} */ + let reactions = [] + /** @type {string | undefined} */ + let nextBatch = undefined + do { + /** @type {Ty.Pagination<Ty.Event.Outer<Ty.Event.M_Reaction>>} */ + const res = await getRelations(roomID, eventID, {from: nextBatch}, relType) + reactions = reactions.concat(res.chunk) + nextBatch = res.next_batch + } while (nextBatch) + return reactions +} + +/** + * @param {string} roomID + * @param {string} type + * @param {string} stateKey + * @param {string} [mxid] + * @returns {Promise<string>} event ID + */ +async function sendState(roomID, type, stateKey, content, mxid) { + console.log(`[api] state: ${roomID}: ${type}/${stateKey}`) + assert.ok(type) + assert.ok(typeof stateKey === "string") + /** @type {Ty.R.EventSent} */ + // encodeURIComponent is necessary because state key can contain some special characters like / but you must encode them so they fit in a single component of the URI + const root = await mreq.mreq("PUT", path(`/client/v3/rooms/${roomID}/state/${type}/${encodeURIComponent(stateKey)}`, mxid), content) + return root.event_id +} + +/** + * @param {string} roomID + * @param {string} type + * @param {any} content + * @param {string?} [mxid] + * @param {number} [timestamp] timestamp of the newly created event, in unix milliseconds + */ +async function sendEvent(roomID, type, content, mxid, timestamp) { + if (!["m.room.message", "m.reaction", "m.sticker"].includes(type)) { + console.log(`[api] event ${type} to ${roomID} as ${mxid || "default sim"}`) + } + /** @type {Ty.R.EventSent} */ + const root = await mreq.mreq("PUT", path(`/client/v3/rooms/${roomID}/send/${type}/${makeTxnId.makeTxnId()}`, mxid, {ts: timestamp}), content) + return root.event_id +} + +/** + * @param {string} roomID + * @param {string} eventID + * @param {string?} [mxid] + * @returns {Promise<string>} event ID + */ +async function redactEvent(roomID, eventID, mxid) { + /** @type {Ty.R.EventRedacted} */ + const root = await mreq.mreq("PUT", path(`/client/v3/rooms/${roomID}/redact/${eventID}/${makeTxnId.makeTxnId()}`, mxid), {}) + return root.event_id +} + +/** + * @param {string} roomID + * @param {boolean} isTyping + * @param {string} mxid + * @param {number} [duration] milliseconds + */ +async function sendTyping(roomID, isTyping, mxid, duration) { + await mreq.mreq("PUT", path(`/client/v3/rooms/${roomID}/typing/${mxid}`, mxid), { + typing: isTyping, + timeout: duration + }) +} + +async function profileSetDisplayname(mxid, displayname) { + await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/displayname`, mxid), { + displayname + }) +} + +async function profileSetAvatarUrl(mxid, avatar_url) { + await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/avatar_url`, mxid), { + avatar_url + }) +} + +/** + * Set a user's power level within a room. + * @param {string} roomID + * @param {string} mxid + * @param {number} newPower + */ +async function setUserPower(roomID, mxid, newPower) { + assert(roomID[0] === "!") + assert(mxid[0] === "@") + // Yes there's no shortcut https://github.com/matrix-org/matrix-appservice-bridge/blob/2334b0bae28a285a767fe7244dad59f5a5963037/src/components/intent.ts#L352 + const power = await getStateEvent(roomID, "m.room.power_levels", "") + power.users = power.users || {} + + // Check if it has really changed to avoid sending a useless state event + // (Can't diff kstate here because of (a) circular imports (b) kstate has special behaviour diffing power levels) + const oldPowerLevel = power.users?.[mxid] ?? power.users_default ?? 0 + if (oldPowerLevel === newPower) return + + // Bridge bot can't demote equal power users, so need to decide which user will send the event + const botPowerLevel = power.users?.[`@${reg.sender_localpart}:${reg.ooye.server_name}`] ?? power.users_default ?? 0 + const eventSender = oldPowerLevel >= botPowerLevel ? mxid : undefined + + // Update the event content + if (newPower == null || newPower === (power.users_default ?? 0)) { + delete power.users[mxid] + } else { + power.users[mxid] = newPower + } + + await sendState(roomID, "m.room.power_levels", "", power, eventSender) + return power +} + +/** + * Set a user's power level for a whole room hierarchy. + * @param {string} spaceID + * @param {string} mxid + * @param {number} power + */ +async function setUserPowerCascade(spaceID, mxid, power) { + assert(spaceID[0] === "!") + assert(mxid[0] === "@") + const rooms = await getFullHierarchy(spaceID) + await setUserPower(spaceID, mxid, power) + for (const room of rooms) { + await setUserPower(room.room_id, mxid, power) + } +} + +async function ping() { + // not using mreq so that we can read the status code + const res = await fetch(`${mreq.baseUrl}/client/v1/appservice/${reg.id}/ping`, { + method: "POST", + headers: { + Authorization: `Bearer ${reg.as_token}` + }, + body: "{}" + }) + const root = await res.json() + return { + ok: res.ok, + status: res.status, + root + } +} + +/** + * @param {string} mxc + * @param {RequestInit} [init] + * @return {Promise<Response & {body: streamWeb.ReadableStream<Uint8Array>}>} + */ +async function getMedia(mxc, init = {}) { + const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/) + assert(mediaParts) + const res = await fetch(`${mreq.baseUrl}/client/v1/media/download/${mediaParts[1]}/${mediaParts[2]}`, { + headers: { + Authorization: `Bearer ${reg.as_token}` + }, + ...init + }) + assert(res.body) + // @ts-ignore + return res +} + +/** + * Updates the m.read receipt in roomID to point to eventID. + * This doesn't modify m.fully_read, which matches [the behaviour of matrix-bot-sdk.](https://github.com/element-hq/matrix-bot-sdk/blob/e72a4c498e00c6c339a791630c45d00a351f56a8/src/MatrixClient.ts#L1227) + * @param {string} roomID + * @param {string} eventID + * @param {string?} [mxid] + */ +async function sendReadReceipt(roomID, eventID, mxid) { + await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/receipt/m.read/${eventID}`, mxid), {}) +} + +/** + * Acknowledge an event as read by calling api.sendReadReceipt on it. + * @param {Ty.Event.Outer<any>} event + * @param {string?} [mxid] + */ +async function ackEvent(event, mxid) { + await sendReadReceipt(event.room_id, event.event_id, mxid) +} + +/** + * Resolve a room alias to a room ID. + * @param {string} alias + */ +async function getAlias(alias) { + /** @type {Ty.R.ResolvedRoom} */ + const root = await mreq.mreq("GET", `/client/v3/directory/room/${encodeURIComponent(alias)}`) + return root.room_id +} + +/** + * @param {string} type namespaced event type, e.g. m.direct + * @param {string} [mxid] you + * @returns the *content* of the account data "event" + */ +async function getAccountData(type, mxid) { + if (!mxid) mxid = `@${reg.sender_localpart}:${reg.ooye.server_name}` + const root = await mreq.mreq("GET", `/client/v3/user/${mxid}/account_data/${type}`) + return root +} + +/** + * @param {string} type namespaced event type, e.g. m.direct + * @param {any} content whatever you want + * @param {string} [mxid] you + */ +async function setAccountData(type, content, mxid) { + if (!mxid) mxid = `@${reg.sender_localpart}:${reg.ooye.server_name}` + await mreq.mreq("PUT", `/client/v3/user/${mxid}/account_data/${type}`, content) +} + +/** + * @param {{presence: "online" | "offline" | "unavailable", status_msg?: string}} data + * @param {string} mxid + */ +async function setPresence(data, mxid) { + await mreq.mreq("PUT", path(`/client/v3/presence/${mxid}/status`, mxid), data) +} + +/** + * @param {string} mxid + * @returns {Promise<{displayname?: string, avatar_url?: string}>} + */ +function getProfile(mxid) { + return mreq.mreq("GET", `/client/v3/profile/${mxid}`) +} + +module.exports.path = path +module.exports.register = register +module.exports.createRoom = createRoom +module.exports.joinRoom = joinRoom +module.exports.inviteToRoom = inviteToRoom +module.exports.leaveRoom = leaveRoom +module.exports.leaveRoomWithReason = leaveRoomWithReason +module.exports.getEvent = getEvent +module.exports.getEventForTimestamp = getEventForTimestamp +module.exports.getAllState = getAllState +module.exports.getStateEvent = getStateEvent +module.exports.getJoinedMembers = getJoinedMembers +module.exports.getMembers = getMembers +module.exports.getHierarchy = getHierarchy +module.exports.getFullHierarchy = getFullHierarchy +module.exports.generateFullHierarchy = generateFullHierarchy +module.exports.getRelations = getRelations +module.exports.getFullRelations = getFullRelations +module.exports.sendState = sendState +module.exports.sendEvent = sendEvent +module.exports.redactEvent = redactEvent +module.exports.sendTyping = sendTyping +module.exports.profileSetDisplayname = profileSetDisplayname +module.exports.profileSetAvatarUrl = profileSetAvatarUrl +module.exports.setUserPower = setUserPower +module.exports.setUserPowerCascade = setUserPowerCascade +module.exports.ping = ping +module.exports.getMedia = getMedia +module.exports.sendReadReceipt = sendReadReceipt +module.exports.ackEvent = ackEvent +module.exports.getAlias = getAlias +module.exports.getAccountData = getAccountData +module.exports.setAccountData = setAccountData +module.exports.setPresence = setPresence +module.exports.getProfile = getProfile diff --git a/matrix/api.test.js b/src/matrix/api.test.js similarity index 100% rename from matrix/api.test.js rename to src/matrix/api.test.js diff --git a/src/matrix/appservice.js b/src/matrix/appservice.js new file mode 100644 index 0000000..67f16ee --- /dev/null +++ b/src/matrix/appservice.js @@ -0,0 +1,8 @@ +// @ts-check + +const {reg} = require("../matrix/read-registration") +const {AppService} = require("@cloudrac3r/in-your-element") +const as = new AppService(reg) +as.listen() + +module.exports.as = as diff --git a/matrix/file.js b/src/matrix/file.js similarity index 60% rename from matrix/file.js rename to src/matrix/file.js index f0ee29a..2070a56 100644 --- a/matrix/file.js +++ b/src/matrix/file.js @@ -1,8 +1,9 @@ // @ts-check -const fetch = require("node-fetch").default - const passthrough = require("../passthrough") +const {reg, writeRegistration} = require("./read-registration.js") +const Ty = require("../types") + const {sync, db, select} = passthrough /** @type {import("./mreq")} */ const mreq = sync.require("./mreq") @@ -46,11 +47,8 @@ async function uploadDiscordFileToMxc(path) { return existingFromDb } - // Download from Discord - const promise = fetch(url, {}).then(/** @param {import("node-fetch").Response} res */ async res => { - // Upload to Matrix - const root = await module.exports._actuallyUploadDiscordFileToMxc(urlNoExpiry, res) - + // Download from Discord and upload to Matrix + const promise = module.exports._actuallyUploadDiscordFileToMxc(url).then(root => { // Store relationship in database db.prepare("INSERT INTO file (discord_url, mxc_url) VALUES (?, ?)").run(urlNoExpiry, root.content_uri) inflight.delete(urlNoExpiry) @@ -62,15 +60,33 @@ async function uploadDiscordFileToMxc(path) { return promise } -async function _actuallyUploadDiscordFileToMxc(url, res) { - const body = res.body - /** @type {import("../types").R.FileUploaded} */ - const root = await mreq.mreq("POST", "/media/v3/upload", body, { - headers: { - "Content-Type": res.headers.get("content-type") +/** + * @param {string} url + * @returns {Promise<Ty.R.FileUploaded>} + */ +async function _actuallyUploadDiscordFileToMxc(url) { + const res = await fetch(url, {}) + try { + /** @type {Ty.R.FileUploaded} */ + const root = await mreq.mreq("POST", "/media/v3/upload", res.body, { + headers: { + "Content-Type": res.headers.get("content-type") + } + }) + return root + } catch (e) { + if (e instanceof mreq.MatrixServerError && e.data.error?.includes("Content-Length") && !reg.ooye.content_length_workaround) { + reg.ooye.content_length_workaround = true + const root = await _actuallyUploadDiscordFileToMxc(url) + console.error("OOYE cannot stream uploads to Synapse. The `content_length_workaround` option" + + "\nhas been activated in registration.yaml, which works around the problem, but" + + "\nhalves the speed of bridging d->m files. A better way to resolve this problem" + + "\nis to run an nginx reverse proxy to Synapse and re-run OOYE setup.") + writeRegistration(reg) + return root } - }) - return root + throw e + } } function guildIcon(guild) { @@ -82,8 +98,8 @@ function userAvatar(user) { } function memberAvatar(guildID, user, member) { - if (!member.avatar) return userAvatar(user) - return `/guilds/${guildID}/users/${user.id}/avatars/${member.avatar}.png?size=${IMAGE_SIZE}` + if (!member?.avatar) return userAvatar(user) + return `/guilds/${guildID}/users/${user.id}/avatars/${member?.avatar}.png?size=${IMAGE_SIZE}` } function emoji(emojiID, animated) { @@ -93,18 +109,17 @@ function emoji(emojiID, animated) { } const stickerFormat = new Map([ - [1, {label: "PNG", ext: "png", mime: "image/png"}], - [2, {label: "APNG", ext: "png", mime: "image/apng"}], - [3, {label: "LOTTIE", ext: "json", mime: "lottie"}], - [4, {label: "GIF", ext: "gif", mime: "image/gif"}] + [1, {label: "PNG", ext: "png", mime: "image/png", endpoint: "/stickers/"}], + [2, {label: "APNG", ext: "png", mime: "image/apng", endpoint: "/stickers/"}], + [3, {label: "LOTTIE", ext: "json", mime: "lottie", endpoint: "/stickers/"}], + [4, {label: "GIF", ext: "gif", mime: "image/gif", endpoint: "https://media.discordapp.net/stickers/"}] ]) /** @param {{id: string, format_type: number}} sticker */ function sticker(sticker) { const format = stickerFormat.get(sticker.format_type) if (!format) throw new Error(`No such format ${sticker.format_type} for sticker ${JSON.stringify(sticker)}`) - const ext = format.ext - return `/stickers/${sticker.id}.${ext}` + return `${format.endpoint}${sticker.id}.${format.ext}` } module.exports.DISCORD_IMAGES_BASE = DISCORD_IMAGES_BASE diff --git a/matrix/file.test.js b/src/matrix/file.test.js similarity index 100% rename from matrix/file.test.js rename to src/matrix/file.test.js diff --git a/matrix/kstate.js b/src/matrix/kstate.js similarity index 79% rename from matrix/kstate.js rename to src/matrix/kstate.js index 67bb063..03d09e0 100644 --- a/matrix/kstate.js +++ b/src/matrix/kstate.js @@ -8,6 +8,8 @@ const passthrough = require("../passthrough") const {sync} = passthrough /** @type {import("./file")} */ const file = sync.require("./file") +/** @type {import("./api")} */ +const api = sync.require("./api") /** Mutates the input. Not recursive - can only include or exclude entire state events. */ function kstateStripConditionals(kstate) { @@ -85,6 +87,12 @@ function diffKState(actual, target) { diff[key] = temp } + } else if (key === "chat.schildi.hide_ui/read_receipts") { + // Special handling: don't add this key if it's new. Do overwrite if already present. + if (key in actual) { + diff[key] = target[key] + } + } else if (key in actual) { // diff if (!isDeepStrictEqual(actual[key], target[key])) { @@ -102,8 +110,32 @@ function diffKState(actual, target) { return diff } +/* c8 ignore start */ + +/** + * Async because it gets all room state from the homeserver. + * @param {string} roomID + */ +async function roomToKState(roomID) { + const root = await api.getAllState(roomID) + return stateToKState(root) +} + +/** + * @param {string} roomID + * @param {any} kstate + */ +async function applyKStateDiffToRoom(roomID, kstate) { + const events = await kstateToState(kstate) + return Promise.all(events.map(({type, state_key, content}) => + api.sendState(roomID, type, state_key, content) + )) +} + module.exports.kstateStripConditionals = kstateStripConditionals module.exports.kstateUploadMxc = kstateUploadMxc module.exports.kstateToState = kstateToState module.exports.stateToKState = stateToKState module.exports.diffKState = diffKState +module.exports.roomToKState = roomToKState +module.exports.applyKStateDiffToRoom = applyKStateDiffToRoom diff --git a/matrix/kstate.test.js b/src/matrix/kstate.test.js similarity index 90% rename from matrix/kstate.test.js rename to src/matrix/kstate.test.js index 0538450..1b67ad5 100644 --- a/matrix/kstate.test.js +++ b/src/matrix/kstate.test.js @@ -234,3 +234,31 @@ test("diffKState: kstate keys must contain a slash separator", t => { , /does not contain a slash separator/) t.pass() }) + +test("diffKState: don't add hide_ui when not present", t => { + test("diffKState: detects new properties", t => { + t.deepEqual( + diffKState({ + }, { + "chat.schildi.hide_ui/read_receipts/": {} + }), + { + } + ) + }) +}) + +test("diffKState: overwriten hide_ui when present", t => { + test("diffKState: detects new properties", t => { + t.deepEqual( + diffKState({ + "chat.schildi.hide_ui/read_receipts/": {hidden: true} + }, { + "chat.schildi.hide_ui/read_receipts/": {} + }), + { + "chat.schildi.hide_ui/read_receipts/": {} + } + ) + }) +}) diff --git a/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js similarity index 96% rename from matrix/matrix-command-handler.js rename to src/matrix/matrix-command-handler.js index 36a7649..93bc312 100644 --- a/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -14,7 +14,7 @@ const mxUtils = sync.require("../m2d/converters/utils") const dUtils = sync.require("../discord/utils") /** @type {import("./kstate")} */ const ks = sync.require("./kstate") -const reg = require("./read-registration") +const {reg} = require("./read-registration") const PREFIXES = ["//", "/"] @@ -217,15 +217,14 @@ const commands = [{ } else { // Upload it to Discord and have the bridge sync it back to Matrix again 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 resizeInput = await api.getMedia(e.url, {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")}) + await discord.snow.assets.createGuildEmoji(guildID, {name: e.name, image: "data:image/png;base64," + resizeOutput.data.toString("base64")}) } api.sendEvent(event.room_id, "m.room.message", { ...ctx, diff --git a/matrix/mreq.js b/src/matrix/mreq.js similarity index 50% rename from matrix/mreq.js rename to src/matrix/mreq.js index d6a21ae..9179825 100644 --- a/matrix/mreq.js +++ b/src/matrix/mreq.js @@ -1,15 +1,11 @@ // @ts-check -const fetch = require("node-fetch").default -const mixin = require("@cloudrac3r/mixin-deep") const stream = require("stream") -const getStream = require("get-stream") - -const passthrough = require("../passthrough") -const { sync } = passthrough -/** @type {import("./read-registration")} */ -const reg = sync.require("./read-registration.js") +const streamWeb = require("stream/web") +const {buffer} = require("stream/consumers") +const mixin = require("@cloudrac3r/mixin-deep") +const {reg} = require("./read-registration.js") const baseUrl = `${reg.ooye.server_origin}/_matrix` class MatrixServerError extends Error { @@ -22,41 +18,49 @@ class MatrixServerError extends Error { } } +/** + * @param {undefined | string | object | streamWeb.ReadableStream | stream.Readable} body + * @returns {Promise<string | streamWeb.ReadableStream | stream.Readable | Buffer>} + */ +async function _convertBody(body) { + if (body == undefined || Object.is(body.constructor, Object)) { + return JSON.stringify(body) // almost every POST request is going to follow this one + } else if (body instanceof stream.Readable && reg.ooye.content_length_workaround) { + return await buffer(body) // content length workaround is set, so convert to buffer. the buffer consumer accepts node streams. + } else if (body instanceof stream.Readable) { + return stream.Readable.toWeb(body) // native fetch can only consume web streams + } else if (body instanceof streamWeb.ReadableStream && reg.ooye.content_length_workaround) { + return await buffer(body) // content lenght workaround is set, so convert to buffer. the buffer consumer accepts async iterables, which web streams are. + } + return body +} + +/* c8 ignore start */ + /** * @param {string} method * @param {string} url - * @param {any} [body] + * @param {string | object | streamWeb.ReadableStream | stream.Readable} [bodyIn] * @param {any} [extra] */ -async function mreq(method, url, body, extra = {}) { - if (body == undefined || Object.is(body.constructor, Object)) { - body = JSON.stringify(body) - } else if (body instanceof stream.Readable && reg.ooye.content_length_workaround) { - body = await getStream.buffer(body) - } +async function mreq(method, url, bodyIn, extra = {}) { + const body = await _convertBody(bodyIn) + /** @type {RequestInit} */ const opts = mixin({ method, body, headers: { Authorization: `Bearer ${reg.as_token}` - } + }, + ...(body && {duplex: "half"}), // https://github.com/octokit/request.js/pull/571/files }, extra) - // console.log(baseUrl + url, opts) const res = await fetch(baseUrl + url, opts) const root = await res.json() if (!res.ok || root.errcode) { - if (root.error?.includes("Content-Length")) { - console.error(`OOYE cannot stream uploads to Synapse. Please choose one of these workarounds:` - + `\n * Run an nginx reverse proxy to Synapse, and point registration.yaml's` - + `\n \`server_origin\` to nginx` - + `\n * Set \`content_length_workaround: true\` in registration.yaml (this will` - + `\n halve the speed of bridging d->m files)`) - throw new Error("Synapse is not accepting stream uploads, see the message above.") - } - delete opts.headers.Authorization + delete opts.headers?.["Authorization"] throw new MatrixServerError(root, {baseUrl, url, ...opts}) } return root @@ -81,5 +85,7 @@ async function withAccessToken(token, callback) { } module.exports.MatrixServerError = MatrixServerError +module.exports.baseUrl = baseUrl module.exports.mreq = mreq module.exports.withAccessToken = withAccessToken +module.exports._convertBody = _convertBody diff --git a/src/matrix/mreq.test.js b/src/matrix/mreq.test.js new file mode 100644 index 0000000..7ac343e --- /dev/null +++ b/src/matrix/mreq.test.js @@ -0,0 +1,47 @@ +// @ts-check + +const assert = require("assert") +const stream = require("stream") +const streamWeb = require("stream/web") +const {buffer} = require("stream/consumers") +const {test} = require("supertape") +const {_convertBody} = require("./mreq") +const {reg} = require("./read-registration") + +async function *generator() { + yield "a" + yield "b" +} + +reg.ooye.content_length_workaround = false + +test("convert body: converts object to string", async t => { + t.equal(await _convertBody({a: "1"}), `{"a":"1"}`) +}) + +test("convert body: leaves undefined as undefined", async t => { + t.equal(await _convertBody(undefined), undefined) +}) + +test("convert body: leaves web readable as web readable", async t => { + const webReadable = stream.Readable.toWeb(stream.Readable.from(generator())) + t.equal(await _convertBody(webReadable), webReadable) +}) + +test("convert body: converts node readable to web readable (for native fetch upload)", async t => { + const readable = stream.Readable.from(generator()) + const webReadable = await _convertBody(readable) + assert(webReadable instanceof streamWeb.ReadableStream) + t.deepEqual(await buffer(webReadable), Buffer.from("ab")) +}) + +test("convert body: converts node readable to buffer", async t => { + reg.ooye.content_length_workaround = true + const readable = stream.Readable.from(generator()) + t.deepEqual(await _convertBody(readable), Buffer.from("ab")) +}) + +test("convert body: converts web readable to buffer", async t => { + const webReadable = stream.Readable.toWeb(stream.Readable.from(generator())) + t.deepEqual(await _convertBody(webReadable), Buffer.from("ab")) +}) diff --git a/src/matrix/power.js b/src/matrix/power.js new file mode 100644 index 0000000..d323d17 --- /dev/null +++ b/src/matrix/power.js @@ -0,0 +1,35 @@ +// @ts-check + +const {db, from} = require("../passthrough") +const {reg} = require("./read-registration") +const ks = require("./kstate") + +/** Apply global power level requests across ALL rooms where the member cache entry exists but the power level has not been applied yet. */ +function _getAffectedRooms() { + return from("member_cache") + .join("member_power", "mxid") + .join("channel_room", "room_id") // only include rooms that are bridged + .and("where member_power.room_id = '*' and member_cache.power_level != member_power.power_level") + .selectUnsafe("mxid", "member_cache.room_id", "member_power.power_level") + .all() +} + +async function applyPower() { + // Migrate reg.ooye.invite setting to database + for (const mxid of reg.ooye.invite) { + db.prepare("INSERT OR IGNORE INTO member_power (mxid, room_id, power_level) VALUES (?, ?, 100)").run(mxid, "*") + } + + const rows = _getAffectedRooms() + for (const row of rows) { + const kstate = await ks.roomToKState(row.room_id) + const diff = ks.diffKState(kstate, {"m.room.power_levels/": {users: {[row.mxid]: row.power_level}}}) + await ks.applyKStateDiffToRoom(row.room_id, diff) + // There is a listener on m.room.power_levels to do this same update, + // but we update it here anyway since the homeserver does not always deliver the event round-trip. + db.prepare("UPDATE member_cache SET power_level = ? WHERE room_id = ? AND mxid = ?").run(row.power_level, row.room_id, row.mxid) + } +} + +module.exports._getAffectedRooms = _getAffectedRooms +module.exports.applyPower = applyPower diff --git a/src/matrix/read-registration.js b/src/matrix/read-registration.js new file mode 100644 index 0000000..d126851 --- /dev/null +++ b/src/matrix/read-registration.js @@ -0,0 +1,85 @@ +// @ts-check + +const fs = require("fs") +const crypto = require("crypto") +const assert = require("assert").strict +const path = require("path") + +const registrationFilePath = path.join(process.cwd(), "registration.yaml") + +/** @param {import("../types").AppServiceRegistrationConfig} reg */ +function checkRegistration(reg) { + reg["ooye"].invite = reg.ooye.invite.filter(mxid => mxid.endsWith(`:${reg.ooye.server_name}`)) // one day I will understand why typescript disagrees with dot notation on this line + assert(reg.ooye?.max_file_size) + assert(reg.ooye?.namespace_prefix) + assert(reg.ooye?.server_name) + assert(reg.sender_localpart?.startsWith(reg.ooye.namespace_prefix), "appservice's localpart must be in the namespace it controls") + assert(reg.ooye?.server_origin.match(/^https?:\/\//), "server origin must start with http or https") + assert.notEqual(reg.ooye?.server_origin.slice(-1), "/", "server origin must not end in slash") + assert.match(reg.url, /^https?:/, "url must start with http:// or https://") +} + +/* c8 ignore next 4 */ +/** @param {import("../types").AppServiceRegistrationConfig} reg */ +function writeRegistration(reg) { + fs.writeFileSync(registrationFilePath, JSON.stringify(reg, null, 2)) +} + +/** + * @param {string} serverName + * @returns {import("../types").InitialAppServiceRegistrationConfig} reg + */ +function getTemplateRegistration(serverName) { + const namespace_prefix = "_ooye_" + return { + id: "ooye", + as_token: crypto.randomBytes(32).toString("hex"), + hs_token: crypto.randomBytes(32).toString("hex"), + namespaces: { + users: [{ + exclusive: true, + regex: `@${namespace_prefix}.*:${serverName}` + }], + aliases: [{ + exclusive: true, + regex: `#${namespace_prefix}.*:${serverName}` + }] + }, + protocols: [ + "discord" + ], + sender_localpart: `${namespace_prefix}bot`, + rate_limited: false, + socket: 6693, + ooye: { + namespace_prefix, + server_name: serverName, + max_file_size: 5000000, + content_length_workaround: false, + include_user_id_in_mxid: false, + invite: [] + } + } +} + +function readRegistration() { + /** @type {import("../types").AppServiceRegistrationConfig} */ // @ts-ignore + let result = null + try { + const content = fs.readFileSync(registrationFilePath, "utf8") + result = JSON.parse(content) + result.ooye.invite ||= [] + /* c8 ignore next */ + } catch (e) {} + return result +} + +/** @type {import("../types").AppServiceRegistrationConfig} */ // @ts-ignore +let reg = readRegistration() + +module.exports.registrationFilePath = registrationFilePath +module.exports.readRegistration = readRegistration +module.exports.getTemplateRegistration = getTemplateRegistration +module.exports.writeRegistration = writeRegistration +module.exports.checkRegistration = checkRegistration +module.exports.reg = reg diff --git a/src/matrix/read-registration.test.js b/src/matrix/read-registration.test.js new file mode 100644 index 0000000..5fb3b55 --- /dev/null +++ b/src/matrix/read-registration.test.js @@ -0,0 +1,29 @@ +// @ts-check + +const tryToCatch = require("try-to-catch") +const {test} = require("supertape") +const {reg, checkRegistration, getTemplateRegistration} = require("./read-registration") + +test("reg: has necessary parameters", t => { + const propertiesToCheck = ["sender_localpart", "id", "as_token", "ooye"] + t.deepEqual( + propertiesToCheck.filter(p => p in reg), + propertiesToCheck + ) +}) + +test("check: passes on sample", t => { + checkRegistration(reg) + t.pass("all assertions passed") +}) + +test("check: fails on template as template is missing some required values that are gathered during setup", t => { + let err + try { + // @ts-ignore + checkRegistration(getTemplateRegistration("cadence.moe")) + } catch (e) { + err = e + } + t.ok(err, "one of the assertions failed as expected") +}) diff --git a/matrix/txnid.js b/src/matrix/txnid.js similarity index 100% rename from matrix/txnid.js rename to src/matrix/txnid.js diff --git a/matrix/txnid.test.js b/src/matrix/txnid.test.js similarity index 100% rename from matrix/txnid.test.js rename to src/matrix/txnid.test.js diff --git a/passthrough.js b/src/passthrough.js similarity index 71% rename from passthrough.js rename to src/passthrough.js index ec80004..8eedfc4 100644 --- a/passthrough.js +++ b/src/passthrough.js @@ -3,11 +3,10 @@ /** * @typedef {Object} Passthrough * @property {import("repl").REPLServer} repl - * @property {typeof import("./config")} config * @property {import("./d2m/discord-client")} discord - * @property {import("heatsync").default} sync + * @property {import("heatsync")} sync * @property {import("better-sqlite3/lib/database")} db - * @property {import("matrix-appservice").AppService} as + * @property {import("@cloudrac3r/in-your-element").AppService} as * @property {import("./db/orm").from} from * @property {import("./db/orm").select} select */ diff --git a/stdin.js b/src/stdin.js similarity index 76% rename from stdin.js rename to src/stdin.js index 5e23f72..fea5fad 100644 --- a/stdin.js +++ b/src/stdin.js @@ -2,12 +2,12 @@ const repl = require("repl") const util = require("util") -const {addbot} = require("./addbot") +const {addbot} = require("../addbot") const passthrough = require("./passthrough") -const {discord, config, sync, db} = passthrough +const {discord, sync, db, select, from, as} = passthrough -const data = sync.require("./test/data") +const data = sync.require("../test/data") const createSpace = sync.require("./d2m/actions/create-space") const createRoom = sync.require("./d2m/actions/create-room") const registerUser = sync.require("./d2m/actions/register-user") @@ -19,20 +19,18 @@ const eventDispatcher = sync.require("./d2m/event-dispatcher") const updatePins = sync.require("./d2m/actions/update-pins") const speedbump = sync.require("./d2m/actions/speedbump") const ks = sync.require("./matrix/kstate") +const setPresence = sync.require("./d2m/actions/set-presence") +const channelWebhook = sync.require("./m2d/actions/channel-webhook") const guildID = "112760669178241024" -const extraContext = {} - if (process.stdin.isTTY) { - setImmediate(() => { // assign after since old extraContext data will get removed + setImmediate(() => { if (!passthrough.repl) { const cli = repl.start({ prompt: "", eval: customEval, writer: s => s }) - Object.assign(cli.context, extraContext, passthrough) + Object.assign(cli.context, passthrough) passthrough.repl = cli - } else { - Object.assign(passthrough.repl.context, extraContext) } - // @ts-expect-error Says exit isn't assignable to a string + // @ts-ignore sync.addTemporaryListener(passthrough.repl, "exit", () => process.exit()) }) } @@ -61,9 +59,3 @@ async function customEval(input, _context, _filename, callback) { return callback(null, util.inspect(e, false, 100, true)) } } - -sync.events.once(__filename, () => { - for (const key in extraContext) { - delete passthrough.repl.context[key] - } -}) diff --git a/types.d.ts b/src/types.d.ts similarity index 66% rename from types.d.ts rename to src/types.d.ts index 6036867..37da633 100644 --- a/types.d.ts +++ b/src/types.d.ts @@ -16,14 +16,21 @@ export type AppServiceRegistrationConfig = { } protocols: [string] rate_limited: boolean + socket?: string | number, ooye: { namespace_prefix: string max_file_size: number server_name: string server_origin: string + bridge_origin: string + discord_token: string + discord_client_secret: string content_length_workaround: boolean include_user_id_in_mxid: boolean invite: string[] + discord_origin?: string + discord_cdn_origin?: string, + web_password: string } old_bridge?: { as_token: string @@ -31,6 +38,34 @@ export type AppServiceRegistrationConfig = { } } +export type InitialAppServiceRegistrationConfig = { + id: string + as_token: string + hs_token: string + sender_localpart: string + namespaces: { + users: { + exclusive: boolean + regex: string + }[] + aliases: { + exclusive: boolean + regex: string + }[] + } + protocols: [string] + rate_limited: boolean + socket?: string | number, + ooye: { + namespace_prefix: string + server_name: string + max_file_size: number + content_length_workaround: boolean + invite: string[] + include_user_id_in_mxid: boolean + } +} + export type WebhookCreds = { id: string token: string @@ -82,7 +117,7 @@ export namespace Event { sender: string content: T origin_server_ts: number - unsigned: any + unsigned?: any event_id: string } @@ -105,7 +140,7 @@ export namespace Event { content: any state_key: string origin_server_ts: number - unsigned: any + unsigned?: any event_id: string user_id: string age: number @@ -132,6 +167,8 @@ export namespace Event { export type M_Room_Message_File = { msgtype: "m.file" | "m.image" | "m.video" | "m.audio" body: string + format?: "org.matrix.custom.html" + formatted_body?: string filename?: string url: string info?: any @@ -149,6 +186,8 @@ export namespace Event { export type M_Room_Message_Encrypted_File = { msgtype: "m.file" | "m.image" | "m.video" | "m.audio" body: string + format?: "org.matrix.custom.html" + formatted_body?: string filename?: string file: { url: string @@ -209,6 +248,49 @@ export namespace Event { name?: string } + export type M_Room_Topic = { + topic?: string + } + + export type M_Room_PinnedEvents = { + pinned: string[] + } + + export type M_Power_Levels = { + /** The level required to ban a user. Defaults to 50 if unspecified. */ + ban?: number, + /** The level required to send specific event types. This is a mapping from event type to power level required. */ + events?: { + [event_id: string]: number + }, + /** The default level required to send message events. Can be overridden by the `events` key. Defaults to 0 if unspecified. */ + events_default?: number, + /** The level required to invite a user. Defaults to 0 if unspecified. */ + invite?: number, + /** The level required to kick a user. Defaults to 50 if unspecified. */ + kick?: number, + /** The power level requirements for specific notification types. This is a mapping from `key` to power level for that notifications key. */ + notifications?: { + room: number, + [key: string]: number + }, + /** The level required to redact an event sent by another user. Defaults to 50 if unspecified. */ + redact?: number, + /** The default level required to send state events. Can be overridden by the `events` key. Defaults to 50 if unspecified. */ + state_default?: number, + /** The power levels for specific users. This is a mapping from `user_id` to power level for that user. */ + users?: { + [mxid: string]: number + }, + /**The power level for users in the room whose `user_id` is not mentioned in the `users` key. Defaults to 0 if unspecified. */ + users_default?: number + } + + export type M_Space_Child = { + via?: string[] + suggested?: boolean + } + export type M_Reaction = { "m.relates_to": { rel_type: "m.annotation" @@ -270,6 +352,11 @@ export namespace R { room_id: string room_type?: string } + + export type ResolvedRoom = { + room_id: string + servers: string[] + } } export type Pagination<T> = { diff --git a/types.js b/src/types.js similarity index 100% rename from types.js rename to src/types.js diff --git a/src/web/auth.js b/src/web/auth.js new file mode 100644 index 0000000..c14dcd8 --- /dev/null +++ b/src/web/auth.js @@ -0,0 +1,33 @@ +// @ts-check + +const h3 = require("h3") +const {db} = require("../passthrough") +const {reg} = require("../matrix/read-registration") + +/** + * Combined guilds managed by Discord account + Matrix account. + * @param {h3.H3Event} event + * @returns {Promise<Set<string>>} guild IDs + */ +async function getManagedGuilds(event) { + const session = await useSession(event) + const managed = new Set(session.data.managedGuilds || []) + if (session.data.mxid) { + const matrixGuilds = db.prepare("SELECT guild_id FROM guild_space INNER JOIN member_cache ON space_id = room_id WHERE mxid = ? AND power_level >= 50").pluck().all(session.data.mxid) + for (const id of matrixGuilds) { + managed.add(id) + } + } + return managed +} + +/** + * @param {h3.H3Event} event + * @returns {ReturnType<typeof h3.useSession<{userID?: string, mxid?: string, managedGuilds?: string[], state?: string, selfService?: boolean, password?: string}>>} + */ +function useSession(event) { + return h3.useSession(event, {password: reg.as_token, maxAge: 365 * 24 * 60 * 60}) +} + +module.exports.getManagedGuilds = getManagedGuilds +module.exports.useSession = useSession diff --git a/src/web/pug-sync.js b/src/web/pug-sync.js new file mode 100644 index 0000000..f49f5a2 --- /dev/null +++ b/src/web/pug-sync.js @@ -0,0 +1,92 @@ +// @ts-check + +const assert = require("assert/strict") +const fs = require("fs") +const {join} = require("path") +const getRelativePath = require("get-relative-path") +const h3 = require("h3") +const {defineEventHandler, defaultContentType, setResponseStatus, getQuery} = h3 +const {compileFile} = require("@cloudrac3r/pug") +const pretty = process.argv.join(" ").includes("test") + +const {sync} = require("../passthrough") +/** @type {import("./auth")} */ +const auth = sync.require("./auth") + +// Pug + +let globals = {} + +/** @type {Map<string, (event: import("h3").H3Event, locals: Record<string, any>) => Promise<string>>} */ +const pugCache = new Map() + +function addGlobals(obj) { + Object.assign(globals, obj) +} + +/** + * @param {import("h3").H3Event} event + * @param {string} filename + * @param {Record<string, any>} locals + */ +function render(event, filename, locals) { + const path = join(__dirname, "pug", filename) + + function compile() { + try { + const template = compileFile(path, {pretty}) + pugCache.set(path, async (event, locals) => { + defaultContentType(event, "text/html; charset=utf-8") + const session = await auth.useSession(event) + const managed = await auth.getManagedGuilds(event) + const rel = (to, paramsObject) => { + let result = getRelativePath(event.path, to) + if (paramsObject) { + const params = new URLSearchParams(paramsObject) + result += "?" + params.toString() + } + return result + } + return template(Object.assign({}, + getQuery(event), // Query parameters can be easily accessed on the top level but don't allow them to overwrite anything + globals, // Globals + locals, // Explicit locals overwrite globals in case we need to DI something + {session, event, rel, managed} // These are assigned last so they overwrite everything else. It would be catastrophically bad if they can't be trusted. + )) + }) + /* c8 ignore start */ + } catch (e) { + pugCache.set(path, async (event) => { + setResponseStatus(event, 500, "Internal Template Error") + defaultContentType(event, "text/plain") + return e.toString() + }) + } + /* c8 ignore stop */ + } + + if (!pugCache.has(path)) { + compile() + fs.watch(path, {persistent: false}, compile) + fs.watch(join(__dirname, "pug", "includes"), {persistent: false}, compile) + } + + const cb = pugCache.get(path) + assert(cb) + return cb(event, locals) +} + +/** + * @param {import("h3").Router} router + * @param {string} url + * @param {string} filename + */ +function createRoute(router, url, filename) { + router.get(url, defineEventHandler(async event => { + return render(event, filename, {}) + })) +} + +module.exports.addGlobals = addGlobals +module.exports.render = render +module.exports.createRoute = createRoute diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug new file mode 100644 index 0000000..92ffa1b --- /dev/null +++ b/src/web/pug/guild.pug @@ -0,0 +1,219 @@ +extends includes/template.pug + +mixin badge-readonly + .s-badge.s-badge__xs.s-badge__icon.s-badge__muted + != icons.Icons.IconEyeSm + | Read-only + +mixin badge-private + .s-badge.s-badge__xs.s-badge__icon.s-badge__warning + != icons.Icons.IconLockSm + | Private + +mixin discord(channel, radio=false) + //- Previously, we passed guild.roles as the second parameter, but this doesn't quite match Discord's behaviour. See issue #42 for why this was changed. + //- Basically we just want to assign badges based on the channel overwrites, without considering the guild's base permissions. /shrug + - let permissions = dUtils.getPermissions([], [{id: guild_id, name: "@everyone", permissions: 1<<10 | 1<<11}], null, channel.permission_overwrites) + .s-user-card.s-user-card__small + if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel) + != icons.Icons.IconLock + else if channel.type === 5 + != icons.Icons.IconBullhorn + else if channel.type === 2 + != icons.Icons.IconPhone + else if channel.type === 11 || channel.type === 12 + != icons.Icons.IconCollection + else + include includes/hash.svg + .s-user-card--info.ws-nowrap + if radio + = channel.name + else + .s-user-card--link.fs-body1 + a(href=`https://discord.com/channels/${channel.guild_id}/${channel.id}`)= channel.name + if channel.parent_id + .s-user-card--location= discord.channels.get(channel.parent_id).name + if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel) + +badge-private + else if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.SendMessages) + +badge-readonly + +mixin matrix(row, radio=false, badge="") + .s-user-card.s-user-card__small + != icons.Icons.IconMessage + .s-user-card--info.ws-nowrap + if radio + = row.nick || row.name + else + .s-user-card--link.fs-body1 + a(href=`https://matrix.to/#/${row.room_id}`)= row.nick || row.name + if row.join_rule === "invite" + +badge-private + +block body + .s-page-title.mb24 + h1.s-page-title--header= guild.name + + .d-flex.g16(class="sm:fw-wrap") + .fl-grow1 + h2.fs-headline1 Invite a Matrix user + + form.d-grid.g-af-column.gy4.gx8.jc-start(method="post" action=rel("/api/invite") hx-post=rel("/api/invite") hx-trigger="submit" hx-swap="none" hx-on::after-request="if (event.detail.successful) this.reset()" hx-disabled-elt="input, button" hx-indicator="#invite-button") + label.s-label(for="mxid") Matrix ID + input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org" pattern="@([^:]+):([a-z0-9:\\-]+\\.[a-z0-9.:\\-]+)") + label.s-label(for="permissions") Permissions + .s-select + select#permissions(name="permissions") + option(value="default") Default + option(value="moderator") Moderator + option(value="admin") Admin + input(type="hidden" name="guild_id" value=guild_id) + .grid--row-start2 + button.s-btn.s-btn__filled#invite-button Invite + div + .s-card.d-flex.ai-center.jc-center(style="min-width: 132px; min-height: 132px;") + button.s-btn(class=space_id ? "s-btn__muted" : "s-btn__filled" hx-get=rel(`/qr?guild_id=${guild_id}`) hx-indicator="closest button" hx-swap="outerHTML" hx-disabled-elt="this") Show QR + + if space_id + h3.mt32.fs-category Privacy level + span#privacy-level-loading + .s-card + form(hx-post=rel("/api/privacy-level") hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="input") + input(type="hidden" name="guild_id" value=guild_id) + + .s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental.d-grid.gx16.ai-center(style="grid-template-columns: auto 1fr") + input(type="radio" name="privacy_level" value="directory" id="privacy-level-directory" checked=(privacy_level === 2)) + label.d-flex.gx8.jc-center.grid--row-start3(for="privacy-level-directory") + != icons.Icons.IconPlusSm + != icons.Icons.IconInternationalSm + .fl-grow1 Directory + + input(type="radio" name="privacy_level" value="link" id="privacy-level-link" checked=(privacy_level === 1)) + label.d-flex.gx8.jc-center.grid--row-start2(for="privacy-level-link") + != icons.Icons.IconPlusSm + != icons.Icons.IconLinkSm + .fl-grow1 Link + + input(type="radio" name="privacy_level" value="invite" id="privacy-level-invite" checked=(privacy_level === 0)) + label.d-flex.gx8.jc-center.grid--row-start1(for="privacy-level-invite") + svg.svg-icon(width="14" height="14" viewBox="0 0 14 14") + != icons.Icons.IconLockSm + .fl-grow1 Invite + + p.s-description.m0 In-app direct invite from another user + p.s-description.m0 Shareable invite links, like Discord + p.s-description.m0 Publicly listed in directory, like Discord server discovery + + h2.mt48.fs-headline1 Features + .s-card.d-grid.px0.g16 + form.d-flex.ai-center.g16 + #url-preview-loading.p8 + - let value = !!select("guild_space", "url_preview", {guild_id}).pluck().get() + input(type="hidden" name="guild_id" value=guild_id) + input.s-toggle-switch#url-preview(name="url_preview" type="checkbox" hx-post=rel("/api/url-preview") hx-indicator="#url-preview-loading" hx-disabled-elt="this" checked=value autocomplete="off") + label.s-label.fl-grow1(for="url-preview") + | Show Discord's URL previews on Matrix + p.s-description Shows info about links posted to chat. Discord's previews are generally better quality than Synapse's, especially for social media and videos. + + form.d-flex.ai-center.g16 + #presence-loading.p8 + - value = !!select("guild_space", "presence", {guild_id}).pluck().get() + input(type="hidden" name="guild_id" value=guild_id) + input.s-toggle-switch#presence(name="presence" type="checkbox" hx-post=rel("/api/presence") hx-indicator="#presence-loading" hx-disabled-elt="this" checked=value autocomplete="off") + label.s-label(for="presence") + | Show online statuses on Matrix + p.s-description This might cause lag on really big Discord servers. + + if space_id + h2.mt48.fs-headline1 Channel setup + + h3.mt32.fs-category Linked channels + .s-card.bs-sm.p0 + form.s-table-container(method="post" action=rel("/api/unlink") hx-confirm="Do you want to unlink these channels?\nIt may take a moment to clean up Matrix resources.") + input(type="hidden" name="guild_id" value=guild_id) + table.s-table.s-table__bx-simple + each row in linkedChannelsWithDetails + tr + td.w40: +discord(row.channel) + td.p2: button.s-btn.s-btn__muted.s-btn__xs(name="channel_id" value=row.channel.id hx-post=rel("/api/unlink") hx-trigger="click" hx-disabled-elt="this")!= icons.Icons.IconLinkSm + td: +matrix(row) + else + tr + td(colspan="3") + .s-empty-state No channels linked between Discord and Matrix yet... + + h3.fs-category.mt32 Auto-create + .s-card.d-grid.px0 + form.d-flex.ai-center.g16 + #autocreate-loading.p8 + - let value = !!select("guild_active", "autocreate", {guild_id}).pluck().get() + input(type="hidden" name="guild_id" value=guild_id) + input.s-toggle-switch#autocreate(name="autocreate" type="checkbox" hx-post=rel("/api/autocreate") hx-indicator="#autocreate-loading" hx-disabled-elt="this" checked=value autocomplete="off") + label.s-label.fl-grow1(for="autocreate") + | Create new Matrix rooms automatically + p.s-description If you want, OOYE can automatically create new Matrix rooms and link them when an unlinked Discord channel is spoken in. + + if space_id + h3.mt32.fs-category Manually link channels + form.d-flex.g16.ai-start(hx-post=rel("/api/link") hx-trigger="submit" hx-disabled-elt="input, button" hx-indicator="#link-button") + .fl-grow2.s-btn-group.fd-column.w40 + each channel in unlinkedChannels + input.s-btn--radio(type="radio" name="discord" required id=channel.id value=channel.id) + label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id) + +discord(channel, true, "Announcement") + else + .s-empty-state.p8 All Discord channels are linked. + .fl-grow1.s-btn-group.fd-column.w30 + each room in unlinkedRooms + input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id) + label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id) + +matrix(room, true) + else + .s-empty-state.p8 All Matrix rooms are linked. + input(type="hidden" name="guild_id" value=guild_id) + div + button.s-btn.s-btn__icon.s-btn__filled#link-button + != icons.Icons.IconMerge + = ` Link` + + details.mt48 + summary Debug room list + .d-grid.grid__2.gx24 + div + h3.mt24 Channels + p Channels are read from the channel_room table and then merged with the discord.channels memory cache to make the merged list. Anything in memory cache that's not in channel_room is considered unlinked. + div + h3.mt24 Rooms + p Rooms use the same merged list as channels, based on augmented channel_room data. Then, rooms are read from the space. Anything in the space that's not merged is considered unlinked. + div + h3.mt24 Unavailable channels: Deleted from Discord + .s-card.p0 + ul.my8.ml24 + each row in removedUncachedChannels + li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`)= row.nick || row.name + h3.mt24 Unavailable channels: Wrong type + .s-card.p0 + ul.my8.ml24 + each row in removedWrongTypeChannels + li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name} + h3.mt24 Unavailable channels: Bridge can't access + .s-card.p0 + ul.my8.ml24 + each row in removedPrivateChannels + li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`)= row.name + div- // Rooms + h3.mt24 Unavailable rooms: Already linked + .s-card.p0 + ul.my8.ml24 + each row in removedLinkedRooms + li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name + h3.mt24 Unavailable rooms: Wrong type + .s-card.p0 + ul.my8.ml24 + each row in removedWrongTypeRooms + li: a(href=`https://matrix.to/#/${row.room_id}`) (#{row.room_type}) #{row.name} + h3.mt24 Unavailable rooms: Archived thread + .s-card.p0 + ul.my8.ml24 + each row in removedArchivedThreadRooms + li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name diff --git a/src/web/pug/guild_access_denied.pug b/src/web/pug/guild_access_denied.pug new file mode 100644 index 0000000..42fea7b --- /dev/null +++ b/src/web/pug/guild_access_denied.pug @@ -0,0 +1,36 @@ +extends includes/template.pug + +block body + if !session.data.userID + .s-empty-state.wmx4.p48 + != icons.Spots.SpotEmptyXL + p You need to log in to manage your servers. + .d-flex.jc-center.g8 + a.s-btn.s-btn__icon.s-btn__featured.s-btn__filled(href=rel("/oauth")) + != icons.Icons.IconDiscord + = ` Log in with Discord` + a.s-btn.s-btn__icon.s-btn__matrix.s-btn__filled(href=rel("/log-in-with-matrix")) + != icons.Icons.IconSpeechBubble + = ` Log in with Matrix` + + else if !guild_id + .s-empty-state.wmx4.p48 + != icons.Spots.SpotEmptyXL + p Select a server from the top right corner to continue. + p If the server you're looking for isn't there, try #[a(href=rel("/oauth?action=add")) logging in again.] + + else if !discord.guilds.has(guild_id) || !managed.has(guild_id) + .s-empty-state.wmx4.p48 + != icons.Spots.SpotAlertXL + p Either the selected server doesn't exist, or you don't have the Manage Server permission on Discord. + p If you've checked your permissions, try #[a(href=rel("/oauth")) logging in again.] + + else if !row + .s-empty-state.wmx4.p48 + != icons.Spots.SpotAlertXL + p Please add the bot to your server using the buttons on the home page. + + else + .s-empty-state.wmx4.p48 + != icons.Spots.SpotAlertXL + p Access denied. diff --git a/src/web/pug/guild_not_linked.pug b/src/web/pug/guild_not_linked.pug new file mode 100644 index 0000000..59de2fb --- /dev/null +++ b/src/web/pug/guild_not_linked.pug @@ -0,0 +1,53 @@ +extends includes/template.pug + +mixin space(space) + .s-user-card.flex__1 + span.s-avatar.s-avatar__32.s-user-card--avatar + if space.avatar + img.s-avatar--image(src=mUtils.getPublicUrlForMxc(space.avatar)) + else + .s-avatar--letter.bg-silver-400.bar-md(aria-hidden="true")= space.name[0] + .s-user-card--info.ai-start + strong= space.name + if space.topic + ul.s-user-card--awards + li= space.topic + +block body + .s-notice.s-notice__info.d-flex.g16 + div + != icons.Icons.IconInfo + div + - const self = `@${reg.sender_localpart}:${reg.ooye.server_name}` + strong You picked self-service mode + .mt4 To complete setup, you need to manually choose a Matrix space to link with #[strong= guild.name]. + .mt4 On Matrix, invite #[code.s-code-block: a.fc-black.s-link(href=`https://matrix.to/#/${self}` target="_blank")= self] to a space. Then you can pick the space on this page. + + h3.mt32.fs-category Choose a space + + form.s-card.bs-sm.p0.s-table-container.bar-md(method="post" action=rel("/api/link-space")) + input(type="hidden" name="guild_id" value=guild_id) + table.s-table.s-table__bx-simple + each space in spaces + tr + td.p0: +space(space) + td: button.s-btn(name="space_id" value=space.room_id hx-post=rel("/api/link-space") hx-trigger="click" hx-disabled-elt="this") Link with this space + else + if session.data.mxid + tr + td.p16 Invite the bridge to a space, and the space will show up here. + else + tr + td.d-flex.ai-center.pl16.g16 + | You need to log in with Matrix first. + a.s-btn.s-btn__matrix.s-btn__outlined(href=rel(`/log-in-with-matrix`, {next: `./guild?guild_id=${guild_id}`})) Log in with Matrix + + h3.mt48.fs-category Auto-create + .s-card + form.d-flex.ai-center.g8(method="post" action=rel("/api/autocreate") hx-post=rel("/api/autocreate") hx-indicator="#easy-mode-button") + input(type="hidden" name="guild_id" value=guild_id) + input(type="hidden" name="autocreate" value="true") + label.s-label.fl-grow1 + | Changed your mind? + p.s-description If you want, OOYE can create and manage the Matrix space so you don't have to. + button.s-btn.s-btn__outlined#easy-mode-button Use easy mode diff --git a/src/web/pug/home.pug b/src/web/pug/home.pug new file mode 100644 index 0000000..d562250 --- /dev/null +++ b/src/web/pug/home.pug @@ -0,0 +1,56 @@ +extends includes/template.pug + +block body + - let locked = reg.ooye.web_password && reg.ooye.web_password !== session.data.password + + if locked + aside.s-notice.s-notice__warning.p8 + .d-flex.flex__center.jc-space-between.s-banner--container.g8(class="md:fw-wrap") + .d-flex.ai-center.g8 + .flex--item!= icons.Icons.IconLock + p.m0 <strong>Private instance.</strong> You need the password to use this instance of Out Of Your Element. + form(method="post" action=rel("/api/password")) + input.s-input(placeholder="Enter password" name="password") + + .h32 + + .s-page-title.mb24 + h1.s-page-title--header Out Of Your Element + + else + .s-page-title.mb24 + h1.s-page-title--header Bridge a Discord server + + .d-grid.g24.grid__2.mb24(class="sm:grid__1") + .s-card.bs-md.d-flex.fd-column + h2 Easy mode + p Add the bot to your Discord server. + p It will automatically create new Matrix rooms for you. + .fl-grow1 + a.s-btn.s-btn__filled.s-btn__icon(href=rel("/oauth?action=add")) + != icons.Icons.IconPlus + = ` Add to server` + .s-card.bs-md.d-flex.fd-column + h2 Self-service + p OOYE will link an existing Discord server and Matrix space together. + p Choose this option if you already have a community set up on Matrix. + p Or, choose this if you're migrating from a different bridge. + .fl-grow1 + a.s-btn.s-btn__outlined.s-btn__icon(href=rel("/oauth?action=add-self-service")) + != icons.Icons.IconUnorderedList + = ` Set up self-service` + + .s-prose + h2 What is this? + p #[a(href="https://gitdab.com/cadence/out-of-your-element") Out Of Your Element] is a bridge between the Discord and Matrix chat apps. It lets people on both platforms chat with each other without needing to get everyone on the same app. + p Just chat like usual, and the bridge will forward messages back and forth between the two platforms, so everyone sees the whole conversation. + p All kinds of content are supported, including pictures, threads, emojis, and @mentions. + p It's really easy to set up, even if you only have Discord. Just add the bot to your server, and it'll make everything available on Matrix automatically. + + if locked + h2 This is a private instance + p Anybody can run their own instance of the Out Of Your Element software. The person running this instance has made it private, so you can't add it to your server just yet. If you know who's in charge of #{reg.ooye.server_name}, ask them for the password. + + h2 Run your own instance + p You can still use Out Of Your Element by running your own copy of the software, but this requires some technical skill. + p To get started, #[a(href="https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/get-started.md") check the installation instructions.] diff --git a/src/web/pug/includes/hash.svg b/src/web/pug/includes/hash.svg new file mode 100644 index 0000000..461f2dc --- /dev/null +++ b/src/web/pug/includes/hash.svg @@ -0,0 +1 @@ +<svg fill="none" viewBox="0 0 16 16" height="16" width="16"><path stroke="currentcolor" stroke-width="2" d="m6.75 1-2.5 14m7.5-14-2.5 14M14 10.25H1m14-4.5H2"></path></svg> diff --git a/src/web/pug/includes/template.pug b/src/web/pug/includes/template.pug new file mode 100644 index 0000000..d9f1c30 --- /dev/null +++ b/src/web/pug/includes/template.pug @@ -0,0 +1,148 @@ +mixin guild(guild) + span.s-avatar.s-avatar__32.s-user-card--avatar + if guild.icon + img.s-avatar--image(src=`https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=32`) + else + .s-avatar--letter.bg-silver-400.bar-md(aria-hidden="true")= guild.name[0] + .s-user-card--info.ai-start + strong= guild.name + ul.s-user-card--awards + li #{discord.guildChannelMap.get(guild.id).filter(c => [0, 5, 15, 16].includes(discord.channels.get(c).type)).length} channels + +mixin define-theme(name, h, s, l) + style. + :root { + --#{name}-h: #{h}; + --#{name}-s: #{s}; + --#{name}-l: #{l}; + --#{name}: var(--#{name}-400); + --#{name}-100: hsl(var(--#{name}-h), calc(var(--#{name}-s) + 0 * 1%), clamp(70%, calc(var(--#{name}-l) + 50 * 1%), 95%)); + --#{name}-200: hsl(var(--#{name}-h), calc(var(--#{name}-s) + 0 * 1%), clamp(55%, calc(var(--#{name}-l) + 35 * 1%), 90%)); + --#{name}-300: hsl(var(--#{name}-h), calc(var(--#{name}-s) + 0 * 1%), clamp(35%, calc(var(--#{name}-l) + 15 * 1%), 75%)); + --#{name}-400: hsl(var(--#{name}-h), calc(var(--#{name}-s) + 0 * 1%), clamp(20%, calc(var(--#{name}-l) + 0 * 1%), 60%)); + --#{name}-500: hsl(var(--#{name}-h), calc(var(--#{name}-s) + 0 * 1%), clamp(15%, calc(var(--#{name}-l) + -14 * 1%), 45%)); + --#{name}-600: hsl(var(--#{name}-h), calc(var(--#{name}-s) + 0 * 1%), clamp(5%, calc(var(--#{name}-l) + -26 * 1%), 30%)); + } + +mixin define-themed-button(name, theme) + style. + .s-btn.s-btn__#{name} { + --_bu-bg-active: var(--#{theme}-300); + --_bu-bg-hover: var(--#{theme}-200); + --_bu-bg-selected: var(--#{theme}-300); + --_bu-fc: var(--#{theme}-500); + --_bu-fc-active: var(--_bu-fc); + --_bu-fc-hover: var(--#{theme}-500); + --_bu-fc-selected: var(--#{theme}-600); + --_bu-filled-bc: transparent; + --_bu-filled-bc-selected: var(--_bu-filled-bc); + --_bu-filled-bg: var(--#{theme}-400); + --_bu-filled-bg-active: var(--#{theme}-500); + --_bu-filled-bg-hover: var(--#{theme}-500); + --_bu-filled-bg-selected: var(--#{theme}-600); + --_bu-filled-fc: var(--white); + --_bu-filled-fc-active: var(--_bu-filled-fc); + --_bu-filled-fc-hover: var(--_bu-filled-fc); + --_bu-filled-fc-selected: var(--_bu-filled-fc); + --_bu-outlined-bc: var(--#{theme}-400); + --_bu-outlined-bc-selected: var(--#{theme}-500); + --_bu-outlined-bg-selected: var(--_bu-bg-selected); + --_bu-outlined-fc-selected: var(--_bu-fc-selected); + --_bu-number-fc: var(--white); + --_bu-number-fc-filled: var(--#{theme}); + } + +doctype html +html(lang="en") + head + title Out Of Your Element + <meta name="viewport" content="width=device-width, initial-scale=1"> + link(rel="stylesheet" type="text/css" href=rel("/static/stacks.min.css")) + <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 80%22><text y=%22.83em%22 font-size=%2283%22>💬</text></svg>"> + meta(name="htmx-config" content='{"requestClass":"is-loading"}') + style. + .s-prose a { + text-decoration: underline; + } + .themed { + --theme-base-primary-color-h: 266; + --theme-base-primary-color-s: 53%; + --theme-base-primary-color-l: 63%; + --theme-dark-primary-color-h: 266; + --theme-dark-primary-color-s: 53%; + --theme-dark-primary-color-l: 63%; + } + .s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental input[type="radio"]:checked ~ label:not(.s-toggle-switch--label-off) { + --_ts-multiple-bg: var(--green-400); + --_ts-multiple-fc: var(--white); + } + .s-btn__dropdown:has(+ :popover-open) { + background-color: var(--theme-topbar-item-background-hover, var(--black-200)) !important; + } + +define-themed-button("matrix", "black") + body.themed.theme-system + header.s-topbar + .s-topbar--skip-link(href="#content") Skip to main content + .s-topbar--container.wmx9 + a.s-topbar--logo(href=rel("/")) + img.s-avatar.s-avatar__32(src=rel("/icon.png")) + nav.s-topbar--navigation + ul.s-topbar--content + li.ps-relative.g8 + if !session.data.mxid + a.s-btn.s-btn__icon.s-btn__matrix.s-btn__outlined.as-center(href=rel("/log-in-with-matrix")) + != icons.Icons.IconSpeechBubble + = ` Log in` + span(class="sm:d-none")= ` with Matrix` + if !session.data.userID + a.s-btn.s-btn__icon.s-btn__featured.s-btn__outlined.as-center(href=rel("/oauth")) + != icons.Icons.IconDiscord + = ` Log in` + span(class="sm:d-none")= ` with Discord` + if guild_id && managed.has(guild_id) && discord.guilds.has(guild_id) + button.s-topbar--item.s-btn.s-btn__muted.s-btn__dropdown.pr32.bar0.s-user-card(popovertarget="guilds") + +guild(discord.guilds.get(guild_id)) + else if managed.size + button.s-topbar--item.s-btn.s-btn__muted.s-btn__dropdown.pr24.s-user-card.bar0.fc-black(popovertarget="guilds") + | Your servers + else if session.data.mxid || session.data.userID + .d-flex.ai-center + .s-badge.s-badge__bot.py6.px16.bar-md + | No servers available + #guilds(popover data-popper-placement="bottom" style="display: revert; width: revert;").s-popover.overflow-visible + .s-popover--arrow.s-popover--arrow__tc + .s-popover--content.overflow-y-auto.overflow-x-hidden + ul.s-menu(role="menu") + each guild in [...managed].map(id => discord.guilds.get(id)).filter(g => g).sort((a, b) => a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1) + li(role="menuitem") + a.s-topbar--item.s-user-card.d-flex.p4(href=rel(`/guild?guild_id=${guild.id}`)) + +guild(guild) + //- Body + .mx-auto.w100.wmx9.py24.px8.fs-body1#content + block body + //- Guild list popover + script. + document.querySelectorAll("[popovertarget]").forEach(e => { + e.addEventListener("click", () => { + const rect = e.getBoundingClientRect() + const t = `:popover-open { position: absolute; top: ${Math.floor(rect.bottom)}px; left: ${Math.floor(rect.left + rect.width / 2)}px; width: ${Math.floor(rect.width)}px; transform: translateX(-50%); box-sizing: content-box; margin: 0 }` + document.styleSheets[0].insertRule(t, document.styleSheets[0].cssRules.length) + }) + }) + script(src=rel("/static/htmx.js")) + //- Error dialog + aside.s-modal#server-error(aria-hidden="true") + .s-modal--dialog + h1.s-modal--header Server error + pre.overflow-auto#server-error-content + button.s-modal--close.s-btn.s-btn__muted(aria-label="Close" type="button" onclick="hideError()")!= icons.Icons.IconClearSm + .s-modal--footer + button.s-btn.s-btn__outlined.s-btn__muted(type="button" onclick="hideError()") OK + script. + function hideError() { + document.getElementById("server-error").setAttribute("aria-hidden", "true") + } + document.body.addEventListener("htmx:responseError", event => { + document.getElementById("server-error").setAttribute("aria-hidden", "false") + document.getElementById("server-error-content").textContent = event.detail.xhr.responseText + }) diff --git a/src/web/pug/invite.pug b/src/web/pug/invite.pug new file mode 100644 index 0000000..81a428d --- /dev/null +++ b/src/web/pug/invite.pug @@ -0,0 +1,33 @@ +extends includes/template.pug + +block body + if !isValid + .s-empty-state.wmx4.p48 + != icons.Spots.SpotExpireXL + p This QR code has expired. + p Refresh the guild management page to generate a new one. + + else + - let guild = discord.guilds.get(guild_id) + + .s-page-title.mb24 + h1.s-page-title--header= guild.name + + .d-flex.g16#form-container + .fl-grow1 + h2.fs-headline1 Invite a Matrix user + + form.d-flex.gy16.fd-column(method="post" action=rel("/api/invite") hx-post=rel("/api/invite") hx-indicator="#invite-button" hx-select="#ok" hx-target="#form-container") + .d-flex.gy4.fd-column + label.s-label(for="mxid") Matrix ID + input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org") + .d-flex.gy4.fd-column + label.s-label(for="permissions") Permissions + .s-select + select#permissions(name="permissions") + option(value="default") Default + option(value="moderator") Moderator + option(value="admin") Admin + input(type="hidden" name="nonce" value=nonce) + div + button.s-btn.s-btn__filled#invite-button Invite diff --git a/src/web/pug/log-in-with-matrix.pug b/src/web/pug/log-in-with-matrix.pug new file mode 100644 index 0000000..3bb72e2 --- /dev/null +++ b/src/web/pug/log-in-with-matrix.pug @@ -0,0 +1,16 @@ +extends includes/template.pug + +block body + .s-page-title.mb24 + h1.s-page-title--header Log in with Matrix + + .d-flex.g16#form-container + .fl-grow1 + form.d-flex.gy16.fd-column(method="post" action=rel("/api/log-in-with-matrix") hx-post=rel("/api/log-in-with-matrix") hx-indicator="#log-in-button" hx-select="#ok" hx-target="#form-container") + if next + input(type="hidden" name="next" value=next) + .d-flex.gy4.fd-column + label.s-label(for="mxid") Your Matrix ID + input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org" pattern="@([^:]+):([a-z0-9:\\-]+\\.[a-z0-9.:\\-]+)") + div + button.s-btn.s-btn__github#log-in-button Continue with Matrix diff --git a/src/web/pug/ok.pug b/src/web/pug/ok.pug new file mode 100644 index 0000000..bf32e8d --- /dev/null +++ b/src/web/pug/ok.pug @@ -0,0 +1,6 @@ +extends includes/template.pug + +block body + .ta-center.wmx5.p48.mx-auto#ok + != spot ? icons.Spots[spot] : icons.Spots.SpotApproveXL + p.mt24.fs-body2= msg diff --git a/src/web/routes/download-discord.js b/src/web/routes/download-discord.js new file mode 100644 index 0000000..bbf33b0 --- /dev/null +++ b/src/web/routes/download-discord.js @@ -0,0 +1,85 @@ +// @ts-check + +const assert = require("assert/strict") +const {defineEventHandler, getValidatedRouterParams, sendRedirect, createError, H3Event} = require("h3") +const {z} = require("zod") + +/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore +let hasher = null +// @ts-ignore +require("xxhash-wasm")().then(h => hasher = h) + +const {discord, as, select} = require("../../passthrough") + +const schema = { + params: z.object({ + channel_id: z.string().regex(/^[0-9]+$/), + attachment_id: z.string().regex(/^[0-9]+$/), + file_name: z.string().regex(/^[-A-Za-z0-9_.,]+$/) + }) +} + +/** + * @param {H3Event} event + * @returns {import("snowtransfer").SnowTransfer} + */ +function getSnow(event) { + /* c8 ignore next */ + return event.context.snow || discord.snow +} + +/** @type {Map<string, Promise<string>>} */ +const cache = new Map() + +/** @param {string} url */ +function timeUntilExpiry(url) { + const params = new URL(url).searchParams + const ex = params.get("ex") + assert(ex) // refreshed urls from the discord api always include this parameter + const time = parseInt(ex, 16)*1000 - Date.now() + if (time > 0) return time + return false +} + +function defineMediaProxyHandler(domain) { + return defineEventHandler(async event => { + const params = await getValidatedRouterParams(event, schema.params.parse) + + const unsignedHash = hasher.h64(params.attachment_id) + const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range + + const row = select("media_proxy", "permitted_hash", {permitted_hash: signedHash}).get() + if (row == null) { + throw createError({ + status: 403, + data: `The file you requested isn't permitted by this media proxy.` + }) + } + + const url = `https://${domain}/attachments/${params.channel_id}/${params.attachment_id}/${params.file_name}` + let promise = cache.get(url) + /** @type {string | undefined} */ + let refreshed + if (promise) { + refreshed = await promise + if (!timeUntilExpiry(refreshed)) promise = undefined + } + if (!promise) { + const snow = getSnow(event) + promise = snow.channel.refreshAttachmentURLs([url]).then(x => x.refreshed_urls[0].refreshed) + cache.set(url, promise) + refreshed = await promise + const time = timeUntilExpiry(refreshed) + assert(time) // the just-refreshed URL will always be in the future + setTimeout(() => { + cache.delete(url) + }, time).unref() + } + assert(refreshed) // will have been assigned by one of the above branches + + return sendRedirect(event, refreshed) + }) +} + +as.router.get(`/download/discordcdn/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("cdn.discordapp.com")) +as.router.get(`/download/discordmedia/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("media.discordapp.net")) diff --git a/src/web/routes/download-discord.test.js b/src/web/routes/download-discord.test.js new file mode 100644 index 0000000..b0b0077 --- /dev/null +++ b/src/web/routes/download-discord.test.js @@ -0,0 +1,49 @@ +// @ts-check + +const tryToCatch = require("try-to-catch") +const {test} = require("supertape") +const {router} = require("../../../test/web") +const {MatrixServerError} = require("../../matrix/mreq") + +const snow = { + channel: { + async refreshAttachmentURLs(attachments) { + if (typeof attachments === "string") attachments = [attachments] + return { + refreshed_urls: attachments.map(a => ({ + original: a, + refreshed: a + `?ex=${Math.floor(Date.now() / 1000 + 3600).toString(16)}` + })) + } + } + } +} + +test("web download discord: access denied if not a known attachment", async t => { + const [error] = await tryToCatch(() => + router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name", { + params: { + channel_id: "1", + attachment_id: "2", + file_name: "image.png" + }, + snow + }) + ) + t.ok(error) +}) + +test("web download discord: works if a known attachment", async t => { + const event = {} + await router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name", { + params: { + channel_id: "655216173696286746", + attachment_id: "1314358913482621010", + file_name: "image.png" + }, + event, + snow + }) + t.equal(event.node.res.statusCode, 302) + t.match(event.node.res.getHeader("location"), /https:\/\/cdn.discordapp.com\/attachments\/655216173696286746\/1314358913482621010\/image\.png\?ex=/) +}) diff --git a/src/web/routes/download-matrix.js b/src/web/routes/download-matrix.js new file mode 100644 index 0000000..8f790c5 --- /dev/null +++ b/src/web/routes/download-matrix.js @@ -0,0 +1,55 @@ +// @ts-check + +const assert = require("assert/strict") +const {defineEventHandler, getValidatedRouterParams, setResponseStatus, setResponseHeader, sendStream, createError, H3Event} = require("h3") +const {z} = require("zod") + +/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore +let hasher = null +// @ts-ignore +require("xxhash-wasm")().then(h => hasher = h) + +const {sync, as, select} = require("../../passthrough") + +const schema = { + params: z.object({ + server_name: z.string(), + media_id: z.string() + }) +} + +/** + * @param {H3Event} event + * @returns {import("../../matrix/api")} + */ +function getAPI(event) { + /* c8 ignore next */ + return event.context.api || sync.require("../../matrix/api") +} + +as.router.get(`/download/matrix/:server_name/:media_id`, defineEventHandler(async event => { + const params = await getValidatedRouterParams(event, schema.params.parse) + + const serverAndMediaID = `${params.server_name}/${params.media_id}` + const unsignedHash = hasher.h64(serverAndMediaID) + const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range + + const row = select("media_proxy", "permitted_hash", {permitted_hash: signedHash}).get() + if (row == null) { + throw createError({ + status: 403, + data: `The file you requested isn't permitted by this media proxy.` + }) + } + + const api = getAPI(event) + const res = await api.getMedia(`mxc://${params.server_name}/${params.media_id}`) + + const contentType = res.headers.get("content-type") + assert(contentType) + + setResponseStatus(event, res.status) + setResponseHeader(event, "Content-Type", contentType) + setResponseHeader(event, "Transfer-Encoding", "chunked") + return res.body +})) diff --git a/src/web/routes/download-matrix.test.js b/src/web/routes/download-matrix.test.js new file mode 100644 index 0000000..421d2da --- /dev/null +++ b/src/web/routes/download-matrix.test.js @@ -0,0 +1,35 @@ +// @ts-check + +const tryToCatch = require("try-to-catch") +const {test} = require("supertape") +const {router} = require("../../../test/web") + +test("web download matrix: access denied if not a known attachment", async t => { + const [error] = await tryToCatch(() => + router.test("get", "/download/matrix/:server_name/:media_id", { + params: { + server_name: "cadence.moe", + media_id: "1" + } + }) + ) + t.ok(error) +}) + +test("web download matrix: works if a known attachment", async t => { + const event = {} + await router.test("get", "/download/matrix/:server_name/:media_id", { + params: { + server_name: "cadence.moe", + media_id: "KrwlqopRyMxnEBcWDgpJZPxh", + }, + event, + api: { + async getMedia(mxc, init) { + return new Response("", {status: 200, headers: {"content-type": "image/png"}}) + } + } + }) + t.equal(event.node.res.statusCode, 200) + t.equal(event.node.res.getHeader("content-type"), "image/png") +}) diff --git a/src/web/routes/guild-settings.js b/src/web/routes/guild-settings.js new file mode 100644 index 0000000..b640d36 --- /dev/null +++ b/src/web/routes/guild-settings.js @@ -0,0 +1,94 @@ +// @ts-check + +const assert = require("assert/strict") +const {z} = require("zod") +const {defineEventHandler, createError, readValidatedBody, getRequestHeader, setResponseHeader, sendRedirect, H3Event} = require("h3") + +const {as, db, sync, select} = require("../../passthrough") + +/** @type {import("../auth")} */ +const auth = sync.require("../auth") +/** @type {import("../../d2m/actions/set-presence")} */ +const setPresence = sync.require("../../d2m/actions/set-presence") + +/** + * @param {H3Event} event + * @returns {import("../../d2m/actions/create-space")} + */ +function getCreateSpace(event) { + /* c8 ignore next */ + return event.context.createSpace || sync.require("../../d2m/actions/create-space") +} + +/** + * @typedef Options + * @prop {(value: string?) => number} transform + * @prop {(event: H3Event, guildID: string) => any} [after] + * @prop {keyof import("../../db/orm-defs").Models} table + */ + +/** + * @template {string} T + * @param {T} key + * @param {Partial<Options>} [inputOptions] + */ +function defineToggle(key, inputOptions) { + /** @type {Options} */ + const options = { + transform: x => +!!x, // convert toggle to 0 or 1 + table: "guild_space" + } + Object.assign(options, inputOptions) + return defineEventHandler(async event => { + const bodySchema = z.object({ + guild_id: z.string(), + [key]: z.string().optional() + }) + /** @type {Record<T, string?> & Record<"guild_id", string> & Record<string, unknown>} */ // @ts-ignore + const parsedBody = await readValidatedBody(event, bodySchema.parse) + const managed = await auth.getManagedGuilds(event) + if (!managed.has(parsedBody.guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"}) + + const value = options.transform(parsedBody[key]) + assert(typeof value === "number") + db.prepare(`UPDATE ${options.table} SET ${key} = ? WHERE guild_id = ?`).run(value, parsedBody.guild_id) + + return (options.after && await options.after(event, parsedBody.guild_id)) || null + }) +} + +as.router.post("/api/autocreate", defineToggle("autocreate", { + table: "guild_active", + after(event, guild_id) { + // If showing a partial page due to incomplete setup, need to refresh the whole page to show the alternate version + const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get() + if (!spaceID) { + if (getRequestHeader(event, "HX-Request")) { + setResponseHeader(event, "HX-Refresh", "true") + } else { + return sendRedirect(event, "", 302) + } + } + } +})) + +as.router.post("/api/url-preview", defineToggle("url_preview")) + +as.router.post("/api/presence", defineToggle("presence", { + after() { + setPresence.guildPresenceSetting.update() + } +})) + +as.router.post("/api/privacy-level", defineToggle("privacy_level", { + transform(value) { + assert(value) + const i = ["invite", "link", "directory"].indexOf(value) + assert.notEqual(i, -1) + return i + }, + async after(event, guildID) { + const createSpace = getCreateSpace(event) + await createSpace.syncSpaceFully(guildID) // this is inefficient but OK to call infrequently on user request + } +})) diff --git a/src/web/routes/guild-settings.test.js b/src/web/routes/guild-settings.test.js new file mode 100644 index 0000000..fccc266 --- /dev/null +++ b/src/web/routes/guild-settings.test.js @@ -0,0 +1,96 @@ +// @ts-check + +const tryToCatch = require("try-to-catch") +const {router, test} = require("../../../test/web") +const {select} = require("../../passthrough") +const {MatrixServerError} = require("../../matrix/mreq") + +test("web autocreate: checks permissions", async t => { + const [error] = await tryToCatch(() => router.test("post", "/api/autocreate", { + body: { + guild_id: "66192955777486848" + } + })) + t.equal(error.data, "Can't change settings for a guild you don't have Manage Server permissions in") +}) + + +test("web autocreate: turns off autocreate and does htmx page refresh when guild not linked", async t => { + const event = {} + await router.test("post", "/api/autocreate", { + sessionData: { + managedGuilds: ["66192955777486848"] + }, + body: { + guild_id: "66192955777486848", + // autocreate is false + }, + headers: { + "hx-request": "true" + }, + event + }) + t.equal(event.node.res.getHeader("hx-refresh"), "true") + t.equal(select("guild_active", "autocreate", {guild_id: "66192955777486848"}).pluck().get(), 0) +}) + +test("web autocreate: turns on autocreate and issues 302 when not using htmx", async t => { + const event = {} + await router.test("post", "/api/autocreate", { + sessionData: { + managedGuilds: ["66192955777486848"] + }, + body: { + guild_id: "66192955777486848", + autocreate: "yes" + }, + event + }) + t.equal(event.node.res.getHeader("location"), "") + t.equal(select("guild_active", "autocreate", {guild_id: "66192955777486848"}).pluck().get(), 1) +}) + +test("web privacy level: checks permissions", async t => { + const [error] = await tryToCatch(() => router.test("post", "/api/privacy-level", { + body: { + guild_id: "112760669178241024", + privacy_level: "directory" + } + })) + t.equal(error.data, "Can't change settings for a guild you don't have Manage Server permissions in") +}) + +test("web privacy level: updates privacy level", async t => { + let called = 0 + await router.test("post", "/api/privacy-level", { + sessionData: { + managedGuilds: ["112760669178241024"] + }, + body: { + guild_id: "112760669178241024", + privacy_level: "directory" + }, + createSpace: { + async syncSpaceFully(guildID) { + called++ + t.equal(guildID, "112760669178241024") + return "" + } + } + }) + t.equal(called, 1) + t.equal(select("guild_space", "privacy_level", {guild_id: "112760669178241024"}).pluck().get(), 2) // directory = 2 +}) + +test("web presence: updates presence", async t => { + await router.test("post", "/api/presence", { + sessionData: { + managedGuilds: ["112760669178241024"] + }, + body: { + guild_id: "112760669178241024" + // presence is on by default - turn it off + } + }) + t.equal(select("guild_space", "presence", {guild_id: "112760669178241024"}).pluck().get(), 0) +}) diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js new file mode 100644 index 0000000..8c2d99d --- /dev/null +++ b/src/web/routes/guild.js @@ -0,0 +1,239 @@ +// @ts-check + +const DiscordTypes = require("discord-api-types/v10") +const assert = require("assert/strict") +const {z} = require("zod") +const {H3Event, defineEventHandler, sendRedirect, createError, getValidatedQuery, readValidatedBody, setResponseHeader} = require("h3") +const {randomUUID} = require("crypto") +const {LRUCache} = require("lru-cache") +const Ty = require("../../types") +const uqr = require("uqr") + +const {id: botID} = require("../../../addbot") +const {discord, as, sync, select, from, db} = require("../../passthrough") +/** @type {import("../pug-sync")} */ +const pugSync = sync.require("../pug-sync") +/** @type {import("../../d2m/actions/create-space")} */ +const createSpace = sync.require("../../d2m/actions/create-space") +/** @type {import("../auth")} */ +const auth = require("../auth") +/** @type {import("../../discord/utils")} */ +const utils = sync.require("../../discord/utils") +const {reg} = require("../../matrix/read-registration") + +const schema = { + guild: z.object({ + guild_id: z.string().optional() + }), + qr: z.object({ + guild_id: z.string().optional() + }), + invite: z.object({ + mxid: z.string().regex(/@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)/), + permissions: z.enum(["default", "moderator", "admin"]), + guild_id: z.string().optional(), + nonce: z.string().optional() + }), + inviteNonce: z.object({ + nonce: z.string() + }) +} + +/** + * @param {H3Event} event + * @returns {import("../../matrix/api")} + */ +function getAPI(event) { + /* c8 ignore next */ + return event.context.api || sync.require("../../matrix/api") +} + +/** @type {LRUCache<string, string>} nonce to guild id */ +const validNonce = new LRUCache({max: 200}) + +/** + * Modifies the input, removing items that don't pass the filter. Returns the items that didn't pass. + * @param {T[]} xs + * @param {(x: T, i?: number) => any} fn + * @template T + * @returns T[] + */ +function filterTo(xs, fn) { + /** @type {T[]} */ + const filtered = [] + for (let i = xs.length-1; i >= 0; i--) { + const x = xs[i] + if (!fn(x, i)) { + filtered.unshift(x) + xs.splice(i, 1) + } + } + return filtered +} + +/** + * @param {DiscordTypes.APIGuild} guild + * @param {Ty.R.Hierarchy[]} rooms + * @param {string[]} roles + */ +function getChannelRoomsLinks(guild, rooms, roles) { + function getPosition(channel) { + let position = 0 + let looking = channel + while (looking.parent_id) { + looking = discord.channels.get(looking.parent_id) + position = looking.position * 1000 + } + if (channel.position) position += channel.position + return position + } + + let channelIDs = discord.guildChannelMap.get(guild.id) + assert(channelIDs) + + let linkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {channel_id: channelIDs}).all() + let linkedChannelsWithDetails = linkedChannels.map(c => ({channel: discord.channels.get(c.channel_id), ...c})) + let removedUncachedChannels = filterTo(linkedChannelsWithDetails, c => c.channel) + let linkedChannelIDs = linkedChannelsWithDetails.map(c => c.channel_id) + linkedChannelsWithDetails.sort((a, b) => getPosition(a.channel) - getPosition(b.channel)) + + let unlinkedChannelIDs = channelIDs.filter(c => !linkedChannelIDs.includes(c)) + /** @type {DiscordTypes.APIGuildChannel[]} */ // @ts-ignore + let unlinkedChannels = unlinkedChannelIDs.map(c => discord.channels.get(c)) + let removedWrongTypeChannels = filterTo(unlinkedChannels, c => c && [0, 5].includes(c.type)) + let removedPrivateChannels = filterTo(unlinkedChannels, c => { + const permissions = utils.getPermissions(roles, guild.roles, botID, c["permission_overwrites"]) + return utils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel) + }) + unlinkedChannels.sort((a, b) => getPosition(a) - getPosition(b)) + + let linkedRoomIDs = linkedChannels.map(c => c.room_id) + let unlinkedRooms = [...rooms] + let removedLinkedRooms = filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id)) + let removedWrongTypeRooms = filterTo(unlinkedRooms, r => !r.room_type) + // https://discord.com/developers/docs/topics/threads#active-archived-threads + // need to filter out linked archived threads from unlinkedRooms, will just do that by comparing against the name + let removedArchivedThreadRooms = filterTo(unlinkedRooms, r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/)) + + return { + linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms, + removedUncachedChannels, removedWrongTypeChannels, removedPrivateChannels, removedLinkedRooms, removedWrongTypeRooms, removedArchivedThreadRooms + } +} + +as.router.get("/guild", defineEventHandler(async event => { + const {guild_id} = await getValidatedQuery(event, schema.guild.parse) + const session = await auth.useSession(event) + const managed = await auth.getManagedGuilds(event) + const row = from("guild_active").join("guild_space", "guild_id", "left").select("space_id", "privacy_level", "autocreate").where({guild_id}).get() + // @ts-ignore + const guild = discord.guilds.get(guild_id) + + // Permission problems + if (!guild_id || !guild || !managed.has(guild_id) || !row) { + return pugSync.render(event, "guild_access_denied.pug", {guild_id, row}) + } + + // Self-service guild that hasn't been linked yet - needs a special page encouraging the link flow + if (!row.space_id && row.autocreate === 0) { + const spaces = db.prepare("SELECT room_id, type, name, topic, avatar FROM invite LEFT JOIN guild_space ON invite.room_id = guild_space.space_id WHERE mxid = ? AND space_id IS NULL AND type = 'm.space'").all(session.data.mxid) + return pugSync.render(event, "guild_not_linked.pug", {guild, guild_id, spaces}) + } + + const roles = guild.members?.find(m => m.user.id === botID)?.roles || [] + + // Easy mode guild that hasn't been linked yet - need to remove elements that would require an existing space + if (!row.space_id) { + const links = getChannelRoomsLinks(guild, [], roles) + return pugSync.render(event, "guild.pug", {guild, guild_id, ...links, ...row}) + } + + // Linked guild + const api = getAPI(event) + const rooms = await api.getFullHierarchy(row.space_id) + const links = getChannelRoomsLinks(guild, rooms, roles) + return pugSync.render(event, "guild.pug", {guild, guild_id, ...links, ...row}) +})) + +as.router.get("/qr", defineEventHandler(async event => { + const {guild_id} = await getValidatedQuery(event, schema.qr.parse) + const managed = await auth.getManagedGuilds(event) + const row = from("guild_active").join("guild_space", "guild_id", "left").select("space_id", "privacy_level", "autocreate").where({guild_id}).get() + // @ts-ignore + const guild = discord.guilds.get(guild_id) + + // Permission problems + if (!guild_id || !guild || !managed.has(guild_id) || !row) { + return pugSync.render(event, "guild_access_denied.pug", {guild_id, row}) + } + + const nonce = randomUUID() + validNonce.set(nonce, guild_id) + + const data = `${reg.ooye.bridge_origin}/invite?nonce=${nonce}` + // necessary to scale the svg pixel-perfectly on the page + // https://github.com/unjs/uqr/blob/244952a8ba2d417f938071b61e11fb1ff95d6e75/src/svg.ts#L24 + const generatedSvg = uqr.renderSVG(data, {pixelSize: 3}) + const svg = generatedSvg.replace(/viewBox="0 0 ([0-9]+) ([0-9]+)"/, `data-nonce="${nonce}" width="$1" height="$2" $&`) + assert.notEqual(svg, generatedSvg) + + return svg +})) + +as.router.get("/invite", defineEventHandler(async event => { + const {nonce} = await getValidatedQuery(event, schema.inviteNonce.parse) + const isValid = validNonce.has(nonce) + const guild_id = validNonce.get(nonce) + const guild = discord.guilds.get(guild_id || "") + return pugSync.render(event, "invite.pug", {isValid, nonce, guild_id, guild}) +})) + +as.router.post("/api/invite", defineEventHandler(async event => { + const parsedBody = await readValidatedBody(event, schema.invite.parse) + const managed = await auth.getManagedGuilds(event) + const api = getAPI(event) + + // Check guild ID or nonce + if (parsedBody.guild_id) { + var guild_id = parsedBody.guild_id + if (!managed.has(guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't invite users to a guild you don't have Manage Server permissions in"}) + } else if (parsedBody.nonce) { + if (!validNonce.has(parsedBody.nonce)) throw createError({status: 403, message: "Nonce expired", data: "Nonce means number-used-once, and, well, you tried to use it twice..."}) + let ok = validNonce.get(parsedBody.nonce) + assert(ok) + var guild_id = ok + validNonce.delete(parsedBody.nonce) + } else { + throw createError({status: 400, message: "Missing guild ID", data: "Passing a guild ID or a nonce is required."}) + } + + // Check guild is bridged + const guild = discord.guilds.get(guild_id) + assert(guild) + const spaceID = await createSpace.ensureSpace(guild) + + // Check for existing invite to the space + let spaceMember + try { + spaceMember = await api.getStateEvent(spaceID, "m.room.member", parsedBody.mxid) + } catch (e) {} + + if (!spaceMember || !["invite", "join"].includes(spaceMember.membership)) { + // Invite + await api.inviteToRoom(spaceID, parsedBody.mxid) + } + + // Permissions + const powerLevel = + ( parsedBody.permissions === "admin" ? 100 + : parsedBody.permissions === "moderator" ? 50 + : 0) + if (powerLevel) await api.setUserPowerCascade(spaceID, parsedBody.mxid, powerLevel) + + if (parsedBody.guild_id) { + setResponseHeader(event, "HX-Refresh", true) + return sendRedirect(event, `/guild?guild_id=${guild_id}`, 302) + } else { + return sendRedirect(event, "/ok?msg=User has been invited.", 302) + } +})) diff --git a/src/web/routes/guild.test.js b/src/web/routes/guild.test.js new file mode 100644 index 0000000..ea59173 --- /dev/null +++ b/src/web/routes/guild.test.js @@ -0,0 +1,277 @@ +// @ts-check + +const tryToCatch = require("try-to-catch") +const {router, test} = require("../../../test/web") +const {MatrixServerError} = require("../../matrix/mreq") + +let nonce + +test("web guild: access denied when not logged in", async t => { + const html = await router.test("get", "/guild?guild_id=112760669178241024", { + sessionData: { + }, + }) + t.has(html, "You need to log in to manage your servers.") +}) + +test("web guild: asks to select guild if not selected", async t => { + const html = await router.test("get", "/guild", { + sessionData: { + userID: "1", + managedGuilds: [] + }, + }) + t.has(html, "Select a server from the top right corner to continue.") +}) + +test("web guild: access denied when guild id messed up", async t => { + const html = await router.test("get", "/guild?guild_id=1", { + sessionData: { + userID: "1", + managedGuilds: [] + }, + }) + t.has(html, "the selected server doesn't exist") +}) + +test("web qr: access denied when guild id messed up", async t => { + const html = await router.test("get", "/qr?guild_id=1", { + sessionData: { + userID: "1", + managedGuilds: [] + }, + }) + t.has(html, "the selected server doesn't exist") +}) + +test("web invite: access denied with invalid nonce", async t => { + const html = await router.test("get", "/invite?nonce=1") + t.match(html, /This QR code has expired./) +}) + + + +test("web guild: can view unbridged guild", async t => { + const html = await router.test("get", "/guild?guild_id=66192955777486848", { + sessionData: { + managedGuilds: ["66192955777486848"] + } + }) + t.has(html, `<h1 class="s-page-title--header">Function & Arg</h1>`) +}) + +test("web guild: unbridged self-service guild prompts log in to matrix", async t => { + const html = await router.test("get", "/guild?guild_id=665289423482519565", { + sessionData: { + managedGuilds: ["665289423482519565"] + } + }) + t.has(html, `You picked self-service mode`) + t.has(html, `You need to log in with Matrix first`) +}) + +test("web guild: unbridged self-service guild asks to be invited", async t => { + const html = await router.test("get", "/guild?guild_id=665289423482519565", { + sessionData: { + mxid: "@user:example.org", + managedGuilds: ["665289423482519565"] + } + }) + t.has(html, `On Matrix, invite <`) +}) + +test("web guild: unbridged self-service guild shows available spaces", async t => { + const html = await router.test("get", "/guild?guild_id=665289423482519565", { + sessionData: { + mxid: "@cadence:cadence.moe", + managedGuilds: ["665289423482519565"] + } + }) + t.has(html, `<strong>Data Horde</strong>`) + t.has(html, `<li>here is the space topic</li>`) + t.has(html, `<img class="s-avatar--image" src="https://bridge.example.org/download/matrix/cadence.moe/TLqQOsTSrZkVKwBSWYTZNTrw">`) + t.notMatch(html, /<strong>some room<\/strong>/) + t.notMatch(html, /<strong>somebody else's space<\/strong>/) +}) + + +test("web guild: can view bridged guild when logged in with discord", async t => { + const html = await router.test("get", "/guild?guild_id=112760669178241024", { + sessionData: { + managedGuilds: ["112760669178241024"] + }, + api: { + async getStateEvent(roomID, type, key) { + return {} + }, + async getMembers(roomID, membership) { + return {chunk: []} + }, + async getFullHierarchy(roomID) { + return [] + } + } + }) + t.has(html, `<h1 class="s-page-title--header">Psychonauts 3</h1>`) +}) + +test("web guild: can view bridged guild when logged in with matrix", async t => { + const html = await router.test("get", "/guild?guild_id=112760669178241024", { + sessionData: { + mxid: "@cadence:cadence.moe" + }, + api: { + async getStateEvent(roomID, type, key) { + return {} + }, + async getMembers(roomID, membership) { + return {chunk: []} + }, + async getFullHierarchy(roomID) { + return [] + } + } + }) + t.has(html, `<h1 class="s-page-title--header">Psychonauts 3</h1>`) +}) + +test("web qr: generates nonce", async t => { + const html = await router.test("get", "/qr?guild_id=112760669178241024", { + sessionData: { + managedGuilds: ["112760669178241024"] + } + }) + nonce = html.match(/data-nonce="([a-f0-9-]+)"/)?.[1] + t.ok(nonce) +}) + +test("web invite: page loads with valid nonce", async t => { + const html = await router.test("get", `/invite?nonce=${nonce}`) + t.has(html, "Invite a Matrix user") +}) + + + + +test("api invite: access denied with nothing", async t => { + const [error] = await tryToCatch(() => + router.test("post", `/api/invite`, { + body: { + mxid: "@cadence:cadence.moe", + permissions: "moderator" + } + }) + ) + t.equal(error.message, "Missing guild ID") +}) + +test("api invite: access denied when not in guild", async t => { + const [error] = await tryToCatch(() => + router.test("post", `/api/invite`, { + body: { + mxid: "@cadence:cadence.moe", + permissions: "moderator", + guild_id: "112760669178241024" + } + }) + ) + t.equal(error.message, "Forbidden") +}) + +test("api invite: can invite with valid nonce", async t => { + let called = 0 + const [error] = await tryToCatch(() => + router.test("post", `/api/invite`, { + body: { + mxid: "@cadence:cadence.moe", + permissions: "moderator", + nonce + }, + api: { + async getStateEvent(roomID, type, key) { + called++ + return {membership: "leave"} + }, + async inviteToRoom(roomID, mxidToInvite, mxid) { + t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") + called++ + }, + async setUserPowerCascade(roomID, mxid, power) { + t.equal(power, 50) // moderator + called++ + } + } + }) + ) + t.notOk(error) + t.equal(called, 3) +}) + +test("api invite: access denied when nonce has been used", async t => { + const [error] = await tryToCatch(() => + router.test("post", `/api/invite`, { + body: { + mxid: "@cadence:cadence.moe", + permissions: "moderator", + nonce + } + }) + ) + t.equal(error.message, "Nonce expired") +}) + +test("api invite: can invite to a moderated guild", async t => { + let called = 0 + const [error] = await tryToCatch(() => + router.test("post", `/api/invite`, { + body: { + mxid: "@cadence:cadence.moe", + permissions: "admin", + guild_id: "112760669178241024" + }, + sessionData: { + managedGuilds: ["112760669178241024"] + }, + api: { + async getStateEvent(roomID, type, key) { + called++ + throw new MatrixServerError({errcode: "M_NOT_FOUND", error: "Event not found or something"}) + }, + async inviteToRoom(roomID, mxidToInvite, mxid) { + t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") + called++ + }, + async setUserPowerCascade(roomID, mxid, power) { + t.equal(power, 100) // moderator + called++ + } + } + }) + ) + t.notOk(error) + t.equal(called, 3) +}) + +test("api invite: does not reinvite joined users", async t => { + let called = 0 + const [error] = await tryToCatch(() => + router.test("post", `/api/invite`, { + body: { + mxid: "@cadence:cadence.moe", + permissions: "default", + guild_id: "112760669178241024" + }, + sessionData: { + managedGuilds: ["112760669178241024"] + }, + api: { + async getStateEvent(roomID, type, key) { + called++ + return {membership: "join"} + } + } + }) + ) + t.notOk(error) + t.equal(called, 1) +}) diff --git a/src/web/routes/info.js b/src/web/routes/info.js new file mode 100644 index 0000000..0ccdeca --- /dev/null +++ b/src/web/routes/info.js @@ -0,0 +1,65 @@ +// @ts-check + +const {z} = require("zod") +const {defineEventHandler, getValidatedQuery, H3Event} = require("h3") +const {as, from, sync, select} = require("../../passthrough") + +/** @type {import("../../m2d/converters/utils")} */ +const mUtils = sync.require("../../m2d/converters/utils") + +/** + * @param {H3Event} event + * @returns {import("../../matrix/api")} + */ +function getAPI(event) { + /* c8 ignore next */ + return event.context.api || sync.require("../../matrix/api") +} + +const schema = { + message: z.object({ + message_id: z.string().regex(/^[0-9]+$/) + }) +} + +as.router.get("/api/message", defineEventHandler(async event => { + const api = getAPI(event) + + const {message_id} = await getValidatedQuery(event, schema.message.parse) + const metadatas = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id").where({message_id}) + .select("event_id", "event_type", "event_subtype", "part", "reaction_part", "room_id", "source").and("ORDER BY part ASC, reaction_part DESC").all() + + if (metadatas.length === 0) { + return new Response("Message not found", {status: 404, statusText: "Not Found"}) + } + + const events = await Promise.all(metadatas.map(metadata => + api.getEvent(metadata.room_id, metadata.event_id).then(raw => ({ + metadata: Object.assign({sender: raw.sender}, metadata), + raw + })) + )) + + /* c8 ignore next */ + const primary = events.find(e => e.metadata.part === 0) || events[0] + const mxid = primary.metadata.sender + const source = primary.metadata.source === 0 ? "matrix" : "discord" + + let matrix_author = undefined + if (source === "matrix") { + matrix_author = select("member_cache", ["displayname", "avatar_url", "mxid"], {room_id: primary.metadata.room_id, mxid}).get() + if (!matrix_author) { + try { + matrix_author = await api.getProfile(mxid) + } catch (e) { + matrix_author = {} + } + } + if (!matrix_author.displayname) matrix_author.displayname = mxid + if (matrix_author.avatar_url) matrix_author.avatar_url = mUtils.getPublicUrlForMxc(matrix_author.avatar_url) + else matrix_author.avatar_url = null + matrix_author["mxid"] = mxid + } + + return {source, matrix_author, events} +})) diff --git a/src/web/routes/info.test.js b/src/web/routes/info.test.js new file mode 100644 index 0000000..28dac3b --- /dev/null +++ b/src/web/routes/info.test.js @@ -0,0 +1,219 @@ +// @ts-check + +const assert = require("assert/strict") +const {router, test} = require("../../../test/web") + +test("web info: returns 404 when message doesn't exist", async t => { + const res = await router.test("get", "/api/message?message_id=1") + assert(res instanceof Response) + t.equal(res.status, 404) +}) + +test("web info: returns data for a matrix message and profile", async t => { + let called = 0 + const raw = { + type: "m.room.message", + room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "testing :heart_pink: :heart_pink: ", + format: "org.matrix.custom.html", + formatted_body: "testing <img data-mx-emoticon=\"\" src=\"mxc://cadence.moe/AyAhnRNjWyFhJYTRibYwQpvf\" alt=\":heart_pink:\" title=\":heart_pink:\" height=\"32\" vertical-align=\"middle\" /> <img data-mx-emoticon=\"\" src=\"mxc://cadence.moe/AyAhnRNjWyFhJYTRibYwQpvf\" alt=\":heart_pink:\" title=\":heart_pink:\" height=\"32\" vertical-align=\"middle\" />" + }, + origin_server_ts: 1739312945302, + unsigned: { + membership: "join", + age: 10063702303 + }, + event_id: "$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk", + user_id: "@cadence:cadence.moe", + age: 10063702303 + } + const res = await router.test("get", "/api/message?message_id=1339000288144658482", { + api: { + // @ts-ignore - returning static data when method could be called with a different typescript generic + async getEvent(roomID, eventID) { + called++ + t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") + t.equal(eventID, "$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk") + return raw + }, + async getProfile(mxid) { + called++ + t.equal(mxid, "@cadence:cadence.moe") + return { + displayname: "okay 🤍 yay 🤍" + } + } + } + }) + t.deepEqual(res, { + source: "matrix", + matrix_author: { + displayname: "okay 🤍 yay 🤍", + avatar_url: null, + mxid: "@cadence:cadence.moe" + }, + events: [{ + metadata: { + event_id: "$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk", + event_subtype: "m.text", + event_type: "m.room.message", + part: 0, + reaction_part: 0, + room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe", + sender: "@cadence:cadence.moe", + source: 0 + }, + raw + }] + }) + t.equal(called, 2) +}) + +test("web info: returns data for a matrix message without profile", async t => { + let called = 0 + const raw = { + type: "m.room.message", + room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "testing :heart_pink: :heart_pink: ", + format: "org.matrix.custom.html", + formatted_body: "testing <img data-mx-emoticon=\"\" src=\"mxc://cadence.moe/AyAhnRNjWyFhJYTRibYwQpvf\" alt=\":heart_pink:\" title=\":heart_pink:\" height=\"32\" vertical-align=\"middle\" /> <img data-mx-emoticon=\"\" src=\"mxc://cadence.moe/AyAhnRNjWyFhJYTRibYwQpvf\" alt=\":heart_pink:\" title=\":heart_pink:\" height=\"32\" vertical-align=\"middle\" />" + }, + origin_server_ts: 1739312945302, + unsigned: { + membership: "join", + age: 10063702303 + }, + event_id: "$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk", + user_id: "@cadence:cadence.moe", + age: 10063702303 + } + const res = await router.test("get", "/api/message?message_id=1339000288144658482", { + api: { + // @ts-ignore - returning static data when method could be called with a different typescript generic + async getEvent(roomID, eventID) { + called++ + t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") + t.equal(eventID, "$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk") + return raw + } + } + }) + t.deepEqual(res, { + source: "matrix", + matrix_author: { + displayname: "@cadence:cadence.moe", + avatar_url: null, + mxid: "@cadence:cadence.moe" + }, + events: [{ + metadata: { + event_id: "$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk", + event_subtype: "m.text", + event_type: "m.room.message", + part: 0, + reaction_part: 0, + room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe", + sender: "@cadence:cadence.moe", + source: 0 + }, + raw + }] + }) + t.equal(called, 1) +}) + +test("web info: returns data for a discord message", async t => { + let called = 0 + const raw1 = { + type: "m.room.message", + sender: "@_ooye_accavish:cadence.moe", + content: { + "m.mentions": {}, + msgtype: "m.text", + body: "brony music mentioned on wikipedia's did you know and also unrelated cat pic" + }, + origin_server_ts: 1749377203735, + unsigned: { + membership: "join", + age: 119 + }, + event_id: "$AfrB8hzXkDMvuoWjSZkDdFYomjInWH7jMBPkwQMN8AI", + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + } + const raw2 = { + type: "m.room.message", + sender: "@_ooye_accavish:cadence.moe", + content: { + "m.mentions": {}, + msgtype: "m.image", + url: "mxc://cadence.moe/ABOMymxHcpVeecHvmSIYmYXx", + external_url: "https://bridge.cadence.moe/download/discordcdn/112760669178241024/1381212840710504448/image.png", + body: "image.png", + filename: "image.png", + info: { + mimetype: "image/png", + w: 966, + h: 368, + size: 166060 + } + }, + origin_server_ts: 1749377203789, + unsigned: { + membership: "join", + age: 65 + }, + event_id: "$43baKEhJfD-RlsFQi0LB16Zxd8yMqp0HSVL00TDQOqM", + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + } + const res = await router.test("get", "/api/message?message_id=1381212840957972480", { + api: { + // @ts-ignore - returning static data when method could be called with a different typescript generic + async getEvent(roomID, eventID) { + called++ + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") + if (eventID === raw1.event_id) { + return raw1 + } else { + assert(eventID === raw2.event_id) + return raw2 + } + } + } + }) + t.deepEqual(res, { + source: "discord", + matrix_author: undefined, + events: [{ + metadata: { + event_id: "$AfrB8hzXkDMvuoWjSZkDdFYomjInWH7jMBPkwQMN8AI", + event_subtype: "m.text", + event_type: "m.room.message", + part: 0, + reaction_part: 1, + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", + sender: "@_ooye_accavish:cadence.moe", + source: 1 + }, + raw: raw1 + }, { + metadata: { + event_id: "$43baKEhJfD-RlsFQi0LB16Zxd8yMqp0HSVL00TDQOqM", + event_subtype: "m.image", + event_type: "m.room.message", + part: 1, + reaction_part: 0, + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", + sender: "@_ooye_accavish:cadence.moe", + source: 1 + }, + raw: raw2 + }] + }) + t.equal(called, 2) +}) diff --git a/src/web/routes/link.js b/src/web/routes/link.js new file mode 100644 index 0000000..c5f404e --- /dev/null +++ b/src/web/routes/link.js @@ -0,0 +1,216 @@ +// @ts-check + +const {z} = require("zod") +const {defineEventHandler, createError, readValidatedBody, setResponseHeader, H3Event} = require("h3") +const Ty = require("../../types") +const DiscordTypes = require("discord-api-types/v10") + +const {discord, db, as, sync, select, from} = require("../../passthrough") +/** @type {import("../auth")} */ +const auth = sync.require("../auth") +/** @type {import("../../matrix/mreq")} */ +const mreq = sync.require("../../matrix/mreq") +const {reg} = require("../../matrix/read-registration") + +/** + * @param {H3Event} event + * @returns {import("../../matrix/api")} + */ +function getAPI(event) { + /* c8 ignore next */ + return event.context.api || sync.require("../../matrix/api") +} + +/** + * @param {H3Event} event + * @returns {import("../../d2m/actions/create-room")} + */ +function getCreateRoom(event) { + /* c8 ignore next */ + return event.context.createRoom || sync.require("../../d2m/actions/create-room") +} + +/** + * @param {H3Event} event + * @returns {import("../../d2m/actions/create-space")} + */ +function getCreateSpace(event) { + /* c8 ignore next */ + return event.context.createSpace || sync.require("../../d2m/actions/create-space") +} + +const schema = { + linkSpace: z.object({ + guild_id: z.string(), + space_id: z.string() + }), + link: z.object({ + guild_id: z.string(), + matrix: z.string(), + discord: z.string() + }), + unlink: z.object({ + guild_id: z.string(), + channel_id: z.string() + }) +} + +as.router.post("/api/link-space", defineEventHandler(async event => { + const parsedBody = await readValidatedBody(event, schema.linkSpace.parse) + const session = await auth.useSession(event) + const managed = await auth.getManagedGuilds(event) + const api = getAPI(event) + + // Check guild ID + const guildID = parsedBody.guild_id + if (!managed.has(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"}) + + // Check space ID + if (!session.data.mxid) throw createError({status: 403, message: "Forbidden", data: "Can't link with your Matrix space if you aren't logged in to Matrix"}) + const spaceID = parsedBody.space_id + const inviteType = select("invite", "type", {mxid: session.data.mxid, room_id: spaceID}).pluck().get() + if (inviteType !== "m.space") throw createError({status: 403, message: "Forbidden", data: "You personally must invite OOYE to that space on Matrix"}) + + // Check they are not already bridged + const existing = select("guild_space", "guild_id", {}, "WHERE guild_id = ? OR space_id = ?").get(guildID, spaceID) + if (existing) throw createError({status: 400, message: "Bad Request", data: `Guild ID ${guildID} or space ID ${spaceID} are already bridged and cannot be reused`}) + + // Check space exists and bridge is joined + try { + await api.joinRoom(parsedBody.space_id) + } catch (e) { + throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`}) + } + + // Check bridge has PL 100 + const me = `@${reg.sender_localpart}:${reg.ooye.server_name}` + /** @type {Ty.Event.M_Power_Levels?} */ + let powerLevelsStateContent = null + try { + powerLevelsStateContent = await api.getStateEvent(spaceID, "m.room.power_levels", "") + } catch (e) {} + const selfPowerLevel = powerLevelsStateContent?.users?.[me] ?? powerLevelsStateContent?.users_default ?? 0 + if (selfPowerLevel < (powerLevelsStateContent?.state_default ?? 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix space"}) + + // Check inviting user is a moderator in the space + const invitingPowerLevel = powerLevelsStateContent?.users?.[session.data.mxid] ?? powerLevelsStateContent?.users_default ?? 0 + if (invitingPowerLevel < (powerLevelsStateContent?.state_default ?? 50)) throw createError({status: 403, message: "Forbidden", data: `You need to be at least power level 50 (moderator) in the target Matrix space to set up OOYE, but you are currently power level ${invitingPowerLevel}.`}) + + // Insert database entry + db.transaction(() => { + db.prepare("INSERT INTO guild_space (guild_id, space_id) VALUES (?, ?)").run(guildID, spaceID) + db.prepare("DELETE FROM invite WHERE room_id = ?").run(spaceID) + })() + + setResponseHeader(event, "HX-Refresh", "true") + return null // 204 +})) + +as.router.post("/api/link", defineEventHandler(async event => { + const parsedBody = await readValidatedBody(event, schema.link.parse) + const managed = await auth.getManagedGuilds(event) + const api = getAPI(event) + const createRoom = getCreateRoom(event) + const createSpace = getCreateSpace(event) + + // Check guild ID or nonce + const guildID = parsedBody.guild_id + if (!managed.has(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"}) + + // Check guild is bridged + const guild = discord.guilds.get(guildID) + if (!guild) throw createError({status: 400, message: "Bad Request", data: "Discord guild does not exist or bot has not joined it"}) + const spaceID = await createSpace.ensureSpace(guild) + + // Check channel exists + const channel = discord.channels.get(parsedBody.discord) + if (!channel) throw createError({status: 400, message: "Bad Request", data: "Discord channel does not exist"}) + + // Check channel is part of the guild + if (!("guild_id" in channel) || channel.guild_id !== guildID) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel.id} is not part of guild ${guildID}`}) + + // Check channel and room are not already bridged + const row = from("channel_room").select("channel_id", "room_id").and("WHERE channel_id = ? OR room_id = ?").get(channel.id, parsedBody.matrix) + if (row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${row.channel_id} or room ID ${parsedBody.matrix} are already bridged and cannot be reused`}) + + // Check room is part of the guild's space + let found = false + for await (const room of api.generateFullHierarchy(spaceID)) { + if (room.room_id === parsedBody.matrix && !room.room_type) { + found = true + break + } + } + if (!found) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged space"}) + + // Check room exists and bridge is joined + try { + await api.joinRoom(parsedBody.matrix) + } catch (e) { + throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`}) + } + + // Check bridge has PL 100 + const me = `@${reg.sender_localpart}:${reg.ooye.server_name}` + /** @type {Ty.Event.M_Power_Levels?} */ + let powerLevelsStateContent = null + try { + powerLevelsStateContent = await api.getStateEvent(parsedBody.matrix, "m.room.power_levels", "") + } catch (e) {} + const selfPowerLevel = powerLevelsStateContent?.users?.[me] ?? powerLevelsStateContent?.users_default ?? 0 + if (selfPowerLevel < (powerLevelsStateContent?.state_default ?? 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix room"}) + + // Insert database entry, but keep the room's existing properties if they are set + const nick = await api.getStateEvent(parsedBody.matrix, "m.room.name", "").then(content => content.name || null).catch(() => null) + const avatar = await api.getStateEvent(parsedBody.matrix, "m.room.avatar", "").then(content => content.url || null).catch(() => null) + const topic = await api.getStateEvent(parsedBody.matrix, "m.room.topic", "").then(content => content.topic || null).catch(() => null) + db.prepare("INSERT INTO channel_room (channel_id, room_id, name, guild_id, nick, custom_avatar, custom_topic) VALUES (?, ?, ?, ?, ?, ?, ?)").run(channel.id, parsedBody.matrix, channel.name, guildID, nick, avatar, topic) + + // Sync room data and space child + await createRoom.syncRoom(parsedBody.discord) + + // Send a notification in the room + if (channel.type === DiscordTypes.ChannelType.GuildText) { + await api.sendEvent(parsedBody.matrix, "m.room.message", { + msgtype: "m.notice", + body: "👋 This room is now bridged with Discord. Say hi!" + }) + } + + setResponseHeader(event, "HX-Refresh", "true") + return null // 204 +})) + +as.router.post("/api/unlink", defineEventHandler(async event => { + const {channel_id, guild_id} = await readValidatedBody(event, schema.unlink.parse) + const managed = await auth.getManagedGuilds(event) + const createRoom = getCreateRoom(event) + + // Check guild ID or nonce + if (!managed.has(guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"}) + + // Check guild exists + const guild = discord.guilds.get(guild_id) + if (!guild) throw createError({status: 400, message: "Bad Request", data: "Discord guild does not exist or bot has not joined it"}) + + // Check that the channel (if it exists) is part of this guild + /** @type {any} */ + let channel = discord.channels.get(channel_id) + if (channel) { + if (!("guild_id" in channel) || channel.guild_id !== guild_id) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel_id} is not part of guild ${guild_id}`}) + } else { + // Otherwise, if the channel isn't cached, it must have been deleted. + // There's no other authentication here - it's okay for anyone to unlink a deleted channel just by knowing its ID. + channel = {id: channel_id} + } + + // Check channel is currently bridged + const row = select("channel_room", "channel_id", {channel_id: channel_id}).get() + if (!row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel_id} is not currently bridged`}) + + // Do it + await createRoom.unbridgeDeletedChannel(channel, guild_id) + + setResponseHeader(event, "HX-Refresh", "true") + return null // 204 +})) diff --git a/src/web/routes/link.test.js b/src/web/routes/link.test.js new file mode 100644 index 0000000..0d8d366 --- /dev/null +++ b/src/web/routes/link.test.js @@ -0,0 +1,632 @@ +// @ts-check + +const tryToCatch = require("try-to-catch") +const {router, test} = require("../../../test/web") +const {MatrixServerError} = require("../../matrix/mreq") +const {select, db} = require("../../passthrough") +const assert = require("assert").strict + +test("web link space: access denied when not logged in to Discord", async t => { + const [error] = await tryToCatch(() => router.test("post", "/api/link-space", { + sessionData: { + }, + body: { + space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe", + guild_id: "665289423482519565" + } + })) + t.equal(error.data, "Can't edit a guild you don't have Manage Server permissions in") +}) + +test("web link space: access denied when not logged in to Matrix", async t => { + const [error] = await tryToCatch(() => router.test("post", "/api/link-space", { + sessionData: { + managedGuilds: ["665289423482519565"] + }, + body: { + space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe", + guild_id: "665289423482519565" + } + })) + t.equal(error.data, "Can't link with your Matrix space if you aren't logged in to Matrix") +}) + +test("web link space: access denied when bot was invited by different user", async t => { + const [error] = await tryToCatch(() => router.test("post", "/api/link-space", { + sessionData: { + managedGuilds: ["665289423482519565"], + mxid: "@user:example.org" + }, + body: { + space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe", + guild_id: "665289423482519565" + } + })) + t.equal(error.data, "You personally must invite OOYE to that space on Matrix") +}) + +test("web link space: access denied when guild is already in use", async t => { + const [error] = await tryToCatch(() => router.test("post", "/api/link-space", { + sessionData: { + managedGuilds: ["112760669178241024"], + mxid: "@cadence:cadence.moe" + }, + body: { + space_id: "!jjmvBegULiLucuWEHU:cadence.moe", + guild_id: "112760669178241024" + } + })) + t.equal(error.data, "Guild ID 112760669178241024 or space ID !jjmvBegULiLucuWEHU:cadence.moe are already bridged and cannot be reused") +}) + +test("web link space: check that OOYE is joined", async t => { + let called = 0 + const [error] = await tryToCatch(() => router.test("post", "/api/link-space", { + sessionData: { + managedGuilds: ["665289423482519565"], + mxid: "@cadence:cadence.moe" + }, + body: { + space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe", + guild_id: "665289423482519565" + }, + api: { + async joinRoom(roomID) { + called++ + throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}) + } + } + })) + t.equal(error.data, "M_FORBIDDEN - not allowed to join I guess") + t.equal(called, 1) +}) + +test("web link space: check that OOYE has PL 100 (not missing)", async t => { + let called = 0 + const [error] = await tryToCatch(() => router.test("post", "/api/link-space", { + sessionData: { + managedGuilds: ["665289423482519565"], + mxid: "@cadence:cadence.moe" + }, + body: { + space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe", + guild_id: "665289423482519565" + }, + api: { + async joinRoom(roomID) { + called++ + return roomID + }, + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") + t.equal(type, "m.room.power_levels") + throw new MatrixServerError({errcode: "M_NOT_FOUND", error: "what if I told you that power levels never existed"}) + } + } + })) + t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix space") + t.equal(called, 2) +}) + +test("web link space: check that OOYE has PL 100 (not users_default)", async t => { + let called = 0 + const [error] = await tryToCatch(() => router.test("post", "/api/link-space", { + sessionData: { + managedGuilds: ["665289423482519565"], + mxid: "@cadence:cadence.moe" + }, + body: { + space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe", + guild_id: "665289423482519565" + }, + api: { + async joinRoom(roomID) { + called++ + return roomID + }, + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return {} + } + } + })) + t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix space") + t.equal(called, 2) +}) + +test("web link space: check that OOYE has PL 100 (not 50)", async t => { + let called = 0 + const [error] = await tryToCatch(() => router.test("post", "/api/link-space", { + sessionData: { + managedGuilds: ["665289423482519565"], + mxid: "@cadence:cadence.moe" + }, + body: { + space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe", + guild_id: "665289423482519565" + }, + api: { + async joinRoom(roomID) { + called++ + return roomID + }, + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return {users: {"@_ooye_bot:cadence.moe": 50}} + } + } + })) + t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix space") + t.equal(called, 2) +}) + +test("web link space: check that inviting user has PL 50", async t => { + let called = 0 + const [error] = await tryToCatch(() => router.test("post", "/api/link-space", { + sessionData: { + managedGuilds: ["665289423482519565"], + mxid: "@cadence:cadence.moe" + }, + body: { + space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe", + guild_id: "665289423482519565" + }, + api: { + async joinRoom(roomID) { + called++ + return roomID + }, + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return {users: {"@_ooye_bot:cadence.moe": 100}} + } + } + })) + t.equal(error.data, "You need to be at least power level 50 (moderator) in the target Matrix space to set up OOYE, but you are currently power level 0.") + t.equal(called, 2) +}) + +test("web link space: successfully adds entry to database and loads page", async t => { + let called = 0 + await router.test("post", "/api/link-space", { + sessionData: { + managedGuilds: ["665289423482519565"], + mxid: "@cadence:cadence.moe" + }, + body: { + space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe", + guild_id: "665289423482519565" + }, + api: { + async joinRoom(roomID) { + called++ + return roomID + }, + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return {users: {"@_ooye_bot:cadence.moe": 100, "@cadence:cadence.moe": 50}} + } + } + }) + t.equal(called, 2) + + // check that the entry was added to the database + t.equal(select("guild_space", "privacy_level", {guild_id: "665289423482519565", space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe"}).pluck().get(), 0) + + // check that the guild info page now loads + const html = await router.test("get", "/guild?guild_id=665289423482519565", { + sessionData: { + managedGuilds: ["665289423482519565"], + mxid: "@cadence:cadence.moe" + }, + api: { + async getFullHierarchy(spaceID) { + return [] + } + } + }) + t.has(html, `<h1 class="s-page-title--header">Data Horde</h1>`) +}) + +// ***** + +test("web link room: access denied when not logged in to Discord", async t => { + const [error] = await tryToCatch(() => router.test("post", "/api/link", { + sessionData: { + }, + body: { + discord: "665310973967597573", + matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe", + guild_id: "665289423482519565" + } + })) + t.equal(error.data, "Can't edit a guild you don't have Manage Server permissions in") +}) + +test("web link room: check that guild exists", async t => { + const [error] = await tryToCatch(() => router.test("post", "/api/link", { + sessionData: { + managedGuilds: ["1"] + }, + body: { + discord: "665310973967597573", + matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe", + guild_id: "1" + } + })) + t.equal(error.data, "Discord guild does not exist or bot has not joined it") +}) + +test("web link room: check that channel exists", async t => { + const [error] = await tryToCatch(() => router.test("post", "/api/link", { + sessionData: { + managedGuilds: ["665289423482519565"] + }, + body: { + discord: "1", + matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe", + guild_id: "665289423482519565" + } + })) + t.equal(error.data, "Discord channel does not exist") +}) + +test("web link room: check that channel is part of guild", async t => { + const [error] = await tryToCatch(() => router.test("post", "/api/link", { + sessionData: { + managedGuilds: ["665289423482519565"] + }, + body: { + discord: "112760669178241024", + matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe", + guild_id: "665289423482519565" + } + })) + t.equal(error.data, "Channel ID 112760669178241024 is not part of guild 665289423482519565") +}) + +test("web link room: check that channel is not already linked", async t => { + const [error] = await tryToCatch(() => router.test("post", "/api/link", { + sessionData: { + managedGuilds: ["112760669178241024"] + }, + body: { + discord: "112760669178241024", + matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe", + guild_id: "112760669178241024" + } + })) + t.equal(error.data, "Channel ID 112760669178241024 or room ID !NDbIqNpJyPvfKRnNcr:cadence.moe are already bridged and cannot be reused") +}) + +test("web link room: checks the autocreate setting if the space doesn't exist yet", async t => { + let called = 0 + const [error] = await tryToCatch(() => router.test("post", "/api/link", { + sessionData: { + managedGuilds: ["665289423482519565"] + }, + body: { + discord: "665310973967597573", + matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe", + guild_id: "665289423482519565" + }, + createSpace: { + async ensureSpace(guild) { + called++ + t.equal(guild.id, "665289423482519565") + // simulate what ensureSpace is intended to check + const autocreate = 0 + assert.equal(autocreate, 1, "refusing to implicitly create a space for guild 665289423482519565. set the guild_active data first before calling ensureSpace/syncSpace.") + return "" + } + } + })) + t.match(error.message, /refusing to implicitly create a space/) + t.equal(called, 1) +}) + +test("web link room: check that room is part of space (not in hierarchy)", async t => { + let called = 0 + const [error] = await tryToCatch(() => router.test("post", "/api/link", { + sessionData: { + managedGuilds: ["665289423482519565"] + }, + body: { + discord: "665310973967597573", + matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe", + guild_id: "665289423482519565" + }, + api: { + async *generateFullHierarchy(spaceID) { + called++ + t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") + } + } + })) + t.equal(error.data, "Matrix room needs to be part of the bridged space") + t.equal(called, 1) +}) + +test("web link room: check that bridge can join room", async t => { + let called = 0 + const [error] = await tryToCatch(() => router.test("post", "/api/link", { + sessionData: { + managedGuilds: ["665289423482519565"] + }, + body: { + discord: "665310973967597573", + matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe", + guild_id: "665289423482519565" + }, + api: { + async joinRoom(roomID) { + called++ + throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}) + }, + async *generateFullHierarchy(spaceID) { + called++ + t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") + yield { + room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", + children_state: {}, + guest_can_join: false, + num_joined_members: 2 + } + /* c8 ignore next */ + } + } + })) + t.equal(error.data, "M_FORBIDDEN - not allowed to join I guess") + t.equal(called, 2) +}) + +test("web link room: check that bridge has PL 100 in target room (event missing)", async t => { + let called = 0 + const [error] = await tryToCatch(() => router.test("post", "/api/link", { + sessionData: { + managedGuilds: ["665289423482519565"] + }, + body: { + discord: "665310973967597573", + matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe", + guild_id: "665289423482519565" + }, + api: { + async joinRoom(roomID) { + called++ + return roomID + }, + async *generateFullHierarchy(spaceID) { + called++ + t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") + yield { + room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", + children_state: {}, + guest_can_join: false, + num_joined_members: 2 + } + /* c8 ignore next */ + }, + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + throw new MatrixServerError({errcode: "M_NOT_FOUND", error: "what if I told you there's no such thing as power levels"}) + } + } + })) + t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix room") + t.equal(called, 3) +}) + +test("web link room: check that bridge has PL 100 in target room (users default)", async t => { + let called = 0 + const [error] = await tryToCatch(() => router.test("post", "/api/link", { + sessionData: { + managedGuilds: ["665289423482519565"] + }, + body: { + discord: "665310973967597573", + matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe", + guild_id: "665289423482519565" + }, + api: { + async joinRoom(roomID) { + called++ + return roomID + }, + async *generateFullHierarchy(spaceID) { + called++ + t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") + yield { + room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", + children_state: {}, + guest_can_join: false, + num_joined_members: 2 + } + /* c8 ignore next */ + }, + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return {users_default: 50} + } + } + })) + t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix room") + t.equal(called, 3) +}) + +test("web link room: successfully calls createRoom", async t => { + let called = 0 + await router.test("post", "/api/link", { + sessionData: { + managedGuilds: ["665289423482519565"] + }, + body: { + discord: "665310973967597573", + matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe", + guild_id: "665289423482519565" + }, + api: { + async joinRoom(roomID) { + called++ + return roomID + }, + async *generateFullHierarchy(spaceID) { + called++ + t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") + yield { + room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", + children_state: {}, + guest_can_join: false, + num_joined_members: 2 + } + /* c8 ignore next */ + }, + async getStateEvent(roomID, type, key) { + if (type === "m.room.power_levels") { + called++ + t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") + t.equal(key, "") + return {users: {"@_ooye_bot:cadence.moe": 100}} + } else if (type === "m.room.name") { + called++ + t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") + return {} + } else if (type === "m.room.avatar") { + called++ + t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") + return {} + } else if (type === "m.room.topic") { + called++ + t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") + return {} + } + }, + async sendEvent(roomID, type, content) { + called++ + t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") + t.equal(type, "m.room.message") + t.match(content.body, /👋/) + return "" + } + }, + createRoom: { + async syncRoom(channelID) { + called++ + t.equal(channelID, "665310973967597573") + return "!NDbIqNpJyPvfKRnNcr:cadence.moe" + } + } + }) + t.equal(called, 8) +}) + +// ***** + +test("web unlink room: access denied if not logged in to Discord", async t => { + const [error] = await tryToCatch(() => router.test("post", "/api/unlink", { + body: { + channel_id: "665310973967597573", + guild_id: "665289423482519565" + } + })) + t.equal(error.data, "Can't edit a guild you don't have Manage Server permissions in") +}) + +test("web unlink room: checks that guild exists", async t => { + const [error] = await tryToCatch(() => router.test("post", "/api/unlink", { + sessionData: { + managedGuilds: ["2"] + }, + body: { + channel_id: "665310973967597573", + guild_id: "2" + } + })) + t.equal(error.data, "Discord guild does not exist or bot has not joined it") +}) + +test("web unlink room: checks that the channel is part of the guild", async t => { + const [error] = await tryToCatch(() => router.test("post", "/api/unlink", { + sessionData: { + managedGuilds: ["665289423482519565"] + }, + body: { + channel_id: "112760669178241024", + guild_id: "665289423482519565" + } + })) + t.equal(error.data, "Channel ID 112760669178241024 is not part of guild 665289423482519565") +}) + +test("web unlink room: successfully calls unbridgeDeletedChannel when the channel does exist", async t => { + let called = 0 + await router.test("post", "/api/unlink", { + sessionData: { + managedGuilds: ["665289423482519565"] + }, + body: { + channel_id: "665310973967597573", + guild_id: "665289423482519565" + }, + createRoom: { + async unbridgeDeletedChannel(channel) { + called++ + t.equal(channel.id, "665310973967597573") + } + } + }) + t.equal(called, 1) +}) + +test("web unlink room: successfully calls unbridgeDeletedChannel when the channel does not exist", async t => { + let called = 0 + await router.test("post", "/api/unlink", { + sessionData: { + managedGuilds: ["112760669178241024"] + }, + body: { + channel_id: "489237891895768942", + guild_id: "112760669178241024" + }, + createRoom: { + async unbridgeDeletedChannel(channel) { + called++ + t.equal(channel.id, "489237891895768942") + } + } + }) + t.equal(called, 1) +}) + +test("web unlink room: checks that the channel is bridged", async t => { + db.prepare("DELETE FROM channel_room WHERE channel_id = '665310973967597573'").run() + const [error] = await tryToCatch(() => router.test("post", "/api/unlink", { + sessionData: { + managedGuilds: ["665289423482519565"] + }, + body: { + channel_id: "665310973967597573", + guild_id: "665289423482519565" + } + })) + t.equal(error.data, "Channel ID 665310973967597573 is not currently bridged") +}) diff --git a/src/web/routes/log-in-with-matrix.js b/src/web/routes/log-in-with-matrix.js new file mode 100644 index 0000000..574c312 --- /dev/null +++ b/src/web/routes/log-in-with-matrix.js @@ -0,0 +1,123 @@ +// @ts-check + +const {z} = require("zod") +const {randomUUID} = require("crypto") +const {defineEventHandler, getValidatedQuery, sendRedirect, readValidatedBody, createError, getRequestHeader, H3Event} = require("h3") +const {LRUCache} = require("lru-cache") + +const {as, db, select} = require("../../passthrough") +const {reg} = require("../../matrix/read-registration") + +const {sync} = require("../../passthrough") +const assert = require("assert").strict +/** @type {import("../pug-sync")} */ +const pugSync = sync.require("../pug-sync") +/** @type {import("../auth")} */ +const auth = sync.require("../auth") + +const schema = { + form: z.object({ + mxid: z.string().regex(/^@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)$/), + next: z.string().optional() + }), + token: z.object({ + token: z.string().optional(), + next: z.string().optional() + }) +} + +/** + * @param {H3Event} event + * @returns {import("../../matrix/api")} + */ +function getAPI(event) { + /* c8 ignore next */ + return event.context.api || sync.require("../../matrix/api") +} + +/** @type {LRUCache<string, string>} token to mxid */ +const validToken = new LRUCache({max: 200}) + +/* + 1st request, GET, they clicked the button, need to input their mxid + 2nd request, POST, they input their mxid and we need to send a link + 3rd request, GET, they clicked the link and we need to set the session data (just their mxid) +*/ + +as.router.get("/log-in-with-matrix", defineEventHandler(async event => { + let {token, next} = await getValidatedQuery(event, schema.token.parse) + + if (!token) { + // We are in the first request and need to tell them to input their mxid + return pugSync.render(event, "log-in-with-matrix.pug", {next}) + } + + const userAgent = getRequestHeader(event, "User-Agent") + if (userAgent?.match(/bot|matrix/)) throw createError({status: 400, data: "Sorry URL previewer, you can't have this URL."}) + + if (!validToken.has(token)) return sendRedirect(event, `${reg.ooye.bridge_origin}/log-in-with-matrix`, 302) + + const session = await auth.useSession(event) + const mxid = validToken.get(token) + assert(mxid) + validToken.delete(token) + + await session.update({mxid}) + + if (!next) next = "./" // open to homepage where they can see they're logged in + return sendRedirect(event, next, 302) +})) + +as.router.post("/api/log-in-with-matrix", defineEventHandler(async event => { + const api = getAPI(event) + const {mxid, next} = await readValidatedBody(event, schema.form.parse) + + // Don't extend a duplicate invite for the same user + for (const alreadyInvited of validToken.values()) { + if (mxid === alreadyInvited) { + return sendRedirect(event, "../ok?msg=We already sent you a link on Matrix. Please click it!", 302) + } + } + + // Check if we have an existing DM + let roomID = select("direct", "room_id", {mxid}).pluck().get() + if (roomID) { + // Check that the person is/still in the room + try { + var member = await api.getStateEvent(roomID, "m.room.member", mxid) + } catch (e) {} + + // Invite them back to the room if needed + if (!member || member.membership === "leave") { + await api.inviteToRoom(roomID, mxid) + } + } + + // No existing DM, create a new room and invite + else { + roomID = await api.createRoom({ + invite: [mxid], + is_direct: true, + preset: "trusted_private_chat" + }) + // Store the newly created room in account data (Matrix doesn't do this for us automatically, sigh...) + db.prepare("REPLACE INTO direct (mxid, room_id) VALUES (?, ?)").run(mxid, roomID) + } + + const token = randomUUID() + + console.log(`web log in requested for ${mxid}`) + const paramsObject = {token} + if (next) paramsObject.next = next + const params = new URLSearchParams(paramsObject) + let link = `${reg.ooye.bridge_origin}/log-in-with-matrix?${params.toString()}` + const body = `Hi, this is Out Of Your Element! You just clicked the "log in" button on the website.\nOpen this link to finish: ${link}\nThe link can be used once.` + await api.sendEvent(roomID, "m.room.message", { + msgtype: "m.text", + body + }) + + validToken.set(token, mxid) + + return sendRedirect(event, "../ok?msg=Please check your inbox on Matrix!&spot=SpotMailXL", 302) +})) diff --git a/src/web/routes/log-in-with-matrix.test.js b/src/web/routes/log-in-with-matrix.test.js new file mode 100644 index 0000000..bc9c7e0 --- /dev/null +++ b/src/web/routes/log-in-with-matrix.test.js @@ -0,0 +1,169 @@ +// @ts-check + +const tryToCatch = require("try-to-catch") +const {router, test} = require("../../../test/web") +const {MatrixServerError} = require("../../matrix/mreq") + +// ***** first request ***** + +test("log in with matrix: shows web page with form on first request", async t => { + const html = await router.test("get", "/log-in-with-matrix", { + }) + t.has(html, `hx-post="api/log-in-with-matrix"`) +}) + +// ***** second request ***** + +let token + +test("log in with matrix: checks if mxid format looks valid", async t => { + const [error] = await tryToCatch(() => router.test("post", "/api/log-in-with-matrix", { + body: { + mxid: "x@cadence:cadence.moe" + } + })) + t.match(error.data.fieldErrors.mxid, /must match pattern/) +}) + +test("log in with matrix: checks if mxid domain format looks valid", async t => { + const [error] = await tryToCatch(() => router.test("post", "/api/log-in-with-matrix", { + body: { + mxid: "@cadence:cadence." + } + })) + t.match(error.data.fieldErrors.mxid, /must match pattern/) +}) + +test("log in with matrix: sends message when there is no existing dm room", async t => { + const event = {} + let called = 0 + await router.test("post", "/api/log-in-with-matrix", { + body: { + mxid: "@cadence:cadence.moe" + }, + api: { + async createRoom() { + called++ + return "!created:cadence.moe" + }, + async sendEvent(roomID, type, content) { + called++ + t.equal(roomID, "!created:cadence.moe") + t.equal(type, "m.room.message") + token = content.body.match(/log-in-with-matrix\?token=([a-f0-9-]+)/)[1] + t.ok(token, "log in token not issued") + return "" + } + }, + event + }) + t.match(event.node.res.getHeader("location"), /Please check your inbox on Matrix/) + t.equal(called, 2) +}) + +test("log in with matrix: does not send another message when a log in is in progress", async t => { + const event = {} + await router.test("post", "/api/log-in-with-matrix", { + body: { + mxid: "@cadence:cadence.moe" + }, + event + }) + t.match(event.node.res.getHeader("location"), /We already sent you a link on Matrix/) +}) + +test("log in with matrix: reuses room from direct", async t => { + const event = {} + let called = 0 + await router.test("post", "/api/log-in-with-matrix", { + body: { + mxid: "@user1:example.org" + }, + api: { + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!existing:cadence.moe") + t.equal(type, "m.room.member") + t.equal(key, "@user1:example.org") + return {membership: "join"} + }, + async sendEvent(roomID) { + called++ + t.equal(roomID, "!existing:cadence.moe") + return "" + } + }, + event + }) + t.match(event.node.res.getHeader("location"), /Please check your inbox on Matrix/) + t.equal(called, 2) +}) + +test("log in with matrix: reuses room from direct, reinviting if user has left", async t => { + const event = {} + let called = 0 + await router.test("post", "/api/log-in-with-matrix", { + body: { + mxid: "@user2:example.org" + }, + api: { + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!existing:cadence.moe") + t.equal(type, "m.room.member") + t.equal(key, "@user2:example.org") + throw new MatrixServerError({errcode: "M_NOT_FOUND"}) + }, + async inviteToRoom(roomID, mxid) { + called++ + t.equal(roomID, "!existing:cadence.moe") + t.equal(mxid, "@user2:example.org") + }, + async sendEvent(roomID) { + called++ + t.equal(roomID, "!existing:cadence.moe") + return "" + } + }, + event + }) + t.match(event.node.res.getHeader("location"), /Please check your inbox on Matrix/) + t.equal(called, 3) +}) + +// ***** third request ***** + + +test("log in with matrix: does not use up token when requested by Synapse URL previewer", async t => { + const event = {} + const [error] = await tryToCatch(() => router.test("get", `/log-in-with-matrix?token=${token}`, { + headers: { + "user-agent": "Synapse (bot; +https://github.com/matrix-org/synapse)" + }, + event + })) + t.equal(error.data, "Sorry URL previewer, you can't have this URL.") +}) + +test("log in with matrix: does not use up token when requested by Discord URL previewer", async t => { + const event = {} + const [error] = await tryToCatch(() => router.test("get", `/log-in-with-matrix?token=${token}`, { + headers: { + "user-agent": "Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)" + }, + event + })) + t.equal(error.data, "Sorry URL previewer, you can't have this URL.") +}) + +test("log in with matrix: successful request when using valid token", async t => { + const event = {} + await router.test("get", `/log-in-with-matrix?token=${token}`, {event}) + t.equal(event.node.res.getHeader("location"), "./") +}) + +test("log in with matrix: won't log in again if token has been used", async t => { + const event = {} + await router.test("get", `/log-in-with-matrix?token=${token}`, {event}) + t.equal(event.node.res.getHeader("location"), "https://bridge.example.org/log-in-with-matrix") +}) diff --git a/src/web/routes/oauth.js b/src/web/routes/oauth.js new file mode 100644 index 0000000..80765d6 --- /dev/null +++ b/src/web/routes/oauth.js @@ -0,0 +1,92 @@ +// @ts-check + +const {z} = require("zod") +const {randomUUID} = require("crypto") +const {defineEventHandler, getValidatedQuery, sendRedirect, createError} = require("h3") +const {SnowTransfer, tokenless} = require("snowtransfer") +const DiscordTypes = require("discord-api-types/v10") +const getRelativePath = require("get-relative-path") + +const {discord, as, db, sync} = require("../../passthrough") +const {id} = require("../../../addbot") +/** @type {import("../auth")} */ +const auth = sync.require("../auth") +const {reg} = require("../../matrix/read-registration") + +const redirect_uri = `${reg.ooye.bridge_origin}/oauth` + +const schema = { + first: z.object({ + action: z.string().optional() + }), + code: z.object({ + state: z.string(), + code: z.string(), + guild_id: z.string().optional() + }), + token: z.object({ + token_type: z.string(), + access_token: z.string(), + expires_in: z.coerce.number(), + refresh_token: z.string(), + scope: z.string() + }) +} + +as.router.get("/oauth", defineEventHandler(async event => { + const session = await auth.useSession(event) + let scope = "guilds" + + if (!reg.ooye.web_password || reg.ooye.web_password === session.data.password) { + const parsedFirstQuery = await getValidatedQuery(event, schema.first.safeParse) + if (parsedFirstQuery.data?.action === "add") { + scope = "bot+guilds" + await session.update({selfService: false}) + } else if (parsedFirstQuery.data?.action === "add-self-service") { + scope = "bot+guilds" + await session.update({selfService: true}) + } + } + + async function tryAgain() { + const newState = randomUUID() + await session.update({state: newState}) + return sendRedirect(event, `https://discord.com/oauth2/authorize?client_id=${id}&scope=${scope}&permissions=1610883072&response_type=code&redirect_uri=${redirect_uri}&state=${newState}`) + } + + const parsedQuery = await getValidatedQuery(event, schema.code.safeParse) + if (!parsedQuery.success) return tryAgain() + + const savedState = session.data.state + if (!savedState) throw createError({status: 400, message: "Missing state", data: "Missing saved state parameter. Please try again, and make sure you have cookies enabled."}) + if (savedState != parsedQuery.data.state) return tryAgain() + + const oauthResult = await tokenless.getOauth2Token(id, redirect_uri, reg.ooye.discord_client_secret, parsedQuery.data.code) + const parsedToken = schema.token.safeParse(oauthResult) + if (!parsedToken.success) { + throw createError({status: 502, message: "Invalid token response", data: `Discord completed OAuth, but returned this instead of an OAuth access token: ${JSON.stringify(oauthResult)}`}) + } + + const userID = Buffer.from(parsedToken.data.access_token.split(".")[0], "base64").toString() + const client = new SnowTransfer(`Bearer ${parsedToken.data.access_token}`) + try { + const guilds = await client.user.getGuilds() + var managedGuilds = guilds.filter(g => BigInt(g.permissions) & DiscordTypes.PermissionFlagsBits.ManageGuild).map(g => g.id) + await session.update({managedGuilds, userID, state: undefined}) + } catch (e) { + throw createError({status: 502, message: "API call failed", data: e.message}) + } + + // Set auto-create for the guild + // @ts-ignore + if (managedGuilds.includes(parsedQuery.data.guild_id)) { + const autocreateInteger = +!session.data.selfService + db.prepare("INSERT INTO guild_active (guild_id, autocreate) VALUES (?, ?) ON CONFLICT DO UPDATE SET autocreate = ?").run(parsedQuery.data.guild_id, autocreateInteger, autocreateInteger) + } + + if (parsedQuery.data.guild_id) { + return sendRedirect(event, getRelativePath(event.path, `/guild?guild_id=${parsedQuery.data.guild_id}`), 302) + } + + return sendRedirect(event, getRelativePath(event.path, "/"), 302) +})) diff --git a/src/web/routes/password.js b/src/web/routes/password.js new file mode 100644 index 0000000..e1dd299 --- /dev/null +++ b/src/web/routes/password.js @@ -0,0 +1,21 @@ +// @ts-check + +const {z} = require("zod") +const {defineEventHandler, readValidatedBody, sendRedirect} = require("h3") +const {as, sync} = require("../../passthrough") + +/** @type {import("../auth")} */ +const auth = sync.require("../auth") + +const schema = { + password: z.object({ + password: z.string() + }) +} + +as.router.post("/api/password", defineEventHandler(async event => { + const {password} = await readValidatedBody(event, schema.password.parse) + const session = await auth.useSession(event) + await session.update({password}) + return sendRedirect(event, "../") +})) diff --git a/src/web/server.js b/src/web/server.js new file mode 100644 index 0000000..7c8ed3e --- /dev/null +++ b/src/web/server.js @@ -0,0 +1,71 @@ +// @ts-check + +const assert = require("assert") +const fs = require("fs") +const {join} = require("path") +const h3 = require("h3") +const {defineEventHandler, defaultContentType, getRequestHeader, setResponseHeader, handleCacheHeaders} = h3 +const icons = require("@stackoverflow/stacks-icons") +const DiscordTypes = require("discord-api-types/v10") +const dUtils = require("../discord/utils") +const reg = require("../matrix/read-registration") + +const {sync, discord, as, select} = require("../passthrough") +/** @type {import("./pug-sync")} */ +const pugSync = sync.require("./pug-sync") +/** @type {import("../m2d/converters/utils")} */ +const mUtils = sync.require("../m2d/converters/utils") +const {id} = require("../../addbot") + +// Pug + +pugSync.addGlobals({id, h3, discord, select, DiscordTypes, dUtils, mUtils, icons, reg: reg.reg}) +pugSync.createRoute(as.router, "/", "home.pug") +pugSync.createRoute(as.router, "/ok", "ok.pug") + +// Routes + +sync.require("./routes/download-matrix") +sync.require("./routes/download-discord") +sync.require("./routes/guild-settings") +sync.require("./routes/guild") +sync.require("./routes/info") +sync.require("./routes/link") +sync.require("./routes/log-in-with-matrix") +sync.require("./routes/oauth") +sync.require("./routes/password") + +// Files + +function compressResponse(event, response) { + if (!getRequestHeader(event, "accept-encoding")?.includes("gzip")) return + /* c8 ignore next */ + if (typeof response.body !== "string") return + const stream = new Response(response.body).body + assert(stream) + setResponseHeader(event, "content-encoding", "gzip") + response.body = stream.pipeThrough(new CompressionStream("gzip")) +} + +as.router.get("/static/stacks.min.css", defineEventHandler({ + onBeforeResponse: compressResponse, + handler: async event => { + handleCacheHeaders(event, {maxAge: 86400}) + defaultContentType(event, "text/css") + return fs.promises.readFile(require.resolve("@stackoverflow/stacks/dist/css/stacks.css"), "utf-8") + } +})) + +as.router.get("/static/htmx.js", defineEventHandler({ + onBeforeResponse: compressResponse, + handler: async event => { + handleCacheHeaders(event, {maxAge: 86400}) + defaultContentType(event, "text/javascript") + return fs.promises.readFile(require.resolve("htmx.org/dist/htmx.js"), "utf-8") + } +})) + +as.router.get("/icon.png", defineEventHandler(event => { + handleCacheHeaders(event, {maxAge: 86400}) + return fs.promises.readFile(join(__dirname, "../../docs/img/icon.png")) +})) diff --git a/src/web/server.test.js b/src/web/server.test.js new file mode 100644 index 0000000..6ed3535 --- /dev/null +++ b/src/web/server.test.js @@ -0,0 +1,37 @@ +// @ts-check + +const streamWeb = require("stream/web") +const {test} = require("../../test/web") +const {router} = require("../../test/web") +const assert = require("assert").strict + +require("./server") + +test("web server: can get home", async t => { + t.has(await router.test("get", "/", {}), /a bridge between the Discord and Matrix chat apps/) +}) + +test("web server: can get htmx", async t => { + t.match(await router.test("get", "/static/htmx.js", {}), /htmx =/) +}) + +test("web server: can get css", async t => { + t.match(await router.test("get", "/static/stacks.min.css", {}), /--stacks-/) +}) + +test("web server: can get icon", async t => { + const content = await router.test("get", "/icon.png", {}) + t.ok(content instanceof Buffer) +}) + +test("web server: compresses static resources", async t => { + const content = await router.test("get", "/static/stacks.min.css", { + headers: { + "accept-encoding": "gzip" + } + }) + assert(content instanceof streamWeb.ReadableStream) + const firstChunk = await content.getReader().read() + t.ok(firstChunk.value instanceof Uint8Array, "can get data") + t.deepEqual(firstChunk.value.slice(0, 3), Uint8Array.from([31, 139, 8]), "has compressed gzip header") +}) diff --git a/start.js b/start.js old mode 100644 new mode 100755 index 819281f..ca6212b --- a/start.js +++ b/start.js @@ -1,39 +1,40 @@ +#!/usr/bin/env node // @ts-check +const fs = require("fs") const sqlite = require("better-sqlite3") -const migrate = require("./db/migrate") +const migrate = require("./src/db/migrate") const HeatSync = require("heatsync") -const config = require("./config") -const passthrough = require("./passthrough") -const db = new sqlite("db/ooye.db") +const {reg} = require("./src/matrix/read-registration") +const passthrough = require("./src/passthrough") +const db = new sqlite("ooye.db") -const sync = new HeatSync() +const sync = new HeatSync({watchFunction: fs.watchFile}) -Object.assign(passthrough, {config, sync, db}) +Object.assign(passthrough, {sync, db}) -const DiscordClient = require("./d2m/discord-client") +const DiscordClient = require("./src/d2m/discord-client") -const discord = new DiscordClient(config.discordToken, "full") +const discord = new DiscordClient(reg.ooye.discord_token) passthrough.discord = discord -const as = require("./matrix/appservice") +const {as} = require("./src/matrix/appservice") passthrough.as = as -const orm = sync.require("./db/orm") +const orm = sync.require("./src/db/orm") passthrough.from = orm.from passthrough.select = orm.select -sync.require("./m2d/event-dispatcher") - -discord.snow.requestHandler.on("requestError", data => { - console.error("request error", data) -}) +const power = require("./src/matrix/power.js") +sync.require("./src/m2d/event-dispatcher") ;(async () => { await migrate.migrate(db) await discord.cloud.connect() console.log("Discord gateway started") + sync.require("./src/web/server") + await power.applyPower() - require("./stdin") + require("./src/stdin") })() diff --git a/test/addbot.test.js b/test/addbot.test.js new file mode 100644 index 0000000..17c6dda --- /dev/null +++ b/test/addbot.test.js @@ -0,0 +1,8 @@ +// @ts-check + +const {addbot} = require("../addbot") +const {test} = require("supertape") + +test("addbot: returns message and invite link", t => { + t.equal(addbot(), `Open this link to add the bot to a Discord server:\nhttps://discord.com/oauth2/authorize?client_id=684280192553844747&scope=bot&permissions=1610883072 `) +}) diff --git a/test/data.js b/test/data.js index 77b2ada..a8ff8a8 100644 --- a/test/data.js +++ b/test/data.js @@ -18,6 +18,85 @@ module.exports = { id: "112760669178241024", default_thread_rate_limit_per_user: 0, guild_id: "112760669178241024" + }, + updates: { + type: 0, + topic: "Updates and release announcements for Out Of Your Element.", + rate_limit_per_user: 0, + position: 0, + permission_overwrites: [{ + type: 0, + id: "112760669178241024", + deny: "2048", + allow: "0" + }], + parent_id: null, + nsfw: false, + name: "updates", + last_message_id: "1329413270196715564", + id: "1161864271370666075", + guild_id: "112760669178241024" + }, + /** @type {DiscordTypes.APITextChannel} */ + saving_the_world: { + type: 0, + topic: "Anything and everything archiving/preservation related", + rate_limit_per_user: 0, + position: 0, + permission_overwrites: [ + { + id: "665289423482519565", + type: DiscordTypes.OverwriteType.Role, + allow: "0", + deny: String(DiscordTypes.PermissionFlagsBits.SendMessages) + }, + { + id: "684524730274807911", + type: DiscordTypes.OverwriteType.Role, + allow: String(DiscordTypes.PermissionFlagsBits.SendMessages), + deny: "0" + } + ], + parent_id: null, + name: "saving-the-world", + last_pin_timestamp: "2021-04-14T18:39:41+00:00", + last_message_id: "1335828749479837750", + id: "665310973967597573", + guild_id: "665289423482519565" + }, + character_art: { + version: 1749274266694, + type: 0, + topic: null, + rate_limit_per_user: 0, + position: 22, + permission_overwrites: [ + { + type: 0, + id: "1235396773510647810", + deny: "0", + allow: "3072" + }, + { + type: 0, + id: "1236581109391949875", + deny: "0", + allow: "0" + }, + { + type: 0, + id: "1234728422044074064", + deny: "3072", + allow: "309237645312" + } + ], + parent_id: "1234730744291528714", + nsfw: false, + name: "character-art", + last_message_id: "1384358176106872924", + id: "1235072132095021096", + flags: 0, + guild_id: "1234728422044074064" } }, room: { @@ -26,7 +105,7 @@ module.exports = { "m.room.topic/": {topic: "#collective-unconscious | https://docs.google.com/document/d/blah/edit | I spread, pipe, and whip because it is my will. :headstone:\n\nChannel ID: 112760669178241024\nGuild ID: 112760669178241024"}, "m.room.guest_access/": {guest_access: "can_join"}, "m.room.history_visibility/": {history_visibility: "shared"}, - "m.space.parent/!jjWAGMeQdNrVZSSfvz:cadence.moe": { + "m.space.parent/!jjmvBegULiLucuWEHU:cadence.moe": { via: ["cadence.moe"], canonical: true }, @@ -34,16 +113,17 @@ module.exports = { join_rule: "restricted", allow: [{ type: "m.room_membership", - room_id: "!jjWAGMeQdNrVZSSfvz:cadence.moe" + room_id: "!jjmvBegULiLucuWEHU:cadence.moe" }] }, "m.room.avatar/": { - discord_path: "/icons/112760669178241024/a_f83622e09ead74f0c5c527fe241f8f8c.png?size=1024", - url: "mxc://cadence.moe/zKXGZhmImMHuGQZWJEFKJbsF" + url: {$url: "/icons/112760669178241024/a_f83622e09ead74f0c5c527fe241f8f8c.png?size=1024"} }, "m.room.power_levels/": { + events_default: 0, events: { - "m.room.avatar": 0 + "m.reaction": 0, + "m.room.redaction": 0 }, users: { "@test_auto_invite:example.org": 100 @@ -52,7 +132,7 @@ module.exports = { room: 0 } }, - "chat.schildi.hide_ui/read_receipts": {hidden: true}, + "chat.schildi.hide_ui/read_receipts": {}, "uk.half-shot.bridge/moe.cadence.ooye://discord/112760669178241024/112760669178241024": { bridgebot: "@_ooye_bot:cadence.moe", protocol: { @@ -62,7 +142,7 @@ module.exports = { network: { id: "112760669178241024", displayname: "Psychonauts 3", - avatar_url: "mxc://cadence.moe/zKXGZhmImMHuGQZWJEFKJbsF" + avatar_url: {$url: "/icons/112760669178241024/a_f83622e09ead74f0c5c527fe241f8f8c.png?size=1024"} }, channel: { id: "112760669178241024", @@ -73,6 +153,7 @@ module.exports = { } }, guild: { + /** @type {DiscordTypes.APIGuild} */ // @ts-ignore general: { owner_id: "112760500130975744", premium_tier: 3, @@ -174,6 +255,25 @@ module.exports = { hoist: true, flags: 0, color: 16745267 + }, { + version: 1743122443142, + unicode_emoji: null, + tags: {}, + position: 3, + permissions: "0", + name: "Realdditors", + mentionable: true, + managed: false, + id: "1182745800661540927", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 16729344 + }, + color: 16729344 } ], discovery_splash: null, @@ -193,6 +293,784 @@ module.exports = { max_stage_video_channel_users: 300, system_channel_flags: 0|0, safety_alerts_channel_id: null + }, + fna: { + application_id: null, + roles: [], + activity_instances: [], + banner: null, + stickers: [], + joined_at: "2020-04-25T07:36:09.644000+00:00", + default_message_notifications: 1, + afk_timeout: 60, + clan: null, + hub_type: null, + afk_channel_id: "216367750216548362", + discovery_splash: null, + splash: null, + explicit_content_filter: 0, + max_members: 500000, + premium_subscription_count: 0, + voice_states: [], + id: "66192955777486848", + premium_tier: 0, + name: "Function & Arg", + premium_progress_bar_enabled: false, + icon: "8bfeb3237cd8697d1d1cd5c626ca8cea", + large: true, + verification_level: 0, + public_updates_channel_id: null, + stage_instances: [], + rules_channel_id: null, + emojis: [], + owner_id: "66186356581208064", + threads: [], + max_stage_video_channel_users: 50, + description: null, + unavailable: false, + features: [ + "CHANNEL_ICON_EMOJIS_GENERATED", + "NEW_THREAD_PERMISSIONS", + "THREADS_ENABLED", + "SOUNDBOARD" + ], + latest_onboarding_question_id: null, + max_video_channel_users: 25, + home_header: null, + mfa_level: 0, + system_channel_id: null, + guild_scheduled_events: [], + nsfw_level: 0, + vanity_url_code: null, + member_count: 966, + presences: [], + application_command_counts: {}, + system_channel_flags: 0, + preferred_locale: "en-US", + region: "deprecated", + inventory_settings: null, + soundboard_sounds: [], + version: 1711491959939, + incidents_data: null, + embedded_activities: [], + nsfw: false, + safety_alerts_channel_id: null, + lazy: true + }, + data_horde: { + preferred_locale: "en-US", + afk_channel_id: null, + profile: null, + owner_id: "222343226990788609", + soundboard_sounds: [], + hub_type: null, + mfa_level: 0, + activity_instances: [], + inventory_settings: null, + voice_states: [], + system_channel_id: "675397790204952636", + id: "665289423482519565", + member_count: 138, + clan: null, + default_message_notifications: 1, + name: "Data Horde", + banner: null, + premium_subscription_count: 0, + max_stage_video_channel_users: 50, + max_members: 500000, + incidents_data: null, + joined_at: "2020-05-10T02:00:10.646000+00:00", + unavailable: false, + discovery_splash: null, + threads: [], + system_channel_flags: 0, + safety_alerts_channel_id: null, + nsfw: false, + nsfw_level: 0, + stage_instances: [], + large: false, + icon: "d7c4bdb35c10f21e475a50fb205d5c32", + roles: [ + { + version: 1683238686112, + unicode_emoji: null, + tags: {}, + position: 0, + permissions: "968619318849", + name: "@everyone", + mentionable: false, + managed: false, + id: "665289423482519565", + icon: null, + hoist: false, + flags: 0, + color: 0 + }, + { + version: 1683791258594, + unicode_emoji: null, + tags: {}, + position: 22, + permissions: "7515668211", + name: "Founder", + mentionable: true, + managed: false, + id: "665290147377578005", + icon: null, + hoist: false, + flags: 0, + color: 1752220 + }, + { + version: 1683791258594, + unicode_emoji: null, + tags: {}, + position: 22, + permissions: "8194", + name: "Moderator", + mentionable: true, + managed: false, + id: "682789592390281245", + icon: null, + hoist: false, + flags: 0, + color: 1752220 + }, + { + version: 1683791258580, + unicode_emoji: null, + tags: {}, + position: 19, + permissions: "6546775617", + name: "Gaming Alexandria", + mentionable: false, + managed: false, + id: "684524730274807911", + icon: null, + hoist: false, + flags: 0, + color: 15844367 + } + ], + description: null, + afk_timeout: 300, + verification_level: 1, + latest_onboarding_question_id: null, + guild_scheduled_events: [], + rules_channel_id: null, + embedded_activities: [], + region: "deprecated", + vanity_url_code: null, + application_id: null, + premium_tier: 0, + explicit_content_filter: 0, + stickers: [], + public_updates_channel_id: null, + splash: null, + premium_progress_bar_enabled: false, + features: [], + lazy: true, + max_video_channel_users: 25, + application_command_counts: {}, + home_header: null, + version: 1717720047590, + emojis: [], + presences: [] + }, + pathfinder: { + activity_instances: [], + max_video_channel_users: 25, + mfa_level: 0, + owner_id: "182266888003256320", + stage_instances: [], + profile: null, + rules_channel_id: null, + splash: null, + inventory_settings: null, + max_members: 25000000, + icon: "ec42ae174a7c246568da98983b611f64", + safety_alerts_channel_id: null, + latest_onboarding_question_id: null, + id: "1234728422044074064", + name: "Hub Pathfinder", + embedded_activities: [], + banner: null, + hub_type: null, + threads: [], + lazy: true, + system_channel_id: "1234728422475829318", + member_count: 21, + region: "deprecated", + description: null, + premium_features: null, + verification_level: 0, + unavailable: false, + stickers: [], + application_command_counts: {}, + roles: [ + { + version: 1741255049095, + unicode_emoji: null, + tags: {}, + position: 0, + permissions: "2173706675146305", + name: "@everyone", + mentionable: false, + managed: false, + id: "1234728422044074064", + icon: null, + hoist: false, + flags: 0, + colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, + color: 0 + }, + { + version: 1749271325117, + unicode_emoji: null, + tags: { bot_id: "684280192553844747" }, + position: 8, + permissions: "1610883072", + name: "Matrix Bridge", + mentionable: false, + managed: true, + id: "1235117664326783049", + icon: null, + hoist: false, + flags: 0, + colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, + color: 0 + }, + { + version: 1749271325132, + unicode_emoji: null, + tags: {}, + position: 12, + permissions: "0", + name: "Tuesday", + mentionable: false, + managed: false, + id: "1235396773510647810", + icon: null, + hoist: false, + flags: 0, + colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, + color: 0 + }, + { + version: 1749271325129, + unicode_emoji: null, + tags: {}, + position: 11, + permissions: "0", + name: "Thursday", + mentionable: false, + managed: false, + id: "1235397020919926844", + icon: null, + hoist: false, + flags: 0, + colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, + color: 0 + }, + { + version: 1749271325174, + unicode_emoji: null, + tags: {}, + position: 20, + permissions: "0", + name: "Fighter", + mentionable: false, + managed: false, + id: "1236579627615518720", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 12657443 + }, + color: 12657443 + }, + { + version: 1749271325189, + unicode_emoji: null, + tags: {}, + position: 24, + permissions: "0", + name: "Bard", + mentionable: false, + managed: false, + id: "1236579780544036904", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 12468701 + }, + color: 12468701 + }, + { + version: 1749271325179, + unicode_emoji: null, + tags: {}, + position: 22, + permissions: "0", + name: "Cleric", + mentionable: false, + managed: false, + id: "1236579861997555763", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 14186005 + }, + color: 14186005 + }, + { + version: 1749271325138, + unicode_emoji: null, + tags: {}, + position: 14, + permissions: "0", + name: "Wizard", + mentionable: false, + managed: false, + id: "1236579900731822110", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 3106806 + }, + color: 3106806 + }, + { + version: 1749271325176, + unicode_emoji: null, + tags: {}, + position: 21, + permissions: "0", + name: "Druid", + mentionable: false, + managed: false, + id: "1236579988254232606", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 8248698 + }, + color: 8248698 + }, + { + version: 1749271325147, + unicode_emoji: null, + tags: {}, + position: 15, + permissions: "0", + name: "Witch", + mentionable: false, + managed: false, + id: "1236580304232255581", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 1737848 + }, + color: 1737848 + }, + { + version: 1749271325206, + unicode_emoji: null, + tags: {}, + position: 28, + permissions: "8", + name: "DM", + mentionable: false, + managed: false, + id: "1236581109391949875", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 6507441 + }, + color: 6507441 + }, + { + version: 1749271325156, + unicode_emoji: null, + tags: {}, + position: 17, + permissions: "0", + name: "Ranger", + mentionable: false, + managed: false, + id: "1240571725914312825", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 2067276 + }, + color: 2067276 + }, + { + version: 1749271325151, + unicode_emoji: null, + tags: {}, + position: 16, + permissions: "0", + name: "Rogue", + mentionable: false, + managed: false, + id: "1249165855632265267", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 9936031 + }, + color: 9936031 + }, + { + version: 1749271325123, + unicode_emoji: null, + tags: {}, + position: 10, + permissions: "0", + name: "Questions Ping!", + mentionable: false, + managed: false, + id: "1249167820571541534", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 13297400 + }, + color: 13297400 + }, + { + version: 1749271325198, + unicode_emoji: null, + tags: {}, + position: 25, + permissions: "0", + name: "Barbarian", + mentionable: false, + managed: false, + id: "1344484288241991730", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 8145454 + }, + color: 8145454 + }, + { + version: 1749271325200, + unicode_emoji: null, + tags: {}, + position: 26, + permissions: "0", + name: "Alchemist", + mentionable: false, + managed: false, + id: "1352190431944900628", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 15844367 + }, + color: 15844367 + }, + { + version: 1749271325168, + unicode_emoji: null, + tags: {}, + position: 19, + permissions: "0", + name: "Investigator", + mentionable: false, + managed: false, + id: "1353890353391866028", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 10068223 + }, + color: 10068223 + }, + { + version: 1749271325134, + unicode_emoji: null, + tags: {}, + position: 13, + permissions: "0", + name: "Monday", + mentionable: false, + managed: false, + id: "1359752622130593802", + icon: null, + hoist: false, + flags: 0, + colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, + color: 0 + }, + { + version: 1749271325162, + unicode_emoji: null, + tags: {}, + position: 18, + permissions: "0", + name: "Monk", + mentionable: false, + managed: false, + id: "1359753361963880590", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 3447003 + }, + color: 3447003 + }, + { + version: 1749271325183, + unicode_emoji: null, + tags: {}, + position: 23, + permissions: "0", + name: "Champion", + mentionable: false, + managed: false, + id: "1359753472186122320", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 15277667 + }, + color: 15277667 + }, + { + version: 1749271325114, + unicode_emoji: null, + tags: { bot_id: "431544605209788416" }, + position: 7, + permissions: "275415166016", + name: "Tupperbox", + mentionable: false, + managed: true, + id: "1377128320814153862", + icon: null, + hoist: false, + flags: 0, + colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, + color: 0 + }, + { + version: 1749271325120, + unicode_emoji: null, + tags: {}, + position: 9, + permissions: "0", + name: "PbD ping", + mentionable: false, + managed: false, + id: "1377139953510907995", + icon: null, + hoist: false, + flags: 0, + colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, + color: 0 + }, + { + version: 1749271325109, + unicode_emoji: null, + tags: { bot_id: "644942473315090434" }, + position: 6, + permissions: "535529122897", + name: "RPG Sage", + mentionable: false, + managed: true, + id: "1377144599310503959", + icon: null, + hoist: false, + flags: 0, + colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, + color: 0 + }, + { + version: 1749271325106, + unicode_emoji: null, + tags: { bot_id: "572698679618568193" }, + position: 5, + permissions: "278528", + name: "Dicecord", + mentionable: false, + managed: true, + id: "1378726921990307974", + icon: null, + hoist: false, + flags: 0, + colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, + color: 0 + }, + { + version: 1749271325203, + unicode_emoji: null, + tags: { bot_id: "443545183997657120" }, + position: 27, + permissions: "2097540216", + name: "ChannelBot", + mentionable: false, + managed: true, + id: "1380744875108204658", + icon: null, + hoist: false, + flags: 0, + colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, + color: 0 + }, + { + version: 1749271325101, + unicode_emoji: null, + tags: {}, + position: 4, + permissions: "0", + name: "Play-by-Discord", + mentionable: false, + managed: false, + id: "1380748596537720872", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 16377559 + }, + color: 16377559 + }, + { + version: 1749271325098, + unicode_emoji: null, + tags: {}, + position: 3, + permissions: "0", + name: "Boredom Busters", + mentionable: false, + managed: false, + id: "1380756348190462015", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 14542591 + }, + color: 14542591 + }, + { + version: 1749271361998, + unicode_emoji: null, + tags: {}, + position: 1, + permissions: "0", + name: "Bots", + mentionable: false, + managed: false, + id: "1380767647578460311", + icon: null, + hoist: true, + flags: 0, + colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, + color: 0 + }, + { + version: 1749271362001, + unicode_emoji: null, + tags: {}, + position: 2, + permissions: "0", + name: "Players", + mentionable: false, + managed: false, + id: "1380768596929806356", + icon: null, + hoist: true, + flags: 0, + colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, + color: 0 + } + ], + vanity_url_code: null, + afk_timeout: 300, + premium_tier: 0, + joined_at: "2024-05-01T06:36:38.605000+00:00", + public_updates_channel_id: null, + premium_subscription_count: 0, + soundboard_sounds: [], + home_header: null, + discovery_splash: null, + guild_scheduled_events: [], + system_channel_flags: 0, + preferred_locale: "en-US", + large: false, + explicit_content_filter: 0, + moderator_reporting: null, + features: [ + "TIERLESS_BOOSTING_SYSTEM_MESSAGE", + "ACTIVITY_FEED_DISABLED_BY_USER" + ], + version: 1750145431881, + owner_configured_content_level: null, + voice_states: [], + default_message_notifications: 1, + application_id: null, + incidents_data: null, + nsfw_level: 0, + premium_progress_bar_enabled: false, + afk_channel_id: null, + max_stage_video_channel_users: 50, + nsfw: false } }, user: { @@ -210,6 +1088,19 @@ module.exports = { global_name: "Clyde", avatar_decoration_data: null, banner_color: null + }, + jerassicore: { + username: "ser_jurassicore", + public_flags: 0, + primary_guild: null, + id: "493801948345139202", + global_name: "Jurassicore", + display_name_styles: null, + discriminator: "0", + collectibles: null, + clan: null, + avatar_decoration_data: null, + avatar: "2a4fa0de3aaea30f457ed7bba64176aa" } }, member: { @@ -451,6 +1342,63 @@ module.exports = { components: [] }, simple_room_mention: { + type: 0, + tts: false, + timestamp: "2023-07-10T20:04:25.939000+00:00", + referenced_message: null, + pinned: false, + nonce: "1128054139385806848", + mentions: [], + mention_roles: [], + mention_everyone: false, + member: { + roles: [ + "112767366235959296", "118924814567211009", + "204427286542417920", "199995902742626304", + "222168467627835392", "238028326281805825", + "259806643414499328", "265239342648131584", + "271173313575780353", "287733611912757249", + "225744901915148298", "305775031223320577", + "318243902521868288", "348651574924541953", + "349185088157777920", "378402925128712193", + "392141548932038658", "393912152173576203", + "482860581670486028", "495384759074160642", + "638988388740890635", "373336013109461013", + "530220455085473813", "454567553738473472", + "790724320824655873", "1123518980456452097", + "1040735082610167858", "695946570482450442", + "1123460940935991296", "849737964090556488" + ], + premium_since: null, + pending: false, + nick: null, + mute: false, + joined_at: "2015-11-11T09:55:40.321000+00:00", + flags: 0, + deaf: false, + communication_disabled_until: null, + avatar: null + }, + id: "1128054143064494233", + flags: 0, + embeds: [], + edited_timestamp: null, + content: "<#1100319550446252084>", + components: [], + channel_id: "266767590641238027", + author: { + username: "kumaccino", + public_flags: 128, + id: "113340068197859328", + global_name: "kumaccino", + discriminator: "0", + avatar_decoration: null, + avatar: "b48302623a12bc7c59a71328f72ccb39" + }, + attachments: [], + guild_id: "112760669178241024" + }, + nicked_room_mention: { type: 0, tts: false, timestamp: "2023-07-10T20:04:25.939000+00:00", @@ -888,6 +1836,93 @@ module.exports = { components: [] } }, + reply_to_unknown_message: { + type: 19, + content: "enigmatic", + mentions: [ + { + id: "1060361805152669766", + username: "occimyy", + avatar: "009d2bf557bca7d4f5a1d5b75a4e2eea", + discriminator: "0", + public_flags: 0, + flags: 0, + banner: null, + accent_color: null, + global_name: "Lily", + avatar_decoration_data: null, + banner_color: null, + clan: null, + primary_guild: null + } + ], + mention_roles: [], + attachments: [], + embeds: [], + timestamp: "2025-02-22T23:34:14.036000+00:00", + edited_timestamp: null, + flags: 0, + components: [], + id: "1343002945670746173", + channel_id: "392141322863116319", + author: { + id: "114147806469554185", + username: "extremity", + avatar: "0c73816563bf912ccebf1a0f1546cfe4", + discriminator: "0", + public_flags: 768, + flags: 768, + banner: null, + accent_color: null, + global_name: null, + avatar_decoration_data: null, + banner_color: null, + clan: null, + primary_guild: null + }, + pinned: false, + mention_everyone: false, + tts: false, + message_reference: { + type: 0, + channel_id: "392141322863116319", + message_id: "1342606571380674560", + guild_id: "112760669178241024" + }, + position: 0, + referenced_message: { + type: 0, + content: "BILLY BOB THE GREAT", + mentions: [], + mention_roles: [], + attachments: [], + embeds: [], + timestamp: "2025-02-21T21:19:11.041000+00:00", + edited_timestamp: null, + flags: 0, + components: [], + id: "1342606571380674560", + channel_id: "392141322863116319", + author: { + id: "1060361805152669766", + username: "occimyy", + avatar: "009d2bf557bca7d4f5a1d5b75a4e2eea", + discriminator: "0", + public_flags: 0, + flags: 0, + banner: null, + accent_color: null, + global_name: "Occimyy", + avatar_decoration_data: null, + banner_color: null, + clan: null, + primary_guild: null + }, + pinned: false, + mention_everyone: false, + tts: false + } + }, attachment_no_content: { id: "1124628646670389348", type: 0, @@ -1298,6 +2333,95 @@ module.exports = { attachments: [], guild_id: "112760669178241024" }, + reply_to_matrix_user_mention: { + type: 19, + content: "kys", + mentions: [], + mention_roles: [], + attachments: [], + embeds: [], + timestamp: "2025-08-04T05:31:26.506000+00:00", + edited_timestamp: null, + flags: 0, + components: [], + id: "1401799674192723998", + channel_id: "112760669178241024", + author: { + id: "114147806469554185", + username: "extremity", + avatar: "e0394d500407a8fa93774e1835b8b03a", + discriminator: "0", + public_flags: 0, + flags: 0, + banner: null, + accent_color: null, + global_name: "Extremity", + avatar_decoration_data: null, + collectibles: null, + display_name_styles: null, + banner_color: null, + clan: null, + primary_guild: null + }, + pinned: false, + mention_everyone: false, + tts: false, + message_reference: { + type: 0, + channel_id: "112760669178241024", + message_id: "1401760355339862066", + guild_id: "112760669178241024" + }, + referenced_message: { + type: 0, + content: "<@114147806469554185> you owe me $30", + mentions: [ + { + id: "114147806469554185", + username: "extremity", + avatar: "e0394d500407a8fa93774e1835b8b03a", + discriminator: "0", + public_flags: 0, + flags: 0, + banner: null, + accent_color: null, + global_name: "Extremity", + avatar_decoration_data: null, + collectibles: null, + display_name_styles: null, + banner_color: null, + clan: null, + primary_guild: null + } + ], + mention_roles: [], + attachments: [], + embeds: [], + timestamp: "2025-08-04T02:55:12.161000+00:00", + edited_timestamp: null, + flags: 0, + components: [], + id: "1401760355339862066", + channel_id: "112760669178241024", + author: { + id: "1152700216189911081", + username: "okay 🤍 yay 🤍", + avatar: "90bc1d6912252d4fa9f92a2f5f6d347b", + discriminator: "0000", + public_flags: 0, + flags: 0, + bot: true, + global_name: null, + clan: null, + primary_guild: null + }, + pinned: false, + mention_everyone: false, + tts: false, + application_id: "684280192553844747", + webhook_id: "1152700216189911081" + } + }, reply_with_video: { id: "1197621094983676007", type: 19, @@ -1562,6 +2686,182 @@ module.exports = { attachments: [], guild_id: "1100319549670301727" }, + infinidoge_reply_to_ami_matrix_smalltext_reply_to_infinidoge: { + type: 19, + tts: false, + timestamp: "2024-08-15T20:45:20.697000+00:00", + referenced_message: { + webhook_id: "1179987856631017473", + type: 0, + tts: false, + timestamp: "2024-08-15T20:43:42.705000+00:00", + pinned: false, + mentions: [ + { + username: "infinidoge1337", + public_flags: 768, + id: "197126718400626689", + global_name: "Infinidoge", + discriminator: "0", + clan: null, + avatar_decoration_data: null, + avatar: "89c59de97fdb5f90999d86fed01f23fe" + } + ], + mention_roles: [], + mention_everyone: false, + id: "1273743950028607530", + flags: 0, + embeds: [], + edited_timestamp: null, + content: "> -# <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/813954820460642336/1100319550446252084/1273733346005880925 <@197126718400626689>:\n" + + "> -# Neat that they thought of that\n" + + 'let me guess they got a lot of bug reports like "empty chest with no loot?"', + components: [], + channel_id: "1100319550446252084", + author: { + username: "Ami (she/her)", + id: "1179987856631017473", + global_name: null, + discriminator: "0000", + bot: true, + avatar: "8b2ca50a10b39f4f908f536cd82d7953" + }, + attachments: [], + application_id: "684280192553844747" + }, + pinned: false, + nonce: "1273744360743108608", + message_reference: { + type: 0, + message_id: "1273743950028607530", + guild_id: "813954820460642336", + channel_id: "1100319550446252084" + }, + mentions: [], + mention_roles: [], + mention_everyone: false, + member: { + roles: [ "832496260219928577" ], + premium_since: null, + pending: false, + nick: null, + mute: false, + joined_at: "2021-05-05T21:22:36.181000+00:00", + flags: 0, + deaf: false, + communication_disabled_until: null, + banner: null, + avatar: null + }, + id: "1273744361036841061", + flags: 0, + embeds: [], + edited_timestamp: null, + content: "Most likely", + components: [], + channel_id: "1100319550446252084", + author: { + username: "infinidoge1337", + public_flags: 768, + id: "197126718400626689", + global_name: "Infinidoge", + discriminator: "0", + clan: null, + avatar_decoration_data: null, + avatar: "89c59de97fdb5f90999d86fed01f23fe" + }, + attachments: [], + guild_id: "813954820460642336" + }, + infinidoge_reply_to_ami_matrix_smalltext_singleline_reply_to_infinidoge: { + type: 19, + tts: false, + timestamp: "2024-08-15T20:45:20.697000+00:00", + referenced_message: { + webhook_id: "1179987856631017473", + type: 0, + tts: false, + timestamp: "2024-08-15T20:43:42.705000+00:00", + pinned: false, + mentions: [ + { + username: "infinidoge1337", + public_flags: 768, + id: "197126718400626689", + global_name: "Infinidoge", + discriminator: "0", + clan: null, + avatar_decoration_data: null, + avatar: "89c59de97fdb5f90999d86fed01f23fe" + } + ], + mention_roles: [], + mention_everyone: false, + id: "1273743950028607530", + flags: 0, + embeds: [], + edited_timestamp: null, + content: "> -# <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/813954820460642336/1100319550446252084/1273733346005880925 <@197126718400626689>:" + + " Neat that they thought of that\n" + + 'let me guess they got a lot of bug reports like "empty chest with no loot?"', + components: [], + channel_id: "1100319550446252084", + author: { + username: "Ami (she/her)", + id: "1179987856631017473", + global_name: null, + discriminator: "0000", + bot: true, + avatar: "8b2ca50a10b39f4f908f536cd82d7953" + }, + attachments: [], + application_id: "684280192553844747" + }, + pinned: false, + nonce: "1273744360743108608", + message_reference: { + type: 0, + message_id: "1273743950028607530", + guild_id: "813954820460642336", + channel_id: "1100319550446252084" + }, + mentions: [], + mention_roles: [], + mention_everyone: false, + member: { + roles: [ "832496260219928577" ], + premium_since: null, + pending: false, + nick: null, + mute: false, + joined_at: "2021-05-05T21:22:36.181000+00:00", + flags: 0, + deaf: false, + communication_disabled_until: null, + banner: null, + avatar: null + }, + id: "1273744361036841061", + flags: 0, + embeds: [], + edited_timestamp: null, + content: "Most likely", + components: [], + channel_id: "1100319550446252084", + author: { + username: "infinidoge1337", + public_flags: 768, + id: "197126718400626689", + global_name: "Infinidoge", + discriminator: "0", + clan: null, + avatar_decoration_data: null, + avatar: "89c59de97fdb5f90999d86fed01f23fe" + }, + attachments: [], + guild_id: "813954820460642336" + }, sticker: { id: "1106366167788044450", type: 0, @@ -1815,6 +3115,351 @@ module.exports = { edited_timestamp: null, flags: 0, components: [] + }, + embed_will_be_generated_on_reply: { + type: 19, + content: "https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM", + mentions: [], + mention_roles: [], + attachments: [], + embeds: [ + { + type: "link", + url: "https://matrix.to/", + title: "Matrix - Decentralised and secure communication", + description: "You're invited to talk on Matrix. If you don't already have a client this link will help you pick one, and join the conversation. If you already have one, this link will help you join the conversation", + reference_id: "1278002262400176128", + thumbnail: { + url: "https://matrix.org/blog/img/matrix-logo.png", + proxy_url: "https://images-ext-1.discordapp.net/external/3yPmfN-_U_7Xn8hLSG77nY9IvdtITH0GPrB6OX3JjEI/https/matrix.org/blog/img/matrix-logo.png", + width: 800, + height: 400, + placeholder: "OAgOBIComJeHeId/dXgAAAAAAA==", + placeholder_version: 1 + } + } + ], + timestamp: "2024-08-27T14:44:43.490000+00:00", + edited_timestamp: null, + flags: 0, + components: [], + id: "1278002262400176128", + channel_id: "1100319550446252084", + author: { + id: "772659086046658620", + username: "cadence.worm", + avatar: "4b5c4b28051144e4c111f0113a0f1cf1", + discriminator: "0", + public_flags: 0, + flags: 0, + banner: null, + accent_color: null, + global_name: "cadence", + avatar_decoration_data: null, + banner_color: null, + clan: null + }, + pinned: false, + mention_everyone: false, + tts: false, + message_reference: { + type: 0, + channel_id: "1100319550446252084", + message_id: "1278001833876525057", + guild_id: "1100319549670301727" + }, + position: 0, + referenced_message: { + type: 0, + content: "b", + mentions: [], + mention_roles: [], + attachments: [], + embeds: [], + timestamp: "2024-08-27T14:43:01.322000+00:00", + edited_timestamp: "2024-08-27T14:43:06.277000+00:00", + flags: 0, + components: [], + id: "1278001833876525057", + channel_id: "1100319550446252084", + author: { + id: "772659086046658620", + username: "cadence.worm", + avatar: "4b5c4b28051144e4c111f0113a0f1cf1", + discriminator: "0", + public_flags: 0, + flags: 0, + banner: null, + accent_color: null, + global_name: "cadence", + avatar_decoration_data: null, + banner_color: null, + clan: null + }, + pinned: false, + mention_everyone: false, + tts: false + } + }, + forwarded_image: { type: 0, + content: "", + mentions: [], + mention_roles: [], + attachments: [], + embeds: [], + timestamp: "2024-10-16T22:25:01.973000+00:00", + edited_timestamp: null, + flags: 16384, + components: [], + id: "1296237495993892916", + channel_id: "112760669178241024", + author: { + id: "113340068197859328", + username: "kumaccino", + avatar: "a8829abe66866d7797b36f0bfac01086", + discriminator: "0", + public_flags: 128, + flags: 128, + banner: null, + accent_color: null, + global_name: "kumaccino", + avatar_decoration_data: null, + banner_color: null, + clan: null + }, + pinned: false, + mention_everyone: false, + tts: false, + message_reference: { + type: 1, + channel_id: "1019762340922663022", + message_id: "1019779830469894234" + }, + position: 0, + message_snapshots: [ + { + message: { + type: 0, + content: "", + mentions: [], + mention_roles: [], + attachments: [ + { + id: "1296237494987133070", + filename: "100km.gif", + size: 2965649, + url: "https://cdn.discordapp.com/attachments/112760669178241024/1296237494987133070/100km.gif?ex=67118ebd&is=67103d3d&hm=8ed76d424f92f11366989f2ebc713d4f8206706ef712571e934da45b59944f77&", proxy_url: "https://media.discordapp.net/attachments/112760669178241024/1296237494987133070/100km.gif?ex=67118ebd&is=67103d3d&hm=8ed76d424f92f11366989f2ebc713d4f8206706ef712571e934da45b59944f77&", width: 300, + height: 300, + content_type: "image/gif" + } + ], + embeds: [], + timestamp: "2022-09-15T01:20:58.177000+00:00", + edited_timestamp: null, + flags: 0, + components: [] + } + } + ] + }, + constructed_forwarded_message: { type: 0, + content: "", + mentions: [], + mention_roles: [], + attachments: [], + embeds: [], + timestamp: "2024-10-16T22:25:01.973000+00:00", + edited_timestamp: null, + flags: 16384, + components: [], + id: "1296237495993892916", + channel_id: "112760669178241024", + author: { + id: "113340068197859328", + username: "kumaccino", + avatar: "a8829abe66866d7797b36f0bfac01086", + discriminator: "0", + public_flags: 128, + flags: 128, + banner: null, + accent_color: null, + global_name: "kumaccino", + avatar_decoration_data: null, + banner_color: null, + clan: null + }, + pinned: false, + mention_everyone: false, + tts: false, + message_reference: { + type: 1, + channel_id: "176333891320283136", + message_id: "1191567971970191490" + }, + position: 0, + message_snapshots: [ + { + message: { + type: 0, + content: "What's cooking, good looking? <:hipposcope:393635038903926784>", + mentions: [], + mention_roles: [], + attachments: [ + { + id: "1296237494987133070", + filename: "100km.gif", + size: 2965649, + url: "https://cdn.discordapp.com/attachments/112760669178241024/1296237494987133070/100km.gif?ex=67118ebd&is=67103d3d&hm=8ed76d424f92f11366989f2ebc713d4f8206706ef712571e934da45b59944f77&", proxy_url: "https://media.discordapp.net/attachments/112760669178241024/1296237494987133070/100km.gif?ex=67118ebd&is=67103d3d&hm=8ed76d424f92f11366989f2ebc713d4f8206706ef712571e934da45b59944f77&", width: 300, + height: 300, + content_type: "image/gif" + } + ], + embeds: [{ + type: "rich", + title: "This man is 100 km away from your house", + author: { + name: "This man" + }, + fields: [{ + name: "Distance away", + value: "99 km" + }, { + name: "Distance away", + value: "98 km" + }] + }], + timestamp: "2022-09-15T01:20:58.177000+00:00", + edited_timestamp: null, + flags: 0, + components: [] + } + } + ] + }, + constructed_forwarded_text: { type: 0, + content: "What's cooking everybody ‼️", + mentions: [], + mention_roles: [], + attachments: [], + embeds: [], + timestamp: "2024-10-16T22:25:01.973000+00:00", + edited_timestamp: null, + flags: 16384, + components: [], + id: "1296237495993892916", + channel_id: "112760669178241024", + author: { + id: "113340068197859328", + username: "kumaccino", + avatar: "a8829abe66866d7797b36f0bfac01086", + discriminator: "0", + public_flags: 128, + flags: 128, + banner: null, + accent_color: null, + global_name: "kumaccino", + avatar_decoration_data: null, + banner_color: null, + clan: null + }, + pinned: false, + mention_everyone: false, + tts: false, + message_reference: { + type: 1, + channel_id: "497161350934560778", + message_id: "0" + }, + position: 0, + message_snapshots: [ + { + message: { + type: 0, + content: "What's cooking, good looking?", + mentions: [], + mention_roles: [], + attachments: [], + embeds: [], + timestamp: "2022-09-15T01:20:58.177000+00:00", + edited_timestamp: null, + flags: 0, + components: [] + } + } + ] + }, + forwarded_dont_scan_for_mentions: { + type: 0, + tts: false, + timestamp: "2025-02-08T09:07:45.547000+00:00", + position: 0, + pinned: false, + nonce: "1337711633497063424", + message_snapshots: [ + { + message: { + type: 0, + timestamp: "2025-02-08T09:00:07.662000+00:00", + mentions: [], + flags: 0, + embeds: [], + edited_timestamp: null, + content: "If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile https://social.luca.run/@luca/113950834185678114", + components: [], + attachments: [] + } + } + ], + message_reference: { + type: 1, + message_id: "1337709539516223539", + guild_id: "500415824616620032", + channel_id: "794935364182867968" + }, + mentions: [], + mention_roles: [], + mention_everyone: false, + member: { + roles: [ + "1152297516755337248", + "300045569441660938", + "365531770420199435", + "1035943385338482698", + "1205645591212990515", + "1084555882341339259" + ], + premium_since: null, + pending: false, + nick: null, + mute: false, + joined_at: "2023-12-27T13:02:41.614000+00:00", + flags: 0, + deaf: false, + communication_disabled_until: null, + banner: null, + avatar: null + }, + id: "1337711460024844350", + flags: 16384, + embeds: [], + edited_timestamp: null, + content: "", + components: [], + channel_type: 0, + channel_id: "286888431945252874", + author: { + username: "athenna2000", + public_flags: 0, + primary_guild: null, + id: "620341774984151063", + global_name: "Amelia 🍄", + discriminator: "0", + clan: null, + avatar_decoration_data: null, + avatar: "a30f5b1bf17b5a5f387f1bb49771a2f8" + }, + attachments: [], + guild_id: "286888431945252874" } }, pk_message: { @@ -2159,6 +3804,7 @@ module.exports = { }, webhook_id: "1109360903096369153" }, + reply_with_only_embed: { type: 19, tts: false, @@ -2910,6 +4556,64 @@ module.exports = { edited_timestamp: null, flags: 0, components: [] + }, + tenor_gif: { + type: 0, + content: "<@&1182745800661540927> get real https://tenor.com/view/get-real-gif-26176788", + mentions: [], + mention_roles: [ "1182745800661540927" ], + attachments: [], + embeds: [ + { + type: "gifv", + url: "https://tenor.com/view/get-real-gif-26176788", + provider: { name: "Tenor", url: "https://tenor.co" }, + thumbnail: { + url: "https://media.tenor.com/Bz5pfRIu81oAAAAe/get-real.png", + proxy_url: "https://images-ext-1.discordapp.net/external/I71Ngw9drAKZhL_lhQRnAD_A-DkRNgN3EeZ2njv3Vi4/https/media.tenor.com/Bz5pfRIu81oAAAAe/get-real.png", + width: 632, + height: 640, + placeholder: "IBgSHwSYaIePiHh/d7h3d4eEJvkchZsA", + placeholder_version: 1, + flags: 0 + }, + video: { + url: "https://media.tenor.com/Bz5pfRIu81oAAAPo/get-real.mp4", + proxy_url: "https://images-ext-1.discordapp.net/external/vNEtsZd1p_mWQh-nEIa0ZBndMEo2_oa1sAOMyXsgoWI/https/media.tenor.com/Bz5pfRIu81oAAAPo/get-real.mp4", + width: 632, + height: 640, + placeholder: "IBgSHwSYaIePiHh/d7h3d4eEJvkchZsA", + placeholder_version: 1, + flags: 0 + } + } + ], + timestamp: "2025-06-08T03:49:08.500000+00:00", + edited_timestamp: null, + flags: 0, + components: [], + id: "1381117821190279271", + channel_id: "1099031887500034088", + author: { + id: "771520384671416320", + username: "Bojack Horseman", + avatar: "d14f47194b6ebe4da2e18a56fc6dacfd", + discriminator: "9703", + public_flags: 0, + flags: 0, + bot: true, + banner: null, + accent_color: null, + global_name: null, + avatar_decoration_data: null, + collectibles: null, + banner_color: null, + clan: null, + primary_guild: null + }, + pinned: false, + mention_everyone: false, + tts: false } }, message_update: { @@ -3534,6 +5238,70 @@ module.exports = { ], guild_id: "112760669178241024", id: "1210387798297682020" + }, + embed_generated_on_reply: { + attachments: [], + author: { + avatar: "4b5c4b28051144e4c111f0113a0f1cf1", + avatar_decoration_data: null, + clan: null, + discriminator: "0", + global_name: "cadence", + id: "772659086046658620", + public_flags: 0, + username: "cadence.worm" + }, + channel_id: "1100319550446252084", + components: [], + content: "https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM", + edited_timestamp: null, + embeds: [ + { + description: "You're invited to talk on Matrix. If you don't already have a client this link will help you pick one, and join the conversation. If you already have one, this link will help you join the conversation", + reference_id: "1278002262400176128", + thumbnail: { + height: 400, + placeholder: "OAgOBIComJeHeId/dXgAAAAAAA==", + placeholder_version: 1, + proxy_url: "https://images-ext-1.discordapp.net/external/3yPmfN-_U_7Xn8hLSG77nY9IvdtITH0GPrB6OX3JjEI/https/matrix.org/blog/img/matrix-logo.png", + url: "https://matrix.org/blog/img/matrix-logo.png", + width: 800 + }, + title: "Matrix - Decentralised and secure communication", + type: "link", + url: "https://matrix.to/" + } + ], + flags: 0, + guild_id: "1100319549670301727", + id: "1278002262400176128", + member: { + avatar: null, + banner: null, + communication_disabled_until: null, + deaf: false, + flags: 0, + joined_at: "2023-04-25T07:17:03.696000+00:00", + mute: false, + nick: "worm", + pending: false, + premium_since: null, + roles: [] + }, + mention_everyone: false, + mention_roles: [], + mentions: [], + message_reference: { + channel_id: "1100319550446252084", + guild_id: "1100319549670301727", + message_id: "1278001833876525057", + type: 0 + }, + pinned: false, + position: 0, + timestamp: "2024-08-27T14:44:43.490000+00:00", + tts: false, + type: 19 } }, special_message: { @@ -3684,6 +5452,120 @@ module.exports = { guild_id: "372271956562542592" } }, + reply_with_missing_referenced_message: { + type: 19, + content: "there must have been 2 different thumbnails used - the other one was this I think: https://lostmediawiki.com/w/images/6/6d/YOUTUBE_HACKED%21.jpg", + mentions: [ + { + id: "112816036671184896", + username: "accavish", + avatar: "0efadd2b09568c89e81f47d321c1db9f", + discriminator: "0", + public_flags: 0, + flags: 0, + banner: null, + accent_color: null, + global_name: "Yuv.yuv", + avatar_decoration_data: null, + banner_color: null, + clan: null + } + ], + mention_roles: [], + attachments: [], + embeds: [ + { + type: "image", + url: "https://lostmediawiki.com/w/images/6/6d/YOUTUBE_HACKED%21.jpg", + reference_id: "1277081824962809919", + thumbnail: { + url: "https://lostmediawiki.com/w/images/6/6d/YOUTUBE_HACKED%21.jpg", + proxy_url: "https://images-ext-1.discordapp.net/external/DqHSi6Hsvkn8CeYqcpNsgcPun_yykRNdKzyPTLxkrJ8/https/lostmediawiki.com/w/images/6/6d/YOUTUBE_HACKED%2521.jpg", + width: 898, + height: 459, + placeholder: "MPcFLIoEgndse3pydlUHeZSQZw==", + placeholder_version: 1 + } + } + ], + timestamp: "2024-08-25T01:47:14.104000+00:00", + edited_timestamp: null, + flags: 0, + components: [], + id: "1277081824962809919", + channel_id: "112760669178241024", + author: { + id: "628334893109215263", + username: "thecracksoverhead", + avatar: "e4eaad082f5ff0359cafa8d3ad5ddd4f", + discriminator: "0", + public_flags: 0, + flags: 0, + banner: null, + accent_color: null, + global_name: "jdl", + avatar_decoration_data: null, + banner_color: null, + clan: null + }, + pinned: false, + mention_everyone: false, + tts: false, + message_reference: { + type: 0, + channel_id: "112760669178241024", + message_id: "1277081326008143934", + guild_id: "112760669178241024" + }, + position: 0 + }, + ephemeral_message: { + webhook_id: "684280192553844747", + type: 20, + tts: false, + timestamp: "2024-09-29T11:22:04.865000+00:00", + position: 0, + pinned: false, + nonce: "1289910062243905536", + mentions: [], + mention_roles: [], + mention_everyone: false, + interaction_metadata: { + user: {baby: true}, + type: 2, + name: "invite", + id: "1289910063691206717", + command_type: 1, + authorizing_integration_owners: {baby: true} + }, + interaction: { + user: {baby: true}, + type: 2, + name: "invite", + id: "1289910063691206717" + }, + id: "1289910064995504182", + flags: 64, + embeds: [], + edited_timestamp: null, + content: "`@cadence:cadence.moe` is already in this server and this channel.", + components: [], + channel_id: "1100319550446252084", + author: { + username: "Matrix Bridge", + public_flags: 0, + id: "684280192553844747", + global_name: null, + discriminator: "5728", + clan: null, + bot: true, + avatar_decoration_data: null, + avatar: "48ae3c24f2a6ec5c60c41bdabd904018" + }, + attachments: [], + application_id: "684280192553844747" + }, + shard_id: 0 }, interaction_message: { thinking_interaction_without_bot_user: { @@ -3812,5 +5694,250 @@ module.exports = { application_id: "1109360903096369153", guild_id: "497159726455455754" } + }, + invite: { + irl: { + type: 0, + code: 'placeholder', + inviter: { + id: '772659086046658620', + username: 'cadence.worm', + avatar: '466df0c98b1af1e1388f595b4c1ad1b9', + discriminator: '0', + public_flags: 0, + flags: 0, + banner: null, + accent_color: 4534897, + global_name: 'cadence', + avatar_decoration_data: null, + collectibles: null, + banner_color: '#453271', + clan: null, + primary_guild: null + }, + expires_at: '2025-06-15T08:39:43+00:00', + guild: { + id: '1338114140941586518', + name: 'self service', + splash: null, + banner: null, + description: null, + icon: null, + features: [], + verification_level: 0, + vanity_url_code: null, + nsfw_level: 0, + nsfw: false, + premium_subscription_count: 0, + premium_tier: 0 + }, + guild_id: '1338114140941586518', + channel: { id: '1338114141658939517', type: 0, name: 'general' }, + guild_scheduled_event: { + id: '1381190945646710824', + guild_id: '1338114140941586518', + name: 'forest exploration', + description: '', + channel_id: null, + creator_id: '772659086046658620', + image: null, + scheduled_start_time: '2025-06-08T10:00:00.161000+00:00', + scheduled_end_time: '2025-06-08T12:00:00.161000+00:00', + status: 1, + entity_type: 3, + entity_id: null, + recurrence_rule: null, + user_count: 1, + privacy_level: 2, + sku_ids: [], + user_rsvp: null, + guild_scheduled_event_exceptions: [], + entity_metadata: { location: 'the dark forest' } + }, + profile: { + id: '1338114140941586518', + name: 'self service', + icon_hash: null, + member_count: 2, + online_count: 1, + description: null, + banner_hash: null, + game_application_ids: [], + game_activity: {}, + tag: null, + badge: 0, + badge_color_primary: '#ff0000', + badge_color_secondary: '#800000', + badge_hash: null, + traits: [], + features: [], + visibility: 2, + custom_banner_hash: null, + premium_subscription_count: 0, + premium_tier: 0 + } + }, + vc: { + type: 0, + code: 'placeholder', + inviter: { + id: '1024720274928697384', + username: '1024720274928697384', + avatar: '040a0652f1c76af3b71bb2c58ee0057b', + discriminator: '0', + public_flags: 0, + flags: 0, + banner: null, + accent_color: 4259841, + global_name: 'Regalia, Goddess of OH GOD OH FU', + avatar_decoration_data: null, + collectibles: null, + banner_color: '#410001', + clan: null, + primary_guild: null + }, + expires_at: '2025-06-15T07:32:30+00:00', + guild: { + id: '1340545485542391879', + name: 'VRCooking', + splash: null, + banner: null, + description: null, + icon: '8e1948b83d79c11ccb32b9e54a5d85fd', + features: [ 'SOUNDBOARD', 'ACTIVITY_FEED_DISABLED_BY_USER' ], + verification_level: 0, + vanity_url_code: null, + nsfw_level: 0, + nsfw: false, + premium_subscription_count: 0, + premium_tier: 0 + }, + guild_id: '1340545485542391879', + channel: { id: '1368144987707019306', type: 2, name: 'Cooking' }, + guild_scheduled_event: { + id: '1381174024801095751', + guild_id: '1340545485542391879', + name: 'Cooking (Netrunners)', + description: 'Short circuited brain interfaces actually just means your brain is medium rare, yum.', + channel_id: '1368144987707019306', + creator_id: '1024720274928697384', + image: null, + scheduled_start_time: '2025-06-09T03:00:00+00:00', + scheduled_end_time: null, + status: 1, + entity_type: 2, + entity_id: null, + recurrence_rule: null, + user_count: 2, + privacy_level: 2, + sku_ids: [], + user_rsvp: null, + guild_scheduled_event_exceptions: [], + entity_metadata: {} + }, + profile: { + id: '1340545485542391879', + name: 'VRCooking', + icon_hash: '8e1948b83d79c11ccb32b9e54a5d85fd', + member_count: 18, + online_count: 13, + description: null, + banner_hash: null, + game_application_ids: [], + game_activity: {}, + tag: null, + badge: 0, + badge_color_primary: '#ff0000', + badge_color_secondary: '#800000', + badge_hash: null, + traits: [], + features: [], + visibility: 2, + custom_banner_hash: null, + premium_subscription_count: 0, + premium_tier: 0 + } + }, + known_vc: { + type: 0, + code: 'placeholder', + inviter: { + id: '1024720274928697384', + username: '1024720274928697384', + avatar: '040a0652f1c76af3b71bb2c58ee0057b', + discriminator: '0', + public_flags: 0, + flags: 0, + banner: null, + accent_color: 4259841, + global_name: 'Regalia, Goddess of OH GOD OH FU', + avatar_decoration_data: null, + collectibles: null, + banner_color: '#410001', + clan: null, + primary_guild: null + }, + expires_at: '2025-06-15T07:32:30+00:00', + guild: { + id: '112760669178241024', + name: 'Psychonauts 3', + splash: null, + banner: null, + description: null, + icon: '8e1948b83d79c11ccb32b9e54a5d85fd', + features: [ 'SOUNDBOARD', 'ACTIVITY_FEED_DISABLED_BY_USER' ], + verification_level: 0, + vanity_url_code: null, + nsfw_level: 0, + nsfw: false, + premium_subscription_count: 0, + premium_tier: 0 + }, + guild_id: '112760669178241024', + channel: { id: '1162005314908999790', type: 0, name: 'Hey.' }, + guild_scheduled_event: { + id: '1381174024801095751', + guild_id: '112760669178241024', + name: 'Cooking (Netrunners)', + description: 'Short circuited brain interfaces actually just means your brain is medium rare, yum.', + channel_id: '1162005314908999790', + creator_id: '1024720274928697384', + image: null, + scheduled_start_time: '2025-06-09T03:00:00+00:00', + scheduled_end_time: null, + status: 1, + entity_type: 2, + entity_id: null, + recurrence_rule: null, + user_count: 2, + privacy_level: 2, + sku_ids: [], + user_rsvp: null, + guild_scheduled_event_exceptions: [], + entity_metadata: {} + }, + profile: { + id: '112760669178241024', + name: 'Psychonauts 3', + icon_hash: '8e1948b83d79c11ccb32b9e54a5d85fd', + member_count: 18, + online_count: 13, + description: null, + banner_hash: null, + game_application_ids: [], + game_activity: {}, + tag: null, + badge: 0, + badge_color_primary: '#ff0000', + badge_color_secondary: '#800000', + badge_hash: null, + traits: [], + features: [], + visibility: 2, + custom_banner_hash: null, + premium_subscription_count: 0, + premium_tier: 0 + } + } } } diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql index 35368f4..b31f2c3 100644 --- a/test/ooye-test-data.sql +++ b/test/ooye-test-data.sql @@ -1,10 +1,16 @@ BEGIN TRANSACTION; +INSERT INTO guild_active (guild_id, autocreate) VALUES +('112760669178241024', 1), +('66192955777486848', 1), +('665289423482519565', 0); + INSERT INTO guild_space (guild_id, space_id, privacy_level) VALUES -('112760669178241024', '!jjWAGMeQdNrVZSSfvz:cadence.moe', 0); +('112760669178241024', '!jjmvBegULiLucuWEHU:cadence.moe', 0); INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar) VALUES ('112760669178241024', '!kLRqKKUQXcibIMtOpl:cadence.moe', 'heave', 'main', NULL, NULL), +('687028734322147344', '!fGgIymcYWOqjbSRUdV:cadence.moe', 'slow-news-day', NULL, NULL, NULL), ('497161350934560778', '!CzvdIdUQXgUjDVKxeU:cadence.moe', 'amanda-spam', NULL, NULL, NULL), ('160197704226439168', '!hYnGGlPHlbujVVfktC:cadence.moe', 'the-stanley-parable-channel', 'bots', NULL, NULL), ('1100319550446252084', '!BnKuBPCvyfOkhcUjEu:cadence.moe', 'worm-farm', NULL, NULL, NULL), @@ -13,24 +19,28 @@ INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom ('122155380120748034', '!cqeGDbPiMFAhLsqqqq:cadence.moe', 'cadences-mind', 'coding', NULL, NULL), ('176333891320283136', '!qzDBLKlildpzrrOnFZ:cadence.moe', '🌈丨davids-horse_she-took-the-kids', 'wonderland', NULL, 'mxc://cadence.moe/EVvrSkKIRONHjtRJsMLmHWLS'), ('489237891895768942', '!tnedrGVYKFNUdnegvf:tchncs.de', 'ex-room-doesnt-exist-any-more', NULL, NULL, NULL), -('1160894080998461480', '!TqlyQmifxGUggEmdBN:cadence.moe', 'ooyexperiment', NULL, NULL, NULL); +('1160894080998461480', '!TqlyQmifxGUggEmdBN:cadence.moe', 'ooyexperiment', NULL, NULL, NULL), +('1161864271370666075', '!mHmhQQPwXNananMUqq:cadence.moe', 'updates', NULL, NULL, NULL); -INSERT INTO sim (user_id, sim_name, localpart, mxid) VALUES -('0', 'bot', '_ooye_bot', '@_ooye_bot:cadence.moe'), -('820865262526005258', 'crunch_god', '_ooye_crunch_god', '@_ooye_crunch_god:cadence.moe'), -('771520384671416320', 'bojack_horseman', '_ooye_bojack_horseman', '@_ooye_bojack_horseman:cadence.moe'), -('112890272819507200', '.wing.', '_ooye_.wing.', '@_ooye_.wing.:cadence.moe'), -('114147806469554185', 'extremity', '_ooye_extremity', '@_ooye_extremity:cadence.moe'), -('111604486476181504', 'kyuugryphon', '_ooye_kyuugryphon', '@_ooye_kyuugryphon:cadence.moe'), -('1109360903096369153', 'amanda', '_ooye_amanda', '@_ooye_amanda:cadence.moe'), -('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '_pk_zoego', '_ooye__pk_zoego', '@_ooye__pk_zoego:cadence.moe'); +INSERT INTO sim (user_id, username, sim_name, mxid) VALUES +('0', 'Matrix Bridge', 'bot', '@_ooye_bot:cadence.moe'), +('820865262526005258', 'Crunch God', 'crunch_god', '@_ooye_crunch_god:cadence.moe'), +('771520384671416320', 'Bojack Horseman', 'bojack_horseman', '@_ooye_bojack_horseman:cadence.moe'), +('112890272819507200', 'wing', '.wing.', '@_ooye_.wing.:cadence.moe'), +('114147806469554185', 'extremity', 'extremity', '@_ooye_extremity:cadence.moe'), +('111604486476181504', 'kyuugryphon', 'kyuugryphon', '@_ooye_kyuugryphon:cadence.moe'), +('1109360903096369153', 'Amanda', 'amanda', '@_ooye_amanda:cadence.moe'), +('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '_pk_zoego', '_pk_zoego', '@_ooye__pk_zoego:cadence.moe'), +('320067006521147393', 'papiophidian', 'papiophidian', '@_ooye_papiophidian:cadence.moe'), +('772659086046658620', 'cadence.worm', 'cadence', '@_ooye_cadence:cadence.moe'); + +INSERT INTO sim_member (mxid, room_id, hashed_profile_content) VALUES +('@_ooye_bojack_horseman:cadence.moe', '!hYnGGlPHlbujVVfktC:cadence.moe', NULL), +('@_ooye_cadence:cadence.moe', '!BnKuBPCvyfOkhcUjEu:cadence.moe', NULL); INSERT INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES ('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '196188877885538304', 'Azalea &flwr; 🌺'); -INSERT INTO sim_member (mxid, room_id, hashed_profile_content) VALUES -('@_ooye_bojack_horseman:cadence.moe', '!hYnGGlPHlbujVVfktC:cadence.moe', NULL); - INSERT INTO message_channel (message_id, channel_id) VALUES ('1106366167788044450', '122155380120748034'), ('1106366167788044451', '122155380120748034'), @@ -54,7 +64,16 @@ INSERT INTO message_channel (message_id, channel_id) VALUES ('1197612733600895076', '112760669178241024'), ('1202543413652881428', '1160894080998461480'), ('1207486471489986620', '1160894080998461480'), -('1210387798297682020', '112760669178241024'); +('1210387798297682020', '112760669178241024'), +('1273204543739396116', '687028734322147344'), +('1273743950028607530', '1100319550446252084'), +('1278002262400176128', '1100319550446252084'), +('1278001833876525057', '1100319550446252084'), +('1191567971970191490', '176333891320283136'), +('1144874214311067708', '687028734322147344'), +('1339000288144658482', '176333891320283136'), +('1381212840957972480', '112760669178241024'), +('1401760355339862066', '112760669178241024'); INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES ('$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg', 'm.room.message', 'm.text', '1126786462646550579', 0, 0, 1), @@ -89,7 +108,16 @@ INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part ('$7tJoMw1h44n2gxgLUE1T_YinGrLbK0x-TDY1z6M7GBw', 'm.room.message', 'm.text', '1197612733600895076', 0, 0, 1), ('$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU', 'm.room.message', 'm.text', '1202543413652881428', 0, 0, 0), ('$OEEK-Wam2FTh6J-6kVnnJ6KnLA_lLRnLTHatKKL62-Y', 'm.room.message', 'm.image', '1207486471489986620', 0, 0, 0), -('$mPSzglkCu-6cZHbYro0RW2u5mHvbH9aXDjO5FCzosc0', 'm.room.message', 'm.text', '1210387798297682020', 0, 0, 1); +('$mPSzglkCu-6cZHbYro0RW2u5mHvbH9aXDjO5FCzosc0', 'm.room.message', 'm.text', '1210387798297682020', 0, 0, 1), +('$qmyjr-ISJtnOM5WTWLI0fT7uSlqRLgpyin2d2NCglCU', 'm.room.message', 'm.text', '1273204543739396116', 0, 0, 0), +('$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4', 'm.room.message', 'm.text', '1273743950028607530', 0, 0, 0), +('$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF', 'm.room.message', 'm.text', '1278002262400176128', 0, 0, 1), +('$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM', 'm.room.message', 'm.text', '1278001833876525057', 0, 0, 1), +('$tBIT8mO7XTTCgIINyiAIy6M2MSoPAdJenRl_RLyYuaE', 'm.room.message', 'm.text', '1191567971970191490', 0, 0, 1), +('$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk', 'm.room.message', 'm.text', '1339000288144658482', 0, 0, 0), +('$AfrB8hzXkDMvuoWjSZkDdFYomjInWH7jMBPkwQMN8AI', 'm.room.message', 'm.text', '1381212840957972480', 0, 1, 1), +('$43baKEhJfD-RlsFQi0LB16Zxd8yMqp0HSVL00TDQOqM', 'm.room.message', 'm.image', '1381212840957972480', 1, 0, 1), +('$7P2O_VTQNHvavX5zNJ35DV-dbJB1Ag80tGQP_JzGdhk', 'm.room.message', 'm.text', '1401760355339862066', 0, 0, 0); INSERT INTO file (discord_url, mxc_url) VALUES ('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'), @@ -109,7 +137,8 @@ INSERT INTO file (discord_url, mxc_url) VALUES ('https://cdn.discordapp.com/emojis/288858540888686602.png', 'mxc://cadence.moe/mwZaCtRGAQQyOItagDeCocEO'), ('https://cdn.discordapp.com/attachments/112760669178241024/1197621094786531358/Ins_1960637570.mp4', 'mxc://cadence.moe/kMqLycqMURhVpwleWkmASpnU'), ('https://cdn.discordapp.com/attachments/1099031887500034088/1112476845502365786/voice-message.ogg', 'mxc://cadence.moe/MRRPDggXQMYkrUjTpxQbmcxB'), -('https://cdn.discordapp.com/attachments/122155380120748034/1174514575220158545/the.yml', 'mxc://cadence.moe/HnQIYQmmlIKwOQsbFsIGpzPP'); +('https://cdn.discordapp.com/attachments/122155380120748034/1174514575220158545/the.yml', 'mxc://cadence.moe/HnQIYQmmlIKwOQsbFsIGpzPP'), +('https://cdn.discordapp.com/attachments/112760669178241024/1296237494987133070/100km.gif', 'mxc://cadence.moe/qDAotmebTfEIfsAIVCEZptLh'); INSERT INTO emoji (emoji_id, name, animated, mxc_url) VALUES ('230201364309868544', 'hippo', 0, 'mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC'), @@ -121,24 +150,46 @@ INSERT INTO emoji (emoji_id, name, animated, mxc_url) VALUES ('606664341298872324', 'online', 0, 'mxc://cadence.moe/LCEqjStXCxvRQccEkuslXEyZ'), ('288858540888686602', 'upstinky', 0, 'mxc://cadence.moe/mwZaCtRGAQQyOItagDeCocEO'); -INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES -('!kLRqKKUQXcibIMtOpl:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL), -('!BpMdOUkWWhFxmTrENV:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'malformed mxc'), -('!fGgIymcYWOqjbSRUdV:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU'), -('!BnKuBPCvyfOkhcUjEu:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU'), -('!maggESguZBqGBZtSnr:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU'), -('!CzvdIdUQXgUjDVKxeU:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU'), -('!cBxtVRxDlZvSVhJXVK:cadence.moe', '@Milan:tchncs.de', 'Milan', NULL), -('!TqlyQmifxGUggEmdBN:cadence.moe', '@ampflower:matrix.org', 'Ampflower 🌺', 'mxc://cadence.moe/PRfhXYBTOalvgQYtmCLeUXko'), -('!TqlyQmifxGUggEmdBN:cadence.moe', '@aflower:syndicated.gay', 'Rose', 'mxc://syndicated.gay/ZkBUPXCiXTjdJvONpLJmcbKP'), -('!TqlyQmifxGUggEmdBN:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL); +INSERT INTO member_cache (room_id, mxid, displayname, avatar_url, power_level) VALUES +('!jjmvBegULiLucuWEHU:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL, 50), +('!kLRqKKUQXcibIMtOpl:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL, 0), +('!kLRqKKUQXcibIMtOpl:cadence.moe', '@test_auto_invite:example.org', NULL, NULL, 0), +('!fGgIymcYWOqjbSRUdV:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU', 0), +('!fGgIymcYWOqjbSRUdV:cadence.moe', '@rnl:cadence.moe', 'RNL', NULL, 0), +('!BnKuBPCvyfOkhcUjEu:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU', 0), +('!BnKuBPCvyfOkhcUjEu:cadence.moe', '@ami:the-apothecary.club', 'Ami (she/her)', NULL, 0), +('!CzvdIdUQXgUjDVKxeU:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU', 0), +('!TqlyQmifxGUggEmdBN:cadence.moe', '@Milan:tchncs.de', 'Milan', NULL, 0), +('!TqlyQmifxGUggEmdBN:cadence.moe', '@ampflower:matrix.org', 'Ampflower 🌺', 'mxc://cadence.moe/PRfhXYBTOalvgQYtmCLeUXko', 0), +('!TqlyQmifxGUggEmdBN:cadence.moe', '@aflower:syndicated.gay', 'Rose', 'mxc://syndicated.gay/ZkBUPXCiXTjdJvONpLJmcbKP', 0), +('!TqlyQmifxGUggEmdBN:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL, 0), +('!iSyXgNxQcEuXoXpsSn:pussthecat.org', '@austin:tchncs.de', 'Austin Huang', 'mxc://tchncs.de/090a2b5e07eed2f71e84edad5207221e6c8f8b8e', 0); + +INSERT INTO reaction (hashed_event_id, message_id, encoded_emoji) VALUES +(5162930312280790092, '1141501302736695317', '%F0%9F%90%88'); + +INSERT INTO member_power (mxid, room_id, power_level) VALUES +('@test_auto_invite:example.org', '*', 100); INSERT INTO lottie (sticker_id, mxc_url) VALUES ('860171525772279849', 'mxc://cadence.moe/ZtvvVbwMIdUZeovWVyGVFCeR'); -INSERT INTO "auto_emoji" ("name","emoji_id","guild_id") VALUES -('L1','1144820033948762203','529176156398682115'), -('L2','1144820084079087647','529176156398682115'), -('_','_','529176156398682115'); +INSERT INTO auto_emoji (name, emoji_id) VALUES +('L1', '1144820033948762203'), +('L2', '1144820084079087647'); + +INSERT INTO media_proxy (permitted_hash) VALUES +(-429802515645771439), +(4558604729745184757); + +INSERT INTO invite (mxid, room_id, type, name, avatar, topic) VALUES +('@cadence:cadence.moe', '!zTMspHVUBhFLLSdmnS:cadence.moe', 'm.space', 'Data Horde', 'mxc://cadence.moe/TLqQOsTSrZkVKwBSWYTZNTrw', 'here is the space topic'), +('@cadence:cadence.moe', '!jjmvBegULiLucuWEHU:cadence.moe', 'm.space', 'Epicord', NULL, NULL), +('@cadence:cadence.moe', '!room:cadence.moe', NULL, 'some room', NULL, NULL), +('@rnl:cadence.moe', '!space:cadence.moe', NULL, 'somebody else''s space', NULL, NULL); + +INSERT INTO direct (mxid, room_id) VALUES +('@user1:example.org', '!existing:cadence.moe'), +('@user2:example.org', '!existing:cadence.moe'); COMMIT; diff --git a/test/res/lottie-bee.png b/test/res/lottie-bee.png index 3fdc19a..4586cb5 100644 Binary files a/test/res/lottie-bee.png and b/test/res/lottie-bee.png differ diff --git a/test/test.js b/test/test.js index 396dea9..3695a84 100644 --- a/test/test.js +++ b/test/test.js @@ -2,56 +2,69 @@ const fs = require("fs") const {join} = require("path") -const stp = require("stream").promises const sqlite = require("better-sqlite3") -const migrate = require("../db/migrate") +const {Writable} = require("stream") +const migrate = require("../src/db/migrate") const HeatSync = require("heatsync") -const {test} = require("supertape") +const {test, extend} = require("supertape") const data = require("./data") -/** @type {import("node-fetch").default} */ -// @ts-ignore -const fetch = require("node-fetch") -const {green} = require("colorette") +const {green} = require("ansi-colors") -const config = require("../config") -const passthrough = require("../passthrough") +const passthrough = require("../src/passthrough") const db = new sqlite(":memory:") -const reg = require("../matrix/read-registration") +const {reg} = require("../src/matrix/read-registration") +reg.ooye.discord_token = "Njg0MjgwMTkyNTUzODQ0NzQ3.Xl3zlw.baby" reg.ooye.server_origin = "https://matrix.cadence.moe" // so that tests will pass even when hard-coded reg.ooye.server_name = "cadence.moe" -reg.ooye.invite = ["@test_auto_invite:example.org"] -reg.id = "baby" // don't actually take authenticated actions on the server -reg.as_token = "baby" -reg.hs_token = "baby" +reg.id = "baby" +reg.as_token = "don't actually take authenticated actions on the server" +reg.hs_token = "don't actually take authenticated actions on the server" +reg.namespaces = { + users: [{regex: "@_ooye_.*:cadence.moe", exclusive: true}], + aliases: [{regex: "#_ooye_.*:cadence.moe", exclusive: true}] +} +reg.ooye.bridge_origin = "https://bridge.example.org" const sync = new HeatSync({watchFS: false}) const discord = { + // @ts-ignore - ignore guilds, because my data dump is missing random properties guilds: new Map([ - [data.guild.general.id, data.guild.general] + [data.guild.general.id, data.guild.general], + [data.guild.fna.id, data.guild.fna], + [data.guild.data_horde.id, data.guild.data_horde] + ]), + guildChannelMap: new Map([ + [data.guild.general.id, [data.channel.general.id, data.channel.updates.id]], + [data.guild.fna.id, []], + [data.guild.data_horde.id, [data.channel.saving_the_world.id]] ]), application: { id: "684280192553844747" }, + // @ts-ignore - ignore channels, because my data dump is missing random properties channels: new Map([ + [data.channel.general.id, data.channel.general], + [data.channel.updates.id, data.channel.updates], ["497161350934560778", { guild_id: "497159726455455754" }], ["498323546729086986", { guild_id: "497159726455455754", name: "bad-boots-prison" - }] + }], + [data.channel.saving_the_world.id, data.channel.saving_the_world] ]) } -Object.assign(passthrough, { discord, config, sync, db }) +Object.assign(passthrough, { discord, sync, db }) -const orm = sync.require("../db/orm") +const orm = sync.require("../src/db/orm") passthrough.from = orm.from passthrough.select = orm.select -const file = sync.require("../matrix/file") +const file = sync.require("../src/matrix/file") /* c8 ignore next */ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not allowed to upload files during testing.\nURL: ${url}`) } @@ -76,27 +89,28 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not async function download({url, to}) { if (await fs.existsSync(to)) return const res = await fetch(url) - await stp.pipeline(res.body, fs.createWriteStream(to, {encoding: "binary"})) + // @ts-ignore + await res.body.pipeTo(Writable.toWeb(fs.createWriteStream(to, {encoding: "binary"}))) } await allReporter([ - {url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/RLMgJGfgTPjIQtvvWZsYjhjy", to: "test/res/RLMgJGfgTPjIQtvvWZsYjhjy.png"}, - {url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/bZFuuUSEebJYXUMSxuuSuLTa", to: "test/res/bZFuuUSEebJYXUMSxuuSuLTa.png"}, - {url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/qWmbXeRspZRLPcjseyLmeyXC", to: "test/res/qWmbXeRspZRLPcjseyLmeyXC.png"}, - {url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/wcouHVjbKJJYajkhJLsyeJAA", to: "test/res/wcouHVjbKJJYajkhJLsyeJAA.png"}, - {url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/WbYqNlACRuicynBfdnPYtmvc", to: "test/res/WbYqNlACRuicynBfdnPYtmvc.gif"}, - {url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/HYcztccFIPgevDvoaWNsEtGJ", to: "test/res/HYcztccFIPgevDvoaWNsEtGJ.png"}, - {url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/lHfmJpzgoNyNtYHdAmBHxXix", to: "test/res/lHfmJpzgoNyNtYHdAmBHxXix.png"}, - {url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/MtRdXixoKjKKOyHJGWLsWLNU", to: "test/res/MtRdXixoKjKKOyHJGWLsWLNU.png"}, - {url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/HXfFuougamkURPPMflTJRxGc", to: "test/res/HXfFuougamkURPPMflTJRxGc.png"}, - {url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/ikYKbkhGhMERAuPPbsnQzZiX", to: "test/res/ikYKbkhGhMERAuPPbsnQzZiX.png"}, - {url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/AYPpqXzVJvZdzMQJGjioIQBZ", to: "test/res/AYPpqXzVJvZdzMQJGjioIQBZ.png"}, - {url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/UVuzvpVUhqjiueMxYXJiFEAj", to: "test/res/UVuzvpVUhqjiueMxYXJiFEAj.png"}, + {url: "https://cadence.moe/friends/ooye_test/RLMgJGfgTPjIQtvvWZsYjhjy.png", to: "test/res/RLMgJGfgTPjIQtvvWZsYjhjy.png"}, + {url: "https://cadence.moe/friends/ooye_test/bZFuuUSEebJYXUMSxuuSuLTa.png", to: "test/res/bZFuuUSEebJYXUMSxuuSuLTa.png"}, + {url: "https://cadence.moe/friends/ooye_test/qWmbXeRspZRLPcjseyLmeyXC.png", to: "test/res/qWmbXeRspZRLPcjseyLmeyXC.png"}, + {url: "https://cadence.moe/friends/ooye_test/wcouHVjbKJJYajkhJLsyeJAA.png", to: "test/res/wcouHVjbKJJYajkhJLsyeJAA.png"}, + {url: "https://cadence.moe/friends/ooye_test/WbYqNlACRuicynBfdnPYtmvc.gif", to: "test/res/WbYqNlACRuicynBfdnPYtmvc.gif"}, + {url: "https://cadence.moe/friends/ooye_test/HYcztccFIPgevDvoaWNsEtGJ.png", to: "test/res/HYcztccFIPgevDvoaWNsEtGJ.png"}, + {url: "https://cadence.moe/friends/ooye_test/lHfmJpzgoNyNtYHdAmBHxXix.png", to: "test/res/lHfmJpzgoNyNtYHdAmBHxXix.png"}, + {url: "https://cadence.moe/friends/ooye_test/MtRdXixoKjKKOyHJGWLsWLNU.png", to: "test/res/MtRdXixoKjKKOyHJGWLsWLNU.png"}, + {url: "https://cadence.moe/friends/ooye_test/HXfFuougamkURPPMflTJRxGc.png", to: "test/res/HXfFuougamkURPPMflTJRxGc.png"}, + {url: "https://cadence.moe/friends/ooye_test/ikYKbkhGhMERAuPPbsnQzZiX.png", to: "test/res/ikYKbkhGhMERAuPPbsnQzZiX.png"}, + {url: "https://cadence.moe/friends/ooye_test/AYPpqXzVJvZdzMQJGjioIQBZ.png", to: "test/res/AYPpqXzVJvZdzMQJGjioIQBZ.png"}, + {url: "https://cadence.moe/friends/ooye_test/UVuzvpVUhqjiueMxYXJiFEAj.png", to: "test/res/UVuzvpVUhqjiueMxYXJiFEAj.png"}, {url: "https://ezgif.com/images/format-demo/butterfly.gif", to: "test/res/butterfly.gif"}, {url: "https://ezgif.com/images/format-demo/butterfly.png", to: "test/res/butterfly.png"}, ]) }, {timeout: 60000}) } - /* c8 ignore end */ + /* c8 ignore stop */ const p = migrate.migrate(db) test("migrate: migration works", async t => { @@ -112,27 +126,45 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not db.exec(fs.readFileSync(join(__dirname, "ooye-test-data.sql"), "utf8")) - require("../db/orm.test") - require("../discord/utils.test") - require("../matrix/kstate.test") - require("../matrix/api.test") - require("../matrix/file.test") - require("../matrix/read-registration.test") - require("../matrix/txnid.test") - require("../d2m/actions/create-room.test") - require("../d2m/actions/create-space.test") - require("../d2m/actions/register-user.test") - require("../d2m/converters/edit-to-changes.test") - require("../d2m/converters/emoji-to-key.test") - require("../d2m/converters/lottie.test") - require("../d2m/converters/message-to-event.test") - require("../d2m/converters/message-to-event.embeds.test") - require("../d2m/converters/message-to-event.pk.test") - require("../d2m/converters/pins-to-list.test") - require("../d2m/converters/remove-reaction.test") - require("../d2m/converters/thread-to-announcement.test") - require("../d2m/converters/user-to-mxid.test") - require("../m2d/converters/event-to-message.test") - require("../m2d/converters/utils.test") - require("../m2d/converters/emoji-sheet.test") + require("./addbot.test") + require("../src/db/orm.test") + require("../src/web/server.test") + require("../src/web/routes/download-discord.test") + require("../src/web/routes/download-matrix.test") + require("../src/web/routes/guild.test") + require("../src/web/routes/guild-settings.test") + require("../src/web/routes/info.test") + require("../src/web/routes/link.test") + require("../src/web/routes/log-in-with-matrix.test") + require("../src/discord/utils.test") + require("../src/matrix/kstate.test") + require("../src/matrix/api.test") + require("../src/matrix/file.test") + require("../src/matrix/mreq.test") + require("../src/matrix/read-registration.test") + require("../src/matrix/txnid.test") + require("../src/d2m/actions/create-room.test") + require("../src/d2m/actions/create-space.test") + require("../src/d2m/actions/register-user.test") + require("../src/d2m/converters/edit-to-changes.test") + require("../src/d2m/converters/emoji-to-key.test") + require("../src/d2m/converters/lottie.test") + require("../src/d2m/converters/message-to-event.test") + require("../src/d2m/converters/message-to-event.embeds.test") + require("../src/d2m/converters/message-to-event.pk.test") + require("../src/d2m/converters/pins-to-list.test") + require("../src/d2m/converters/remove-reaction.test") + require("../src/d2m/converters/thread-to-announcement.test") + require("../src/d2m/converters/user-to-mxid.test") + require("../src/m2d/event-dispatcher.test") + require("../src/m2d/converters/diff-pins.test") + require("../src/m2d/converters/event-to-message.test") + require("../src/m2d/converters/emoji.test") + require("../src/m2d/converters/utils.test") + require("../src/m2d/converters/emoji-sheet.test") + require("../src/discord/interactions/invite.test") + require("../src/discord/interactions/matrix-info.test") + require("../src/discord/interactions/permissions.test") + require("../src/discord/interactions/privacy.test") + require("../src/discord/interactions/reactions.test") })() diff --git a/test/web.js b/test/web.js new file mode 100644 index 0000000..09af95b --- /dev/null +++ b/test/web.js @@ -0,0 +1,112 @@ +const passthrough = require("../src/passthrough") +const h3 = require("h3") +const http = require("http") +const {SnowTransfer} = require("snowtransfer") +const assert = require("assert").strict +const domino = require("domino") +const {extend} = require("supertape") +const {reg} = require("../src/matrix/read-registration") + +const {AppService} = require("@cloudrac3r/in-your-element") +const defaultAs = new AppService(reg) + +/** + * @param {string} html + */ +function getContent(html) { + const doc = domino.createDocument(html) + doc.querySelectorAll("svg").cache.forEach(e => e.remove()) + const content = doc.getElementById("content") + assert(content) + return content.innerHTML.trim() +} + +const test = extend({ + has: operator => /** @param {string | RegExp} expected */ (html, expected, message = "should have substring in html content") => { + const content = getContent(html) + const is = expected instanceof RegExp ? content.match(expected) : content.includes(expected) + const {output, result} = operator.equal(content, expected.toString()) + return { + expected: expected.toString(), + message, + is, + result: result, + output: output + } + } +}) + +class Router { + constructor() { + /** @type {Map<string, h3.EventHandler>} */ + this.routes = new Map() + for (const method of ["get", "post", "put", "patch", "delete"]) { + this[method] = function(url, handler) { + const key = `${method} ${url}` + this.routes.set(key, handler) + } + } + } + + /** + * @param {string} method + * @param {string} inputUrl + * @param {{event?: any, params?: any, body?: any, sessionData?: any, api?: Partial<import("../src/matrix/api")>, snow?: {[k in keyof SnowTransfer]?: Partial<SnowTransfer[k]>}, createRoom?: Partial<import("../src/d2m/actions/create-room")>, createSpace?: Partial<import("../src/d2m/actions/create-space")>, headers?: any}} [options] + */ + async test(method, inputUrl, options = {}) { + const url = new URL(inputUrl, "http://a") + const key = `${method} ${options.route || url.pathname}` + /* c8 ignore next */ + if (!this.routes.has(key)) throw new Error(`Route not found: "${key}"`) + + const req = { + method: method.toUpperCase(), + headers: options.headers || {}, + url + } + const event = options.event || {} + + if (typeof options.body === "object" && options.body.constructor === Object) { + options.body = JSON.stringify(options.body) + req.headers["content-type"] = "application/json" + } + + try { + return await this.routes.get(key)(Object.assign(event, { + __is_event__: true, + method: method.toUpperCase(), + path: `${url.pathname}${url.search}`, + _requestBody: options.body, + node: { + req, + res: new http.ServerResponse(req) + }, + context: { + api: options.api, + params: options.params, + snow: options.snow, + createRoom: options.createRoom, + createSpace: options.createSpace, + sessions: { + h3: { + id: "h3", + createdAt: 0, + data: options.sessionData || {} + } + } + } + })) + } catch (error) { + // Post-process error data + defaultAs.app.options.onError(error) + throw error + } + } +} + +const router = new Router() + +passthrough.as = {router, on() {}, options: defaultAs.app.options} + +module.exports.router = router +module.exports.test = test