diff --git a/docs/developer-orientation.md b/docs/developer-orientation.md
index bff64c2..0f5e0c0 100644
--- a/docs/developer-orientation.md
+++ b/docs/developer-orientation.md
@@ -113,6 +113,7 @@ Total transitive production dependencies: 144
* (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) denque: Much faster than using arrays as a queue.
* (0) discord-api-types: Bitfields needed at runtime and types needed for development.
* (0) domino: DOM implementation that's already pulled in by turndown.
* (2) enquirer: Interactive prompting for the initial setup rather than forcing users to edit YAML non-interactively.
diff --git a/docs/docker.md b/docs/docker.md
index 7e3eb7d..04b9cf4 100644
--- a/docs/docker.md
+++ b/docs/docker.md
@@ -66,7 +66,7 @@ I had no hand in creating these and have not used or tested them whatsoever. I m
***The following list is distributed for your information, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.***
-- by melody: https://git.shork.ch/docker-images/out-of-your-element
+- by melody: https://git.shork.ch/oci-images/out-of-your-element
- by sim2kid: https://github.com/sim2kid/ooye-docker
- by Katharos Technology: https://github.com/katharostech/docker_ooye
- by Emma: https://cgit.rory.gay/nix/OOYE-module.git/tree
diff --git a/docs/get-started.md b/docs/get-started.md
index 5b14b2a..fa67678 100644
--- a/docs/get-started.md
+++ b/docs/get-started.md
@@ -56,8 +56,16 @@ After that, to get into the rooms on your Matrix account, use the invite form on
I hope you enjoy Out Of Your Element!
-----
- hthrflwrs on cohost`
- + ` This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO`
+ formatted_body: ` This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO`
+ ` 1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:`
+ `
+# Extras
+
+## /plu/ral support
+
+To support /plu/ral webhook proxying, OOYE needs to call the /plu/ral API authenticated. I can't provide an API key for everyone to share, so you have to make your own. Here's how:
+
+1. Open the [/plu/ral app on Discord](https://discord.com/discovery/applications/1291501048493768784) and "Add App" -> "Add to My Apps (use everywhere)"
+2. Go to any text channel on Discord and use the `/api` command from /plu/ral.
+3. Create a new application named Out Of Your Element. Copy the displayed token.
+3. Run `node scripts/plu-ral-api.js` and paste the token.
# Appendix
diff --git a/package-lock.json b/package-lock.json
index eb07b4d..986e6ca 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "out-of-your-element",
- "version": "3.5.1",
+ "version": "3.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "out-of-your-element",
- "version": "3.5.1",
+ "version": "3.6.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@chriscdn/promise-semaphore": "^3.0.1",
@@ -18,14 +18,15 @@
"@cloudrac3r/pngjs": "^7.0.3",
"@cloudrac3r/pug": "^4.0.4",
"@cloudrac3r/stream-type": "^1.0.0",
- "@cloudrac3r/turndown": "^7.1.4",
+ "@cloudrac3r/turndown": "^7.1.5",
"@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.17.1",
- "discord-api-types": "^0.38.38",
+ "cloudstorm": "^0.19.0",
+ "denque": "^2.1.0",
+ "discord-api-types": "^0.38.52",
"domino": "^2.1.6",
"enquirer": "^2.4.1",
"entities": "^5.0.0",
@@ -36,8 +37,8 @@
"lru-cache": "^11.0.2",
"mime-types": "^2.1.35",
"prettier-bytes": "^1.0.4",
- "sharp": "^0.34.5",
- "snowtransfer": "^0.17.5",
+ "sharp": "^0.35.3",
+ "snowtransfer": "^0.19.0",
"try-to-catch": "^4.0.5",
"uqr": "^0.1.2",
"xxhash-wasm": "^1.0.2",
@@ -267,18 +268,18 @@
}
},
"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==",
+ "version": "7.1.5",
+ "resolved": "https://registry.npmjs.org/@cloudrac3r/turndown/-/turndown-7.1.5.tgz",
+ "integrity": "sha512-pENoG62B4UM2ecsg57cuSRDa1OV/YFYTFGg1aoQsKUdHBBglhE9I7EwEMYpS96QoM/auyglfaxjgTeqhcSGCIA==",
"license": "MIT",
"dependencies": {
"domino": "^2.1.6"
}
},
"node_modules/@emnapi/runtime": {
- "version": "1.9.1",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
- "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -301,9 +302,9 @@
}
},
"node_modules/@img/sharp-darwin-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
- "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
+ "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
"cpu": [
"arm64"
],
@@ -313,19 +314,19 @@
"darwin"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ "@img/sharp-libvips-darwin-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-darwin-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
- "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
+ "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
"cpu": [
"x64"
],
@@ -335,19 +336,38 @@
"darwin"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-darwin-x64": "1.2.4"
+ "@img/sharp-libvips-darwin-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-freebsd-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
+ "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
- "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
+ "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
"cpu": [
"arm64"
],
@@ -361,9 +381,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
- "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
+ "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
"cpu": [
"x64"
],
@@ -377,9 +397,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
- "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
+ "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
"cpu": [
"arm"
],
@@ -393,9 +413,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
- "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
+ "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
"cpu": [
"arm64"
],
@@ -409,9 +429,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
- "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
+ "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
"cpu": [
"ppc64"
],
@@ -425,9 +445,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
- "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
+ "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
"cpu": [
"riscv64"
],
@@ -441,9 +461,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
- "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
+ "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
"cpu": [
"s390x"
],
@@ -457,9 +477,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
- "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
+ "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
"cpu": [
"x64"
],
@@ -473,9 +493,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
- "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
+ "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
"cpu": [
"arm64"
],
@@ -489,9 +509,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
- "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
+ "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
"cpu": [
"x64"
],
@@ -505,9 +525,9 @@
}
},
"node_modules/@img/sharp-linux-arm": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
- "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
+ "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
"cpu": [
"arm"
],
@@ -517,19 +537,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-arm": "1.2.4"
+ "@img/sharp-libvips-linux-arm": "1.3.2"
}
},
"node_modules/@img/sharp-linux-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
- "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
+ "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
"cpu": [
"arm64"
],
@@ -539,19 +559,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-arm64": "1.2.4"
+ "@img/sharp-libvips-linux-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-ppc64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
- "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
+ "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
"cpu": [
"ppc64"
],
@@ -561,19 +581,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ "@img/sharp-libvips-linux-ppc64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-riscv64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
- "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
+ "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
"cpu": [
"riscv64"
],
@@ -583,19 +603,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ "@img/sharp-libvips-linux-riscv64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-s390x": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
- "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
+ "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
"cpu": [
"s390x"
],
@@ -605,19 +625,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-s390x": "1.2.4"
+ "@img/sharp-libvips-linux-s390x": "1.3.2"
}
},
"node_modules/@img/sharp-linux-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
- "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
+ "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
"cpu": [
"x64"
],
@@ -627,19 +647,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-x64": "1.2.4"
+ "@img/sharp-libvips-linux-x64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
- "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
+ "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
"cpu": [
"arm64"
],
@@ -649,19 +669,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
- "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
+ "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
"cpu": [
"x64"
],
@@ -671,38 +691,54 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2"
}
},
"node_modules/@img/sharp-wasm32": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
- "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
- "cpu": [
- "wasm32"
- ],
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
+ "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
- "@emnapi/runtime": "^1.7.0"
+ "@emnapi/runtime": "^1.11.1"
},
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-webcontainers-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
+ "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
- "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
+ "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
"cpu": [
"arm64"
],
@@ -712,16 +748,16 @@
"win32"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
- "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
+ "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
"cpu": [
"ia32"
],
@@ -731,16 +767,16 @@
"win32"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
- "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
+ "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
"cpu": [
"x64"
],
@@ -750,7 +786,7 @@
"win32"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
@@ -1003,9 +1039,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "22.19.19",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
- "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
+ "version": "22.20.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
+ "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1163,16 +1199,16 @@
}
},
"node_modules/brace-expansion": {
- "version": "5.0.6",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
- "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
- "node": "18 || 20 || >=22"
+ "node": "20 || >=22"
}
},
"node_modules/buffer": {
@@ -1316,13 +1352,13 @@
}
},
"node_modules/cloudstorm": {
- "version": "0.17.1",
- "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.17.1.tgz",
- "integrity": "sha512-LYUwzHagRYRd93XocOqi+HCHdzPYI9cW7Yf7pYqinxgG+Qka1OiqBKWTCcLiEuiqXaOV30kr8c6aZ/c1QcDP4Q==",
+ "version": "0.19.0",
+ "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.19.0.tgz",
+ "integrity": "sha512-mjuP5o2nS/CgSljajh08KjN6jYMfNBBttSPHc1YUN74f7OkmhUhv8QE8JFDXomxCfdnLNJcqBQtjRLrs8nkfVQ==",
"license": "MIT",
"dependencies": {
- "discord-api-types": "^0.38.47",
- "snowtransfer": "^0.17.5"
+ "discord-api-types": "^0.38.49",
+ "snowtransfer": "^0.19.0"
},
"engines": {
"node": ">=22.0.0"
@@ -1457,6 +1493,15 @@
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"license": "MIT"
},
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/destr": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
@@ -1473,9 +1518,9 @@
}
},
"node_modules/discord-api-types": {
- "version": "0.38.47",
- "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.47.tgz",
- "integrity": "sha512-XgXQodHQBAE6kfD7kMvVo30863iHX1LHSqNq6MGUTDwIFCCvHva13+rwxyxVXDqudyApMNAd32PGjgVETi5rjA==",
+ "version": "0.38.53",
+ "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.53.tgz",
+ "integrity": "sha512-HL1zz/UuZ+bbJjA/X8Kbxx9gk8v9rJAbTeWRNYKmIdjwJ7EovjlHgoJTxcLpATfNJ+AonOtMdy3Y5MVIJAAd/A==",
"license": "MIT",
"workspaces": [
"scripts/actions/documentation"
@@ -2433,9 +2478,9 @@
"license": "MIT"
},
"node_modules/semver": {
- "version": "7.7.4",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
- "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -2445,47 +2490,52 @@
}
},
"node_modules/sharp": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
- "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
- "hasInstallScript": true,
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
+ "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"license": "Apache-2.0",
"dependencies": {
- "@img/colour": "^1.0.0",
+ "@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
- "semver": "^7.7.3"
+ "semver": "^7.8.5"
},
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-darwin-arm64": "0.34.5",
- "@img/sharp-darwin-x64": "0.34.5",
- "@img/sharp-libvips-darwin-arm64": "1.2.4",
- "@img/sharp-libvips-darwin-x64": "1.2.4",
- "@img/sharp-libvips-linux-arm": "1.2.4",
- "@img/sharp-libvips-linux-arm64": "1.2.4",
- "@img/sharp-libvips-linux-ppc64": "1.2.4",
- "@img/sharp-libvips-linux-riscv64": "1.2.4",
- "@img/sharp-libvips-linux-s390x": "1.2.4",
- "@img/sharp-libvips-linux-x64": "1.2.4",
- "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
- "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
- "@img/sharp-linux-arm": "0.34.5",
- "@img/sharp-linux-arm64": "0.34.5",
- "@img/sharp-linux-ppc64": "0.34.5",
- "@img/sharp-linux-riscv64": "0.34.5",
- "@img/sharp-linux-s390x": "0.34.5",
- "@img/sharp-linux-x64": "0.34.5",
- "@img/sharp-linuxmusl-arm64": "0.34.5",
- "@img/sharp-linuxmusl-x64": "0.34.5",
- "@img/sharp-wasm32": "0.34.5",
- "@img/sharp-win32-arm64": "0.34.5",
- "@img/sharp-win32-ia32": "0.34.5",
- "@img/sharp-win32-x64": "0.34.5"
+ "@img/sharp-darwin-arm64": "0.35.3",
+ "@img/sharp-darwin-x64": "0.35.3",
+ "@img/sharp-freebsd-wasm32": "0.35.3",
+ "@img/sharp-libvips-darwin-arm64": "1.3.2",
+ "@img/sharp-libvips-darwin-x64": "1.3.2",
+ "@img/sharp-libvips-linux-arm": "1.3.2",
+ "@img/sharp-libvips-linux-arm64": "1.3.2",
+ "@img/sharp-libvips-linux-ppc64": "1.3.2",
+ "@img/sharp-libvips-linux-riscv64": "1.3.2",
+ "@img/sharp-libvips-linux-s390x": "1.3.2",
+ "@img/sharp-libvips-linux-x64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2",
+ "@img/sharp-linux-arm": "0.35.3",
+ "@img/sharp-linux-arm64": "0.35.3",
+ "@img/sharp-linux-ppc64": "0.35.3",
+ "@img/sharp-linux-riscv64": "0.35.3",
+ "@img/sharp-linux-s390x": "0.35.3",
+ "@img/sharp-linux-x64": "0.35.3",
+ "@img/sharp-linuxmusl-arm64": "0.35.3",
+ "@img/sharp-linuxmusl-x64": "0.35.3",
+ "@img/sharp-webcontainers-wasm32": "0.35.3",
+ "@img/sharp-win32-arm64": "0.35.3",
+ "@img/sharp-win32-ia32": "0.35.3",
+ "@img/sharp-win32-x64": "0.35.3"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
}
},
"node_modules/shebang-command": {
@@ -2579,12 +2629,12 @@
}
},
"node_modules/snowtransfer": {
- "version": "0.17.7",
- "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.17.7.tgz",
- "integrity": "sha512-scbOjYezo1Ycfk21atCEkeXIISTT7R7JTHCdiZ/7m7k4XbSb6o5q8Mu2fev5IqFpNyqIVjA0d/MZQ+eP/gtwfg==",
+ "version": "0.19.0",
+ "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.19.0.tgz",
+ "integrity": "sha512-Vebj4FCdpUsEKAzqC1p50ol5o09CLnrffvBCpTv0FNFefvXPXwogxR8kr4zJApIRt7oWQVZZVkxAq4XnGlICjQ==",
"license": "MIT",
"dependencies": {
- "discord-api-types": "^0.38.47"
+ "discord-api-types": "^0.38.49"
},
"engines": {
"node": ">=22.0.0"
@@ -2809,9 +2859,9 @@
}
},
"node_modules/try-to-catch": {
- "version": "4.0.5",
- "resolved": "https://registry.npmjs.org/try-to-catch/-/try-to-catch-4.0.5.tgz",
- "integrity": "sha512-VKBslDQsy4pGj2TMNgDdskWb7AWSi/9dPEmcNv3sdL0+aRMQTPJo6aEqlcuN0vbOwFfsE1oAXmx3bFdf6vrJFg==",
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/try-to-catch/-/try-to-catch-4.1.0.tgz",
+ "integrity": "sha512-phAezhZqLgHHTpbZYe2GgmKe8itTgEX3gfd+YeIsmllgOOpXCDPn1DkDzVWj0mGQPriJgD6AagoUEcX+M2RlGw==",
"license": "MIT",
"engines": {
"node": ">=22"
diff --git a/package.json b/package.json
index 9dfd2a8..06b2a49 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "out-of-your-element",
- "version": "3.5.1",
+ "version": "3.6.0",
"description": "A bridge between Matrix and Discord",
"main": "index.js",
"repository": {
@@ -27,14 +27,15 @@
"@cloudrac3r/pngjs": "^7.0.3",
"@cloudrac3r/pug": "^4.0.4",
"@cloudrac3r/stream-type": "^1.0.0",
- "@cloudrac3r/turndown": "^7.1.4",
+ "@cloudrac3r/turndown": "^7.1.5",
"@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.17.1",
- "discord-api-types": "^0.38.38",
+ "cloudstorm": "^0.19.0",
+ "denque": "^2.1.0",
+ "discord-api-types": "^0.38.52",
"domino": "^2.1.6",
"enquirer": "^2.4.1",
"entities": "^5.0.0",
@@ -45,8 +46,8 @@
"lru-cache": "^11.0.2",
"mime-types": "^2.1.35",
"prettier-bytes": "^1.0.4",
- "sharp": "^0.34.5",
- "snowtransfer": "^0.17.5",
+ "sharp": "^0.35.3",
+ "snowtransfer": "^0.19.0",
"try-to-catch": "^4.0.5",
"uqr": "^0.1.2",
"xxhash-wasm": "^1.0.2",
diff --git a/scripts/plu-ral-api.js b/scripts/plu-ral-api.js
new file mode 100644
index 0000000..d3468a3
--- /dev/null
+++ b/scripts/plu-ral-api.js
@@ -0,0 +1,17 @@
+// @ts-check
+
+const {reg, writeRegistration} = require("../src/matrix/read-registration")
+const {prompt} = require("enquirer")
+
+;(async () => {
+ /** @type {{api_key: string}} */
+ const apiKeyResponse = await prompt({
+ type: "text",
+ name: "api_key",
+ message: "Paste your personal /plu/ral API key"
+ })
+
+ reg.ooye.plu_ral_api_key = apiKeyResponse.api_key
+ writeRegistration(reg)
+ console.log("Saved. This change should be applied instantly.")
+})()
diff --git a/scripts/setup.js b/scripts/setup.js
index 69b62a2..191ca3e 100644
--- a/scripts/setup.js
+++ b/scripts/setup.js
@@ -359,12 +359,12 @@ function defineEchoHandler() {
console.log("โ
Emojis are ready...")
// set profile data on discord...
- await discord.snow.user.updateSelf({avatar: "data:image/png;base64," + avatarBuffer.toString("base64")})
+ await discord.snow.user.editSelf({avatar: "data:image/png;base64," + avatarBuffer.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.profileSetDisplayname(mxid, "Out Of Your Element (bot)")
await api.profileSetAvatarUrl(mxid, avatarUrl)
console.log("โ
Matrix profile updated...")
diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js
index 7f110ad..a6230b2 100644
--- a/src/d2m/actions/create-room.js
+++ b/src/d2m/actions/create-room.js
@@ -163,6 +163,7 @@ async function channelToKState(channel, guild, di) {
events_default: everyoneCanSend ? 0 : READ_ONLY_ROOM_EVENTS_DEFAULT_POWER,
events: {
"m.reaction": 0,
+ "org.matrix.msc3381.poll.response": 0,
"m.room.redaction": 0, // only affects redactions of own events, required to be able to un-react
...pollStartPowerLevel
},
diff --git a/src/d2m/actions/create-room.test.js b/src/d2m/actions/create-room.test.js
index c9e098b..fec5c55 100644
--- a/src/d2m/actions/create-room.test.js
+++ b/src/d2m/actions/create-room.test.js
@@ -147,6 +147,7 @@ test("channel2room: read-only discord channel", async t => {
events_default: 50, // <-- it should be read-only!
events: {
"m.reaction": 0,
+ "org.matrix.msc3381.poll.response": 0,
"m.room.redaction": 0
},
notifications: {
diff --git a/src/d2m/actions/create-space.js b/src/d2m/actions/create-space.js
index 7a751e2..9f5d7e3 100644
--- a/src/d2m/actions/create-space.js
+++ b/src/d2m/actions/create-space.js
@@ -8,6 +8,8 @@ const {reg} = require("../../matrix/read-registration")
const passthrough = require("../../passthrough")
const {discord, sync, db, select} = passthrough
+/** @type {import("../../matrix/mreq")} */
+const mreq = sync.require("../../matrix/mreq")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/file")} */
@@ -237,8 +239,12 @@ async function syncSpaceExpressions(data, checkBeforeSync) {
try {
existing = await api.getStateEvent(spaceID, "im.ponies.room_emotes", eventKey)
} catch (e) {
- // State event not found. This space doesn't have any existing emojis. We create a dummy empty event for comparison's sake.
- existing = fn([], guild)
+ if (e instanceof mreq.MatrixServerError && e.httpStatus < 500) {
+ // State event not found. This space doesn't have any existing emojis. We create a dummy empty event for comparison's sake.
+ existing = fn([], guild)
+ } else {
+ throw e
+ }
}
if (isDeepStrictEqual(existing, content)) return
}
diff --git a/src/d2m/actions/edit-message.js b/src/d2m/actions/edit-message.js
index f86a9c8..31f3627 100644
--- a/src/d2m/actions/edit-message.js
+++ b/src/d2m/actions/edit-message.js
@@ -1,24 +1,24 @@
// @ts-check
-const assert = require("assert").strict
-
const passthrough = require("../../passthrough")
const {sync, db, select, from} = passthrough
+const {reg} = require("../../matrix/read-registration")
/** @type {import("../converters/edit-to-changes")} */
const editToChanges = sync.require("../converters/edit-to-changes")
-/** @type {import("./register-pk-user")} */
-const registerPkUser = sync.require("./register-pk-user")
+/** @type {import("./speedbump")} */
+const speedbump = sync.require("./speedbump")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/mreq")} */
const mreq = sync.require("../../matrix/mreq")
+/** @type {import("../../discord/utils")} */
+const dUtils = sync.require("../../discord/utils")
/**
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
* @param {import("discord-api-types/v10").APIGuild} guild
- * @param {{speedbump_id: string, speedbump_webhook_id: string} | null} row data about the webhook which is proxying messages in this channel
*/
-async function editMessage(message, guild, row) {
+async function editMessage(message, guild) {
const historicalRoomOfMessage = from("message_room").join("historical_channel_room", "historical_room_index").where({message_id: message.id}).select("room_id").get()
const currentRoom = from("channel_room").join("historical_channel_room", "room_id").where({channel_id: message.channel_id}).select("room_id", "historical_room_index").get()
if (!currentRoom) return
@@ -27,11 +27,9 @@ async function editMessage(message, guild, row) {
let {roomID, eventsToRedact, eventsToReplace, eventsToSend, senderMxid, promotions} = await editToChanges.editToChanges(message, guild, api)
- if (row && row.speedbump_webhook_id === message.webhook_id) {
- // Handle the PluralKit public instance
- if (row.speedbump_id === "466378653216014359") {
- senderMxid = await registerPkUser.syncUser(message.id, message.author, roomID, true)
- }
+ // Sync proxy user profile (if sent by proxy)
+ if (dUtils.isWebhookMessage(message)) {
+ senderMxid = await speedbump.getWebhookSenderId(message, guild.id, roomID)
}
// 1. Replace all the things.
diff --git a/src/d2m/actions/register-pk-user.js b/src/d2m/actions/register-pk-user.js
index 6ecd077..e68d2cc 100644
--- a/src/d2m/actions/register-pk-user.js
+++ b/src/d2m/actions/register-pk-user.js
@@ -1,6 +1,6 @@
// @ts-check
-const assert = require("assert")
+const assert = require("assert").strict
const {reg} = require("../../matrix/read-registration")
const Ty = require("../../types")
@@ -103,6 +103,7 @@ async function memberToStateContent(pkMessage, author) {
// We prefer to use the member's avatar URL data since the image upload can be cached across channels,
// unlike the userAvatar URL which is unique per channel, due to the webhook ID being in the URL.
const avatar = pkMessage.member.avatar_url || pkMessage.member.webhook_avatar_url || pkMessage.system.avatar_url || file.userAvatar(author)
+ const color = pkMessage.member.color ?? pkMessage.system.color
const content = {
displayname: author.username,
@@ -110,6 +111,12 @@ async function memberToStateContent(pkMessage, author) {
"moe.cadence.ooye.pk_member": pkMessage.member
}
if (avatar) content.avatar_url = await file.uploadDiscordFileToMxc(avatar)
+ if (color) {
+ content["eu.she-a.color"] = {
+ on_dark: `#${color}`,
+ on_light: `#${color}`
+ }
+ }
return content
}
@@ -132,10 +139,10 @@ 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)
+ db.prepare("REPLACE INTO sim_proxy (user_id, proxy_owner_id, displayname, proxy_app) VALUES (?, ?, ?, 0)").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()
+ const senderMxid = from("sim_proxy").join("sim", "user_id").join("sim_member", "mxid").where({displayname: author.username, room_id: roomID, proxy_app: 0}).pluck("mxid").get()
if (!senderMxid) throw e
return senderMxid
}
diff --git a/src/d2m/actions/register-plu-ral-user.js b/src/d2m/actions/register-plu-ral-user.js
new file mode 100644
index 0000000..04fa28c
--- /dev/null
+++ b/src/d2m/actions/register-plu-ral-user.js
@@ -0,0 +1,170 @@
+// @ts-check
+
+const assert = require("assert").strict
+const {reg} = require("../../matrix/read-registration")
+const Ty = require("../../types")
+
+const passthrough = require("../../passthrough")
+const {sync, db, select, from} = passthrough
+/** @type {import("../../matrix/api")} */
+const api = sync.require("../../matrix/api")
+/** @type {import("../../matrix/file")} */
+const file = sync.require("../../matrix/file")
+/** @type {import("./register-user")} */
+const registerUser = sync.require("./register-user")
+
+/** @returns {Promise
${html}` diff --git a/src/d2m/converters/message-to-event.test.components.js b/src/d2m/converters/message-to-event.test.components.js index 137b63b..1ef83c3 100644 --- a/src/d2m/converters/message-to-event.test.components.js +++ b/src/d2m/converters/message-to-event.test.components.js @@ -1,6 +1,7 @@ const {test} = require("supertape") const {messageToEvent} = require("./message-to-event") const data = require("../../../test/data") +const {mockGetEffectivePower} = require("../../matrix/utils.test") test("message2event components: pk question mark output", async t => { const events = await messageToEvent(data.message_with_components.pk_question_mark_response, data.guild.general, {}) @@ -77,3 +78,24 @@ test("message2event components: pk question mark output", async t => { msgtype: "m.text", }]) }) + +test("message2event components: pk ping message legacy components", async t => { + const events = await messageToEvent(data.message_with_components.pk_ping_components_v1, data.guild.general, {}, { + api: { + async getJoinedMembers() { + return {joined: {}} + }, + getEffectivePower: mockGetEffectivePower() + } + }) + t.deepEqual(events, [{ + $type: "m.room.message", + msgtype: "m.text", + body: "โญ cadence used `/๐ Ping author`" + + "\nPsst, **Red** (@cadence.worm:), you have been pinged by @cadence.worm:." + + "\n[Jump https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$l9FMmsEbh9K0NUReeEpWOMZYGRlUOE8yLcm6P-TYHSM?via=cadence.moe] ", + format: "org.matrix.custom.html", + formatted_body: "
โญ cadence used /๐ Ping authorPsst, Red (@cadence.worm), you have been pinged by @cadence.worm.`, + "m.mentions": {} + }]) +}) + test("message2event embeds: 4 images", async t => { const events = await messageToEvent(data.message_with_embeds.four_images, data.guild.general) t.deepEqual(events, [{ @@ -254,9 +282,7 @@ test("message2event embeds: vx image", async t => { }, { $type: "m.room.message", msgtype: "m.notice", - body: "| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix" - + "\n| " - + "\n| ## Twitter https://twitter.com/tomorrowcorp/status/1760330671074287875" + body: "| ## Twitter https://twitter.com/tomorrowcorp/status/1760330671074287875" + "\n| " + "\n| ## Tomorrow Corporation (@TomorrowCorp) https://vxtwitter.com/TomorrowCorp/status/1760330671074287875" + "\n| " @@ -266,15 +292,16 @@ test("message2event embeds: vx image", async t => { + "\n| " + "\n| ๐ 123 ๐ 36" + "\n| " - + "\n| ๐ธ https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg", + + "\n| ๐ธ https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg" + + "\n| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix", format: "org.matrix.custom.html", - formatted_body: `This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO` + + `
1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:` + + `
hthrflwrs on cohost โ 29/2/2024, 2:17 PM
- Both players draw eight cards` + + `
- Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand` + + `
- Both players present their best five-or-less-card pok...
`, "m.mentions": {} }]) }) @@ -291,16 +318,16 @@ test("message2event embeds: vx video", async t => { }, { $type: "m.room.message", msgtype: "m.notice", - body: "| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix" - + "\n| \n| ## McDonaldโs๐ค@studiopierrot" + body: "| ## McDonaldโs๐ค@studiopierrot" + "\n| \n| ๐ 89 ๐ 21 https://twitter.com/McDonalds/status/1759971752254341417" + "\n| \n| ## McDonald's (@McDonalds) https://vxtwitter.com/McDonalds/status/1759971752254341417" - + "\n| \n| ๐๏ธ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12", + + "\n| \n| ๐๏ธ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12" + + "\n| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix", format: "org.matrix.custom.html", - formatted_body: `vxTwitter / fixvx` - + `
Twitter` + formatted_body: `
`, + + `Twitter` + `
Tomorrow Corporation (@TomorrowCorp)` + `
Mark your calendar with a wet black stain! World of Goo 2 releases on May 23, 2024 on Nintendo Switch, Epic Games Store (Win/Mac), and http://WorldOfGoo2.com (Win/Mac/Linux).` + `
https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms` + `
๐ 123 ๐ 36` - + `๐ธ https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg
๐ธ https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg
` + + `vxTwitter / fixvx
`, "m.mentions": {} }]) }) @@ -317,8 +344,7 @@ test("message2event embeds: youtube video", async t => { }, { $type: "m.room.message", msgtype: "m.notice", - body: "| via YouTube https://www.youtube.com" - + "\n| \n| ## Happy O Funny https://www.youtube.com/channel/UCEpQ9aEb1NafpvWp5Aoizrg" + body: "| ## Happy O Funny https://www.youtube.com/channel/UCEpQ9aEb1NafpvWp5Aoizrg" + "\n| \n| ## Shoebill stork clattering sounds like machine guun~!! (Japan Matsue... https://www.youtube.com/watch?v=kDMHHw8JqLE" + "\n| \n| twitter" + "\n| https://twitter.com/matsuevogelpark" @@ -327,8 +353,7 @@ test("message2event embeds: youtube video", async t => { + "\n| \n| #shoebill #livingdinosaur #happyofunny #weirdcreature #weirdsoun..." + "\n| \n| ๐๏ธ https://www.youtube.com/embed/kDMHHw8JqLE", format: "org.matrix.custom.html", - formatted_body: `vxTwitter / fixvx` - + `
McDonaldโs๐ค@studiopierrot\n\n๐ 89 ๐ 21` + formatted_body: `
`, + + `McDonaldโs๐ค@studiopierrot\n\n๐ 89 ๐ 21` + `
McDonald's (@McDonalds)` - + `
๐๏ธ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12
๐๏ธ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12
` + + `vxTwitter / fixvx
` - + `") // The matrix spec hasn't decided whether \n counts as a newline or not, but I'm going to count it, because if it's in the data it's there for a reason. // But I should not count it if it's between block elements. @@ -890,6 +900,12 @@ async function eventToMessage(event, guild, channel, di) { assert(root) async function forEachNode(event, node) { for (; node; node = node.nextSibling) { + // Don't process mx-reply further + if (node.nodeType === 1 && node.tagName === "MX-REPLY") { + node.remove() + continue + } + // Check written mentions if (node.nodeType === 3 && node.nodeValue.includes("@") && !nodeIsChildOf(node, ["A", "CODE", "PRE"])) { const result = await checkWrittenMentions(node.nodeValue, event.sender, event.room_id, guild, di) diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index 650e442..5dd0af7 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -855,6 +855,40 @@ test("event2message: html lines are bridged correctly", async t => { ) }) +test("event2message: breaks between consecutive blockquotes are preserved", async t => { + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "this was inspired by someone telling me\n\n>betrothed is an insane vocab pull\n\n> i think thats part of why im so scared of cadence\n\n> if an insult comes my way, not only do i not know when, but i'll have to humiliate myself and look it up", + format: "org.matrix.custom.html", + formatted_body: "Happy O Funny` + formatted_body: `
") + input = input.replace(/(?:\n|Happy O Funny` + `
Shoebill stork clattering sounds like machine guun~!! (Japan Matsue...` + `
https://twitter.com/matsuevogelpark
The shoebill (Balaeniceps rex) also known as whalehead, whale-headed stork, or shoe-billed stork, is a very large stork-like bird. It derives its name from its enormous shoe-shaped bill
some people also called them the living dinosaur~~
#shoebill #livingdinosaur #happyofunny #weirdcreature #weirdsoun...` + `๐๏ธ https://www.youtube.com/embed/kDMHHw8JqLE` diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index be1d99f..0742c7b 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -1612,7 +1612,7 @@ test("message2event: expired/invalid invites are sent as-is", async t => { snow: { invite: { async getInvite() { - throw new Error(`{"message": "Unknown Invite", "code": 10006}`) + throw new Error(`Unknown Invite`) } } } diff --git a/src/d2m/discord-client.js b/src/d2m/discord-client.js index c2a0549..b9b45e2 100644 --- a/src/d2m/discord-client.js +++ b/src/d2m/discord-client.js @@ -14,6 +14,8 @@ const {sync} = passthrough /** @type {import("./discord-packets")} */ const discordPackets = sync.require("./discord-packets") +const CONNECTION_DEBUG = false + class DiscordClient { /** * @param {string} discordToken @@ -24,7 +26,7 @@ class DiscordClient { const intents = [ "DIRECT_MESSAGES", "DIRECT_MESSAGE_REACTIONS", "DIRECT_MESSAGE_TYPING", "GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS", "GUILD_MESSAGE_POLLS", - "MESSAGE_CONTENT" + "MESSAGE_CONTENT", "GUILD_MEMBERS" ] if (reg.ooye.receive_presences !== false) intents.push("GUILD_PRESENCES") this.discordToken = discordToken @@ -59,15 +61,17 @@ class DiscordClient { }) } - const addEventLogger = (eventName, logName) => { - this.cloud.on(eventName, (...args) => { - const d = new Date().toISOString().slice(0, 19) - console.error(`[${d} Client ${logName}]`, ...args) - }) + if (CONNECTION_DEBUG) { + const addEventLogger = (eventName, logName) => { + this.cloud.on(eventName, (...args) => { + const d = new Date().toISOString().slice(0, 19) + console.error(`[${d} Client ${logName}]`, ...args) + }) + } + addEventLogger("error", "Error") + addEventLogger("disconnected", "Disconnected") + addEventLogger("ready", "Ready") } - addEventLogger("error", "Error") - addEventLogger("disconnected", "Disconnected") - addEventLogger("ready", "Ready") } } diff --git a/src/d2m/discord-packets.js b/src/d2m/discord-packets.js index afea9ea..988f981 100644 --- a/src/d2m/discord-packets.js +++ b/src/d2m/discord-packets.js @@ -1,173 +1,227 @@ // @ts-check -// Discord library internals type beat - -const DiscordTypes = require("discord-api-types/v10") +const assert = require("assert") +const {scheduler} = require("timers/promises") const passthrough = require("../passthrough") -const {sync, db} = passthrough +const {sync} = passthrough -const utils = { - /** - * @param {import("./discord-client")} client - * @param {import("cloudstorm").IGatewayMessage} message - * @param {string} listen "full", "half", "no" - whether to set up the event listeners for OOYE to operate - */ - async onPacket(client, message, listen) { - // 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") +/** @type {import("../matrix/homeserver-status")} */ +const homeserverStatus = sync.require("../matrix/homeserver-status") - // Client internals, keep track of the state we need - if (message.t === "READY") { - if (client.ready) return - client.ready = true - client.user = message.d.user - client.application = message.d.application - console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`) - interactions.registerInteractions() +let checkedHomeserver = false - } 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) - for (const channel of message.d.channels || []) { - // @ts-ignore - channel.guild_id = message.d.id - arr.push(channel.id) - client.channels.set(channel.id, channel) - } - 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) - } +/** + * @param {import("./discord-client")} client + * @param {import("cloudstorm").IGatewayMessage} message + * @param {string} listen "full", "half", "no" - whether to set up the event listeners for OOYE to operate + */ +async function onPacket(client, message, listen) { + // 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") - if (listen === "full") { - try { - await eventDispatcher.checkMissedExpressions(message.d) - await eventDispatcher.checkMissedMessages(client, message.d) - await eventDispatcher.checkMissedPins(client, message.d) - await eventDispatcher.checkMissedLeaves(client, message.d) - } catch (e) { - console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:") - console.error(e) + // Client internals, keep track of the state we need + if (message.t === "READY") { + if (client.ready) return + client.ready = true + client.user = message.d.user + client.application = message.d.application + console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`) + interactions.registerInteractions() + + } 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) + for (const channel of message.d.channels || []) { + // @ts-ignore + channel.guild_id = message.d.id + arr.push(channel.id) + client.channels.set(channel.id, channel) + } + 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) + } + + if (listen === "full") { + try { + /* + Info about guilds is populated one guild at a time. + For m->d bridging to work, the guild needs to be populated, so we need to have GUILD_CREATE for the guild. + If we ping the homeserver, it will send us any pending events, so we need to wait for all GUILD_CREATES before we ping. + We must attempt a ping because we don't want to try sending missed d->m messages to an offline homeserver. + This delay can be removed if ONE of the following is done: + 1. m->d can queue incoming events until their guild exists in memory + 2. d->m missed messages can have their errors handled and added to queue, rather than pinging first + */ + let isMainCharacter = false + if (!checkedHomeserver) { + checkedHomeserver = true + isMainCharacter = true + console.log("Warming up guilds~") } - } - - } else if (message.t === "GUILD_UPDATE") { - const guild = client.guilds.get(message.d.id) - if (guild) { - for (const prop of Object.keys(message.d)) { - if (!["channels", "threads"].includes(prop)) { - guild[prop] = message.d[prop] - } - } - } - - } else if (message.t === "GUILD_EMOJIS_UPDATE") { - const guild = client.guilds.get(message.d.guild_id) - if (guild) { - guild.emojis = message.d.emojis - } - - } else if (message.t === "GUILD_STICKERS_UPDATE") { - const guild = client.guilds.get(message.d.guild_id) - if (guild) { - guild.stickers = message.d.stickers - } - - } else if (message.t === "GUILD_ROLE_CREATE" || message.t === "GUILD_ROLE_UPDATE" || message.t === "GUILD_ROLE_DELETE") { - const guild = client.guilds.get(message.d.guild_id) - /** Delete this in case of UPDATE or DELETE */ - const targetID = "role_id" in message.d ? message.d.role_id : message.d.role.id - /** Add this in case of CREATE or UPDATE */ - const newRoles = [] - if ("role" in message.d) newRoles.push(message.d.role) - if (guild) { - const targetIndex = guild.roles.findIndex(r => r.id === targetID) - if (targetIndex !== -1) { - // Role already exists. Delete it and maybe replace it. - guild.roles.splice(targetIndex, 1, ...newRoles) + await scheduler.wait(5000) + if (isMainCharacter) { + checkedHomeserver = true + process.stdout.write("Connecting to homeserver... ") + await homeserverStatus.homeserverStatus.waitForOnline(true) + console.log("ok.\nReplaying past events. Welcome to Out Of Your Element.") } else { - // Role doesn't already exist. - guild.roles.push(...newRoles) + await homeserverStatus.homeserverStatus.waitForOnline(false) } + + await eventDispatcher.checkMissedExpressions(message.d) + await eventDispatcher.checkMissedMessages(client, message.d) + await eventDispatcher.checkMissedPins(client, message.d) + await eventDispatcher.checkMissedLeaves(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_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"]) { - 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) - - - } else if (message.t === "CHANNEL_PINS_UPDATE") { - const channel = client.channels.get(message.d.channel_id) - if (channel) { - channel["last_pin_timestamp"] = message.d.last_pin_timestamp - } - - - } else if (message.t === "GUILD_DELETE") { - client.guilds.delete(message.d.id) - const channels = client.guildChannelMap.get(message.d.id) - if (channels) { - for (const id of channels) client.channels.delete(id) - } - client.guildChannelMap.delete(message.d.id) - - - } 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 - 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) + } else if (message.t === "GUILD_UPDATE") { + const guild = client.guilds.get(message.d.id) + if (guild) { + for (const prop of Object.keys(message.d)) { + if (!["channels", "threads"].includes(prop)) { + guild[prop] = message.d[prop] } } } - // Event dispatcher for OOYE bridge operations - if (listen === "full" && message.t) { - try { - 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 === "GUILD_EMOJIS_UPDATE") { + const guild = client.guilds.get(message.d.guild_id) + if (guild) { + guild.emojis = message.d.emojis + } - } else if (message.t === "INTERACTION_CREATE") { - await interactions.dispatchInteraction(message.d) + } else if (message.t === "GUILD_STICKERS_UPDATE") { + const guild = client.guilds.get(message.d.guild_id) + if (guild) { + guild.stickers = message.d.stickers + } - } else if (message.t in eventDispatcher) { - await eventDispatcher[message.t](client, message.d) - } - } catch (e) { - // Let OOYE try to handle errors too - await eventDispatcher.onError(client, e, message) + } else if (message.t === "GUILD_ROLE_CREATE" || message.t === "GUILD_ROLE_UPDATE" || message.t === "GUILD_ROLE_DELETE") { + const guild = client.guilds.get(message.d.guild_id) + /** Delete this in case of UPDATE or DELETE */ + const targetID = "role_id" in message.d ? message.d.role_id : message.d.role.id + /** Add this in case of CREATE or UPDATE */ + const newRoles = [] + if ("role" in message.d) newRoles.push(message.d.role) + if (guild) { + const targetIndex = guild.roles.findIndex(r => r.id === targetID) + if (targetIndex !== -1) { + // Role already exists. Delete it and maybe replace it. + guild.roles.splice(targetIndex, 1, ...newRoles) + } else { + // Role doesn't already exist. + guild.roles.push(...newRoles) } } + + } 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"]) { + 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) + + + } else if (message.t === "CHANNEL_PINS_UPDATE") { + const channel = client.channels.get(message.d.channel_id) + if (channel) { + channel["last_pin_timestamp"] = message.d.last_pin_timestamp + } + + + } else if (message.t === "GUILD_DELETE") { + client.guilds.delete(message.d.id) + const channels = client.guildChannelMap.get(message.d.id) + if (channels) { + for (const id of channels) client.channels.delete(id) + } + client.guildChannelMap.delete(message.d.id) + + + } 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 + 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" && message.t) { + const alwaysRealTimeEvents = ["PRESENCE_UPDATE"] + const optionalEvents = ["TYPING_START"] + if (alwaysRealTimeEvents.includes(message.t) || homeserverStatus.homeserverStatus.isRealTime()) { + // real time + dispatchPacketToBridge(client, message) + } else if (optionalEvents.includes(message.t)) { + // when not real time, skip optional events + } else { + // queue required events + homeserverStatus.homeserverStatus.queuePacket(message) + } } } -module.exports = utils +/** + * @param {import("./discord-client")} client + * @param {import("cloudstorm").IGatewayMessage} message + */ +async function dispatchPacketToBridge(client, message) { + // 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") + + assert(message.t) // checked above + + try { + 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 + await eventDispatcher.onError(client, e, message) + } +} + +module.exports.onPacket = onPacket +module.exports.dispatchPacketToBridge = dispatchPacketToBridge diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index 8101a03..02cb20f 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -60,7 +60,7 @@ module.exports = { matrixEventDispatcher.printError(gatewayMessage.t, "Discord", e, gatewayMessage) - const channelID = gatewayMessage.d["channel_id"] + const channelID = gatewayMessage.d?.["channel_id"] if (!channelID) return const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get() if (!roomID) return @@ -107,7 +107,7 @@ module.exports = { try { messages = await client.snow.channel.getChannelMessages(channel.id, {limit: 100}) } catch (e) { - if (e.message === `{"message": "Missing Access", "code": 50001}`) { // pathetic error handling from SnowTransfer + if (e.message === "Missing Access") { console.log(`[check missed messages] no permissions to look back in channel ${channel.name} (${channel.id})`) continue // Sucks. } else { @@ -288,7 +288,6 @@ module.exports = { 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.unbridgeChannel(channel, guildID) }, @@ -298,6 +297,7 @@ module.exports = { */ async MESSAGE_CREATE(client, message) { if (message.author.username === "Deleted User") return // Nothing we can do for deleted users. + if (select("opt_out", "user_id", {user_id: message.author.id}).pluck().get()) return // This user opted out. 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. @@ -313,11 +313,10 @@ module.exports = { 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 + const {skip} = await speedbump.maybeDoSpeedbump(true, message) + if (skip) return - // @ts-ignore - await sendMessage.sendMessage(message, channel, guild, row) + await sendMessage.sendMessage(message, channel, guild) retrigger.finishedBridging(message.id) }, @@ -335,24 +334,27 @@ module.exports = { if (dUtils.isEphemeralMessage(data)) return // Ephemeral messages are for the eyes of the receiver only! // Edits need to go through the speedbump as well. If the message is delayed but the edit isn't, we don't have anything to edit from. - const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id) - if (affected) return - - if (!row) { - // Check that the sending-to room exists, and deal with Eventual Consistency(TM) - if (!await retrigger.waitForMessage(data.id)) return - } + const {skip, hasCreate} = await speedbump.maybeDoSpeedbump(false, data) + if (skip) 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 retrigger.pauseChanges(message.id, editMessage.editMessage(message, guild, row)) + if (!hasCreate) { + // Standard path for most message updates + // Check that the target message already exists, and deal with Eventual Consistency(TM) + if (!await retrigger.waitForMessage(data.id)) return + await retrigger.pauseChanges(message.id, editMessage.editMessage(message, guild)) + } + else { + // Path for edit packets that were speedbumped into the latest copy of a message that needs to be created + // Just pretend to be MESSAGE_CREATE + await sendMessage.sendMessage(message, channel, guild) + } }, /** @@ -360,6 +362,7 @@ module.exports = { * @param {DiscordTypes.GatewayMessageReactionAddDispatchData} data */ async MESSAGE_REACTION_ADD(client, data) { + if (select("opt_out", "user_id", {user_id: data.user_id}).pluck().get()) return // This user opted out. if (data.user_id === client.user.id) return // m2d reactions are added by the discord bot user - do not reflect them back to matrix. if (data.emoji.name === "โ" && select("event_message", "message_id", {message_id: data.message_id, source: 0, part: 0}).get()) { // source 0 = matrix const guild_id = data.guild_id ?? client.channels.get(data.channel_id)?.["guild_id"] @@ -414,6 +417,7 @@ module.exports = { * @param {DiscordTypes.GatewayTypingStartDispatchData} data */ async TYPING_START(client, data) { + if (data.user_id === client.user.id) return 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() diff --git a/src/db/migrations/0037-remove-leaked-webhooks.js b/src/db/migrations/0037-remove-leaked-webhooks.js index 0228053..b839bd7 100644 --- a/src/db/migrations/0037-remove-leaked-webhooks.js +++ b/src/db/migrations/0037-remove-leaked-webhooks.js @@ -27,7 +27,7 @@ module.exports = async function(db) { const webhook = affectedWebhooks[counter-1] await discord.snow.webhook.deleteWebhookToken(webhook.webhook_id, webhook.webhook_token, "Webhook token possibly compromised during 8th May 2026 outage").catch(e => { - if (e.message === `{"message": "Unknown Webhook", "code": 10015}`) { + if (e.message === "Unknown Webhook") { // OK } else { throw e diff --git a/src/db/migrations/0039-refactor-speedbump.sql b/src/db/migrations/0039-refactor-speedbump.sql new file mode 100644 index 0000000..de739dc --- /dev/null +++ b/src/db/migrations/0039-refactor-speedbump.sql @@ -0,0 +1,19 @@ +BEGIN TRANSACTION; + +CREATE TABLE "channel_speedbump" ( + "channel_id" TEXT NOT NULL, + "speedbump_webhook_id" TEXT NOT NULL, + "speedbump_user_id" TEXT NOT NULL, + PRIMARY KEY("channel_id","speedbump_webhook_id"), + FOREIGN KEY("channel_id") REFERENCES "channel_room"("channel_id") +) WITHOUT ROWID; + +INSERT INTO channel_speedbump (channel_id, speedbump_webhook_id, speedbump_user_id) + SELECT channel_id, speedbump_webhook_id, speedbump_id FROM channel_room WHERE speedbump_id IS NOT NULL AND speedbump_webhook_id IS NOT NULL; + +ALTER TABLE channel_room DROP COLUMN speedbump_id; +ALTER TABLE channel_room DROP COLUMN speedbump_webhook_id; + +ALTER TABLE sim_proxy ADD COLUMN proxy_app INTEGER DEFAULT 0; + +COMMIT; diff --git a/src/db/migrations/0040-opt-out.sql b/src/db/migrations/0040-opt-out.sql new file mode 100644 index 0000000..094dff9 --- /dev/null +++ b/src/db/migrations/0040-opt-out.sql @@ -0,0 +1,9 @@ +BEGIN TRANSACTION; + +CREATE TABLE "opt_out" ( + "user_id" TEXT NOT NULL, + "opted_out_at" INTEGER NOT NULL, + PRIMARY KEY("user_id") +) WITHOUT ROWID; + +COMMIT; diff --git a/src/db/orm-defs.d.ts b/src/db/orm-defs.d.ts index d95bfc3..ff63d32 100644 --- a/src/db/orm-defs.d.ts +++ b/src/db/orm-defs.d.ts @@ -18,13 +18,17 @@ export type Models = { thread_parent: string | null custom_avatar: string | null last_bridged_pin_timestamp: number | null - speedbump_id: string | null - speedbump_webhook_id: string | null speedbump_checked: number | null guild_id: string | null custom_topic: number } + channel_speedbump: { + channel_id: string + speedbump_webhook_id: string + speedbump_user_id: string + } + direct: { mxid: string room_id: string @@ -44,6 +48,7 @@ export type Models = { event_subtype: string | null part: number reaction_part: number + /** 0 = Matrix, 1 = Discord */ source: number } @@ -110,6 +115,11 @@ export type Models = { historical_room_index: number } + opt_out: { + user_id: string + opted_out_at: number + } + role_default: { guild_id: string role_id: string @@ -137,6 +147,8 @@ export type Models = { user_id: string proxy_owner_id: string displayname: string + /** 0 = PluralKit, 1 = /plu/ral */ + proxy_app: number } webhook: { diff --git a/src/discord/interactions/poll.js b/src/discord/interactions/poll.js index 6d7a015..7190d63 100644 --- a/src/discord/interactions/poll.js +++ b/src/discord/interactions/poll.js @@ -55,10 +55,10 @@ async function* _interact({data, message, member, user}, {api}) { title: "Poll", components: [{ type: DiscordTypes.ComponentType.TextDisplay, - content: `-# ${pollComponents.getMultiSelectString(pollRow.max_selections, options.length)}` + content: `## ${pollRow.question_text}` }, { type: DiscordTypes.ComponentType.Label, - label: pollRow.question_text, + label: pollComponents.getMultiSelectString(pollRow.max_selections, options.length), component: { type: DiscordTypes.ComponentType.RadioGroup, custom_id: "POLL_MODAL_SELECTION", diff --git a/src/discord/interactions/reactions.js b/src/discord/interactions/reactions.js index bd2f856..36853cc 100644 --- a/src/discord/interactions/reactions.js +++ b/src/discord/interactions/reactions.js @@ -16,9 +16,9 @@ const utils = sync.require("../../matrix/utils") * @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters
[2]}>} */ async function* _interact({data}, {api}) { - const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") - .select("event_id", "room_id").where({message_id: data.target_id}).get() - if (!row) { + const events = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") + .select("event_id", "room_id").where({message_id: data.target_id}).and("ORDER BY reaction_part").all() + if (!events.length) { return yield {createInteractionResponse: { type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, data: { @@ -35,17 +35,23 @@ async function* _interact({data}, {api}) { } }} - const reactions = await api.getFullRelations(row.room_id, row.event_id, "m.annotation") + const reactions = [] + for (const event of events) { + const reactionsOnEvent = await api.getFullRelations(event.room_id, event.event_id, "m.annotation") + reactions.push(...reactionsOnEvent) + } - /** @type {Map } */ + /** @type {Map >} reaction key (the emoji) -> displaynames */ 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, []) + const displayname = select("member_cache", "displayname", {mxid: reaction.sender, room_id: events[0].room_id}).pluck().get() || reaction.sender + if (!inverted.has(key)) { + inverted.set(key, new Set()) + } // @ts-ignore - inverted.get(key).push(displayname) + inverted.get(key).add(displayname) } if (inverted.size === 0) { @@ -55,7 +61,7 @@ async function* _interact({data}, {api}) { } return yield {editOriginalInteractionResponse: { - content: [...inverted.entries()].map(([key, value]) => `${key} โฎ ${value.join(" โฌฉ ")}`).join("\n"), + content: [...inverted.entries()].sort((a, b) => b[1].size - a[1].size).map(([key, value]) => `${key} โฎ ${[...value].join(" โฌฉ ")}`).join("\n"), }} } diff --git a/src/discord/interactions/reactions.test.js b/src/discord/interactions/reactions.test.js index 50ddeca..8d258f4 100644 --- a/src/discord/interactions/reactions.test.js +++ b/src/discord/interactions/reactions.test.js @@ -43,7 +43,7 @@ test("reactions: different response if nobody reacted", async t => { }) test("reactions: shows reactions if there are some, ignoring discord users", async t => { - let called = 1 + let called = 0 const msgs = await fromAsync(_interact({ data: { target_id: "1126786462646550579" @@ -51,6 +51,7 @@ test("reactions: shows reactions if there are some, ignoring discord users", asy }, { api: { async getFullRelations(roomID, eventID) { + called++ t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") t.equal(eventID, "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg") return [{ @@ -97,3 +98,57 @@ test("reactions: shows reactions if there are some, ignoring discord users", asy ) t.equal(called, 1) }) + +test("reactions: combines reactions from multiple events", async t => { + let called = 0 + const msgs = await fromAsync(_interact({ + data: { + target_id: "1141501302736695316" + } + }, { + api: { + async getFullRelations(roomID, eventID) { + called++ + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") + if (eventID === "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA") { + 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" + } + } + }] + } else if (eventID === "$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCI") { + return [{ + sender: "@cadence:cadence.moe", + content: { + "m.relates_to": { + key: "๐โโฌ", + rel_type: "m.annotation" + } + } + }] + } else { + t.fail(`tried to read reactions on wrong event ${eventID}`) + } + } + } + })) + t.equal(msgs.length, 2) + t.equal( + msgs[1].editOriginalInteractionResponse.content, + "๐ โฎ cadence [they] โฌฉ @rnl:cadence.moe" + + "\n๐โโฌ โฎ cadence [they]" + ) + t.equal(called, 2) +}) diff --git a/src/discord/register-interactions.js b/src/discord/register-interactions.js index e3d58c4..dc97287 100644 --- a/src/discord/register-interactions.js +++ b/src/discord/register-interactions.js @@ -16,7 +16,7 @@ const ping = sync.require("./interactions/ping.js") // User must have EVERY permission in default_member_permissions to be able to use the command function registerInteractions() { - discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{ + discord.snow.interaction.editApplicationCommands(id, [{ name: "Matrix info", contexts: [DiscordTypes.InteractionContextType.Guild], type: DiscordTypes.ApplicationCommandType.Message, @@ -91,40 +91,32 @@ function registerInteractions() { async function dispatchInteraction(interaction) { const interactionId = interaction.data?.["custom_id"] || interaction.data?.["name"] try { - if (interaction.type === DiscordTypes.InteractionType.MessageComponent || interaction.type === DiscordTypes.InteractionType.ModalSubmit) { - // All we get is custom_id, don't know which context the button was clicked in. - // So we namespace these ourselves in the custom_id. Currently the only existing namespace is POLL_. - if (interaction.data.custom_id.startsWith("POLL_")) { - await poll.interact(interaction) + 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 === "Responses") { + /** @type {DiscordTypes.APIMessageApplicationCommandGuildInteraction} */ // @ts-ignore + const messageInteraction = interaction + if (select("poll", "message_id", {message_id: messageInteraction.data.target_id}).get()) { + await pollResponses.interact(messageInteraction) } else { - throw new Error(`Unknown message component ${interaction.data.custom_id}`) + await reactions.interact(messageInteraction) } + } else if (interactionId === "ping") { + await ping.interact(interaction) + } else if (interactionId === "privacy") { + await privacy.interact(interaction) + } else if (interactionId.startsWith("POLL_")) { + await poll.interact(interaction) } else { - 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 === "Responses") { - /** @type {DiscordTypes.APIMessageApplicationCommandGuildInteraction} */ // @ts-ignore - const messageInteraction = interaction - if (select("poll", "message_id", {message_id: messageInteraction.data.target_id}).get()) { - await pollResponses.interact(messageInteraction) - } else { - await reactions.interact(messageInteraction) - } - } else if (interactionId === "ping") { - await ping.interact(interaction) - } else if (interactionId === "privacy") { - await privacy.interact(interaction) - } else { - throw new Error(`Unknown interaction ${interactionId}`) - } + throw new Error(`Unknown interaction ${interactionId}`) } } catch (e) { let stackLines = null diff --git a/src/discord/utils.js b/src/discord/utils.js index 0d400f1..6fb0b43 100644 --- a/src/discord/utils.js +++ b/src/discord/utils.js @@ -182,6 +182,18 @@ function filterTo(xs, fn) { return filtered } +/** + * The parameters correspond to the columns of the channel_room table. + * @param {string} rowChannelID thread ID, OR channel ID if there is no thread + * @param {string | null | undefined} rowThreadParent channel ID if there is a thread + */ +function swapThreadID(rowChannelID, rowThreadParent) { + return { + channelID: rowThreadParent ? rowThreadParent : rowChannelID, + threadID: rowThreadParent ? rowChannelID : undefined + } +} + const supportedPlaintextPreviewExtensions = new Set([ "4d", "abnf", @@ -582,4 +594,5 @@ module.exports.timestampToSnowflakeInexact = timestampToSnowflakeInexact module.exports.getPublicUrlForCdn = getPublicUrlForCdn module.exports.howOldUnbridgedMessage = howOldUnbridgedMessage module.exports.filterTo = filterTo +module.exports.swapThreadID = swapThreadID module.exports.supportedPlaintextPreviewExtensions = supportedPlaintextPreviewExtensions diff --git a/src/m2d/actions/channel-webhook.js b/src/m2d/actions/channel-webhook.js index 09c642b..01b3dd4 100644 --- a/src/m2d/actions/channel-webhook.js +++ b/src/m2d/actions/channel-webhook.js @@ -45,7 +45,7 @@ async function ensureWebhook(channelID, forceCreate = false) { async function withWebhook(channelID, callback) { const webhook = await ensureWebhook(channelID, false) return callback(webhook).catch(async e => { - if (e.message === `{"message": "Unknown Webhook", "code": 10015}`) { // pathetic error handling from SnowTransfer + if (e.message === "Unknown Webhook") { // Our webhook is gone. Maybe somebody deleted it, or removed and re-added OOYE from the guild. const newWebhook = await ensureWebhook(channelID, true) return callback(newWebhook) // not caught; if the error happens again just throw it instead of looping diff --git a/src/m2d/actions/emoji-sheet.js b/src/m2d/actions/emoji-sheet.js index ed5ab88..077240c 100644 --- a/src/m2d/actions/emoji-sheet.js +++ b/src/m2d/actions/emoji-sheet.js @@ -23,7 +23,7 @@ async function getAndConvertEmoji(mxc) { const res = await api.getMedia(mxc, {signal: abortController.signal}) if (res.status !== 200) { const root = await res.json() - throw new mreq.MatrixServerError(root, {mxc}) + throw new mreq.MatrixServerError(root, res.status, {mxc}) } const readable = stream.Readable.fromWeb(res.body) return emojiSheetConverter.convertImageStream(readable, () => { diff --git a/src/m2d/actions/sticker.js b/src/m2d/actions/sticker.js index 8eeb5d2..f5a1e16 100644 --- a/src/m2d/actions/sticker.js +++ b/src/m2d/actions/sticker.js @@ -22,7 +22,7 @@ async function getAndResizeSticker(mxc) { const res = await api.getMedia(mxc) if (res.status !== 200) { const root = await res.json() - throw new mreq.MatrixServerError(root, {mxc}) + throw new mreq.MatrixServerError(root, res.status, {mxc}) } const streamIn = Readable.fromWeb(res.body) diff --git a/src/m2d/actions/typing.js b/src/m2d/actions/typing.js new file mode 100644 index 0000000..c56ce4a --- /dev/null +++ b/src/m2d/actions/typing.js @@ -0,0 +1,39 @@ +// @ts-check + +const {discord} = require("../../passthrough") + +/** @type {Set } channel ids that are currently typing */ +const isTyping = new Set() +/** @type {Map } channel id -> interval */ +const intervals = new Map() + +/** @param {string} channelID */ +function startTyping(channelID) { + isTyping.add(channelID) + // Start a new typing session if needed + if (!intervals.has(channelID)) { + intervals.set(channelID, setInterval(refreshTyping, 8e3, channelID)) + discord.snow.channel.startChannelTyping(channelID).catch(() => {}) + } +} + +/** @param {string} channelID */ +function refreshTyping(channelID) { + if (isTyping.has(channelID)) { + // Continue typing session + discord.snow.channel.startChannelTyping(channelID).catch(() => {}) + } else { + // End typing session + clearInterval(intervals.get(channelID)) + intervals.delete(channelID) + } +} + +/** @param {string} channelID */ +function stopTyping(channelID) { + // Schedule typing session to end + isTyping.delete(channelID) +} + +module.exports.startTyping = startTyping +module.exports.stopTyping = stopTyping diff --git a/src/m2d/actions/update-pins.js b/src/m2d/actions/update-pins.js index 1ff2bb9..1b3f1bd 100644 --- a/src/m2d/actions/update-pins.js +++ b/src/m2d/actions/update-pins.js @@ -16,9 +16,9 @@ async function updatePins(pins, prev) { .select("reference_channel_id", "message_id").where({event_id}).and("ORDER BY part ASC").get() if (!row) continue if (added) { - discord.snow.channel.addChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message pinned on Matrix") + discord.snow.channel.createChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message pinned on Matrix") } else { - discord.snow.channel.removeChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message unpinned on Matrix") + discord.snow.channel.deleteChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message unpinned on Matrix") } } } diff --git a/src/m2d/actions/vote.js b/src/m2d/actions/vote.js index 926b957..84f8cc7 100644 --- a/src/m2d/actions/vote.js +++ b/src/m2d/actions/vote.js @@ -1,18 +1,12 @@ // @ts-check const Ty = require("../../types") -const DiscordTypes = require("discord-api-types/v10") -const {Readable} = require("stream") const assert = require("assert").strict -const crypto = require("crypto") const passthrough = require("../../passthrough") -const {sync, discord, db, select} = passthrough +const {sync, db, select} = passthrough -const {reg} = require("../../matrix/read-registration") -/** @type {import("../../matrix/api")} */ -const api = sync.require("../../matrix/api") -/** @type {import("../../matrix/utils")} */ -const utils = sync.require("../../matrix/utils") +/** @type {import("../../discord/utils")} */ +const dUtils = sync.require("../../discord/utils") /** @type {import("../converters/poll-components")} */ const pollComponents = sync.require("../converters/poll-components") /** @type {import("./channel-webhook")} */ @@ -33,10 +27,11 @@ async function updateVote(event) { // If poll was started on Matrix, the Discord version is using components, so we can update that to the current status if (messageRow.source === 0) { - const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() - assert(channelID) - await webhook.editMessageWithWebhook(channelID, messageID, pollComponents.getPollComponentsFromDatabase(messageID)) + const row = select("channel_room", ["channel_id", "thread_parent"], {room_id: event.room_id}).get() + assert(row) + const {channelID, threadID} = dUtils.swapThreadID(row.channel_id, row.thread_parent) + await webhook.editMessageWithWebhook(channelID, messageID, pollComponents.getPollComponentsFromDatabase(messageID), threadID) } } -module.exports.updateVote = updateVote \ No newline at end of file +module.exports.updateVote = updateVote diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 0a18a14..c4f72a0 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -10,6 +10,7 @@ const domino = require("domino") const assert = require("assert").strict const entities = require("entities") const pb = require("prettier-bytes") +const mimeTypes = require("mime-types") const {tag} = require("@cloudrac3r/html-template-tag") const passthrough = require("../../passthrough") @@ -97,15 +98,6 @@ turndownService.addRule("underline", { } }) -turndownService.addRule("blockquote", { - filter: "blockquote", - replacement: function (content) { - content = content.replace(/^\n+|\n+$/g, "") - content = content.replace(/^/gm, "> ") - return content - } -}) - turndownService.addRule("spoiler", { filter: function (node, options) { return node.tagName === "SPAN" && node.hasAttribute("data-mx-spoiler") @@ -392,7 +384,7 @@ function linkEndOfMessageSpriteSheet(content) { */ async function handleRoomOrMessageLinks(input, di) { let offset = 0 - for (const match of [...input.matchAll(/("?https:\/\/matrix.to\/#\/((?:#|%23|!)[^"/, ?)]+)(?:\/(\$[^"/ ?)]+))?(?:\?[^",:!? )]*?)?)(">|[,<\n )]|$)/g)]) { + for (const match of [...input.matchAll(/("?https:\/\/matrix.to\/#\/((?:#|%23|!|%21)[^"/, ?)]+)(?:\/((?:\$|%24)[^"/ ?)]+))?(?:\?[^",:!? )]*?)?)(">|[,<\n )]|$)/g)]) { assert(typeof match.index === "number") let [_, attributeValue, roomID, eventID, endMarker] = match let result @@ -622,13 +614,20 @@ async function eventToMessage(event, guild, channel, di) { captionContent.addLine(`(Spoiler: ${fileSpoilerReason})`) } + // If filename is not specified, create one from mimetype + let filename = event.content.filename || event.content.body + if (!filename) { + const extension = mimeTypes.extension(event.content.info?.mimetype) + if (!extension) throw new Error("Filename and mimetype missing. Please report this bug to your client.") + filename = `file.${extension}` + } + // File link as alternative to uploading if (!("file" in event.content) && event.content.info?.size > getFileSizeForGuild(guild)) { // Upload (unencrypted) file as link, because it's too large for Discord // Do this by constructing a sample Matrix message with the link and then use the text processor to convert that + the original caption. const url = mxUtils.getPublicUrlForMxc(event.content.url) assert(url) - const filename = event.content.filename || event.content.body const emoji = attachmentEmojis.has(event.content.msgtype) ? attachmentEmojis.get(event.content.msgtype) + " " : "" if (fileIsSpoiler) { captionContent.addLine(`${emoji}Uploaded SPOILER file: <${url}> (${pb(event.content.info.size)})`, tag`${emoji}Uploaded SPOILER file: ${filename} (${pb(event.content.info.size)})`) // the space is necessary to work around a bug in Discord's URL previewer. the preview still gets blurred in the client. @@ -637,7 +636,6 @@ async function eventToMessage(event, guild, channel, di) { } } else { // Upload file as file - let filename = event.content.filename || event.content.body if (fileIsSpoiler) filename = "SPOILER_" + filename if ("file" in event.content) { // Encrypted @@ -775,14 +773,14 @@ async function eventToMessage(event, guild, channel, di) { repliedToEvent.content = repliedToEvent.content["m.new_content"] } /** @type {string} */ - let repliedToContent = repliedToEvent.content.formatted_body || repliedToEvent.content.body + let originalRepliedToContent = 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") { + } else if (typeof originalRepliedToContent !== "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 @@ -790,6 +788,7 @@ async function eventToMessage(event, guild, channel, di) { contentPreview = " (channel details edited)" } else { // Generate a reply preview for a standard message + let repliedToContent = originalRepliedToContent 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|but doesn't render them. I can't figure out how this even works in the browser, so let's just delete those. - input = input.replace(/(?:\n|
)+/g, " ") // Should all be on one line @@ -804,7 +803,18 @@ async function eventToMessage(event, guild, channel, di) { const contentPreviewChunks = chunk(repliedToContent, 50) if (contentPreviewChunks.length) { contentPreview = ": " + contentPreviewChunks[0] - contentPreview = contentPreview.replace(/\bhttps?:\/\/[^ )]*/g, "<$&>") + contentPreview = contentPreview.replace(/\bhttps?:\/\/[^ )]*/g, url => { + const originalUrlIndex = originalRepliedToContent.indexOf(url) + if (originalUrlIndex !== -1 && originalRepliedToContent[originalUrlIndex + url.length]?.match(/[^ )>"'`]/)) { // URL was truncated by chunking, replace it + try { + const u = new URL(url) + return `` + } catch (e) { + return ` ` + } + } + return `<${url}>` // Full URL is present, escape it + }) if (contentPreviewChunks.length > 1) contentPreview = contentPreview.replace(/[,.']$/, "") + "..." } else { contentPreview = "" @@ -855,7 +865,7 @@ async function eventToMessage(event, guild, channel, di) { input = input.replace(/("https:\/\/matrix.to.*?<\/a>):?/g, "$1") // Element adds a bunch of
before
\s*)*<\/blockquote>/g, "
\s*)*<\/blockquote>(?:\n|
\s*)*/g, "
this was inspired by someone telling me
\n\n\nbetrothed is an insane vocab pull
\n
\n\ni think thats part of why im so scared of cadence
\n
\n" + }, + origin_server_ts: 1783571303430, + event_id: "$cdltlkLTUp-ma2uYmLKy7o3RkDgpDbtdYfngST_igWE", + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + allowed_mentions: { + parse: ["users", "roles"] + }, + username: "cadence [they]", + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", + content: "this was inspired by someone telling me" + + "\n\n> betrothed is an insane vocab pull" + + "\n\n> i think thats part of why im so scared of cadence" + + "\n\n> if an insult comes my way, not only do i not know when, but i'll have to humiliate myself and look it up" + }] + } + ) +}) + /*test("event2message: whitespace is retained", async t => { t.deepEqual( await eventToMessage({ @@ -1294,7 +1328,7 @@ test("event2message: characters are encoded properly in code blocks", async t => ) }) -test("event2message: quotes have an appropriate amount of whitespace", async t => { +test("event2message: quotes have an appropriate amount of whitespace (br following)", async t => { t.deepEqual( await eventToMessage({ content: { @@ -1328,6 +1362,40 @@ test("event2message: quotes have an appropriate amount of whitespace", async t = ) }) +test("event2message: quotes have an appropriate amount of whitespace (p following)", async t => { + t.deepEqual( + await eventToMessage({ + content: { + msgtype: "m.text", + body: "wrong body", + format: "org.matrix.custom.html", + formatted_body: "if an insult comes my way, not only do i not know when, but i'll have to humiliate myself and look it up
\n
\n\nshort story
\n
by whose standards? LOL
\n\nI basically went all the way back to childhood (which analysts always like you to do)
\n
laughed out loud
" + }, + 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: "> short story\nby whose standards? LOL\n\n> I basically went all the way back to childhood (which analysts always like you to do)\nlaughed out loud", + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) +}) + test("event2message: lists have appropriate line breaks", async t => { t.deepEqual( await eventToMessage({ @@ -2049,6 +2117,57 @@ test("event2message: should suppress embeds for links in reply preview", async t ) }) +test("event2message: truncated links in reply preview should be unlinked to prevent broken links", async t => { + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: `> <@_ooye_.wing.:cadence.moe> https://huggingface.co/blog/security-incident-july-2026\n\nthis is either written by claude or somebody who's too claude-brained and I just can't get through it`, + format: "org.matrix.custom.html", + formatted_body: `In reply to @_ooye_.wing.:cadence.moe
https://huggingface.co/blog/security-incident-july-2026