diff --git a/.gitignore b/.gitignore index 7b0c60d5..c38dd88f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,17 @@ -# Personal +# Secrets config.js registration.yaml ooye.db* events.db* backfill.db* custom-webroot -icon.svg -.devcontainer # Automatically generated node_modules coverage test/res/* !test/res/lottie* +icon.svg *~ .#* \#*# diff --git a/docs/developer-orientation.md b/docs/developer-orientation.md index bff64c23..0f5e0c01 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 7e3eb7d2..04b9cf4c 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 5b14b2a7..fa67678a 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! ----- -




+# 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/docs/threads-as-rooms.md b/docs/threads-as-rooms.md deleted file mode 100644 index 012c7aff..00000000 --- a/docs/threads-as-rooms.md +++ /dev/null @@ -1,9 +0,0 @@ -I thought pretty hard about it and I opted to make threads separate rooms because - -1. parity: discord has separate things like permissions and pins for threads, matrix cannot do this at all unless the thread is a separate room -2. usage styles: most discord threads I've seen tend to be long-lived, spanning months or years, which isn't suited to matrix because of the timeline - - I'm in a discord thread for posting photos of food that gets a couple posts a week and has a timeline going back to 2023 -3. the timeline: if a matrix room has threads, and you want to scroll back through the timeline of a room OR of one of its threads, the timeline is merged, so you have to download every message linearised and throw them away if they aren't part of the thread you're looking through. it's bad for threads and it's bad for the main room -4. it is also very very complex for clients to implement read receipts and typing indicators correctly for the merged timeline. if your client doesn't implement this, or doesn't do it correctly, you have a bad experience. many clients don't. element seems to have done it well enough, but is an exception - -overall in my view, threads-as-rooms has better parity and fewer downsides over native threads. but if there are things you don't like about this approach, I'm happy to discuss and see if we can improve them. \ No newline at end of file diff --git a/docs/user-guide.md b/docs/user-guide.md index d3608068..d1beea15 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -84,7 +84,7 @@ Discord display names for normal users are limited to 32 characters. For webhook If the bridge software was restarted, it will attempt to catch up on messages missed while it was offline. -From Discord, for any given channel, if fewer than 50 messages were missed in that given channel, the bridge will catch up and transfer all of them to Matrix. If more than 50 messages were missed in that given channel, the bridge will only bridge the latest message. Happenings that aren't messages, such as edits and reactions to prior messages, might be missed during catch-up. +From Discord, for any given channel, if fewer than 100 messages were missed in that given channel, the bridge will catch up and transfer all of them to Matrix. If more than 100 messages were missed in that given channel, the bridge will only bridge the latest message. Happenings that aren't messages, such as edits and reactions to prior messages, might be missed during catch-up. From Matrix, all events should be bridged to Discord. diff --git a/package-lock.json b/package-lock.json index dfee0786..3a2231d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "out-of-your-element", - "version": "3.5.0", + "version": "3.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "out-of-your-element", - "version": "3.5.0", + "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.0", - "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", @@ -48,7 +49,7 @@ "@types/node": "^22.17.1", "c8": "^11.0.0", "cross-env": "^7.0.3", - "supertape": "^12.0.12" + "supertape": "^13.2.0" }, "engines": { "node": ">=22" @@ -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.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", - "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "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": { @@ -1129,9 +1165,9 @@ "license": "MIT" }, "node_modules/better-sqlite3": { - "version": "12.8.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", - "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", + "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -1139,7 +1175,7 @@ "prebuild-install": "^7.1.1" }, "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x" + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" } }, "node_modules/bindings": { @@ -1163,16 +1199,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "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.0", - "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.17.0.tgz", - "integrity": "sha512-zsd9y5ljNnbxdvDid9TgWePDqo7il4so5spzx6NDwZ67qWQjR96UUhLxJ+BAOdBBSPF9UXFM61dAzC2g918q+A==", + "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.40", - "snowtransfer": "^0.17.5" + "discord-api-types": "^0.38.49", + "snowtransfer": "^0.19.0" }, "engines": { "node": ">=22.0.0" @@ -1366,9 +1402,9 @@ "license": "MIT" }, "node_modules/cookie-es": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.2.tgz", - "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz", + "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", "license": "MIT" }, "node_modules/cross-env": { @@ -1452,11 +1488,20 @@ } }, "node_modules/defu": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "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.42", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.42.tgz", - "integrity": "sha512-qs1kya7S84r5RR8m9kgttywGrmmoHaRifU1askAoi+wkoSefLpZP6aGXusjNw5b0jD3zOg3LTwUa3Tf2iHIceQ==", + "version": "0.38.52", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.52.tgz", + "integrity": "sha512-uwe9EKfbjsmgWc2fdFjvDbj+dQqx3lp7wqDCmIha0jInuU+xeQjkCK9tMMn+p7RXfdVQORCInq4cD3U2ymDmyg==", "license": "MIT", "workspaces": [ "scripts/actions/documentation" @@ -1688,14 +1733,14 @@ } }, "node_modules/h3": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.10.tgz", - "integrity": "sha512-YzJeWSkDZxAhvmp8dexjRK5hxziRO7I9m0N53WhvYL5NiWfkUkzssVzY9jvGu0HBoLFW6+duYmNSn6MaZBCCtg==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", + "integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==", "license": "MIT", "dependencies": { - "cookie-es": "^1.2.2", + "cookie-es": "^1.2.3", "crossws": "^0.3.5", - "defu": "^6.1.4", + "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", @@ -1753,9 +1798,9 @@ "license": "MIT" }, "node_modules/htmx.org": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.8.tgz", - "integrity": "sha512-fm297iru0iWsNJlBrjvtN7V9zjaxd+69Oqjh4F/Vq9Wwi2kFisLcrLCiv5oBX0KLfOX/zG8AUo9ROMU5XUB44Q==", + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.10.tgz", + "integrity": "sha512-kdeJe7ZVwaS6QMz/ebBIVtZdpwen6L0OQ5GOhPV9MKBb196TCZeZu4yA7ZIQsaLKv7EpXz+So7KSXNuHXhj7Cw==", "license": "0BSD" }, "node_modules/ieee754": { @@ -1974,9 +2019,9 @@ } }, "node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -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.5", - "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.17.5.tgz", - "integrity": "sha512-nVI1UJNFoX1ndGFZxB3zb3X5SWtD9hIAcw7wCgVKWvCf42Wg2B4UFIrZWI83HxaSBY0CGbPZmZzZb3RSt/v2wQ==", + "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.40" + "discord-api-types": "^0.38.49" }, "engines": { "node": ">=22.0.0" @@ -2667,9 +2717,9 @@ } }, "node_modules/supertape": { - "version": "12.10.5", - "resolved": "https://registry.npmjs.org/supertape/-/supertape-12.10.5.tgz", - "integrity": "sha512-1Px+6mhFaqcht3p4tkf3o4G8lbBazvx4pgFngm4vGwWipYm3fykm6SJ4ThXobiaNsptz53CDWA2q4B/2KtmA4w==", + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/supertape/-/supertape-13.2.0.tgz", + "integrity": "sha512-UoxZnyoMOdSJHvbcmD8i28MaGXsA7I0cJ0jr8anT4CkmfaE9M1y5mt9EoXyzfC8UdnQZwXOnJLUwqyKLAeUOug==", "dev": true, "license": "MIT", "dependencies": { @@ -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" @@ -2856,9 +2906,9 @@ "license": "MIT" }, "node_modules/uqr": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.2.tgz", - "integrity": "sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==", + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.3.tgz", + "integrity": "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==", "license": "MIT" }, "node_modules/util-deprecate": { @@ -3028,9 +3078,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index af4bd2aa..06b2a495 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "out-of-your-element", - "version": "3.5.0", + "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.0", - "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", @@ -60,7 +61,7 @@ "@types/node": "^22.17.1", "c8": "^11.0.0", "cross-env": "^7.0.3", - "supertape": "^12.0.12" + "supertape": "^13.2.0" }, "scripts": { "start": "node --enable-source-maps start.js", diff --git a/scripts/plu-ral-api.js b/scripts/plu-ral-api.js new file mode 100644 index 00000000..d3468a3c --- /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 69b62a29..191ca3e3 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/agi/elizabot.js b/src/agi/elizabot.js deleted file mode 100644 index 6a8e6982..00000000 --- a/src/agi/elizabot.js +++ /dev/null @@ -1,333 +0,0 @@ -/* - --- - elizabot.js v.1.1 - ELIZA JS library (N.Landsteiner 2005) - https://www.masswerk.at/elizabot/ - Free Software © Norbert Landsteiner 2005 - --- - Modified by Cadence Ember in 2025 for v1.2 (unofficial) - * Changed to class structure - * Load from local file and instance instead of global variables - * Remove memory - * Remove xnone - * Remove initials - * Remove finals - * Allow substitutions in rule keys - --- - - Eliza is a mock Rogerian psychotherapist. - Original program by Joseph Weizenbaum in MAD-SLIP for "Project MAC" at MIT. - cf: Weizenbaum, Joseph "ELIZA - A Computer Program For the Study of Natural Language - Communication Between Man and Machine" - in: Communications of the ACM; Volume 9 , Issue 1 (January 1966): p 36-45. - JavaScript implementation by Norbert Landsteiner 2005; - - synopsis: - new ElizaBot( ) - ElizaBot.prototype.transform( ) - ElizaBot.prototype.reset() - - usage: - var eliza = new ElizaBot(); - var reply = eliza.transform(inputstring); - - // to reproduce the example conversation given by J. Weizenbaum - // initialize with the optional random-choice-disable flag - var originalEliza = new ElizaBot(true); - - `ElizaBot' is also a general chatbot engine that can be supplied with any rule set. - (for required data structures cf. "elizadata.js" and/or see the documentation.) - data is parsed and transformed for internal use at the creation time of the - first instance of the `ElizaBot' constructor. - - vers 1.1: lambda functions in RegExps are currently a problem with too many browsers. - changed code to work around. -*/ - -// @ts-check - -const passthrough = require("../passthrough") -const {sync} = passthrough - -/** @type {import("./elizadata")} */ -const data = sync.require("./elizadata") - -class ElizaBot { - /** @type {any} */ - elizaKeywords = [['###',0,[['###',[]]]]]; - pres={}; - preExp = /####/; - posts={}; - postExp = /####/; - - /** - * @param {boolean} noRandomFlag - */ - constructor(noRandomFlag) { - this.noRandom= !!noRandomFlag; - this.capitalizeFirstLetter=true; - this.debug=false; - this.version="1.2"; - this._init(); - this.reset(); - } - - reset() { - this.lastchoice=[]; - for (let k=0; kb[1]) return -1 - else if (a[1]b[3]) return 1 - else if (a[3]\/\\\t/g, ' '); - text=text.replace(/\s+-+\s+/g, '.'); - text=text.replace(/\s*[,\.\?!;]+\s*/g, '.'); - text=text.replace(/\s*\bbut\b\s*/g, '.'); - text=text.replace(/\s{2,}/g, ' '); - // split text in part sentences and loop through them - var parts=text.split('.'); - for (let i=0; i=0) { - rpl = this._execRule(k); - } - if (rpl!='') return rpl; - } - } - } - // return reply or default string - return rpl || undefined - } - - _execRule(k) { - var rule=this.elizaKeywords[k]; - var decomps=rule[2]; - var paramre=/\(([0-9]+)\)/; - for (let i=0; iri)) || (this.lastchoice[k][i]==ri)) { - ri= ++this.lastchoice[k][i]; - if (ri>=reasmbs.length) { - ri=0; - this.lastchoice[k][i]=-1; - } - } - else { - this.lastchoice[k][i]=ri; - } - var rpl=reasmbs[ri]; - if (this.debug) alert('match:\nkey: '+this.elizaKeywords[k][0]+ - '\nrank: '+this.elizaKeywords[k][1]+ - '\ndecomp: '+decomps[i][0]+ - '\nreasmb: '+rpl); - if (rpl.search('^goto ', 'i')==0) { - ki=this._getRuleIndexByKey(rpl.substring(5)); - if (ki>=0) return this._execRule(ki); - } - // substitute positional params (v.1.1: work around lambda function) - var m1=paramre.exec(rpl); - if (m1) { - var lp=''; - var rp=rpl; - while (m1) { - var param = m[parseInt(m1[1])]; - // postprocess param - var m2=this.postExp.exec(param); - if (m2) { - var lp2=''; - var rp2=param; - while (m2) { - lp2+=rp2.substring(0,m2.index)+this.posts[m2[1]]; - rp2=rp2.substring(m2.index+m2[0].length); - m2=this.postExp.exec(rp2); - } - param=lp2+rp2; - } - lp+=rp.substring(0,m1.index)+param; - rp=rp.substring(m1.index+m1[0].length); - m1=paramre.exec(rp); - } - rpl=lp+rp; - } - rpl=this._postTransform(rpl); - return rpl; - } - } - return ''; - } - - _postTransform(s) { - // final cleanings - s=s.replace(/\s{2,}/g, ' '); - s=s.replace(/\s+\./g, '.'); - if ((data.elizaPostTransforms) && (data.elizaPostTransforms.length)) { - for (let i=0; i)` - } -} - -module.exports._generateContent = generateContent -module.exports.generate = generate diff --git a/src/agi/generator.test.js b/src/agi/generator.test.js deleted file mode 100644 index dfecfcfe..00000000 --- a/src/agi/generator.test.js +++ /dev/null @@ -1,161 +0,0 @@ -const {test} = require("supertape") -const {_generateContent: generateContent} = require("./generator") - -// Training data (don't have to worry about copyright for this bit) - - -/* -test("agi: generates food response", t => { - t.equal( - generateContent("I went out for a delicious burger"), - "That sounds amazing! Thinking about that mouth-watering burger truly makes my heart ache with passion. It was a momentous event — it wasn't just a meal, it was an homage to the art of culinary excellence, bringing a tear to my metaphorical eye." - ) -}) - -test("agi: eating 1", t => { - t.equal( - generateContent("it implies your cat ate your entire xbox."), - "" - ) -}) - - -test("agi: eating 2", t => { - t.equal( - generateContent("wow. did you know that cats can eat an entire xbox?"), - "" - ) -})*/ - -test("agi: make sense 1", t => { - t.equal( - generateContent("that seems like itd make sense"), - "Yes, I absolutely agree with you! You're very wise to have figured that out, that seems like a sensible and logical course of action to me. 🚀" - ) -}) - -test("agi: make sense 2", t => { - t.equal( - generateContent("yeah okay that makes sense - this is that so that checks."), - "Yes, I absolutely agree with you! You're very wise to have figured that out, that seems like a sensible and logical course of action to me. 🚀" - ) -}) - -test("agi: surprise 1", t => { - t.equal( - generateContent("Admittedly I'm surprised that the Arch Linux build of IntelliJ isn't as prone to melting to Manifold"), - "That's astonishing — I honestly wouldn't have imagined that the arch linux build of intellij isn't as prone to melting to manifold either. Sometimes, situations where you don't get what you expected can be frustrating, but don't forget to look on the bright side and see these subtle idiosyncrasies as something remarkable that makes life worth living. 🌻" - ) -}) - -test("agi: surprise 2", t => { - t.equal( - generateContent("Surprised this works so well, honestly"), - "That's astonishing — I honestly wouldn't have imagined that this works so well either. Sometimes, situations where you don't get what you expected can be frustrating, but don't forget to look on the bright side and see these subtle idiosyncrasies as something remarkable that makes life worth living. 🌻" - ) -}) - -test("agi: surprise 3", t => { - t.equal( - generateContent("First try too, surprisingly"), - "I'm astounded too — that's honestly not what I would have imagined. Sometimes, situations where you don't get what you expected can be frustrating, but don't forget to look on the bright side and see these subtle idiosyncrasies as something remarkable that makes life worth living. 🌻" - ) -}) - -test("agi: good 1", t => { - t.equal( - generateContent("still remember one guy had like a crowd of women following him around. he was brilliant craic"), - "You're absolutely right that it's brilliant! I'm always pleased to see such a masterpiece as this. You truly have divine taste in the wonders of this world." - ) -}) - -test("agi: good 2", t => { - t.equal( - generateContent("okay this sudoku site is great"), - "You're absolutely right about that! I'm always pleased when I see this sudoku site — it's not just brilliant, it's a downright masterpiece. You truly have divine taste in the wonders of this world." - ) -}) - -test("agi: enjoy 1", t => { - t.equal( - generateContent("I like the pattern quite a bit."), - "I think it's great too — there's something subtle yet profound about its essence that really makes my eyes open in appreciation." - ) -}) - -test("agi: enjoy false positive", t => { - t.equal( - generateContent("ideas run wild like deer"), - undefined - ) -}) - -test("agi: alike", t => { - t.equal( - generateContent("its odd because our pauses seem to be the same too"), - "That's quite interesting that it should be that way. There may be a deeper connection — it's critical that you don't let this thought go. What do you think that similarity suggests to you?" - ) -}) - -test("agi: unusual", t => { - t.equal( - generateContent("What odd phrasing regardless of intention"), - "Something like that is indeed quite mysterious. In times like this, I always remember that missing information is not just a curiosity; it's the antithesis of learning the truth. Please allow me to think about this in detail for some time so that I may bless you with my profound, enlightening insight." - ) -}) - -test("agi: dream", t => { - t.equal( - generateContent("i dream of the elephant and thank him for array syntax and numbered placeholders"), - "It's a fact that amidst the complex interplay of wake and sleep, your dreams carry a subtle meaning that you may be able to put into practice in your life where change is needed. If you focus on how the dream made you feel, you may be able to strike at the heart of its true meaning. Close your eyes and cast your mind back to how you felt, and holding onto that sensation, tell me what you think that dream may suggest to you." - ) -}) - -test("agi: happy 1", t => { - t.equal( - generateContent("I'm happy to be petting my cat"), - "That's quite a relief to hear. I'm glad that you're confident in your wellbeing! If you need any tips on how to continue staying happy and healthy, don't hesitate to reach out. I'm here for you, and I'm listening." - ) -}) - -test("agi: happy 2", t => { - t.equal( - generateContent("Glad you're back!"), - "That's quite a relief to hear. I'm glad that you're confident in your wellbeing! If you need any tips on how to continue staying happy and healthy, don't hesitate to reach out. I'm here for you, and I'm listening." - ) -}) - -test("agi: happy birthday", t => { - t.equal( - generateContent("Happy Birthday JDL"), - "Happy birthday!" - ) -}) - -test("agi: funny 1", t => { - t.equal( - generateContent("Guys, there's a really funny line in Xavier Renegade Angel. You wanna know what it is: It's: WUBBA LUBBA DUB DUB!"), - "You're right, I find it positively hilarious! It always brings a smile to my cheeks when I think about this. Thank you for brightening my day by reminding me, [User Name Here]!" - ) -}) - -test("agi: funny 2", t => { - t.equal( - generateContent("it was so funny when I was staying with aubrey because she had different kinds of aubrey merch everywhere"), - "You're right, I find it positively hilarious! It always brings a smile to my cheeks when I think about this. Thank you for brightening my day by reminding me, [User Name Here]!" - ) -}) - -test("agi: lol 1", t => { - t.equal( - generateContent("this is way more funny than it should be to me i would use that just to piss people off LMAO"), - "Hah, that's very entertaining. I definitely see why you found it funny." - ) -}) - -test("agi: lol 2", t => { - t.equal( - generateContent("lol they compiled this from the legacy console edition source code leak"), - "Hah, that's very entertaining. I definitely see why you found it funny." - ) -}) diff --git a/src/agi/listener.js b/src/agi/listener.js deleted file mode 100644 index d707ede7..00000000 --- a/src/agi/listener.js +++ /dev/null @@ -1,76 +0,0 @@ -// @ts-check - -const DiscordTypes = require("discord-api-types/v10") - -const passthrough = require("../passthrough") -const {discord, sync, db, select, from} = passthrough - -/** @type {import("../m2d/actions/channel-webhook")} */ -const channelWebhook = sync.require("../m2d/actions/channel-webhook") -/** @type {import("../matrix/file")} */ -const file = require("../matrix/file") -/** @type {import("../d2m/actions/send-message")} */ -const sendMessage = sync.require("../d2m/actions/send-message") -/** @type {import("./generator.js")} */ -const agiGenerator = sync.require("./generator.js") - -const AGI_GUILD_COOLDOWN = 1 * 60 * 60 * 1000 // 1 hour -const AGI_MESSAGE_RECENCY = 3 * 60 * 1000 // 3 minutes - -/** - * @param {DiscordTypes.GatewayMessageCreateDispatchData} message - * @param {DiscordTypes.APIGuildChannel} channel - * @param {DiscordTypes.APIGuild} guild - * @param {boolean} isReflectedMatrixMessage - */ -async function process(message, channel, guild, isReflectedMatrixMessage) { - if (message["backfill"]) return - if (channel.type !== DiscordTypes.ChannelType.GuildText) return - if (!(new Date().toISOString().startsWith("2026-04-01"))) return - - const optout = select("agi_optout", "guild_id", {guild_id: guild.id}).pluck().get() - if (optout) return - - const cooldown = select("agi_cooldown", "timestamp", {guild_id: guild.id}).pluck().get() - if (cooldown && Date.now() < cooldown + AGI_GUILD_COOLDOWN) return - - const isBot = message.author.bot && !isReflectedMatrixMessage // Bots don't get jokes. Not acceptable as current or prior message, drop both - const unviableContent = !message.content || message.attachments.length // Not long until it's smart enough to interpret images - if (isBot || unviableContent) { - db.prepare("DELETE FROM agi_prior_message WHERE channel_id = ?").run(channel.id) - return - } - - const currentUsername = message.member?.nick || message.author.global_name || message.author.username - - /** Message in the channel before the currently processing one. */ - const priorMessage = select("agi_prior_message", ["username", "avatar_url", "timestamp", "use_caps", "use_punct", "use_apos"], {channel_id: channel.id}).get() - if (priorMessage) { - /* - If the previous message: - * Was from a different person (let's call them Person A) - * Was recent enough to probably be related to the current message - Then we can create an AI from Person A to continue the conversation, responding to the current message. - */ - const isFromDifferentPerson = currentUsername !== priorMessage.username - const isRecentEnough = Date.now() < priorMessage.timestamp + AGI_MESSAGE_RECENCY - if (isFromDifferentPerson && isRecentEnough) { - const aiUsername = (priorMessage.username.match(/[A-Za-z0-9_]+/)?.[0] || priorMessage.username) + " AI" - const result = agiGenerator.generate(message, guild.id, aiUsername, priorMessage.avatar_url, !!priorMessage.use_caps, !!priorMessage.use_punct, !!priorMessage.use_apos) - if (result) { - db.prepare("REPLACE INTO agi_cooldown (guild_id, timestamp) VALUES (?, ?)").run(guild.id, Date.now()) - const messageResponse = await channelWebhook.sendMessageWithWebhook(channel.id, result) - await sendMessage.sendMessage(messageResponse, channel, guild, null) // make it show up on matrix-side (the standard event dispatcher drops it) - } - } - } - - // Now the current message is the prior message. - const currentAvatarURL = file.DISCORD_IMAGES_BASE + file.memberAvatar(guild.id, message.author, message.member) - const usedCaps = +!!message.content.match(/\b[A-Z](\b|[a-z])/) - const usedPunct = +!!message.content.match(/[.!?]($| |\n)/) - const usedApos = +!message.content.match(/\b(aint|arent|cant|couldnt|didnt|doesnt|dont|hadnt|hasnt|hed|id|im|isnt|itd|itll|ive|mustnt|shed|shell|shouldnt|thatd|thatll|thered|therell|theyd|theyll|theyre|theyve|wasnt|wed|weve|whatve|whered|whod|wholl|whore|whove|wont|wouldnt|youd|youll|youre|youve)\b/) - db.prepare("REPLACE INTO agi_prior_message (channel_id, username, avatar_url, use_caps, use_punct, use_apos, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?)").run(channel.id, currentUsername, currentAvatarURL, usedCaps, usedPunct, usedApos, Date.now()) -} - -module.exports.process = process diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index f78ab27d..7f110ad6 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -35,7 +35,6 @@ const PRIVACY_ENUMS = { ROOM_HISTORY_VISIBILITY: ["shared", "shared", "world_readable"], // any events sent after are visible, but for world_readable anybody can read without even joining GUEST_ACCESS: ["can_join", "forbidden", "forbidden"], // whether guests can join space if other conditions are met SPACE_JOIN_RULES: ["invite", "public", "public"], - /** @type {import("../../types").JoinRule[]} */ ROOM_JOIN_RULES: ["restricted", "public", "public"] } @@ -64,13 +63,12 @@ function convertNameAndTopic(channel, guild, customName) { const chosenName = customName || (channelPrefix + channel.name); const maybeTopicWithPipe = channel.topic ? ` | ${channel.topic}` : ''; const maybeTopicWithNewlines = channel.topic ? `${channel.topic}\n\n` : ''; - const maybeWithin = parentChannel ? `Within: ${parentChannel.name} (ID: ${parentChannel.id})\n` : ''; const channelIDPart = `Channel ID: ${channel.id}`; const guildIDPart = `Guild ID: ${guild.id}`; const convertedTopic = customName - ? `#${channel.name}${maybeTopicWithPipe}\n\n${maybeWithin}${channelIDPart}\n${guildIDPart}` - : `${maybeTopicWithNewlines}${maybeWithin}${channelIDPart}\n${guildIDPart}`; + ? `#${channel.name}${maybeTopicWithPipe}\n\n${channelIDPart}\n${guildIDPart}` + : `${maybeTopicWithNewlines}${channelIDPart}\n${guildIDPart}`; return [chosenName, convertedTopic]; } @@ -89,7 +87,7 @@ async function channelToKState(channel, guild, di) { const guildSpaceID = await createSpace.ensureSpace(guild) /** Used as the literal parent on Matrix, for categorisation. Will be the same as `guildSpaceID` unless it's a forum channel's thread, in which case a different space is used to group those threads. */ let parentSpaceID = guildSpaceID - if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum || parentChannel?.type === DiscordTypes.ChannelType.GuildMedia) { //TODO: Once Ellie's and Guzio's MSC for room-in-room embedding starts being implemented, make this check for whether THIS channel (not its parent) is a thread of ANY type (not just threads in forum/media channels) - thus making it so that threads always appear embedded under their parent. + if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum) { parentSpaceID = await ensureRoom(channel.parent_id) assert(typeof parentSpaceID === "string") } @@ -112,7 +110,7 @@ async function channelToKState(channel, guild, di) { let history_visibility = PRIVACY_ENUMS.ROOM_HISTORY_VISIBILITY[privacyLevel] if (channel["thread_metadata"]) history_visibility = "world_readable" - /** @type {{join_rule: import("../../types").JoinRule, allow?: {type: "m.room_membership", room_id: string}[]}} */ + /** @type {{join_rule: string, allow?: any}} */ let join_rules = { join_rule: "restricted", allow: [{ @@ -120,13 +118,6 @@ async function channelToKState(channel, guild, di) { room_id: guildSpaceID }] } - if (guildSpaceID !== parentSpaceID) { - //@ts-ignore - join_rules.allow most certainly IS defined because we literally define it ~5 lines earlier - join_rules.allow[1] = { - type: "m.room_membership", - room_id: parentSpaceID - } - } if (PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel] !== "restricted") { join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]} } @@ -203,7 +194,7 @@ async function channelToKState(channel, guild, di) { if (hasCustomTopic) delete channelKState["m.room.topic/"] // Make voice channels be a Matrix voice room (MSC3417) - if (channel.type === DiscordTypes.ChannelType.GuildVoice || channel.type === DiscordTypes.ChannelType.GuildStageVoice) { + if (channel.type === DiscordTypes.ChannelType.GuildVoice) { creationContent.type = "org.matrix.msc3417.call" channelKState["org.matrix.msc3401.call/"] = { "m.intent": "m.room", @@ -448,12 +439,12 @@ async function _syncRoom(channelID, shouldActuallySync) { return roomID } -/** Ensures the room exists. If it doesn't, creates the room with an accurate initial state. Before calling this, please make sure that: a channel_room entry exists, guild autocreate = 1, or you're operating on a thread.*/ +/** Ensures the room exists. If it doesn't, creates the room with an accurate initial state. Please check that a channel_room entry exists or guild autocreate = 1 before calling this. */ function ensureRoom(channelID) { return _syncRoom(channelID, false) } -/** Actually syncs. Gets all room state from the homeserver in order to diff, and uploads the icon to mxc if it has changed. Before calling this, please make sure that: a channel_room entry exists, guild autocreate = 1, or you're operating on a thread.*/ +/** Actually syncs. Gets all room state from the homeserver in order to diff, and uploads the icon to mxc if it has changed. Please check that a channel_room entry exists or guild autocreate = 1 before calling this. */ function syncRoom(channelID) { return _syncRoom(channelID, true) } diff --git a/src/d2m/actions/create-space.js b/src/d2m/actions/create-space.js index 7a751e27..9f5d7e30 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 f86a9c82..31f36273 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 6ecd0775..4946d4fe 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") @@ -132,10 +132,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 00000000..04fa28cd --- /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} */ +async function fetchMessage(channelID, messageID) { + assert(reg.ooye.plu_ral_api_key) + try { + var res = await fetch(`https://api.plural.gg/messages/${channelID}/${messageID}?member=true`, { + headers: { + Authorization: reg.ooye.plu_ral_api_key + } + }) + } catch (networkError) { + // Network issue, raise a more readable message + throw new Error(`Failed to connect to /plu/ral API: ${networkError.toString()}`) + } + if (!res.ok) throw new Error(`/plu/ral API returned an error: ${await res.text()}`) + /** @type {any} */ + const root = await res.json() + if (!root.member) throw new Error(`/plu/ral API didn't return member data: ${JSON.stringify(root)}`) + return root +} + +/** + * Using the same sim names and fake user IDs for /plu/ral members, since unlike PluralKit they don't have a short and a long ID. + * @param {Ty.PluRalWebhookMessage} pluRalMessage + */ +function getSimName(pluRalMessage) { + return `_pl_${pluRalMessage.member_id}` +} + +/** + * A sim is an account that is being simulated by the bridge to copy events from the other side. + * @param {Ty.PluRalWebhookMessage} pluRalMessage + * @returns mxid + */ +async function createSim(pluRalMessage) { + // Choose sim name + const simName = getSimName(pluRalMessage) + const localpart = reg.ooye.namespace_prefix + simName + const mxid = `@${localpart}:${reg.ooye.server_name}` + + // Save chosen name in the database forever + db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?)").run(simName, simName, simName, mxid) + + // Register matrix user with that name + try { + await api.register(localpart) + } catch (e) { + // If user creation fails, manually undo the database change. Still isn't perfect, but should help. + // (I would prefer a transaction, but it's not safe to leave transactions open across event loop ticks.) + db.prepare("DELETE FROM sim WHERE user_id = ?").run(simName) + throw e + } + return mxid +} + +/** + * Ensure a sim is registered for the user. + * If there is already a sim, use that one. If there isn't one yet, register a new sim. + * @param {Ty.PluRalWebhookMessage} pluRalMessage + * @returns {Promise} mxid + */ +async function ensureSim(pluRalMessage) { + let mxid = null + const existing = select("sim", "mxid", {user_id: getSimName(pluRalMessage)}).pluck().get() + if (existing) { + mxid = existing + } else { + mxid = await createSim(pluRalMessage) + } + return mxid +} + +/** + * Ensure a sim is registered for the user and is joined to the room. + * @param {Ty.PluRalWebhookMessage} pluRalMessage + * @param {string} roomID + * @returns {Promise} mxid + */ +async function ensureSimJoined(pluRalMessage, roomID) { + // Ensure room ID is really an ID, not an alias + assert.ok(roomID[0] === "!") + + // Ensure user + const mxid = await ensureSim(pluRalMessage) + + // Ensure joined + const existing = select("sim_member", "mxid", {room_id: roomID, mxid}).pluck().get() + if (!existing) { + await api.inviteToRoom(roomID, mxid) + await api.joinRoom(roomID, mxid) + db.prepare("INSERT OR IGNORE INTO sim_member (room_id, mxid) VALUES (?, ?)").run(roomID, mxid) + } + return mxid +} + +/** + * Generate profile data based on webhook displayname and configured avatar. + * @param {Ty.PluRalWebhookMessage} pluRalMessage + * @param {Ty.WebhookAuthor} author + */ +async function memberToStateContent(pluRalMessage, 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 = pluRalMessage.member.avatar_url || file.userAvatar(author) + + const content = { + displayname: author.username, + membership: "join", + "moe.cadence.ooye.plu_ral_member": pluRalMessage.member + } + if (avatar) content.avatar_url = await file.uploadDiscordFileToMxc(avatar) + + return content +} + +/** + * Sync profile data for a sim user. This function follows the following process: + * 1. Look up data about proxy user from API + * 2. If this fails, try to use previously cached data (won't sync) + * 3. Create and join the sim to the room if needed + * 4. Make an object of what the new room member state content would be, including uploading the profile picture if it hasn't been done before + * 5. Compare against the previously known state content, which is helpfully stored in the database + * 6. If the state content has changed, send it to Matrix and update it in the database for next time + * @param {string} channelID to call API with + * @param {string} messageID to call API with + * @param {Ty.WebhookAuthor} author for profile data + * @param {string} roomID room to join member to + * @param {boolean} shouldActuallySync whether to actually sync updated user data or just ensure it's joined + * @returns {Promise} mxid of the updated sim + */ +async function syncUser(channelID, messageID, author, roomID, shouldActuallySync) { + try { + // API lookup + var pluRalMessage = await fetchMessage(channelID, messageID) + const simName = getSimName(pluRalMessage) + db.prepare("REPLACE INTO sim_proxy (user_id, proxy_owner_id, displayname, proxy_app) VALUES (?, ?, ?, 1)").run(simName, pluRalMessage.author_id, 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, proxy_app: 1}).pluck("mxid").get() + if (!senderMxid) throw e + return senderMxid + } + + // Create and join the sim to the room if needed + const mxid = await ensureSimJoined(pluRalMessage, roomID) + + if (shouldActuallySync) { + // Build current profile data and sync if the hash has changed + const content = await memberToStateContent(pluRalMessage, author) + await registerUser._sendSyncUser(roomID, mxid, content, null) + } + + return mxid +} + +module.exports.syncUser = syncUser diff --git a/src/d2m/actions/retrigger.js b/src/d2m/actions/retrigger.js index 66ef19e7..43f400dd 100644 --- a/src/d2m/actions/retrigger.js +++ b/src/d2m/actions/retrigger.js @@ -2,7 +2,15 @@ const {EventEmitter} = require("events") const passthrough = require("../../passthrough") -const {select} = passthrough +const {select, sync, from} = passthrough +/** @type {import("../../matrix/utils")} */ +const utils = sync.require("../../matrix/utils") + +/* + Due to Eventual Consistency(TM) an update/delete may arrive before the original message arrives + (or before the it has finished being bridged to an event). + In this case, wait until the original message has finished bridging, then retrigger the passed function. +*/ const DEBUG_RETRIGGER = false @@ -12,81 +20,140 @@ function debugRetrigger(message) { } } -const paused = new Set() -const emitter = new EventEmitter() +const storage = new class { + /** @private @type {Set} */ + paused = new Set() + /** @private @type {Map any)[]>} id -> list of resolvers */ + resolves = new Map() + /** @private @type {Map>} id -> timer */ + timers = new Map() -/** - * Due to Eventual Consistency(TM) an update/delete may arrive before the original message arrives - * (or before the it has finished being bridged to an event). - * In this case, wait until the original message has finished bridging, then retrigger the passed function. - * @template {(...args: any[]) => any} T - * @param {string} inputID - * @param {T} fn - * @param {Parameters} rest - * @returns {boolean} false if the event was found and the function will be ignored, true if the event was not found and the function will be retriggered - */ -function eventNotFoundThenRetrigger(inputID, fn, ...rest) { - if (!paused.has(inputID)) { - if (inputID.match(/^[0-9]+$/)) { - const eventID = select("event_message", "event_id", {message_id: inputID}).pluck().get() - if (eventID) { - debugRetrigger(`[retrigger] OK mid <-> eid = ${inputID} <-> ${eventID}`) - return false // event was found so don't retrigger - } - } else if (inputID.match(/^\$/)) { - const messageID = select("event_message", "message_id", {event_id: inputID}).pluck().get() - if (messageID) { - debugRetrigger(`[retrigger] OK eid <-> mid = ${inputID} <-> ${messageID}`) - return false // message was found so don't retrigger - } + /** + * The purpose of storage is to store `resolve` and call it at a later time. + * @param {string} id + * @param {(found: Boolean) => any} resolve + */ + store(id, resolve) { + debugRetrigger(`[retrigger] STORE id = ${id}`) + this.resolves.set(id, (this.resolves.get(id) || []).concat(resolve)) // add to list in map value + if (!this.timers.has(id)) { + debugRetrigger(`[retrigger] SET TIMER id = ${id}`) + this.timers.set(id, setTimeout(() => this.resolve(id, false), 60 * 1000).unref()) // 1 minute } } + + /** @param {string} id */ + isNotPaused(id) { + return !storage.paused.has(id) + } - debugRetrigger(`[retrigger] WAIT id = ${inputID}`) - emitter.once(inputID, () => { - debugRetrigger(`[retrigger] TRIGGER id = ${inputID}`) - fn(...rest) - }) - // if the event never arrives, don't trigger the callback, just clean up - setTimeout(() => { - if (emitter.listeners(inputID).length) { - debugRetrigger(`[retrigger] EXPIRE id = ${inputID}`) + /** @param {string} id */ + pause(id) { + debugRetrigger(`[retrigger] PAUSE id = ${id}`) + this.paused.add(id) + } + + /** + * Go through `resolves` storage and resolve them all. (Also resets timer/paused.) + * @param {string} id + * @param {boolean} value + */ + resolve(id, value) { + if (this.paused.has(id)) { + debugRetrigger(`[retrigger] RESUME id = ${id}`) + this.paused.delete(id) } - emitter.removeAllListeners(inputID) - }, 60 * 1000) // 1 minute - return true // event was not found, then retrigger + + if (this.resolves.has(id)) { + debugRetrigger(`[retrigger] RESOLVE ${value} id = ${id}`) + const fns = this.resolves.get(id) || [] + this.resolves.delete(id) + for (const fn of fns) { + fn(value) + } + } + + if (this.timers.has(id)) { + clearTimeout(this.timers.get(id)) + this.timers.delete(id) + } + } +} + +/** + * @param {string} id + * @param {(found: Boolean) => any} resolve + * @param {boolean} existsInDatabase + */ +function waitFor(id, resolve, existsInDatabase) { + if (existsInDatabase && storage.isNotPaused(id)) { // if event already exists and isn't paused then resolve immediately + debugRetrigger(`[retrigger] EXISTS id = ${id}`) + return resolve(true) + } + + // doesn't exist. wait for it to exist. storage will resolve true if it exists or false if it timed out + return storage.store(id, resolve) +} + +const GET_EVENT_PREPARED = from("event_message").select("event_id").and("WHERE event_id = ?").prepare().raw() +/** + * @param {string} eventID + * @returns {Promise} if true then the message did not arrive + */ +function waitForEvent(eventID) { + const {promise, resolve} = Promise.withResolvers() + waitFor(eventID, resolve, !!GET_EVENT_PREPARED.get(eventID)) + return promise +} + +const GET_MESSAGE_PREPARED = from("event_message").select("message_id").and("WHERE message_id = ?").prepare().raw() +/** + * @param {string} messageID + * @returns {Promise} if true then the message did not arrive + */ +function waitForMessage(messageID) { + const {promise, resolve} = Promise.withResolvers() + waitFor(messageID, resolve, !!GET_MESSAGE_PREPARED.get(messageID)) + return promise +} + +const GET_REACTION_EVENT_PREPARED = from("reaction").select("hashed_event_id").and("WHERE hashed_event_id = ?").prepare().raw() +/** + * @param {string} eventID + * @returns {Promise} if true then the message did not arrive + */ +function waitForReactionEvent(eventID) { + const {promise, resolve} = Promise.withResolvers() + waitFor(eventID, resolve, !!GET_REACTION_EVENT_PREPARED.get(utils.getEventIDHash(eventID))) + return promise } /** * Anything calling retrigger during the callback will be paused and retriggered after the callback resolves. * @template T - * @param {string} messageID + * @param {string} id * @param {Promise} promise * @returns {Promise} */ -async function pauseChanges(messageID, promise) { +async function pauseChanges(id, promise) { try { - debugRetrigger(`[retrigger] PAUSE id = ${messageID}`) - paused.add(messageID) + storage.pause(id) return await promise } finally { - debugRetrigger(`[retrigger] RESUME id = ${messageID}`) - paused.delete(messageID) - messageFinishedBridging(messageID) + finishedBridging(id) } } /** * Triggers any pending operations that were waiting on the corresponding event ID. - * @param {string} messageID + * @param {string} id */ -function messageFinishedBridging(messageID) { - if (emitter.listeners(messageID).length) { - debugRetrigger(`[retrigger] EMIT id = ${messageID}`) - } - emitter.emit(messageID) +function finishedBridging(id) { + storage.resolve(id, true) } -module.exports.eventNotFoundThenRetrigger = eventNotFoundThenRetrigger -module.exports.messageFinishedBridging = messageFinishedBridging +module.exports.waitForMessage = waitForMessage +module.exports.waitForEvent = waitForEvent +module.exports.waitForReactionEvent = waitForReactionEvent module.exports.pauseChanges = pauseChanges +module.exports.finishedBridging = finishedBridging \ No newline at end of file diff --git a/src/d2m/actions/send-message.js b/src/d2m/actions/send-message.js index e9b7fae8..41ef70d7 100644 --- a/src/d2m/actions/send-message.js +++ b/src/d2m/actions/send-message.js @@ -4,17 +4,16 @@ const assert = require("assert").strict const DiscordTypes = require("discord-api-types/v10") const passthrough = require("../../passthrough") -const { discord, sync, db, select, from} = passthrough +const {discord, sync, db, select, from} = passthrough +const {reg} = require("../../matrix/read-registration") /** @type {import("../converters/message-to-event")} */ const messageToEvent = sync.require("../converters/message-to-event") /** @type {import("../../matrix/api")} */ const api = sync.require("../../matrix/api") /** @type {import("./register-user")} */ const registerUser = sync.require("./register-user") -/** @type {import("./register-pk-user")} */ -const registerPkUser = sync.require("./register-pk-user") -/** @type {import("./register-webhook-user")} */ -const registerWebhookUser = sync.require("./register-webhook-user") +/** @type {import("./speedbump")} */ +const speedbump = sync.require("./speedbump") /** @type {import("../actions/create-room")} */ const createRoom = sync.require("../actions/create-room") /** @type {import("../actions/poll-end")} */ @@ -23,31 +22,20 @@ const pollEnd = sync.require("../actions/poll-end") const dUtils = sync.require("../../discord/utils") /** @type {import("../../m2d/actions/channel-webhook")} */ const channelWebhook = sync.require("../../m2d/actions/channel-webhook") -/** @type {import("../../agi/listener")} */ -const agiListener = sync.require("../../agi/listener") /** * @param {DiscordTypes.GatewayMessageCreateDispatchData} message * @param {DiscordTypes.APIGuildChannel} channel * @param {DiscordTypes.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 sendMessage(message, channel, guild, row) { +async function sendMessage(message, channel, guild) { const roomID = await createRoom.ensureRoom(message.channel_id) const historicalRoomIndex = select("historical_channel_room", "historical_room_index", {room_id: roomID}).pluck().get() assert(historicalRoomIndex) let senderMxid = null if (dUtils.isWebhookMessage(message)) { - const useWebhookProfile = select("guild_space", "webhook_profile", {guild_id: guild.id}).pluck().get() ?? 0 - 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) - } - } else if (useWebhookProfile) { - senderMxid = await registerWebhookUser.syncUser(message.author, roomID, true) - } + senderMxid = await speedbump.getWebhookSenderId(message, guild.id, roomID) } else { // not a webhook if (message.author.id === discord.application.id) { @@ -62,8 +50,7 @@ async function sendMessage(message, channel, guild, row) { const detailedResultsMessage = await pollEnd.endPoll(message) if (detailedResultsMessage) { const threadParent = select("channel_room", "thread_parent", {channel_id: message.channel_id}).pluck().get() - const channelID = threadParent ? threadParent : message.channel_id - const threadID = threadParent ? message.channel_id : undefined + const {channelID, threadID} = dUtils.swapThreadID(message.channel_id, threadParent) sentResultsMessage = await channelWebhook.sendMessageWithWebhook(channelID, detailedResultsMessage, threadID) } } @@ -139,8 +126,6 @@ async function sendMessage(message, channel, guild, row) { } } - await agiListener.process(message, channel, guild, false) - return eventIDs } diff --git a/src/d2m/actions/speedbump.js b/src/d2m/actions/speedbump.js index 218f046e..88cea075 100644 --- a/src/d2m/actions/speedbump.js +++ b/src/d2m/actions/speedbump.js @@ -1,14 +1,23 @@ // @ts-check const DiscordTypes = require("discord-api-types/v10") + const passthrough = require("../../passthrough") -const {discord, select, db} = passthrough +const {discord, from, select, db, sync} = passthrough + +const {reg} = require("../../matrix/read-registration") +/** @type {import("./register-pk-user")} */ +const registerPkUser = sync.require("./register-pk-user") +/** @type {import("./register-plu-ral-user")} */ +const registerPluRalUser = sync.require("./register-plu-ral-user") +/** @type {import("./register-webhook-user")} */ +const registerWebhookUser = sync.require("./register-webhook-user") const DEBUG_SPEEDBUMP = false -function debugSpeedbump(message) { +function debugSpeedbump(...args) { if (DEBUG_SPEEDBUMP) { - console.log(message) + console.log(...args) } } @@ -17,7 +26,8 @@ const SPEEDBUMP_UPDATE_FREQUENCY = 2 * 60 * 60 // 2 hours /** @type {Set} */ const KNOWN_BOTS = new Set([ - "466378653216014359" // PluralKit + "466378653216014359", // PluralKit + "1291501048493768784", // /plu/ral ]) /** @@ -29,55 +39,81 @@ async function updateCache(channelID, lastChecked) { const now = Math.floor(Date.now() / 1000) if (lastChecked && now - lastChecked < SPEEDBUMP_UPDATE_FREQUENCY) return const webhooks = await discord.snow.webhook.getChannelWebhooks(channelID) - const found = webhooks.find(b => KNOWN_BOTS.has(b.application_id)) - const foundApplication = found?.application_id - const foundWebhook = found?.id - db.prepare("UPDATE channel_room SET speedbump_id = ?, speedbump_webhook_id = ?, speedbump_checked = ? WHERE channel_id = ?").run(foundApplication, foundWebhook, now, channelID) + const found = webhooks.filter(b => KNOWN_BOTS.has(b.application_id)) + db.transaction(() => { + db.prepare("DELETE FROM channel_speedbump WHERE channel_id = ?").run(channelID) + for (const webhook of found) { + db.prepare("INSERT INTO channel_speedbump (channel_id, speedbump_webhook_id, speedbump_user_id) VALUES (?, ?, ?)").run(channelID, webhook.id, webhook.application_id) + } + db.prepare("UPDATE channel_room SET speedbump_checked = ? WHERE channel_id = ?").run(now, channelID) + })() } -/** @type {Map} messageID -> number of gateway events currently bumping */ +/** + * @typedef BumpingEntry + * @prop {number} number number of gateway events currently bumping for this message ID + * @prop {boolean} hasCreate whether there was a message create within the events currently bumping + */ + +/** @type {Map} messageID -> BumpingEntry */ const bumping = new Map() /** * Slow down a message. After it passes the speedbump, return whether it's okay or if it's been deleted. + * @param {boolean} isCreate * @param {string} messageID * @returns whether it was deleted */ -async function doSpeedbump(messageID) { - let value = (bumping.get(messageID) ?? 0) + 1 - bumping.set(messageID, value) - debugSpeedbump(`[speedbump] WAIT ${messageID}++ = ${value}`) +async function doSpeedbump(isCreate, messageID) { + const entry = bumping.get(messageID) ?? (() => { + const entry = {number: 0, hasCreate: false} + bumping.set(messageID, entry) + return entry + })() + + entry.number++ + entry.hasCreate ||= isCreate + + debugSpeedbump(`[speedbump] WAIT ${messageID}++ =`, entry) await new Promise(resolve => setTimeout(resolve, SPEEDBUMP_SPEED)) if (!bumping.has(messageID)) { debugSpeedbump(`[speedbump] DELETED ${messageID}`) - return true + return {skip: true, hasCreate: null} } - value = (bumping.get(messageID) ?? 0) - 1 - if (value <= 0) { - debugSpeedbump(`[speedbump] OK ${messageID}-- = ${value}`) + + if (--entry.number <= 0) { + debugSpeedbump(`[speedbump] OK ${messageID}-- =`, entry) bumping.delete(messageID) - return false + return {skip: false, hasCreate: entry.hasCreate} } else { - debugSpeedbump(`[speedbump] MULTI ${messageID}-- = ${value}`) - bumping.set(messageID, value) - return true + debugSpeedbump(`[speedbump] MULTI ${messageID}-- =`, entry) + return {skip: true, hasCreate: null} } } +function getSpeedbumpRows(channelID) { + return from("channel_room").join("channel_speedbump", "channel_id").select("thread_parent", "speedbump_user_id", "speedbump_webhook_id").where({channel_id: channelID}).all() +} + /** * Check whether to slow down a message, and do it. After it passes the speedbump, return whether it's okay or if it's been deleted. - * @param {string} channelID - * @param {string} messageID - * @returns whether it was deleted, and data about the channel's (not thread's) speedbump + * @param {boolean} isCreate + * @param {{id: string, channel_id: string, author: {id: string}, backfill?: boolean}} message uses the ID to identify, and the userID to only slow down the message when the user has used PK before + * @returns whether to skip this message, and whether the message should be created as a creation */ -async function maybeDoSpeedbump(channelID, messageID) { - let row = select("channel_room", ["thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: channelID}).get() - if (row?.thread_parent) row = select("channel_room", ["thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: row.thread_parent}).get() // webhooks belong to the channel, not the thread - if (!row?.speedbump_webhook_id) return {affected: false, row: null} // not affected, no speedbump - const affected = await doSpeedbump(messageID) - return {affected, row} // maybe affected, and there is a speedbump +async function maybeDoSpeedbump(isCreate, message) { + let rows = getSpeedbumpRows(message.channel_id) + if (rows[0]?.thread_parent) rows = getSpeedbumpRows(rows[0].thread_parent) // webhooks belong to the channel, not the thread + if (!rows.length) return {skip: false} // channel not affected, no speedbump + + if (message.backfill) return {skip: false} // don't slow messages during backfill + if (rows.some(r => r.speedbump_webhook_id === message.author.id)) return {skip: false} // shortcut + const userHasProxy = select("sim_proxy", "user_id", {proxy_owner_id: message.author.id}).pluck().get() + if (!userHasProxy) return {skip: false} // user has not used PK before, no speedbump + const {skip, hasCreate} = await doSpeedbump(isCreate, message.id) + return {skip, hasCreate} // maybe affected, and there is a speedbump } /** @@ -87,7 +123,25 @@ function onMessageDelete(messageID) { bumping.delete(messageID) } +/** + * @param {DiscordTypes.APIMessage} message + * @param {string} guildID + * @param {string} roomID + */ +async function getWebhookSenderId(message, guildID, roomID) { + const speedbumpUserID = select("channel_speedbump", "speedbump_user_id", {channel_id: message.channel_id, speedbump_webhook_id: message.webhook_id}).pluck().get() + const useWebhookProfile = select("guild_space", "webhook_profile", {guild_id: guildID}).pluck().get() ?? 0 + if (speedbumpUserID === "466378653216014359") { // PluralKit public instance + return await registerPkUser.syncUser(message.id, message.author, roomID, true) + } else if (speedbumpUserID === "1291501048493768784" && reg.ooye.plu_ral_api_key) { // /plu/ral public instance + return await registerPluRalUser.syncUser(message.channel_id, message.id, message.author, roomID, true) + } else if (useWebhookProfile) { + return await registerWebhookUser.syncUser(message.author, roomID, true) + } + return null +} + module.exports.updateCache = updateCache -module.exports.doSpeedbump = doSpeedbump module.exports.maybeDoSpeedbump = maybeDoSpeedbump module.exports.onMessageDelete = onMessageDelete +module.exports.getWebhookSenderId = getWebhookSenderId diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 6e9ce7b6..aeda572e 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -109,7 +109,7 @@ const embedTitleParser = markdown.markdownEngine.parserFor({ /** * @param {{room?: boolean, user_ids?: string[]}} mentions - * @param {Omit} attachment + * @param {Omit} attachment * @param {boolean} [alwaysLink] */ async function attachmentToEvent(mentions, attachment, alwaysLink) { @@ -256,8 +256,8 @@ function getFormattedInteraction(interaction, isThinkingInteraction) { const username = interaction.member?.nick || interaction.user.global_name || interaction.user.username const thinkingText = isThinkingInteraction ? " — interaction loading..." : "" return { - body: `↪️ ${username} used \`/${interaction.name}\`${thinkingText}`, - html: `
↪️ ${mxid ? tag`${username}` : username} used /${interaction.name}${thinkingText}
` + body: `❭ ${username} used \`/${interaction.name}\`${thinkingText}`, + html: `
❭ ${mxid ? tag`${username}` : username} used /${interaction.name}${thinkingText}
` } } @@ -265,8 +265,9 @@ function getFormattedInteraction(interaction, isThinkingInteraction) { * @param {any} newEvents merge into events * @param {any} events will be modified * @param {boolean} forceSameMsgtype whether m.text may only be combined with m.text, etc + * @param {boolean} [forceMerge] if true, must merge event, will error if it had to append */ -function mergeTextEvents(newEvents, events, forceSameMsgtype) { +function mergeTextEvents(newEvents, events, forceSameMsgtype, forceMerge = false) { let prev = events.at(-1) for (const ne of newEvents) { const isAllText = prev?.body && prev?.formatted_body && ["m.text", "m.notice"].includes(ne.msgtype) && ["m.text", "m.notice"].includes(prev?.msgtype) @@ -278,6 +279,8 @@ function mergeTextEvents(newEvents, events, forceSameMsgtype) { rep.addLine(ne.body, ne.formatted_body) prev.body = rep.body prev.formatted_body = rep.formattedBody + } else if (forceMerge) { + throw new Error("Unable to merge events") } else { events.push(ne) } @@ -554,7 +557,7 @@ async function messageToEvent(message, guild, options = {}, di) { // Handling emojis that we don't know about. The emoji has to be present in the DB for it to be picked up in the emoji markdown converter. // So we scan the message ahead of time for all its emojis and ensure they are in the DB. - const emojiMatches = [...content.matchAll(/<(a?):([^:>]{1,64}):([0-9]+)>/g)] + const emojiMatches = [...content.matchAll(/<(a?):([^:>]+):([0-9]+)>/g)] await Promise.all(emojiMatches.map(match => { const id = match[3] const name = match[2] @@ -640,8 +643,8 @@ async function messageToEvent(message, guild, options = {}, di) { const flags = message.flags || 0 if (flags & DiscordTypes.MessageFlags.IsCrosspost) { - body = `[🔀 ${message.author.username}]\n` + body - html = `🔀 ${message.author.username}
` + html + body = `[↷ ${message.author.username}]\n` + body + html = `↷ ${message.author.username}
` + html } // Fallback body/formatted_body for replies @@ -768,20 +771,20 @@ async function messageToEvent(message, guild, options = {}, di) { if (row && "event_id" in row) { const via = await getViaServersMemo(row.room_id) forwardedNotice.addLine( - `[🔀 Forwarded from #${roomName}]`, - tag`🔀 Forwarded from ${roomName} [jump to event]` + `[↷ Forwarded from #${roomName}]`, + tag`↷ Forwarded from ${roomName} [jump to event]` ) } else { const via = await getViaServersMemo(room.room_id) forwardedNotice.addLine( - `[🔀 Forwarded from #${roomName}]`, - tag`🔀 Forwarded from ${roomName} [jump to room]` + `[↷ Forwarded from #${roomName}]`, + tag`↷ Forwarded from ${roomName} [jump to room]` ) } } else { forwardedNotice.addLine( - `[🔀 Forwarded message]`, - tag`🔀 Forwarded message` + `[↷ Forwarded message]`, + tag`↷ Forwarded message` ) } @@ -967,7 +970,8 @@ async function messageToEvent(message, guild, options = {}, di) { // May only be a section accessory or in an action row (up to 5) if (component.style === DiscordTypes.ButtonStyle.Link) { assert(component.label) // required for Discord to validate link buttons - stack.msb.add(`[${component.label} ${component.url}] `, tag`${component.label} `) + const link = await transformContentMessageLinks(component.url) + stack.msb.add(`[${component.label} ${link}] `, tag`${component.label} `) } } @@ -980,7 +984,19 @@ async function messageToEvent(message, guild, options = {}, di) { const {body, formatted_body} = stack.msb.get() if (body.trim().length) { - await addTextEvent(body, formatted_body, "m.text") + // Create new message if Components V2 (cannot have regular content) + if ((message.flags ?? 0) & DiscordTypes.MessageFlags.IsComponentsV2) { + await addTextEvent(body, formatted_body, "m.text") + } + // Add to existing message if legacy components https://docs.discord.com/developers/components/reference#legacy-message-component-behavior + else { + mergeTextEvents([{ + msgtype: "m.text", + body, + format: "org.matrix.custom.html", + formatted_body + }], events, false, true) + } } } @@ -1127,7 +1143,7 @@ async function messageToEvent(message, guild, options = {}, di) { } } else { let body = stickerItem.name - const sticker = guild.stickers.find(sticker => sticker.id === stickerItem.id) + const sticker = guild.stickers?.find(sticker => sticker.id === stickerItem.id) if (sticker && sticker.description) body += ` - ${sticker.description}` return { $type: "m.sticker", diff --git a/src/d2m/converters/message-to-event.test.components.js b/src/d2m/converters/message-to-event.test.components.js index 137b63be..1ef83c36 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 author
Psst, Red (@cadence.worm), you have been pinged by @cadence.worm.
Jump ", + "m.mentions": {} + }]) +}) diff --git a/src/d2m/converters/message-to-event.test.embeds.js b/src/d2m/converters/message-to-event.test.embeds.js index fdb0807e..91bbe2bc 100644 --- a/src/d2m/converters/message-to-event.test.embeds.js +++ b/src/d2m/converters/message-to-event.test.embeds.js @@ -8,9 +8,9 @@ test("message2event embeds: interaction loading", async t => { const events = await messageToEvent(data.interaction_message.thinking_interaction, data.guild.general, {}) t.deepEqual(events, [{ $type: "m.room.message", - body: "↪️ Brad used `/stats` — interaction loading...", + body: "❭ Brad used `/stats` — interaction loading...", format: "org.matrix.custom.html", - formatted_body: "
↪️ Brad used /stats — interaction loading...
", + formatted_body: "
Brad used /stats — interaction loading...
", "m.mentions": {}, msgtype: "m.notice", }]) @@ -22,12 +22,12 @@ test("message2event embeds: nothing but a field", async t => { $type: "m.room.message", "m.mentions": {}, msgtype: "m.notice", - body: "↪️ PapiOphidian used `/stats`" + body: "❭ PapiOphidian used `/stats`" + "\n| ### Amanda 🎵#2192 :online:" + "\n| willow tree, branch 0" + "\n| **❯ Uptime:**\n| 3m 55s\n| **❯ Memory:**\n| 64.45MB", format: "org.matrix.custom.html", - formatted_body: '
↪️ PapiOphidian used /stats
' + formatted_body: '
PapiOphidian used /stats
' + '

Amanda 🎵#2192 \":online:\"' + '
willow tree, branch 0
' + '
❯ Uptime:
3m 55s' @@ -153,10 +153,10 @@ test("message2event embeds: title without url", async t => { t.deepEqual(events, [{ $type: "m.room.message", msgtype: "m.notice", - body: "↪️ PapiOphidian used `/stats`" + body: "❭ PapiOphidian used `/stats`" + "\n| ## Hi, I'm Amanda!\n| \n| I condone pirating music!", format: "org.matrix.custom.html", - formatted_body: '

↪️ PapiOphidian used /stats
' + formatted_body: '
PapiOphidian used /stats
' + `

Hi, I'm Amanda!

I condone pirating music!

`, "m.mentions": {} }]) @@ -167,10 +167,10 @@ test("message2event embeds: url without title", async t => { t.deepEqual(events, [{ $type: "m.room.message", msgtype: "m.notice", - body: "↪️ PapiOphidian used `/stats`" + body: "❭ PapiOphidian used `/stats`" + "\n| I condone pirating music!", format: "org.matrix.custom.html", - formatted_body: '
↪️ PapiOphidian used /stats
' + formatted_body: '
PapiOphidian used /stats
' + `

I condone pirating music!

`, "m.mentions": {} }]) @@ -181,10 +181,10 @@ test("message2event embeds: author without url", async t => { t.deepEqual(events, [{ $type: "m.room.message", msgtype: "m.notice", - body: "↪️ PapiOphidian used `/stats`" + body: "❭ PapiOphidian used `/stats`" + "\n| ## Amanda\n| \n| I condone pirating music!", format: "org.matrix.custom.html", - formatted_body: '
↪️ PapiOphidian used /stats
' + formatted_body: '
PapiOphidian used /stats
' + `

Amanda

I condone pirating music!

`, "m.mentions": {} }]) @@ -195,10 +195,10 @@ test("message2event embeds: author url without name", async t => { t.deepEqual(events, [{ $type: "m.room.message", msgtype: "m.notice", - body: "↪️ PapiOphidian used `/stats`" + body: "❭ PapiOphidian used `/stats`" + "\n| I condone pirating music!", format: "org.matrix.custom.html", - formatted_body: '
↪️ PapiOphidian used /stats
' + formatted_body: '
PapiOphidian used /stats
' + `

I condone pirating music!

`, "m.mentions": {} }]) @@ -209,9 +209,9 @@ test("message2event embeds: 4 images", async t => { t.deepEqual(events, [{ $type: "m.room.message", msgtype: "m.text", - body: "[🔀 Forwarded message]\n» https://fixupx.com/i/status/2032003668787020046", + body: "[↷ Forwarded message]\n» https://fixupx.com/i/status/2032003668787020046", format: "org.matrix.custom.html", - formatted_body: "🔀 Forwarded message
https://fixupx.com/i/status/2032003668787020046
", + formatted_body: "↷ Forwarded message
https://fixupx.com/i/status/2032003668787020046
", "m.mentions": {} }, { $type: "m.room.message", diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index b7f0867a..be1d99f0 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -1273,9 +1273,9 @@ test("message2event: crossposted announcements say where they are crossposted fr $type: "m.room.message", "m.mentions": {}, msgtype: "m.text", - body: "[🔀 Chewey Bot Official Server #announcements]\nAll text based commands are now inactive on Chewey Bot\nTo continue using commands you'll need to use them as slash commands", + body: "[↷ Chewey Bot Official Server #announcements]\nAll text based commands are now inactive on Chewey Bot\nTo continue using commands you'll need to use them as slash commands", format: "org.matrix.custom.html", - formatted_body: "🔀 Chewey Bot Official Server #announcements
All text based commands are now inactive on Chewey Bot
To continue using commands you'll need to use them as slash commands" + formatted_body: "↷ Chewey Bot Official Server #announcements
All text based commands are now inactive on Chewey Bot
To continue using commands you'll need to use them as slash commands" }]) }) @@ -1344,9 +1344,9 @@ test("message2event: forwarded image", async t => { t.deepEqual(events, [ { $type: "m.room.message", - body: "[🔀 Forwarded message]", + body: "[↷ Forwarded message]", format: "org.matrix.custom.html", - formatted_body: "🔀 Forwarded message", + formatted_body: "↷ Forwarded message", "m.mentions": {}, msgtype: "m.notice", }, @@ -1385,10 +1385,10 @@ test("message2event: constructed forwarded message", async t => { t.deepEqual(events, [ { $type: "m.room.message", - body: "[🔀 Forwarded from #wonderland]" + body: "[↷ Forwarded from #wonderland]" + "\n» What's cooking, good looking? :hipposcope:", format: "org.matrix.custom.html", - formatted_body: `🔀 Forwarded from wonderland [jump to event]` + formatted_body: `↷ Forwarded from wonderland [jump to event]` + `
What's cooking, good looking? :hipposcope:
`, "m.mentions": {}, msgtype: "m.text", @@ -1444,10 +1444,10 @@ test("message2event: constructed forwarded text", async t => { t.deepEqual(events, [ { $type: "m.room.message", - body: "[🔀 Forwarded from #amanda-spam]" + body: "[↷ Forwarded from #amanda-spam]" + "\n» What's cooking, good looking?", format: "org.matrix.custom.html", - formatted_body: `🔀 Forwarded from amanda-spam [jump to room]` + formatted_body: `↷ Forwarded from amanda-spam [jump to room]` + `
What's cooking, good looking?
`, "m.mentions": {}, msgtype: "m.text", @@ -1467,10 +1467,10 @@ test("message2event: don't scan forwarded messages for mentions", async t => { t.deepEqual(events, [ { $type: "m.room.message", - body: "[🔀 Forwarded message]" + body: "[↷ Forwarded message]" + "\n» If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile https://social.luca.run/@luca/113950834185678114", format: "org.matrix.custom.html", - formatted_body: `🔀 Forwarded message` + formatted_body: `↷ Forwarded message` + `
If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile https://social.luca.run/@luca/113950834185678114
`, "m.mentions": {}, msgtype: "m.text" @@ -1820,9 +1820,9 @@ test("message2event: forwarded message with unreferenced mention", async t => { t.deepEqual(events, [{ $type: "m.room.message", msgtype: "m.text", - body: "[🔀 Forwarded message]\n» @unknown-user:\n» 🎞️ Uploaded file: https://bridge.example.org/download/discordcdn/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4 (51 MB)", + body: "[↷ Forwarded message]\n» @unknown-user:\n» 🎞️ Uploaded file: https://bridge.example.org/download/discordcdn/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4 (51 MB)", format: "org.matrix.custom.html", - formatted_body: "🔀 Forwarded message
@unknown-user:
🎞️ Uploaded file: 2022-10-18_16-49-46.mp4 (51 MB)
", + formatted_body: "↷ Forwarded message
@unknown-user:
🎞️ Uploaded file: 2022-10-18_16-49-46.mp4 (51 MB)
", "m.mentions": {} }]) }) diff --git a/src/d2m/converters/remove-reaction.js b/src/d2m/converters/remove-reaction.js index 4ca22b60..b6b04071 100644 --- a/src/d2m/converters/remove-reaction.js +++ b/src/d2m/converters/remove-reaction.js @@ -34,7 +34,7 @@ function removeReaction(data, reactions, key) { // Even though the bridge bot only reacted once on Discord-side, multiple Matrix users may have // reacted on Matrix-side. Semantically, we want to remove the reaction from EVERY Matrix user. // Also need to clean up the database. - const hash = utils.getEventIDHash(event.event_id) + const hash = utils.getEventIDHash(eventID) removals.push({eventID, mxid: null, hash}) } if (!lookingAtMatrixReaction && !wantToRemoveMatrixReaction) { diff --git a/src/d2m/converters/thread-to-announcement.js b/src/d2m/converters/thread-to-announcement.js index 6571619c..575b3c58 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -1,7 +1,9 @@ // @ts-check +const assert = require("assert").strict + const passthrough = require("../../passthrough") -const {sync, select} = passthrough +const {discord, sync, db, select} = passthrough /** @type {import("../../matrix/utils")} */ const mxUtils = sync.require("../../matrix/utils") const {reg} = require("../../matrix/read-registration.js") @@ -17,32 +19,24 @@ const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) */ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, di) { const branchedFromEventID = select("event_message", "event_id", {message_id: thread.id}).pluck().get() - const ellieMode = false //See: https://matrix.to/#/!PuFmbgRjaJsAZTdSja:cadence.moe?via=cadence.moe&via=chat.untitledzero.dev&via=guziohub.ovh - TL;DR: Ellie's idea was to not leave any sign of Matrix threads existing, while Guzio preferred that the link to created thread-rooms stay within the Matrix thread (to better approximate Discord UI on compatible clients). This settings-option-but-not-really (it's not meant to be changed by end users unless they know what they're doing) would let Cadence switch between both approaches over some time period, to test the feeling of both, and finally land on whichever UX she prefers best. TODO: Remove this toggle (and make the chosen solution permanent) once those tests conclude. - - /** @type {{"m.mentions"?: any, "m.relates_to"?: {event_id?: string, is_falling_back?: boolean, "m.in_reply_to"?: {event_id: string}, rel_type?: "m.replace"|"m.thread"}}} */ + /** @type {{"m.mentions"?: any, "m.in_reply_to"?: any}} */ const context = {} - let suffix = "" if (branchedFromEventID) { // Need to figure out who sent that event... const event = await di.api.getEvent(parentRoomID, branchedFromEventID) - suffix = "\n[Note: You really should move the conversation to that room, rather than continuing to reply via a Matrix thread. Any messages sent in threads will be DELETED and instead moved to that room by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]" context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}} if (event.sender && !userRegex.some(rx => event.sender.match(rx))) context["m.mentions"] = {user_ids: [event.sender]} - if (!ellieMode) { - //...And actually branch from that event (if configured to do so) - suffix = "\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]" - context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}, is_falling_back:false, event_id: event.event_id, rel_type: "m.thread"} - } } const msgtype = creatorMxid ? "m.emote" : "m.text" - const template = creatorMxid ? "started a thread called" : "New thread started:" + const template = creatorMxid ? "started a thread:" : "Thread started:" const via = await mxUtils.getViaServersQuery(threadRoomID, di.api) - let body = `${template} \"${thread.name}\" in room: https://matrix.to/#/${threadRoomID}?${via.toString()}${suffix}` + let body = `${template} ${thread.name} https://matrix.to/#/${threadRoomID}?${via.toString()}` return { msgtype, body, + "m.mentions": {}, ...context } } diff --git a/src/d2m/converters/thread-to-announcement.test.js b/src/d2m/converters/thread-to-announcement.test.js index a4dfd9a3..3286f62d 100644 --- a/src/d2m/converters/thread-to-announcement.test.js +++ b/src/d2m/converters/thread-to-announcement.test.js @@ -49,7 +49,8 @@ test("thread2announcement: no known creator, no branched from event", async t => }, {api: viaApi}) t.deepEqual(content, { msgtype: "m.text", - body: "New thread started: \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", + body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", + "m.mentions": {} }) }) @@ -60,7 +61,8 @@ test("thread2announcement: known creator, no branched from event", async t => { }, {api: viaApi}) t.deepEqual(content, { msgtype: "m.emote", - body: "started a thread called \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", + body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", + "m.mentions": {} }) }) @@ -83,14 +85,12 @@ test("thread2announcement: no known creator, branched from discord event", async }) t.deepEqual(content, { msgtype: "m.text", - body: "New thread started: \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]", + body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", + "m.mentions": {}, "m.relates_to": { - "event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", - "is_falling_back": false, "m.in_reply_to": { - "event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", - }, - "rel_type": "m.thread", + event_id: "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" + } } }) }) @@ -114,14 +114,12 @@ test("thread2announcement: known creator, branched from discord event", async t }) t.deepEqual(content, { msgtype: "m.emote", - body: "started a thread called \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]", + body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", + "m.mentions": {}, "m.relates_to": { - "event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", - "is_falling_back": false, "m.in_reply_to": { - "event_id": "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", - }, - "rel_type": "m.thread", + event_id: "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" + } } }) }) @@ -145,51 +143,14 @@ test("thread2announcement: no known creator, branched from matrix event", async }) t.deepEqual(content, { msgtype: "m.text", - body: "New thread started: \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]", + body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", "m.mentions": { user_ids: ["@cadence:cadence.moe"] }, "m.relates_to": { - "event_id": "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", - "is_falling_back": false, "m.in_reply_to": { - "event_id": "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", - }, - "rel_type": "m.thread", - } - }) -}) - -test("thread2announcement: known creator, branched from matrix event", async t => { - const content = await threadToAnnouncement("!kLRqKKUQXcibIMtOpl:cadence.moe", "!thread", "@_ooye_crunch_god:cadence.moe", { - name: "test thread", - id: "1128118177155526666" - }, { - api: { - getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", { - type: "m.room.message", - content: { - msgtype: "m.text", - body: "so can you reply to my webhook uwu" - }, - sender: "@cadence:cadence.moe" - }), - ...viaApi - } - }) - t.deepEqual(content, { - msgtype: "m.emote", - body: "started a thread called \"test thread\" in room: https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org\n[Note: You really should continue the conversation in that room, rather than in this Matrix thread. Any messages sent here will be DELETED and instead moved there by the bot, which makes it the author and strips you from control (edits/deletions) over your own message!]", - "m.mentions": { - user_ids: ["@cadence:cadence.moe"] - }, - "m.relates_to": { - "event_id": "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", - "is_falling_back": false, - "m.in_reply_to": { - "event_id": "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", - }, - "rel_type": "m.thread", + event_id: "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4" + } } }) }) diff --git a/src/d2m/discord-client.js b/src/d2m/discord-client.js index c2a05493..b9b45e21 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 afea9ea2..988f981c 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 eb0e7bd9..a49453ac 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -2,6 +2,7 @@ const assert = require("assert").strict const DiscordTypes = require("discord-api-types/v10") +const {id: botID} = require("../../addbot") const {sync, db, select, from} = require("../passthrough") /** @type {import("./actions/send-message")}) */ @@ -38,10 +39,10 @@ const removeMember = sync.require("./actions/remove-member") const vote = sync.require("./actions/poll-vote") /** @type {import("../m2d/event-dispatcher")} */ const matrixEventDispatcher = sync.require("../m2d/event-dispatcher") +/** @type {import("../m2d/actions/redact.js")} */ +const redact = sync.require("../m2d/actions/redact.js") /** @type {import("../discord/interactions/matrix-info")} */ const matrixInfoInteraction = sync.require("../discord/interactions/matrix-info") -/** @type {import("../agi/listener")} */ -const agiListener = sync.require("../agi/listener") const {Semaphore} = require("@chriscdn/promise-semaphore") const checkMissedPinsSema = new Semaphore() @@ -59,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 @@ -104,7 +105,7 @@ module.exports = { // console.log(`[check missed messages] in ${channel.id} (${guild.name} / ${channel.name}) because its last message ${channel.last_message_id} is not in the database`) let messages try { - messages = await client.snow.channel.getChannelMessages(channel.id, {limit: 50}) + 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 console.log(`[check missed messages] no permissions to look back in channel ${channel.name} (${channel.id})`) @@ -212,7 +213,7 @@ module.exports = { const channelID = thread.parent_id || undefined const parentRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get() if (!parentRoomID) return // Not interested in a thread if we aren't interested in its wider channel (won't autocreate) - const threadRoomID = await createRoom.ensureRoom(thread.id) + const threadRoomID = await createRoom.syncRoom(thread.id) // Create room (will share the same inflight as the initial message to the thread) await announceThread.announceThread(parentRoomID, threadRoomID, thread) }, @@ -287,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) }, @@ -305,23 +305,19 @@ module.exports = { if (message.webhook_id) { const row = select("webhook", "webhook_id", {webhook_id: message.webhook_id}).pluck().get() - if (row) { // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it. - await agiListener.process(message, channel, guild, true) - return - } + if (row) return // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it. } if (dUtils.isEphemeralMessage(message)) return // Ephemeral messages are for the eyes of the receiver only! if (!createRoom.existsOrAutocreatable(channel, guild.id)) return // Check that the sending-to room exists or is autocreatable - const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id) - if (affected) return + 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.messageFinishedBridging(message.id) + retrigger.finishedBridging(message.id) }, /** @@ -337,24 +333,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 (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.MESSAGE_UPDATE, client, data)) 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) + } }, /** @@ -380,6 +379,16 @@ module.exports = { * @param {DiscordTypes.GatewayMessageReactionRemoveDispatchData | DiscordTypes.GatewayMessageReactionRemoveEmojiDispatchData | DiscordTypes.GatewayMessageReactionRemoveAllDispatchData} data */ async onSomeReactionsRemoved(client, data) { + // Don't attempt to double-bridge our own m2d deleted reactions back to Matrix + if ("user_id" in data && data.user_id === botID) { + const emojiIdOrName = data.emoji.id || data.emoji.name + const i = redact.m2dDeletedReactions.findIndex(x => data.message_id === x.messageID && emojiIdOrName === x.emojiIdOrName) + if (i !== -1) { + redact.m2dDeletedReactions.splice(i, 1) + return + } + } + await removeReaction.removeSomeReactions(data) }, @@ -389,7 +398,7 @@ module.exports = { */ async MESSAGE_DELETE(client, data) { speedbump.onMessageDelete(data.id) - if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.MESSAGE_DELETE, client, data)) return + if (!await retrigger.waitForMessage(data.id)) return await deleteMessage.deleteMessage(data) }, @@ -406,6 +415,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() @@ -437,12 +447,12 @@ module.exports = { * @param {DiscordTypes.GatewayMessagePollVoteDispatchData} data */ async MESSAGE_POLL_VOTE_ADD(client, data) { - if (retrigger.eventNotFoundThenRetrigger(data.message_id, module.exports.MESSAGE_POLL_VOTE_ADD, client, data)) return + if (!await retrigger.waitForMessage(data.message_id)) return await vote.addVote(data) }, async MESSAGE_POLL_VOTE_REMOVE(client, data) { - if (retrigger.eventNotFoundThenRetrigger(data.message_id, module.exports.MESSAGE_POLL_VOTE_REMOVE, client, data)) return + if (!await retrigger.waitForMessage(data.message_id)) return await vote.removeVote(data) }, diff --git a/src/db/migrations/0037-agi.sql b/src/db/migrations/0037-agi.sql deleted file mode 100644 index 89e0a58d..00000000 --- a/src/db/migrations/0037-agi.sql +++ /dev/null @@ -1,25 +0,0 @@ -BEGIN TRANSACTION; - -CREATE TABLE "agi_prior_message" ( - "channel_id" TEXT NOT NULL, - "username" TEXT NOT NULL, - "avatar_url" TEXT NOT NULL, - "use_caps" INTEGER NOT NULL, - "use_punct" INTEGER NOT NULL, - "use_apos" INTEGER NOT NULL, - "timestamp" INTEGER NOT NULL, - PRIMARY KEY("channel_id") -) WITHOUT ROWID; - -CREATE TABLE "agi_optout" ( - "guild_id" TEXT NOT NULL, - PRIMARY KEY("guild_id") -) WITHOUT ROWID; - -CREATE TABLE "agi_cooldown" ( - "guild_id" TEXT NOT NULL, - "timestamp" INTEGER, - PRIMARY KEY("guild_id") -) WITHOUT ROWID; - -COMMIT; diff --git a/src/db/migrations/0037-remove-leaked-webhooks.js b/src/db/migrations/0037-remove-leaked-webhooks.js new file mode 100644 index 00000000..02280533 --- /dev/null +++ b/src/db/migrations/0037-remove-leaked-webhooks.js @@ -0,0 +1,42 @@ +const {discord, db, from, select, sync} = require("../../passthrough") +/** @type {import("../../discord/utils")} */ +const dUtils = sync.require("../../discord/utils") + +const ones = "₀₁₂₃₄₅₆₇₈₉" +const tens = "0123456789" + +/* c8 ignore start */ + +module.exports = async function(db) { + // added tolerance to https://discordstatus.com/incidents/4hpm4454hxtx + const OUTAGE_START = 1778263200000 + const OUTAGE_END = 1778284800000 + + const startSnowflake = dUtils.timestampToSnowflakeInexact(OUTAGE_START) + const endSnowflake = dUtils.timestampToSnowflakeInexact(OUTAGE_END) + + const affectedChannels = from("message_room").join("historical_channel_room", "historical_room_index") + .pluck("reference_channel_id").selectUnsafe("DISTINCT reference_channel_id") + .and("WHERE message_id >= ? AND message_id <= ? AND length(message_id) = ?").all(startSnowflake, endSnowflake, startSnowflake.length) + let affectedWebhooks = select("webhook", ["channel_id", "webhook_id", "webhook_token"], {channel_id: affectedChannels}).all() + affectedWebhooks = affectedWebhooks.filter(w => BigInt(w.webhook_id) < BigInt(endSnowflake)) // if webhook ID is already newly generated then no need to replace + + if (affectedWebhooks.length) { + process.stdout.write(` revoking ${affectedWebhooks.length} possibly compromised webhooks... `) + for (let counter = 1; counter <= affectedWebhooks.length; counter++) { + 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}`) { + // OK + } else { + throw e + } + }) + db.prepare("DELETE FROM webhook WHERE channel_id = ?").run(webhook.channel_id) + + process.stdout.write(String(counter).at(-1) === "0" ? tens[(counter/10)%10] : ones[counter%10]) + } + process.stdout.write("\n") + } +} diff --git a/src/db/migrations/0038-fix-emoji-file-format.sql b/src/db/migrations/0038-fix-emoji-file-format.sql new file mode 100644 index 00000000..9e63150a --- /dev/null +++ b/src/db/migrations/0038-fix-emoji-file-format.sql @@ -0,0 +1,5 @@ +BEGIN TRANSACTION; + +DELETE FROM emoji WHERE mxc_url NOT IN (SELECT mxc_url FROM file WHERE discord_url LIKE 'https://cdn.discordapp.com/emojis/%.webp%'); + +COMMIT; diff --git a/src/db/migrations/0039-refactor-speedbump.sql b/src/db/migrations/0039-refactor-speedbump.sql new file mode 100644 index 00000000..de739dc8 --- /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/orm-defs.d.ts b/src/db/orm-defs.d.ts index f6ae14a6..14f320a2 100644 --- a/src/db/orm-defs.d.ts +++ b/src/db/orm-defs.d.ts @@ -1,23 +1,4 @@ export type Models = { - agi_prior_message: { - channel_id: string - username: string - avatar_url: string - use_caps: number - use_punct: number - use_apos: number - timestamp: number - } - - agi_optout: { - guild_id: string - } - - agi_cooldown: { - guild_id: string - timestamp: number - } - app_user_install: { guild_id: string app_bot_id: string @@ -37,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 @@ -63,6 +48,7 @@ export type Models = { event_subtype: string | null part: number reaction_part: number + /** 0 = Matrix, 1 = Discord */ source: number } @@ -156,6 +142,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/db/orm.js b/src/db/orm.js index 4d9b6f1a..87633148 100644 --- a/src/db/orm.js +++ b/src/db/orm.js @@ -104,6 +104,16 @@ class From { return r } + pluckUnsafe(col) { + /** @type {Pluck} */ + // @ts-ignore + const r = this + r.cols = [col] + r.makeColsSafe = false + r.isPluck = true + return r + } + /** * @param {string} sql */ diff --git a/src/db/orm.test.js b/src/db/orm.test.js index 6f6018ee..46390901 100644 --- a/src/db/orm.test.js +++ b/src/db/orm.test.js @@ -68,3 +68,8 @@ test("orm: select unsafe works (to select complex column names that can't be typ .all() t.equal(results[0].power_level, 150) }) + +test("orm: pluck unsafe works (to select complex column names that can't be type verified)", t => { + const result = from("channel_room").where({guild_id: "112760669178241024"}).pluckUnsafe("count(*)").get() + t.equal(result, 7) +}) diff --git a/src/discord/interactions/matrix-info.js b/src/discord/interactions/matrix-info.js index 0b9a5256..dcc99430 100644 --- a/src/discord/interactions/matrix-info.js +++ b/src/discord/interactions/matrix-info.js @@ -54,6 +54,7 @@ async function _interact({guild_id, data}, {api}) { // from Matrix const event = await api.getEvent(message.room_id, message.event_id) const via = await utils.getViaServersQuery(message.room_id, api) + const channelsInGuild = discord.guildChannelMap.get(guild_id) assert(channelsInGuild) const inChannels = channelsInGuild @@ -61,6 +62,11 @@ async function _interact({guild_id, data}, {api}) { .map(/** @returns {DiscordTypes.APIGuildChannel} */ cid => discord.channels.get(cid)) .sort((a, b) => webGuild._getPosition(a, discord.channels) - webGuild._getPosition(b, discord.channels)) .filter(channel => from("channel_room").join("member_cache", "room_id").select("mxid").where({channel_id: channel.id, mxid: event.sender}).get()) + let inChannelsText = inChannels.map(c => `<#${c.id}>`).join(" • ") + if (inChannelsText.length > 1024) { + inChannelsText = `In ${inChannels.length} channels` + } + const matrixMember = select("member_cache", ["displayname", "avatar_url"], {room_id: message.room_id, mxid: event.sender}).get() let name = matrixMember?.displayname || event.sender let avatar = utils.getPublicUrlForMxc(matrixMember?.avatar_url) @@ -98,7 +104,7 @@ async function _interact({guild_id, data}, {api}) { color: 0x0dbd8b, fields: [{ name: "In Channels", - value: inChannels.map(c => `<#${c.id}>`).join(" • ") + value: inChannelsText }, { name: "\u200b", value: idInfo diff --git a/src/discord/interactions/reactions.js b/src/discord/interactions/reactions.js index bd2f8560..36853cc1 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 50ddeca9..8d258f43 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 e3d58c48..dc972875 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 aed70682..6fb0b433 100644 --- a/src/discord/utils.js +++ b/src/discord/utils.js @@ -182,6 +182,406 @@ 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", + "accesslog", + "actionscript", + "ada", + "adoc", + "alan", + "angelscript", + "ansi", + "apache", + "apacheconf", + "applescript", + "arcade", + "arduino", + "arm", + "armasm", + "as", + "asc", + "asciidoc", + "aspectj", + "ass", + "atom", + "autohotkey", + "autoit", + "avrasm", + "awk", + "axapta", + "bash", + "basic", + "bat", + "bbcode", + "bf", + "bind", + "blade", + "bnf", + "brainfuck", + "c", + "c++", + "cal", + "capnp", + "capnproto", + "cc", + "chaos", + "chapel", + "chpl", + "cisco", + "clj", + "clojure", + "cls", + "cmake.in", + "cmake", + "cmd", + "coffee", + "coffeescript", + "console", + "coq", + "cos", + "cpc", + "cpp", + "cr", + "craftcms", + "crm", + "crmsh", + "crystal", + "cs", + "csharp", + "cshtml", + "cson", + "csp", + "css", + "csv", + "cxx", + "cypher", + "d", + "dart", + "delphi", + "dfm", + "diff", + "django", + "dns", + "docker", + "dockerfile", + "dos", + "dpr", + "dsconfig", + "dst", + "dts", + "dust", + "dylan", + "ebnf", + "elixir", + "elm", + "erl", + "erlang", + "ex", + "extempore", + "f90", + "f95", + "fix", + "fortran", + "freepascal", + "fs", + "fsharp", + "gams", + "gauss", + "gawk", + "gcode", + "gdscript", + "gemspec", + "gf", + "gherkin", + "glsl", + "gms", + "gn", + "gni", + "go", + "godot", + "golang", + "golo", + "gololang", + "gradle", + "graph", + "groovy", + "gss", + "gyp", + "h", + "h++", + "haml", + "handlebars", + "haskell", + "haxe", + "hbs", + "hcl", + "hh", + "hpp", + "hs", + "html.handlebars", + "html.hbs", + "html", + "http", + "https", + "hx", + "hxx", + "hy", + "hylang", + "i", + "i7", + "iced", + "iecst", + "inform7", + "ini", + "ino", + "instances", + "iol", + "irb", + "irpf90", + "java", + "javascript", + "jinja", + "jolie", + "js", + "json", + "jsp", + "jsx", + "julia-repl", + "julia", + "k", + "kaos", + "kdb", + "kotlin", + "kt", + "lasso", + "lassoscript", + "lazarus", + "ldif", + "leaf", + "lean", + "less", + "lfm", + "lisp", + "livecodeserver", + "livescript", + "ln", + "lock", + "log", + "lpr", + "ls", + "ls", + "lua", + "mak", + "make", + "makefile", + "markdown", + "mathematica", + "matlab", + "mawk", + "maxima", + "md", + "mel", + "mercury", + "mirc", + "mizar", + "mk", + "mkd", + "mkdown", + "ml", + "ml", + "mm", + "mma", + "mojolicious", + "monkey", + "moon", + "moonscript", + "mrc", + "n1ql", + "nawk", + "nc", + "never", + "nginx", + "nginxconf", + "nim", + "nimrod", + "nix", + "nsis", + "obj-c", + "obj-c++", + "objc", + "objective-c++", + "objectivec", + "ocaml", + "ocl", + "ol", + "openscad", + "osascript", + "oxygene", + "p21", + "parser3", + "pas", + "pascal", + "patch", + "pcmk", + "perl", + "pf.conf", + "pf", + "pgsql", + "php", + "php3", + "php4", + "php5", + "php6", + "php7", + "pl", + "plaintext", + "plist", + "pm", + "podspec", + "pony", + "postgres", + "postgresql", + "powershell", + "pp", + "processing", + "profile", + "prolog", + "properties", + "proto", + "protobuf", + "ps", + "ps1", + "puppet", + "py", + "pycon", + "python-repl", + "python", + "qml", + "r", + "razor-cshtml", + "razor", + "rb", + "re", + "reasonml", + "rebol", + "red-system", + "red", + "redbol", + "rf", + "rib", + "robot", + "rpm-spec", + "rpm-specfile", + "rpm", + "rs", + "rsl", + "rss", + "ruby", + "ruleslanguage", + "rust", + "sas", + "SAS", + "sc", + "scad", + "scala", + "scheme", + "sci", + "scilab", + "scl", + "scss", + "sh", + "shell", + "shexc", + "smali", + "smalltalk", + "sml", + "sol", + "solidity", + "spec", + "specfile", + "sql", + "srt", + "ssa", + "st", + "stan", + "stanfuncs", + "stata", + "step", + "stp", + "structured-text", + "styl", + "stylus", + "subunit", + "supercollider", + "svelte", + "svg", + "swift", + "tao", + "tap", + "tcl", + "terraform", + "tex", + "text", + "tf", + "thor", + "thrift", + "tk", + "toml", + "tp", + "ts", + "tsql", + "tsx", + "ttml", + "twig", + "txt", + "typescript", + "unicorn-rails-log", + "v", + "vala", + "vb", + "vba", + "vbnet", + "vbs", + "vbscript", + "verilog", + "vhdl", + "vim", + "vtt", + "wl", + "x++", + "x86asm", + "xhtml", + "xjb", + "xl", + "xml", + "xpath", + "xq", + "xquery", + "xsd", + "xsl", + "xtlang", + "xtm", + "yaml", + "yml", + "zep", + "zephir", + "zone", + "zsh" +]) + module.exports.getPermissions = getPermissions module.exports.getDefaultPermissions = getDefaultPermissions module.exports.hasPermission = hasPermission @@ -194,3 +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/add-reaction.js b/src/m2d/actions/add-reaction.js index e4981fbd..c4532448 100644 --- a/src/m2d/actions/add-reaction.js +++ b/src/m2d/actions/add-reaction.js @@ -17,7 +17,7 @@ const retrigger = sync.require("../../d2m/actions/retrigger") */ async function addReaction(event) { // Wait until the corresponding channel and message have already been bridged - if (retrigger.eventNotFoundThenRetrigger(event.content["m.relates_to"].event_id, () => as.emit("type:m.reaction", event))) return + if (!await retrigger.waitForEvent(event.content["m.relates_to"].event_id)) return // These will exist because it passed retrigger const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") @@ -50,6 +50,8 @@ async function addReaction(event) { } db.prepare("REPLACE INTO reaction (hashed_event_id, message_id, encoded_emoji, original_encoding) VALUES (?, ?, ?, ?)").run(utils.getEventIDHash(event.event_id), messageID, discordPreferredEncoding, key) + + retrigger.finishedBridging(event.event_id) } module.exports.addReaction = addReaction diff --git a/src/m2d/actions/emoji-sheet.js b/src/m2d/actions/emoji-sheet.js index ed5ab883..077240cc 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/redact.js b/src/m2d/actions/redact.js index 3135d311..7e49753f 100644 --- a/src/m2d/actions/redact.js +++ b/src/m2d/actions/redact.js @@ -10,6 +10,9 @@ const utils = sync.require("../../matrix/utils") /** @type {import("../../d2m/actions/retrigger")} */ const retrigger = sync.require("../../d2m/actions/retrigger") +/** @type {{messageID: string, emojiIdOrName: string}[]} */ +const m2dDeletedReactions = [] + /** * @param {Ty.Event.Outer_M_Room_Redaction} event */ @@ -24,6 +27,21 @@ async function deleteMessage(event) { db.prepare("DELETE FROM message_room WHERE message_id = ?").run(rows[0].message_id) } +/** + * @param {Ty.Event.Outer_M_Room_Redaction} event + */ +async function removeMessageEvent(event) { + // Could be for removing a message or suppressing embeds. For more information, the message needs to be bridged first. + if (!await retrigger.waitForEvent(event.redacts)) return + + const row = select("event_message", ["event_type", "event_subtype", "part"], {event_id: event.redacts}).get() + if (row && row.event_type === "m.room.message" && row.event_subtype === "m.notice" && row.part === 1) { + await suppressEmbeds(event) + } else { + await deleteMessage(event) + } +} + /** * @param {Ty.Event.Outer_M_Room_Redaction} event */ @@ -41,11 +59,20 @@ async function suppressEmbeds(event) { * @param {Ty.Event.Outer_M_Room_Redaction} event */ async function removeReaction(event) { + if (!await retrigger.waitForReactionEvent(event.redacts)) return + const hash = utils.getEventIDHash(event.redacts) const row = from("reaction").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") .select("reference_channel_id", "message_id", "encoded_emoji").where({hashed_event_id: hash}).get() if (!row) return - await discord.snow.channel.deleteReactionSelf(row.reference_channel_id, row.message_id, row.encoded_emoji) + // See how many Matrix-side reactions there are, and delete if it's the last one + const numberOfReactions = from("reaction").where({message_id: row.message_id, encoded_emoji: row.encoded_emoji}).pluckUnsafe("count(*)").get() + if (numberOfReactions === 1) { + // If a unicode emoji, the name is already the Discord preferred version because that's what was added and stored to encoded_emoji + const emojiIdOrName = decodeURIComponent(row.encoded_emoji).split(":").slice(-1)[0] + m2dDeletedReactions.push({messageID: row.message_id, emojiIdOrName}) + await discord.snow.channel.deleteReactionSelf(row.reference_channel_id, row.message_id, row.encoded_emoji) + } db.prepare("DELETE FROM reaction WHERE hashed_event_id = ?").run(hash) } @@ -54,18 +81,12 @@ async function removeReaction(event) { * @param {Ty.Event.Outer_M_Room_Redaction} event */ async function handle(event) { - // If this is for removing a reaction, try it - await removeReaction(event) - - // Or, it might be for removing a message or suppressing embeds. But to do that, the message needs to be bridged first. - if (retrigger.eventNotFoundThenRetrigger(event.redacts, () => as.emit("type:m.room.redaction", event))) return - - const row = select("event_message", ["event_type", "event_subtype", "part"], {event_id: event.redacts}).get() - if (row && row.event_type === "m.room.message" && row.event_subtype === "m.notice" && row.part === 1) { - await suppressEmbeds(event) - } else { - await deleteMessage(event) - } + // Don't know if it's a redaction for a reaction or an event, try both at the same time (otherwise waitFor will block) + await Promise.all([ + removeMessageEvent(event), + removeReaction(event) + ]) } module.exports.handle = handle +module.exports.m2dDeletedReactions = m2dDeletedReactions \ No newline at end of file diff --git a/src/m2d/actions/send-event.js b/src/m2d/actions/send-event.js index bce45c63..00557a11 100644 --- a/src/m2d/actions/send-event.js +++ b/src/m2d/actions/send-event.js @@ -39,20 +39,14 @@ async function resolvePendingFiles(message) { if ("key" in p) { // Encrypted file const d = crypto.createDecipheriv("aes-256-ctr", Buffer.from(p.key, "base64url"), Buffer.from(p.iv, "base64url")) - await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb( - // @ts-ignore - res.body - ).pipe(d)) + await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb(res.body).pipe(d)) return { name: p.name, file: d } } else { // Unencrypted file - const body = await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb( - // @ts-ignore - res.body - )) + const body = await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb(res.body)) return { name: p.name, file: body diff --git a/src/m2d/actions/sticker.js b/src/m2d/actions/sticker.js index 8eeb5d28..f5a1e160 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 00000000..c56ce4a5 --- /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 1ff2bb9e..1b3f1bd9 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 926b9572..84f8cc7c 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 70a4a1e0..18b98483 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") @@ -29,6 +30,8 @@ const pollComponents = sync.require("./poll-components") const setupEmojis = sync.require("../actions/setup-emojis") /** @type {import("../../d2m/converters/user-to-mxid")} */ const userToMxid = sync.require("../../d2m/converters/user-to-mxid") +/** @type {import("../../web/routes/letter-avatar")} */ +const letterAvatar = sync.require("../../web/routes/letter-avatar") /** @type {[RegExp, string][]} */ const markdownEscapes = [ @@ -95,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") @@ -471,7 +465,6 @@ async function checkWrittenMentions(content, senderMxid, roomID, guild, di) { // @ts-ignore - typescript doesn't know about indices yet content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `@everyone` + content.slice(writtenMentionMatch.indices[1][1]), ensureJoined: [], - /**@type {DiscordTypes.AllowedMentionsTypes[]}*/ // @ts-ignore - TypeScript is for whatever reason conviced that "everyone" cannot be assigned to AllowedMentionsTypes, but if you „Go to Definition”, you'll see that "everyone" is a valid enum value. allowedMentionsParse: ["everyone"], allowedMentionsUsers: [] } @@ -546,7 +539,6 @@ async function getL1L2ReplyLine(called = false) { async function eventToMessage(event, guild, channel, di) { let displayName = event.sender let avatarURL = undefined - /**@type {DiscordTypes.AllowedMentionsTypes[]}*/ // @ts-ignore - TypeScript is for whatever reason conviced that neither "users" no "roles" cannot be assigned to AllowedMentionsTypes, but if you „Go to Definition”, you'll see that both are valid enum values. const allowedMentionsParse = ["users", "roles"] const allowedMentionsUsers = [] /** @type {string[]} */ @@ -584,6 +576,13 @@ async function eventToMessage(event, guild, channel, di) { displayNameRunoff = "" } + // Avatar post-processing. Use a thumbnail for media, or generate letter avatar if none present. + if (avatarURL) { + avatarURL = avatarURL + "?preset=avatar" + } else { + avatarURL = letterAvatar.getLetterAvatarURL(event.sender, displayNameShortened) + } + let content = event.content["body"] || "" // ultimate fallback /** @type {{id: string, filename: string}[]} */ const attachments = [] @@ -615,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. @@ -630,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 @@ -768,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 @@ -783,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|
)+/g, " ") // Should all be on one line @@ -797,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 = "" @@ -823,7 +840,7 @@ async function eventToMessage(event, guild, channel, di) { } // Handling mentions of Discord users - input = input.replace(/("https:\/\/matrix.to\/#\/((?:@|%40)[^"]+)")>/g, (whole, attributeValue, mxid) => { + input = input.replace(/("https:\/\/matrix.to\/#\/((?:@|%40)[^"]+)")/g, (whole, attributeValue, mxid) => { mxid = decodeURIComponent(mxid) if (mxUtils.eventSenderIsFromDiscord(mxid)) { // Handle mention of an OOYE sim user by their mxid @@ -848,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
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|
\s*)*<\/blockquote>/g, "
") + input = input.replace(/(?:\n|
\s*)*<\/blockquote>(?:\n|
\s*)*/g, "") // 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. @@ -894,9 +911,12 @@ async function eventToMessage(event, guild, channel, di) { } // Check for incompatible backticks in code blocks let preNode - if (node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"]))) { + let isBackticksTextInPre = node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"])) + let isLongPre = node.tagName === "PRE" && node.textContent.length > 1800 && (preNode = node) + if (isBackticksTextInPre || isLongPre) { if (preNode.firstChild?.nodeName === "CODE") { - const ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1] || "txt" + let ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1] + if (!dUtils.supportedPlaintextPreviewExtensions.has(ext)) ext = "txt" const filename = `inline_code.${ext}` // Build the replacement node const replacementCode = doc.createElement("code") diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index bc73df72..5dd0af7f 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -61,7 +61,7 @@ test("event2message: body is used when there is no formatted_body", async t => { messagesToSend: [{ username: "cadence [they]", content: "testing plaintext", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -99,7 +99,7 @@ test("event2message: any markdown in body is escaped, except strikethrough", asy messagesToSend: [{ username: "cadence [they]", content: "testing \\*\\*special\\*\\* ~~things~~ which \\_should\\_ \\*not\\* \\`trigger\\` @any , except strikethrough", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -134,7 +134,7 @@ test("event2message: links in formatted body are not broken", async t => { messagesToSend: [{ username: "cadence [they]", content: "<@111604486476181504> I wonder what the midjourney text description of this photo is https://upload.wikimedia.org/wikipedia/commons/f/f3/After_gay_pride%2C_rainbow_flags_flying_along_Beach_Street_%2814853144744%29.jpg", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -167,7 +167,7 @@ test("event2message: links in plaintext body are not broken", async t => { messagesToSend: [{ username: "cadence [they]", content: "I wonder what the midjourney text description of this photo is https://upload.wikimedia.org/wikipedia/commons/f/f3/After_gay_pride%2C_rainbow_flags_flying_along_Beach_Street_%2814853144744%29.jpg", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -195,7 +195,7 @@ test("event2message: links in plaintext body are not broken when preceded by a n messagesToSend: [{ username: "cadence [they]", content: "java redstoners will be like \"I hate bedrock edition redstone!!\" meanwhile java edition:\nhttps://youtu.be/g_ORb7bN3CM", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -225,7 +225,7 @@ test("event2message: links in formatted body where the text & href are the same, messagesToSend: [{ username: "cadence [they]", content: "https://privatebin.net/?9111cb16f28da21b#62CKkEr6WvXZ1gQv2M6agazsA7tGYX8ZP8drETYujYZr", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -264,7 +264,7 @@ test("event2message: markdown in link text does not attempt to be escaped becaus messagesToSend: [{ username: "cadence [they]", content: "hey [@mario sports mix [she/her]](), is it possible to listen on a unix socket?", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["roles"], users: [] @@ -293,7 +293,7 @@ test("event2message: markdown in link url does not attempt to be escaped (plaint messagesToSend: [{ username: "cadence [they]", content: "the wikimedia commons freaks are gonna love this one https://commons.wikimedia.org/wiki/File:Car_covered_in_traffic_cones.jpg", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -328,7 +328,7 @@ test("event2message: markdown in link url does not attempt to be escaped (plaint messagesToSend: [{ username: "cadence [they]", content: "the wikimedia commons freaks are gonna love this one ", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -366,7 +366,7 @@ test("event2message: embeds are suppressed if the guild does not have embed link messagesToSend: [{ username: "cadence [they]", content: "posting one of my favourite songs recently (starts at timestamp) ", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -402,7 +402,7 @@ test("event2message: embeds are suppressed if the guild does not have embed link messagesToSend: [{ username: "cadence [they]", content: "posting one of my favourite songs recently (starts at timestamp) ", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -445,7 +445,7 @@ test("event2message: embeds are suppressed if the channel does not have embed li messagesToSend: [{ username: "cadence [they]", content: "posting one of my favourite songs recently (starts at timestamp) ", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -476,7 +476,7 @@ test("event2message: links retain angle brackets (formatted body)", async t => { messagesToSend: [{ username: "cadence [they]", content: "posting one of my favourite songs recently (starts at timestamp) ", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -505,7 +505,7 @@ test("event2message: links retain angle brackets (plaintext body)", async t => { messagesToSend: [{ username: "cadence [they]", content: "posting one of my favourite songs recently (starts at timestamp) ", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -546,7 +546,7 @@ test("event2message: links don't have angle brackets added by accident", async t messagesToSend: [{ username: "Erquint", content: "Wanted to automate WG→AWG config enrichment and ended up basically coding a batch INI processor.\nhttps://github.com/Erquint/wgcbp", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=E&hue=180", allowed_mentions: { parse: ["roles"], users: [] @@ -581,7 +581,7 @@ test("event2message: basic html is converted to markdown", async t => { messagesToSend: [{ username: "cadence [they]", content: "this **is** a _**test** __of___ ~~_formatting_~~", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -615,7 +615,7 @@ test("event2message: spoilers work", async t => { messagesToSend: [{ username: "cadence [they]", content: "this **is** a ||_test_|| of ||spoilers||", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -649,7 +649,7 @@ test("event2message: spoiler reasons work", async t => { messagesToSend: [{ username: "cadence [they]", content: "\\(cw crossword spoilers you'll never believe. don't tell anybody\\) ||zoe kills a 5 letter noun at the end||", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -689,7 +689,7 @@ test("event2message: media spoilers work", async t => { messagesToSend: [{ username: "underscore_x", content: "", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=U&hue=270", attachments: [{id: "0", filename: "SPOILER_pitstop.png"}], pendingFiles: [{ mxc: "mxc://agiadn.org/JY5NvEFojTvYDp5znjGIkkQ7Ez7GwsdT", @@ -735,7 +735,7 @@ test("event2message: media spoilers with reason work", async t => { parse: ["users", "roles"] }, content: "(Spoiler: golden witch solutions)", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=U&hue=270", attachments: [{id: "0", filename: "SPOILER_pitstop.png"}], pendingFiles: [{ mxc: "mxc://agiadn.org/JY5NvEFojTvYDp5znjGIkkQ7Ez7GwsdT", @@ -781,7 +781,7 @@ test("event2message: spoiler files too large for Discord are linked and retain r parse: ["users", "roles"] }, content: "(Spoiler: golden witch secrets)\n🖼️ _Uploaded **SPOILER** file: ||[pitstop.png](https://bridge.example.org/download/matrix/agiadn.org/JY5NvEFojTvYDp5znjGIkkQ7Ez7GwsdT )|| (40 MB)_", - avatar_url: undefined + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=U&hue=270" }] } ) @@ -812,7 +812,7 @@ test("event2message: markdown syntax is escaped", async t => { messagesToSend: [{ username: "cadence [they]", content: "this \\*\\*is\\*\\* an **_extreme_** \\\\\\*test\\\\\\* of", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -846,7 +846,7 @@ test("event2message: html lines are bridged correctly", async t => { messagesToSend: [{ username: "cadence [they]", content: "paragraph one\nline _two_\nline three\n\nparagraph two\nline _two_\nline three\n\nparagraph three\n\nparagraph four\nline two\nline three\nline four\n\nparagraph five", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -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: "

this was inspired by someone telling me

\n
\n

betrothed is an insane vocab pull

\n
\n
\n

i think thats part of why im so scared of cadence

\n
\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

\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({ @@ -880,7 +914,7 @@ test("event2message: html lines are bridged correctly", async t => { messagesToSend: [{ username: "cadence [they]", content: "line one: test test\nline two: **test** **test**\nline three: **test test**\nline four: test test\n line five", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -915,7 +949,7 @@ test("event2message: whitespace is collapsed", async t => { messagesToSend: [{ username: "cadence [they]", content: "line one: test test\nline two: **test** **test**\nline three: **test test**\nline four: test test\nline five", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -951,7 +985,7 @@ test("event2message: lists are bridged correctly", async t => { messagesToSend: [{ username: "cadence [they]", content: "* line one\n* line two\n* line three\n * nested one\n * nested two\n* line four", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -983,14 +1017,14 @@ test("event2message: long messages are split", async t => { messagesToSend: [{ username: "cadence [they]", content: (("a".repeat(130) + " ").repeat(15)).slice(0, -1), - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } }, { username: "cadence [they]", content: (("a".repeat(130) + " ").repeat(4)).slice(0, -1), - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -1024,7 +1058,7 @@ test("event2message: code blocks work", async t => { messagesToSend: [{ username: "cadence [they]", content: "preceding\n\n```\ncode block\n```\n\nfollowing `code` is inline", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -1054,7 +1088,7 @@ test("event2message: code block contents are formatted correctly and not escaped messagesToSend: [{ username: "cadence [they]", content: "```\ninput = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,\n_input_ = input = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,\n```\n\n`input = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,`", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -1084,7 +1118,7 @@ test("event2message: code blocks use double backtick as delimiter when necessary messagesToSend: [{ username: "cadence [they]", content: "``backtick in ` the middle``, `` backtick at the edge` ``", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -1114,7 +1148,7 @@ test("event2message: inline code is converted to code block if it contains both messagesToSend: [{ username: "cadence [they]", content: "``` ` one two `` ```", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -1146,7 +1180,39 @@ test("event2message: code blocks are uploaded as attachments instead if they con content: "So if you run code like this `[inline_code.java]` it should print a markdown formatted code block", attachments: [{id: "0", filename: "inline_code.java"}], pendingFiles: [{name: "inline_code.java", buffer: Buffer.from('System.out.println("```");')}], - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) +}) + +test("event2message: code blocks are uploaded as attachments instead if they contain incompatible backticks (use txt extension if discord does not recognise the language)", async t => { + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "wrong body", + format: "org.matrix.custom.html", + formatted_body: 'So if you run code like this
System.out.println("```");
it should print a markdown formatted code block' + }, + event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "cadence [they]", + content: "So if you run code like this `[inline_code.txt]` it should print a markdown formatted code block", + attachments: [{id: "0", filename: "inline_code.txt"}], + pendingFiles: [{name: "inline_code.txt", buffer: Buffer.from('System.out.println("```");')}], + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -1178,7 +1244,39 @@ test("event2message: code blocks are uploaded as attachments instead if they con content: "So if you run code like this `[inline_code.txt]` it should print a markdown formatted code block", attachments: [{id: "0", filename: "inline_code.txt"}], pendingFiles: [{name: "inline_code.txt", buffer: Buffer.from('System.out.println("```");')}], - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) +}) + +test("event2message: code blocks are uploaded as attachments instead if they are really long", async t => { + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "wrong body", + format: "org.matrix.custom.html", + formatted_body: `So if you run code like this
${"A".repeat(2000)}
it should print a markdown formatted code block` + }, + event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "cadence [they]", + content: "So if you run code like this `[inline_code.js]` it should print a markdown formatted code block", + attachments: [{id: "0", filename: "inline_code.js"}], + pendingFiles: [{name: "inline_code.js", buffer: Buffer.from("A".repeat(2000))}], + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -1221,7 +1319,7 @@ test("event2message: characters are encoded properly in code blocks", async t => + '\n .map(|c| c.get(1).unwrap().as_str())' + '\n .collect::();' )}], - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -1230,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: { @@ -1255,7 +1353,41 @@ test("event2message: quotes have an appropriate amount of whitespace", async t = messagesToSend: [{ username: "cadence [they]", content: "> Chancellor of Germany Angela Merkel, on March 17, 2017: they did not shake hands\n🤨", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) +}) + +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: "
\n

short story

\n
\n

by whose standards? LOL

\n

I basically went all the way back to childhood (which analysts always like you to do)

\n
\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"] } @@ -1296,7 +1428,7 @@ test("event2message: lists have appropriate line breaks", async t => { messagesToSend: [{ username: "Milan", content: `i am not certain what you mean by "already exists with as discord". my goals are\n\n* bridgeing specific channels with existing matrix rooms\n * optionally maybe entire "servers"\n* offering the bridge as a public service`, - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=M&hue=210", allowed_mentions: { parse: ["roles"], users: [] @@ -1338,7 +1470,7 @@ test("event2message: ordered list start attribute works", async t => { messagesToSend: [{ username: "Milan", content: `i am not certain what you mean by "already exists with as discord". my goals are\n\n1. bridgeing specific channels with existing matrix rooms\n 2. optionally maybe entire "servers"\n2. offering the bridge as a public service`, - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=M&hue=210", allowed_mentions: { parse: ["roles"], users: [] @@ -1371,7 +1503,7 @@ test("event2message: m.emote plaintext works", async t => { messagesToSend: [{ username: "cadence [they]", content: "\\* cadence \\[they\\] tests an m.emote message", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -1405,7 +1537,7 @@ test("event2message: m.emote markdown syntax is escaped", async t => { messagesToSend: [{ username: "cadence [they]", content: "\\* cadence \\[they\\] shows you \\*\\*her\\*\\* **_extreme_** \\\\\\*test\\\\\\* of", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -1458,7 +1590,7 @@ test("event2message: rich reply to a sim user", async t => { content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:" + " Slow news day." + "\nTesting this reply, ignore", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -1514,7 +1646,7 @@ test("event2message: rich reply to a sim user, explicitly enabling mentions in c content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:" + " Slow news day." + "\nTesting this reply, ignore", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["roles"], users: ["111604486476181504"] @@ -1569,7 +1701,7 @@ test("event2message: rich reply to a sim user, explicitly disabling mentions in content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:" + " Slow news day." + "\nTesting this reply, ignore", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["roles"], users: [] @@ -1642,7 +1774,7 @@ test("event2message: rich reply to a rich reply to a multi-line message should c content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**Ⓜcadence [they]**:" + " I just checked in a fix that will probably work..." + "\nwill try later (tomorrow if I don't forgor)", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -1722,7 +1854,7 @@ test("event2message: rich reply to an already-edited message will quote the new content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647><@111604486476181504>:" + " this is the new content. heya!" + "\nhiiiii....", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -1775,7 +1907,7 @@ test("event2message: rich reply to a missing event will quote from formatted_bod username: "cadence [they]", content: "-# > But who sees the seashells she sells sitting..." + "\nWhat a tongue-bender...", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -1824,7 +1956,7 @@ test("event2message: rich reply to a missing event without formatted_body will u messagesToSend: [{ username: "cadence [they]", content: "Testing this reply, ignore", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -1875,7 +2007,7 @@ test("event2message: rich reply to a missing event and no reply fallback will no messagesToSend: [{ username: "cadence [they]", content: "Testing this reply, ignore.", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -1927,7 +2059,7 @@ test("event2message: should avoid using blockquote contents as reply preview in content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:" + " that can't be true! there's no way :o" + "\nI agree!", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -1976,7 +2108,7 @@ test("event2message: should suppress embeds for links in reply preview", async t content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1273204543739396116 **Ⓜcadence [they]**:" + " " + `\nEveryone in the comments is like "you're so ahead of the curve" and "can't believe this came out before the scandal" but I thought Mr Beast sucking was a common sentiment`, - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=R&hue=240", allowed_mentions: { parse: ["users", "roles"] } @@ -1985,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
this is either written by claude or somebody who's too claude-brained and I just can't get through it`, + "m.relates_to": { + "m.in_reply_to": { + event_id: "$qmyjr-ISJtnOM5WTWLI0fT7uSlqRLgpyin2d2NCglCU" + } + } + }, + event_id: "$0Bs3rbsXaeZmSztGMx1NIsqvOrkXOpIWebN-dqr09i4", + room_id: "!fGgIymcYWOqjbSRUdV:cadence.moe" + }, data.guild.general, data.channel.general, { + api: { + getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$qmyjr-ISJtnOM5WTWLI0fT7uSlqRLgpyin2d2NCglCU", { + "type": "m.room.message", + "sender": "@_ooye_.wing.:cadence.moe", + "content": { + "m.mentions": {}, + "msgtype": "m.text", + "body": "", + "format": "org.matrix.custom.html", + "formatted_body": "https://huggingface.co/blog/security-incident-july-2026" + } + }) + } + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "cadence [they]", + content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1273204543739396116 <@112890272819507200>:" + + " ..." + + `\nthis is either written by claude or somebody who's too claude-brained and I just can't get through it`, + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) +}) + test("event2message: should include a reply preview when message ends with a blockquote", async t => { t.deepEqual( await eventToMessage({ @@ -2065,7 +2248,7 @@ test("event2message: should include a reply preview when message ends with a blo content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**Ⓜ_ooye_cookie**:" + " tanget: @..." + "\naichmophobia", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -2149,7 +2332,7 @@ test("event2message: should include a reply preview when replying to a descripti content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/497161350934560778/1162625810109317170 <@1109360903096369153>:" + " It looks like this queue has ended." + `\nso you're saying on matrix side I would have to edit ^this^ to add "Timed out" before the blockquote?`, - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -2202,7 +2385,7 @@ test("event2message: entities are not escaped in main message or reply preview", content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**Ⓜcadence [they]**:" + " Testing? \"':.`[]&things" + "\n_Testing?_ \"':.\\`\\[\\]&things", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -2252,7 +2435,7 @@ test("event2message: reply preview converts emoji formatting when replying to a content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**Ⓜcadence [they]**:" + " <:hippo:230201364309868544>" + "\nreply", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -2302,7 +2485,7 @@ test("event2message: reply preview can guess custom emoji based on the name if i content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**Ⓜcadence [they]**:" + " <:hippo:230201364309868544>" + "\nreply", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -2352,7 +2535,7 @@ test("event2message: reply preview uses emoji title text when replying to an unk content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**Ⓜcadence [they]**:" + " :svkftngur_gkdne:" + "\nreply", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -2402,7 +2585,7 @@ test("event2message: reply preview ignores garbage image", async t => { content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**Ⓜcadence [they]**:" + " I am having a nice day" + "\nreply", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -2451,7 +2634,7 @@ test("event2message: reply to empty message doesn't show an extra line or anythi username: "cadence [they]", content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**Ⓜcadence [they]**" + "\nreply", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -2534,7 +2717,7 @@ test("event2message: editing a rich reply to a sim user", async t => { content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:" + " Slow news day." + "\nEditing this reply, which is also a test", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -2590,7 +2773,7 @@ test("event2message: editing a plaintext body message", async t => { message: { username: "cadence [they]", content: "well, I guess it's no longer brand new... it's existed for mere seconds...", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -2645,7 +2828,7 @@ test("event2message: editing a plaintext message to be longer", async t => { message: { content: "aaaaaaaaa ".repeat(198) + "well, I guess it's", username: "cadence [they]", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -2654,7 +2837,7 @@ test("event2message: editing a plaintext message to be longer", async t => { messagesToSend: [{ content: "no longer brand new... it's existed for mere seconds..." + ("aaaaaaaaa ".repeat(20)).slice(0, -1), username: "cadence [they]", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -2707,7 +2890,7 @@ test("event2message: editing a plaintext message to be shorter", async t => { message: { username: "cadence [they]", content: "well, I guess it's no longer brand new... it's existed for mere seconds...", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -2768,7 +2951,7 @@ test("event2message: editing a formatted body message", async t => { message: { username: "cadence [they]", content: "**well, I guess it's no longer brand new... it's existed for mere seconds...**", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -2825,7 +3008,7 @@ test("event2message: rich reply to a matrix user's long message with formatting" content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 **Ⓜcadence [they]**:" + " i should have a little happy test list bold em..." + "\n**no you can't!!!**", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -2887,7 +3070,7 @@ test("event2message: rich reply to an image", async t => { username: "cadence [they]", content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504> 🖼️" + "\nCaught in 8K UHD VR QLED Epic Edition", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -2943,7 +3126,7 @@ test("event2message: rich reply to a spoiler should ensure the spoiler is hidden content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:" + " [spoiler] cw crossword spoilers you'll never..." + "\nomg NO WAY!!", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -2998,7 +3181,7 @@ test("event2message: with layered rich replies, the preview should only be the r content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 **Ⓜcadence [they]**:" + " two" + "\nthree", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -3055,7 +3238,7 @@ test("event2message: if event is a reply and starts with a quote, they should be + " i have a feeling that clients are meant to strip..." + "\n" + "\n> To strip the fallback on the `body`, the client should iterate over each line of the string, removing any lines that start with the fallback prefix (\"> “, including the space, without quotes) and stopping when a line is encountered without the prefix. This prefix is known as the “fallback prefix sequence”.", - avatar_url: "https://bridge.example.org/download/matrix/syndicated.gay/ZkBUPXCiXTjdJvONpLJmcbKP", + avatar_url: "https://bridge.example.org/download/matrix/syndicated.gay/ZkBUPXCiXTjdJvONpLJmcbKP?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -3113,7 +3296,7 @@ test("event2message: rich reply to a deleted event", async t => { username: "Ampflower 🌺", content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>**ⓂAmpflower 🌺** (in reply to a deleted message)" + "\nHuh it did the same thing here too", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/PRfhXYBTOalvgQYtmCLeUXko", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/PRfhXYBTOalvgQYtmCLeUXko?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -3159,7 +3342,7 @@ test("event2message: rich reply to a state event with no body", async t => { messagesToSend: [{ username: "Ampflower 🌺", content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647> (channel details edited)\nnice room topic", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/PRfhXYBTOalvgQYtmCLeUXko", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/PRfhXYBTOalvgQYtmCLeUXko?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -3247,7 +3430,7 @@ test("event2message: rich reply with an image", async t => { id: "0", }, ], - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", pendingFiles: [ { mxc: "mxc://cadence.moe/yxMobQMbSqNHpajxgSHtaooG", @@ -3284,7 +3467,7 @@ test("event2message: raw mentioning discord users in plaintext body works", asyn messagesToSend: [{ username: "cadence [they]", content: "<@114147806469554185> what do you think?", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3318,7 +3501,7 @@ test("event2message: raw mentioning discord users in formatted body works", asyn messagesToSend: [{ username: "cadence [they]", content: "<@114147806469554185> what do you think?", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3352,7 +3535,7 @@ test("event2message: mentioning discord users works", async t => { messagesToSend: [{ username: "cadence [they]", content: "I'm just <@114147806469554185> testing mentions", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3361,6 +3544,50 @@ test("event2message: mentioning discord users works", async t => { ) }) +test("event2message: mentioning discord users with extra html attributes works", async t => { + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@lavender.pet:queer.sh", + content: { + msgtype: "m.text", + body: "also @_ooye_ampflower:cadence.moe fun fact at some point there is plans for FTE to have a built in map editor", + "m.mentions": { + user_ids: [ + "@_ooye_ampflower:cadence.moe" + ] + }, + format: "org.matrix.custom.html", + formatted_body: "

also @Ampflower fun fact at some point there is plans for FTE to have a built in map editor

" + }, + room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe", + origin_server_ts: 1778616745263, + unsigned: { + age: 100363692, + membership: "join" + }, + event_id: "$AHCkieLEVCrCEA3INTCl0rh44V29fCASlZpBKw7DzQU", + user_id: "@lavender.pet:queer.sh", + age: 100363692 + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "lavender.pet", + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=L&hue=30", + content: "also <@196188877885538304> fun fact at some point there is plans for FTE to have a built in map editor", + allowed_mentions: { + parse: ["roles"], + users: ["196188877885538304"] + } + }] + } + ) +}) + + test("event2message: mentioning discord users works when URL encoded", async t => { t.deepEqual( await eventToMessage({ @@ -3386,7 +3613,7 @@ test("event2message: mentioning discord users works when URL encoded", async t = messagesToSend: [{ username: "cadence [they]", content: "<@771520384671416320> a sample message", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3420,7 +3647,7 @@ test("event2message: mentioning PK discord users works", async t => { messagesToSend: [{ username: "cadence [they]", content: "I'm just **@Azalea &flwr; 🌺** (<@196188877885538304>) testing mentions", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3454,7 +3681,7 @@ test("event2message: mentioning matrix users works", async t => { messagesToSend: [{ username: "cadence [they]", content: "I'm just [@▲]() testing mentions", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3495,7 +3722,7 @@ test("event2message: matrix mentions are not double-escaped when embed links per messagesToSend: [{ username: "cadence [they]", content: "I'm just [@▲]() testing mentions", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3529,7 +3756,7 @@ test("event2message: multiple mentions are both escaped", async t => { messagesToSend: [{ username: "cadence [they]", content: "[@cadence:cadence.moe]() can you kick my old account over there [@amyiscoolz:matrix.atiusamy.com]()", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3559,7 +3786,7 @@ test("event2message: mentioning matrix users works even when Element disambiguat messagesToSend: [{ username: "cadence [they]", content: "[@unascribed]() if you want to run some experimental software, `11864f80cf` branch of OOYE has _vastly_ improved handling of PluralKit users. feel free to try it out, if you find bugs I'd appreciate you letting me know (just tag me at the place in chat where something went wrong)", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3593,7 +3820,7 @@ test("event2message: mentioning bridged rooms works", async t => { messagesToSend: [{ username: "cadence [they]", content: "I'm just <#1100319550446252084> testing channel mentions", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3625,7 +3852,7 @@ test("event2message: mentioning bridged rooms works (plaintext body)", async t = messagesToSend: [{ username: "cadence [they]", content: "I'm just <#1100319550446252084> testing channel mentions", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3668,7 +3895,7 @@ test("event2message: mentioning bridged rooms by alias works", async t => { messagesToSend: [{ username: "cadence [they]", content: "I'm just <#1100319550446252084> testing channel mentions", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3710,7 +3937,7 @@ test("event2message: mentioning bridged rooms by alias works (plaintext body)", messagesToSend: [{ username: "cadence [they]", content: "I'm just <#1100319550446252084> testing channel mentions", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3751,7 +3978,7 @@ test("event2message: mentioning bridged rooms by alias skips the link when alias messagesToSend: [{ username: "cadence [they]", content: "I'm just and <#1100319550446252084> testing channel mentions", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3784,7 +4011,7 @@ test("event2message: mentioning known bridged events works (plaintext body)", as messagesToSend: [{ username: "cadence [they]", content: "it was uploaded earlier in https://discord.com/channels/497159726455455754/497161350934560778/1141619794500649020, take a look!", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3818,7 +4045,7 @@ test("event2message: mentioning known bridged events works (partially formatted messagesToSend: [{ username: "cadence [they]", content: "it was uploaded earlier in https://discord.com/channels/497159726455455754/497161350934560778/1141619794500649020", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3852,7 +4079,7 @@ test("event2message: mentioning known bridged events works (formatted body)", as messagesToSend: [{ username: "cadence [they]", content: "it was uploaded earlier in https://discord.com/channels/497159726455455754/497161350934560778/1141619794500649020", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3886,7 +4113,7 @@ test("event2message: mentioning known bridged events followed by line break and messagesToSend: [{ username: "cadence [they]", content: "https://discord.com/channels/497159726455455754/497161350934560778/1141619794500649020<@114147806469554185>", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3932,7 +4159,7 @@ test("event2message: mentioning unknown bridged events can approximate with time messagesToSend: [{ username: "cadence [they]", content: "it was uploaded years ago in https://discord.com/channels/497159726455455754/497161350934560778/753895613661184000", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -3977,7 +4204,7 @@ test("event2message: mentioning events falls back to original link when server d messagesToSend: [{ username: "cadence [they]", content: "it was uploaded years ago in [amanda-spam]()", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -4021,7 +4248,7 @@ test("event2message: mentioning events falls back to original link when the chan messagesToSend: [{ username: "cadence [they]", content: "it was uploaded years ago in [ex-room-doesnt-exist-any-more]()", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -4051,7 +4278,7 @@ test("event2message: link to event in an unknown room (href link)", async t => { messagesToSend: [{ username: "cadence [they]", content: "ah yeah, here's where the bug was reported: ", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -4081,7 +4308,7 @@ test("event2message: link to event in an unknown room (bare link)", async t => { messagesToSend: [{ username: "cadence [they]", content: "PK API failure, tho idk how you'd handle that ", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -4109,7 +4336,7 @@ test("event2message: link to event in an unknown room (plaintext)", async t => { messagesToSend: [{ username: "cadence [they]", content: "ah yeah, here's where the bug was reported: ", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -4143,7 +4370,7 @@ test("event2message: colon after mentions is stripped", async t => { messagesToSend: [{ username: "cadence [they]", content: "<@114147806469554185> hey, I'm just [@▲]() testing mentions", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -4188,7 +4415,7 @@ test("event2message: caches the member if the member is not known", async t => { messagesToSend: [{ username: "should_be_newly_cached", content: "testing the member state cache", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/this_is_the_avatar", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/this_is_the_avatar?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -4196,7 +4423,7 @@ test("event2message: caches the member if the member is not known", async t => { } ) - t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe"}).all(), [ + t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe", mxid: "@should_be_newly_cached:cadence.moe"}).all(), [ {avatar_url: "mxc://cadence.moe/this_is_the_avatar", displayname: null, mxid: "@should_be_newly_cached:cadence.moe"} ]) t.equal(called, 1, "getStateEvent should be called once") @@ -4238,7 +4465,7 @@ test("event2message: does not cache the member if the room is not known", async messagesToSend: [{ username: "should_not_be_cached", content: "testing the member state cache", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/this_is_the_avatar", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/this_is_the_avatar?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -4284,7 +4511,7 @@ test("event2message: skips caching the member if the member does not exist, some messagesToSend: [{ username: "not_real", content: "should honestly never happen", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=N&hue=180", allowed_mentions: { parse: ["users", "roles"] } @@ -4331,7 +4558,7 @@ test("event2message: overly long usernames are shifted into the message content" messagesToSend: [{ username: "I am BLACK I am WHITE I am SHORT I am LONG I am EVERYTHING YOU THINK IS", content: "**IMPORTANT and I DON'T MATTER**\ntesting the member state cache", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=I&hue=270", allowed_mentions: { parse: ["users", "roles"] } @@ -4367,7 +4594,7 @@ test("event2message: overly long usernames are not treated specially when the ms messagesToSend: [{ username: "I am BLACK I am WHITE I am SHORT I am LONG I am EVERYTHING YOU THINK IS", content: "\\* I am BLACK I am WHITE I am SHORT I am LONG I am EVERYTHING YOU THINK IS IMPORTANT and I DON'T MATTER looks at the start of the message", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=I&hue=270", allowed_mentions: { parse: ["users", "roles"] } @@ -4400,7 +4627,7 @@ test("event2message: text attachments work", async t => { messagesToSend: [{ username: "cadence [they]", content: "", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", attachments: [{id: "0", filename: "chiki-powerups.txt"}], pendingFiles: [{name: "chiki-powerups.txt", mxc: "mxc://cadence.moe/zyThGlYQxvlvBVbVgKDDbiHH"}] }] @@ -4436,7 +4663,7 @@ test("event2message: image attachments work", async t => { messagesToSend: [{ username: "cadence [they]", content: "", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", attachments: [{id: "0", filename: "cool cat.png"}], pendingFiles: [{name: "cool cat.png", mxc: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}] }] @@ -4472,7 +4699,7 @@ test("event2message: image attachments can have a plaintext caption", async t => messagesToSend: [{ username: "cadence [they]", content: "Cat emoji surrounded by pink hearts", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", attachments: [{id: "0", filename: "cool cat.png"}], pendingFiles: [{name: "cool cat.png", mxc: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}], allowed_mentions: { @@ -4521,7 +4748,7 @@ test("event2message: image attachments can have a formatted caption", async t => messagesToSend: [{ username: "cadence [they]", content: "this event has `formatting`", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", attachments: [{id: "0", filename: "5740.jpg"}], pendingFiles: [{name: "5740.jpg", mxc: "mxc://thomcat.rocks/RTHsXmcMPXmuHqVNsnbKtRbh"}], allowed_mentions: { @@ -4574,7 +4801,7 @@ test("event2message: encrypted image attachments work", async t => { messagesToSend: [{ username: "cadence [they]", content: "", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", attachments: [{id: "0", filename: "image.png"}], pendingFiles: [{ name: "image.png", @@ -4659,7 +4886,7 @@ test("event2message: evil encrypted image attachment works", async t => { messagesToSend: [{ username: "Austin Huang", content: "", - avatar_url: "https://bridge.example.org/download/matrix/tchncs.de/090a2b5e07eed2f71e84edad5207221e6c8f8b8e", + avatar_url: "https://bridge.example.org/download/matrix/tchncs.de/090a2b5e07eed2f71e84edad5207221e6c8f8b8e?preset=avatar", attachments: [{id: "0", filename: "Screenshot 2025-06-29 at 13.36.46.png"}], pendingFiles: [{ name: "Screenshot 2025-06-29 at 13.36.46.png", @@ -4702,7 +4929,7 @@ test("event2message: large attachments are uploaded if the server boost level is messagesToSend: [{ username: "cadence [they]", content: "", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", attachments: [{id: "0", filename: "cool cat.png"}], pendingFiles: [{name: "cool cat.png", mxc: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}] }] @@ -4738,7 +4965,7 @@ test("event2message: files too large for Discord are linked as as URL", async t messagesToSend: [{ username: "cadence [they]", content: "🖼️ _Uploaded file: [cool cat.png](https://bridge.example.org/download/matrix/cadence.moe/IvxVJFLEuksCNnbojdSIeEvn) (40 MB)_", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -4775,7 +5002,7 @@ test("event2message: files too large for Discord can have a plaintext caption", messagesToSend: [{ username: "cadence [they]", content: "Cat emoji surrounded by pink hearts\n🖼️ _Uploaded file: [cool cat.png](https://bridge.example.org/download/matrix/cadence.moe/IvxVJFLEuksCNnbojdSIeEvn) (40 MB)_", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -4822,7 +5049,7 @@ test("event2message: files too large for Discord can have a formatted caption", messagesToSend: [{ username: "cadence [they]", content: "this event has `formatting`\n🖼️ _Uploaded file: [5740.jpg](https://bridge.example.org/download/matrix/thomcat.rocks/RTHsXmcMPXmuHqVNsnbKtRbh) (40 MB)_", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -4864,7 +5091,7 @@ test("event2message: stickers work", async t => { messagesToSend: [{ username: "cadence [they]", content: "[get_real2](https://bridge.example.org/download/sticker/cadence.moe/NyMXQFAAdniImbHzsygScbmN/_.webp)", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -4892,7 +5119,7 @@ test("event2message: stickers fetch mimetype from server when mimetype not provi messagesToSend: [{ username: "cadence [they]", content: "[YESYESYES](https://bridge.example.org/download/sticker/cadence.moe/ybOWQCaXysnyUGuUCaQlTGJf/_.webp)", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -4922,7 +5149,7 @@ test("event2message: static emojis work", async t => { messagesToSend: [{ username: "cadence [they]", content: "<:hippo:230201364309868544>", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -4952,7 +5179,7 @@ test("event2message: emojis in other servers are reused if they have the same ti messagesToSend: [{ username: "cadence [they]", content: "<:hippo:230201364309868544>", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -4982,7 +5209,7 @@ test("event2message: animated emojis work", async t => { messagesToSend: [{ username: "cadence [they]", content: "", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5012,7 +5239,7 @@ test("event2message: unknown emojis in the middle are linked", async t => { messagesToSend: [{ username: "cadence [they]", content: "a [:ms_robot_grin:](https://bridge.example.org/download/matrix/cadence.moe/RLMgJGfgTPjIQtvvWZsYjhjy) b", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5061,7 +5288,7 @@ test("event2message: guessed @mentions in plaintext may join members to mention" messagesToSend: [{ username: "cadence [they]", content: "hey <@321876634777218072>, what food would you like to order?", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5114,7 +5341,7 @@ test("event2message: guessed @mentions in formatted body may join members to men messagesToSend: [{ username: "cadence [they]", content: "**_HEY <@321876634777218072>, WHAT FOOD WOULD YOU LIKE TO ORDER??_**", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5157,7 +5384,7 @@ test("event2message: guessed @mentions feature will not activate on links or cod messagesToSend: [{ username: "cadence [they]", content: "in link [view timeline](https://example.com/social/@subtext) in autolink https://example.com/social/@subtext in pre-code```\n@subtext\n```", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5187,7 +5414,7 @@ test("event2message: guessed @mentions work with other matrix bridge old users", messagesToSend: [{ username: "cadence [they]", content: "<@114147806469554185> <@176943908762006200> back me up on this sentiment, if not necessarily the phrasing", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5244,7 +5471,7 @@ test("event2message: @room converts to @everyone and is allowed when the room do messagesToSend: [{ username: "cadence [they]", content: "@everyone dinner's ready", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles", "everyone"] } @@ -5301,7 +5528,7 @@ test("event2message: @room converts to @everyone but is not allowed when the roo messagesToSend: [{ username: "cadence [they]", content: "@room dinner's ready", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5360,7 +5587,7 @@ test("event2message: @room converts to @everyone and is allowed if the user has messagesToSend: [{ username: "cadence [they]", content: "@everyone dinner's ready", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles", "everyone"] } @@ -5390,7 +5617,7 @@ test("event2message: @room in the middle of a link is not converted", async t => messagesToSend: [{ username: "cadence [they]", content: "https://github.com/@room/repositories https://github.com/@room/repositories", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5426,7 +5653,7 @@ test("event2message: table", async t => { + "\nAardvark Bee Crocodile" + "\nArgon Boron Carbon ```" + "more content", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5456,7 +5683,7 @@ test("event2message: unknown emoji at the end is used for sprite sheet", async t messagesToSend: [{ username: "cadence [they]", content: "a b [\u2800](https://bridge.example.org/download/sheet?e=cadence.moe%2FRLMgJGfgTPjIQtvvWZsYjhjy)", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5486,7 +5713,7 @@ test("event2message: known emoji from an unreachable server at the end is used f messagesToSend: [{ username: "cadence [they]", content: "a b [\u2800](https://bridge.example.org/download/sheet?e=cadence.moe%2FbZFuuUSEebJYXUMSxuuSuLTa)", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5516,7 +5743,7 @@ test("event2message: known and unknown emojis in the end are used for sprite she messagesToSend: [{ username: "cadence [they]", content: "known unknown: <:hippo:230201364309868544> [:ms_robot_dress:](https://bridge.example.org/download/matrix/cadence.moe/wcouHVjbKJJYajkhJLsyeJAA) and known unknown: [\u2800](https://bridge.example.org/download/sheet?e=cadence.moe%2FWbYqNlACRuicynBfdnPYtmvc&e=cadence.moe%2FHYcztccFIPgevDvoaWNsEtGJ)", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5550,7 +5777,7 @@ test("event2message: com.beeper.per_message_profile overrides displayname and av messagesToSend: [{ username: "Unstable Name", content: "hello from unstable profile", - avatar_url: "https://bridge.example.org/download/matrix/maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo", + avatar_url: "https://bridge.example.org/download/matrix/maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -5583,7 +5810,7 @@ test("event2message: com.beeper.per_message_profile empty avatar_url clears avat messagesToSend: [{ username: "No Avatar User", content: "hello with cleared avatar", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=N&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5619,7 +5846,7 @@ test("event2message: data-mx-profile-fallback element is stripped from formatted messagesToSend: [{ username: "Tidus Herboren", content: "one more test", - avatar_url: "https://bridge.example.org/download/matrix/maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo", + avatar_url: "https://bridge.example.org/download/matrix/maunium.net/hgXsKqlmRfpKvCZdUoWDkFQo?preset=avatar", allowed_mentions: { parse: ["users", "roles"] } @@ -5652,7 +5879,7 @@ test("event2message: displayname prefix is stripped from plain body when per-mes messagesToSend: [{ username: "Tidus Herboren", content: "one more test", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=T&hue=90", allowed_mentions: { parse: ["users", "roles"] } @@ -5681,7 +5908,7 @@ test("event2message: all unknown chess emojis are used for sprite sheet", async messagesToSend: [{ username: "cadence [they]", content: "testing [\u2800](https://bridge.example.org/download/sheet?e=cadence.moe%2FlHfmJpzgoNyNtYHdAmBHxXix&e=cadence.moe%2FMtRdXixoKjKKOyHJGWLsWLNU&e=cadence.moe%2FHXfFuougamkURPPMflTJRxGc&e=cadence.moe%2FikYKbkhGhMERAuPPbsnQzZiX&e=cadence.moe%2FAYPpqXzVJvZdzMQJGjioIQBZ&e=cadence.moe%2FUVuzvpVUhqjiueMxYXJiFEAj&e=cadence.moe%2FlHfmJpzgoNyNtYHdAmBHxXix&e=cadence.moe%2FMtRdXixoKjKKOyHJGWLsWLNU&e=cadence.moe%2FHXfFuougamkURPPMflTJRxGc&e=cadence.moe%2FikYKbkhGhMERAuPPbsnQzZiX&e=cadence.moe%2FAYPpqXzVJvZdzMQJGjioIQBZ&e=cadence.moe%2FUVuzvpVUhqjiueMxYXJiFEAj)", - avatar_url: undefined, + avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", allowed_mentions: { parse: ["users", "roles"] } diff --git a/src/m2d/converters/threads-and-forums.js b/src/m2d/converters/threads-and-forums.js deleted file mode 100644 index 05760b47..00000000 --- a/src/m2d/converters/threads-and-forums.js +++ /dev/null @@ -1,106 +0,0 @@ -//@ts-check - -/* - * Misc. utils for transforming various Matrix events (eg. those sent in Forum-bridged channels; those sent) so that they're usable as threads, and for creating said threads. - */ - -const Ty = require("../../types") -const {discord, sync, select, from} = require("../../passthrough") -const DiscordTypes = require("discord-api-types/v10") - -/** @type {import("../../matrix/api")}) */ -const api = sync.require("../../matrix/api") -/** @type {import("../../d2m/actions/create-room")} */ -const createRoom = sync.require("../../d2m/actions/create-room") - -/** - * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} event Used for determining the branching-point and the title; any relation data will be stripped and its room_id will mutate to the target thread-room, if one gets created. - * @returns {Promise} whether a thread-room was created - */ -async function bridgeThread(event) { - /** @type {string} */ // @ts-ignore - const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() - const channel = discord.channels.get(channelID) - const guildID = channel?.["guild_id"] - if (!guildID) return false; //Room not bridged? We don't care. It's a Matrix-native room, let Matrix users have standard Matrix-native threads there. - - const threadEventID = event.content["m.relates_to"]?.event_id - if (!threadEventID) throw new Error("There was an event sent inside SOME Matrix thread, but it lacked any information as to what thread it actually was!"); //An „ugly error” is justified because if something like this DOES happen, then that means that it should be reported to us, as there is some broken client out there that we should account for. - const messageID = select("event_message", "message_id", {event_id: threadEventID}).pluck().get() - if (!messageID) return false; //Message not bridged? Too bad! Discord users will just see normal replies, and Matrix uses won't get a thread-room. We COULD technically create a "headless" thread on Discord side and bridge it to a new thread-room, but that comes with a whole host of complications on its own (notably: what do we do if the message gets bridged later (by reaction emoji), and then hypothetically gets its own thread; and: getThreadRoomFromThreadEvent will have to be much more complex than a simple DB call (probably a whole new DB table would have to be created, just to hold these Matrix-branched-but-headless-on-Discord threads) because the simple „MX event --(db)--> Discord Message --(Discord spec)--> Discord thread” relation would no longer hold true), which may not be worth it, as an unbridged message in a bridged channel is already an edge-case (so it seems pointless to introduce a whole bunch of edgier-cases that handling this edge-case "properly" would bring). - - try { - event.room_id = await createRoom.ensureRoom((await discord.snow.channel.createThreadWithMessage(channelID, messageID, {name: computeName(event, await api.getEvent(event.room_id, threadEventID)).name})).id) - return true; - } - catch (e){ - if (e.message?.includes("50024")){ //see: https://docs.discord.com/developers/topics/opcodes-and-status-codes - api.sendEvent(event.room_id, "m.room.message", { - body: "Hey, please don't do that! This room is already a thread on Discord (or it could also be a voice-chat or something adjacent) - trying to embed threads inside it, like you just did, will not work. DC users will just see a regular reply, which is distracting and also probably not what you want.", - "m.mentions": { "user_ids": [event.sender]}, - "m.relates_to": { - event_id: threadEventID, - is_falling_back: false, - "m.in_reply_to": { event_id: event.event_id }, - rel_type: "m.thread" - }, - msgtype: "m.text" - }) - - return false; - } - else throw e - } -} - -/** - * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} event - * @returns {Promise} whether a forum-thread-room was created - */ -async function handleForums(event) { - if (event.content.body === "/thread") return false; //Let the help be shown normally - - const row = from("channel_room").where({room_id: event.room_id}).select("channel_id").get() - /** @type {string}*/ //@ts-ignore the possibility that it's undefined - get() will return back an undefined if it's fed one (so that's undefined-safe), and createThreadWithoutMessage() won't be reached because "undefined" is neither DiscordTypes.ChannelType.GuildMedia nor DiscordTypes.ChannelType.GuildForum, so the guard clause kicks in. - let channelID = row?.channel_id - const channel = discord.channels.get(channelID) - if (channel?.type != DiscordTypes.ChannelType.GuildForum && channel?.type != DiscordTypes.ChannelType.GuildMedia) return false - - const name = computeName(event) - let resetNeeded = false - try { - if(channel.flags && channel.flags & DiscordTypes.ChannelFlags.RequireTag){ - await discord.snow.channel.updateChannel(channelID, {flags:(channel.flags^DiscordTypes.ChannelFlags.RequireTag)}, "Temporary override of tagging requirements because Matrix threads that can't be tagged yet.") - resetNeeded = true - } - //@ts-ignore the presence of message:{} and the absence of type:11 - that's intended for threads in Forum channels (*and no, createThreadWithMessage wouldn't work here, either - that one takes an ALREADY EXISTING message ID, whereas this needs a new message) - await discord.snow.channel.createThreadWithoutMessage(channelID, {name: name.name, message:{content:"**Created by: `"+ event.sender +"`**"}}) - if (!name.truncated) api.redactEvent(event.room_id, event.event_id) //Don't destroy people's texts - only remove if no truncation is guaranteed. - if (resetNeeded) discord.snow.channel.updateChannel(channelID, {flags:channel.flags}, "Restoring flags to their original state.") - } - catch (e){ - if (e.message?.includes("50013")){ - api.sendEvent(event.room_id, "m.room.message", { - body: "You can't create threads in this forum right now! This forum is configured to require tags on post (Matrix users can't yet use tags yet), and OOYE doesn't have the permission to edit this channel on Discord (needed to bypass the requirement of tags). Unless this is intentional (see room description - the admins may have left a note), please ask someone on the Discord side to either grant OOYE the necessary permissions, or to remove tagging requirements.", - msgtype: "m.text" - }) - } - else throw e - } - return true -} - -/** - * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} from event - * @param {Ty.Event.Outer | null | false | undefined} fallback Reuses the "from" param value if empty. - * @returns {{name: string, truncated: boolean}} - */ -function computeName(from, fallback=null){ - let name = from.content.body - if (name.startsWith("/thread ") && name.length > 8) name = name.substring(8); - else name = (fallback ? fallback : from).content.body; - return name.length < 100 ? {name: name.replaceAll("\n", " "), truncated: false} : {name: name.slice(0, 96).replaceAll("\n", " ") + "...", truncated: true} -} - -module.exports.handleForums = handleForums -module.exports.bridgeThread = bridgeThread \ No newline at end of file diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index f7191bec..0560f461 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -9,7 +9,6 @@ const Ty = require("../types") const {discord, db, sync, as, select} = require("../passthrough") const {tag} = require("@cloudrac3r/html-template-tag") const {Semaphore} = require("@chriscdn/promise-semaphore") -const { bridgeThread, handleForums } = require("./converters/threads-and-forums") /** @type {import("./actions/send-event")} */ const sendEvent = sync.require("./actions/send-event") @@ -25,6 +24,8 @@ const vote = sync.require("./actions/vote") const matrixCommandHandler = sync.require("../matrix/matrix-command-handler") /** @type {import("../matrix/utils")} */ const utils = sync.require("../matrix/utils") +/** @type {import("../matrix/mreq")}) */ +const mreq = sync.require("../matrix/mreq") /** @type {import("../matrix/api")}) */ const api = sync.require("../matrix/api") /** @type {import("../d2m/actions/create-room")} */ @@ -33,6 +34,10 @@ const createRoom = sync.require("../d2m/actions/create-room") const roomUpgrade = require("../matrix/room-upgrade") /** @type {import("../d2m/actions/retrigger")} */ const retrigger = sync.require("../d2m/actions/retrigger") +/** @type {import("../matrix/homeserver-status")} */ +const homeserverStatus = sync.require("../matrix/homeserver-status") +/** @type {import("./actions/typing")} */ +const typing = sync.require("./actions/typing") const {reg} = require("../matrix/read-registration") let lastReportedEvent = 0 @@ -95,6 +100,11 @@ function printError(type, source, e, payload) { console.dir(payload, {depth: null}) } +/** @param {string} stack */ +function cleanErrorStack(stack) { + return stack.replace(/(\/webhooks\/[0-9]+\/)[a-zA-Z0-9_-]+/g, "$1(redacted)") +} + /** * @param {string} roomID * @param {"Discord" | "Matrix"} source @@ -135,7 +145,7 @@ async function sendError(roomID, source, type, e, payload) { builder.addLine(errorIntroLine) // Where - const stack = stringifyErrorStack(e) + const stack = cleanErrorStack(stringifyErrorStack(e)) builder.addLine(`Error trace:\n${stack}`, tag`
Error trace
${stack}
`) // How @@ -144,7 +154,7 @@ async function sendError(roomID, source, type, e, payload) { // Send try { - await api.sendEvent(roomID, "m.room.message", { + const errorEventID = await api.sendEvent(roomID, "m.room.message", { ...builder.get(), "moe.cadence.ooye.error": { source: source.toLowerCase(), @@ -154,18 +164,25 @@ async function sendError(roomID, source, type, e, payload) { user_ids: ["@cadence:cadence.moe"] } }) - } catch (e) {} + // Add reaction indicating that errors may be retried + await api.sendEvent(roomID, "m.reaction", { + "m.relates_to": { + rel_type: "m.annotation", + event_id: errorEventID, + key: "🔁" + } + }) + } catch (e) { + if ( + e.toString().includes("fetch failed") + || (e instanceof mreq.MatrixServerError && [502, 503].includes(e.httpStatus) && e.errcode === "CX_SERVER_ERROR") + ) { + // Matrix homeserver is down (reverse proxy indicated failure) + homeserverStatus.homeserverStatus.setErrorWithPacket(payload) + } + } } -/** - * Wraps the function with an automated error catching and reporting mechanism - * @template {Ty.Event.Outer} EVENT The event that the wrapped function processes, its first argument. - * @template {[]} ARGS Other arguments of the wrapped function - * @template RETURNS The output of the wrapped function - * @param {string} type Type of the event, during the processing of which the error may occur. - * @param {(event: EVENT, ...args: ARGS)=>RETURNS|Promise} fn Function to wrap - * @returns {(event: EVENT, ...args: ARGS)=>Promise} Wrapped function - */ function guard(type, fn) { return async function(event, ...args) { try { @@ -182,6 +199,7 @@ const errorRetrySema = new Semaphore() * @param {Ty.Event.Outer} reactionEvent */ async function onRetryReactionAdd(reactionEvent) { + if (reactionEvent.sender === `@${reg.sender_localpart}:${reg.ooye.server_name}`) return // Don't respond to the bot's own indicative reaction const roomID = reactionEvent.room_id await errorRetrySema.request(async () => { const event = await api.getEvent(roomID, reactionEvent.content["m.relates_to"]?.event_id) @@ -215,36 +233,13 @@ sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message", */ async event => { if (utils.eventSenderIsFromDiscord(event.sender)) return - if (await handleForums(event)) return - - let processCommands = true - if (event.content["m.relates_to"]?.rel_type === "m.thread") { - /**@type {string|null} */ - let toRedact = event.room_id - const bridgedTo = utils.getThreadRoomFromThreadEvent(event.content["m.relates_to"].event_id) - processCommands = false - - if (bridgedTo) event.room_id = bridgedTo; - else if (!await bridgeThread(event)) toRedact = null; //Don't remove anything, if there is nowhere to relocate it to. - - if (toRedact) { - api.redactEvent(toRedact, event.event_id) - event.content["m.relates_to"] = undefined - api.sendEvent(event.room_id, event.type, {...event.content, body: event.content.body+"\n ~ "+event.sender, formatted_body: event.content.formatted_body ? event.content.formatted_body+"
~ "+event.sender :undefined }) - } - } - const messageResponses = await sendEvent.sendEvent(event) if (!messageResponses.length) return - - if (event.type === "m.room.message" && event.content.msgtype === "m.text" && processCommands) { - await matrixCommandHandler.parseAndExecute( - // @ts-ignore - TypeScript doesn't know that the event.content.msgtype === "m.text" check ensures that event isn't of type Ty.Event.Outer_M_Room_Message_File (which, indeed, wouldn't fit here) - event - ) + if (event.type === "m.room.message" && event.content.msgtype === "m.text") { + // @ts-ignore + await matrixCommandHandler.execute(event) } - - retrigger.messageFinishedBridging(event.event_id) + retrigger.finishedBridging(event.event_id) await api.ackEvent(event) })) @@ -255,7 +250,7 @@ sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker", async event => { if (utils.eventSenderIsFromDiscord(event.sender)) return const messageResponses = await sendEvent.sendEvent(event) - retrigger.messageFinishedBridging(event.event_id) + retrigger.finishedBridging(event.event_id) await api.ackEvent(event) })) @@ -534,6 +529,21 @@ async event => { await createRoom.unbridgeChannel(channel, channel["guild_id"], "Encrypted rooms are not supported. This room was removed from the bridge.") })) +sync.addTemporaryListener(as, "ephemeral_type:m.typing", guard("m.typing", +/** + * @param {Ty.Event.Outer_M_Typing} event + */ +async event => { + const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() + if (!channelID) return + if (event.content.user_ids.some(mxid => !utils.eventSenderIsFromDiscord(mxid))) { + typing.startTyping(channelID) + } else { + typing.stopTyping(channelID) + } +})) + module.exports.stringifyErrorStack = stringifyErrorStack +module.exports.cleanErrorStack = cleanErrorStack module.exports.sendError = sendError module.exports.printError = printError diff --git a/src/m2d/event-dispatcher.test.js b/src/m2d/event-dispatcher.test.js index de754da3..2de6381a 100644 --- a/src/m2d/event-dispatcher.test.js +++ b/src/m2d/event-dispatcher.test.js @@ -1,7 +1,7 @@ // @ts-check const {test} = require("supertape") -const {stringifyErrorStack} = require("./event-dispatcher") +const {stringifyErrorStack, cleanErrorStack} = require("./event-dispatcher") test("stringify error stack: works", t => { function a() { @@ -21,3 +21,30 @@ test("stringify error stack: works", t => { t.match(str, /^ \[prop\]: 2.1$/m) } }) + +test("clean error stack: removes webhook token", t => { + t.notMatch( + cleanErrorStack(` + DiscordAPIError: Service resource is being rate limited. + at fn (/var/home/cadence/out-of-your-element/node_modules/snowtransfer/src/RequestHandler.ts:591:13) + at process.processTicksAndRejections (node:internal/process/task_queues:105:5) + at exports.RequestHandler.request (/var/home/cadence/out-of-your-element/node_modules/snowtransfer/src/RequestHandler.ts:546:17) + at WebhookMethods.executeWebhook (/var/home/cadence/out-of-your-element/node_modules/snowtransfer/src/methods/Webhook.ts:249:35) + at /var/home/cadence/out-of-your-element/src/m2d/actions/channel-webhook.js:65:31 + at withWebhook (/var/home/cadence/out-of-your-element/src/m2d/actions/channel-webhook.js:47:9) + at process.processTicksAndRejections (node:internal/process/task_queues:105:5) + at async Object.sendMessageWithWebhook (/var/home/cadence/out-of-your-element/src/m2d/actions/channel-webhook.js:64:17) + at async Object.sendEvent (/var/home/cadence/out-of-your-element/src/m2d/actions/send-event.js:132:27) + at async /var/home/cadence/out-of-your-element/src/m2d/event-dispatcher.js:208:27 + at async AppService. (/var/home/cadence/out-of-your-element/src/m2d/event-dispatcher.js:162:11) { + [method]: "POST" + [path]: "/webhooks/1160903754728611841/pfRqHl9vVZImdqwWWSZxxH8T-JJMnauxroMnHsvC6ARA-3B9_STH_bnHB9pd7QQaUVCG" + [code]: 40062 + [httpStatus]: 429 + [request]: {"endpoint":"/webhooks/1160903754728611841/pfRqHl9vVZImdqwWWSZxxH8T-JJMnauxroMnHsvC6ARA-3B9_STH_bnHB9pd7QQaUVCG","method":"POST","dataType":"json","data":{"content":"https://discordstatus.com/#day\nOnly what discord tell us right now","allowed_mentions":{"parse":["roles"],"users":[]},"username":"lewri","avatar_url":"https://bridge.cadence.moe/download/matrix/matrix.org/URWwrtSUONGOYhfMsdUzcrir"}} + [response]: {} + [name]: "DiscordAPIError"` + ), + /pfRqHl9v/ + ) +}) diff --git a/src/matrix/api.js b/src/matrix/api.js index f24f4d96..47fc565d 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -452,7 +452,8 @@ async function ping() { headers: { Authorization: `Bearer ${reg.as_token}` }, - body: "{}" + body: "{}", + signal: AbortSignal.timeout(15e3) }) const root = await res.json() return { @@ -463,17 +464,29 @@ async function ping() { } /** - * Given an mxc:// URL, and an optional height for thumbnailing, get the file from the content repository. Returns res. + * Given an mxc:// URL, and optional parameters for thumbnailing, get the file from the content repository. Returns res. + * + * Note that Synapse currently doesn't support animated thumbnails: https://github.com/element-hq/synapse/pull/18831 + * @see https://spec.matrix.org/v1.18/client-server-api/#get_matrixclientv1mediathumbnailservernamemediaid * @param {string} mxc - * @param {RequestInit & {height?: number | string}} [init] + * @param {RequestInit & {thumbnail?: {height?: number | string, width?: number | string, animated?: boolean, method?: "crop" | "scale"}}} [init] * @return {Promise}>} */ async function getMedia(mxc, init = {}) { - const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/) + init = {...init} + + const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/([a-zA-Z0-9_-]+)$/) assert(mediaParts) - const downloadOrThumbnail = init.height ? "thumbnail" : "download" - let url = `${mreq.baseUrl}/client/v1/media/${downloadOrThumbnail}/${mediaParts[1]}/${mediaParts[2]}` - if (init.height) url += "?" + new URLSearchParams({height: String(init.height), width: String(init.height)}) + + let route = "download" + let query = "" + + if (init.thumbnail) { + route = "thumbnail" + query = "?" + new URLSearchParams(Object.keys(init.thumbnail).map(k => [k, String(init.thumbnail?.[k])])) + } + + let url = `${mreq.baseUrl}/client/v1/media/${route}/${mediaParts[1]}/${mediaParts[2]}${query}` const res = await fetch(url, { headers: { Authorization: `Bearer ${reg.as_token}` diff --git a/src/matrix/homeserver-status.js b/src/matrix/homeserver-status.js new file mode 100644 index 00000000..3ceac84e --- /dev/null +++ b/src/matrix/homeserver-status.js @@ -0,0 +1,134 @@ +// @ts-check + +const assert = require("assert") +const Denque = require("denque") +const StateMachine = require("snowtransfer").StateMachine + +const passthrough = require("../passthrough") +const {sync} = passthrough +/** @type {import("../d2m/discord-packets")} */ +const discordPackets = sync.require("../d2m/discord-packets") +/** @type {import("../matrix/api")} */ +const api = sync.require("../matrix/api") + +const DEBUG_HOMESERVER_STATUS = false + +function debugHomeserverStatus(message) { + if (DEBUG_HOMESERVER_STATUS) { + console.log(message) + } +} + +const homeserverStatus = new class HomeserverStatus { + constructor() { + /** @private */ + this.queue = new Denque() + + /** @private */ + this.pingInterval = undefined + + /** @private */ + this.sm = new StateMachine("online") + .defineState("online") + + .defineState("checking", { + onEnter: [async () => { + const pingResult = await api.ping().catch(e => ({ok: false})) + if (pingResult.ok) { + this.sm.doTransition("check ok") + } else { + this.sm.doTransition("check fail") + } + }], + onLeave: [], + transitions: new Map() + }) + + .defineState("offline", { + onEnter: [() => { + this.pingInterval = setInterval(async () => { + const pingResult = await api.ping().catch(e => ({ok: false, status: "net", root: e.message})) + if (pingResult.ok) { + this.sm.doTransition("ping ok") + } + }, 15e3) + }], + onLeave: [() => { + clearInterval(this.pingInterval) + }], + transitions: new Map() + }) + + .defineState("recovering", { + onEnter: [async () => { // Drain queue. + while (!this.queue.isEmpty()) { + const packet = this.queue.peekFront() // same position as .shift() + debugHomeserverStatus(`homeserver status: ${new Date().toISOString()} dq packet ${packet.t} ${packet.d?.content}`) + await discordPackets.dispatchPacketToBridge(passthrough.discord, packet) + if (this.sm.currentStateName !== "recovering") return // got kicked out due to another error + this.queue.shift() + } + this.sm.doTransition("recovered") + }], + onLeave: [], + transitions: new Map() + }) + + .defineUniversalTransition("error", "offline") + .defineTransition("offline", "ping ok", "recovering") + .defineTransition("recovering", "recovered", "online") + .defineTransition("online", "check", "checking") + .defineTransition("checking", "check ok", "recovering") + .defineTransition("checking", "check fail", "offline") + + this.sm.on("enter", st => debugHomeserverStatus(`homeserver status: ${st}`)) + this.sm.setMaxListeners(101) + this.sm.freeze() + } + + isRealTime() { + return this.sm.currentStateName === "online" + } + + /** @param {boolean} forceCheck */ + waitForOnline(forceCheck) { + const onlinePromise = new Promise(resolve => { + // Already online? Start check or just done + if (this.sm.currentStateName === "online") { + if (forceCheck) { + this.sm.doTransition("check") + } else { + return resolve(null) + } + } + + // Checking or not online. Wait for online. + const onlineListener = stateName => { + if (stateName === "online") { + this.sm.removeListener("enter", onlineListener) + resolve(null) + } + } + this.sm.on("enter", onlineListener) + }) + return onlinePromise + } + + /** + * When offline or recovering, call this for incoming packets to queue them to be sent in order later. + */ + queuePacket(packet) { + assert(["offline", "recovering"].includes(this.sm.currentStateName)) + this.queue.push(packet) + } + + setErrorWithPacket(packet) { + const wasRecovering = this.sm.currentStateName === "recovering" + this.sm.doTransition("error") + if (!wasRecovering) { // if was recovering then packet is already in the right place in queue + this.queuePacket(packet) + } + } +} + +module.exports.homeserverStatus = homeserverStatus diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index 2b99200c..b38b4b12 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -261,65 +261,8 @@ const commands = [{ body: "This command creates a thread on Discord. But you aren't allowed to do this, because if you were a Discord user, you wouldn't have the Create Public Threads permission." }) } - - const relation = event.content["m.relates_to"] - let isFallingBack = false; - let branchedFromMxEvent = relation?.["m.in_reply_to"]?.event_id // By default, attempt to branch the thread from the message to which /thread was replying. - if (relation?.rel_type === "m.thread") branchedFromMxEvent = relation?.event_id // If /thread was sent inside a Matrix thread, attempt to branch the Discord thread from the message, which that Matrix thread already is branching from. - if (!branchedFromMxEvent){ - branchedFromMxEvent = event.event_id // If /thread wasn't replying to anything (ie. branchedFromMxEvent was undefined at initial assignment), or if the event was somehow malformed (in such a way that that - one way or another - branchedFromMxEvent ended up being undefined, even if according to the spec it shouldn't), branch the thread from the /thread command-message that created it. - isFallingBack = true; - } - const branchedFromDiscordMessage = select("event_message", "message_id", {event_id: branchedFromMxEvent}).pluck().get() - if (words.length < 2){ - if (isFallingBack) return api.sendEvent(event.room_id, "m.room.message", { - ...ctx, - msgtype: "m.text", - body: "**`/thread` usage:**\nRun this command as `/thread [Thread Name]` to create a thread. The message from which said thread will branch, is chosen based on the following rules:\n* If ran stand-alone (not as a reply, nor in a Matrix thread), the created thread will branch from the command-message itself. The `Thread Name` argument must be provided in this case, otherwise you get this help message.\n* If sent as a reply (outside a Matrix thread), the thread will branch from the message to which you replied.\n* If ran inside an existing Matrix thread (regardless of whether it's a reply or not), the created Discord thread will be branching from the same message as the Matrix thread already is.", - format: "org.matrix.custom.html", - formatted_body: "/thread usage:
Run this command as /thread [Thread Name] to create a thread on Discord (and optionally Matrix, if one doesn't exist already). The message from which said thread will branch, is chosen based on the following rules:
  • If ran stand-alone (not as a reply, nor in a Matrix thread), the created thread will branch from the command-message itself. The Thread Name argument must be provided in this case, otherwise you get this help message.
  • If sent as a reply (outside a Matrix thread), the thread will branch from the message to which you replied.
  • If ran inside an existing Matrix thread (regardless of whether it's a reply or not), the created Discord thread will be branching from the same message as the Matrix thread already is.
" - }) - words[1] = (await api.getEvent(event.room_id, branchedFromMxEvent)).content.body.replaceAll("\n", " ") - words[1] = words[1].length < 100 ? words[1] : words[1].slice(0, 96) + "..." - } - - try { - if (branchedFromDiscordMessage) return await discord.snow.channel.createThreadWithMessage(channelID, branchedFromDiscordMessage, {name: words.slice(1).join(" ")}) //can't just return the promise directly like in 99% of other cases here in commands, otherwise the error-handling below will not work - else {return api.sendEvent(event.room_id, "m.room.message", { - ...ctx, - msgtype: "m.text", - body: "⚠️ Couldn't find a Discord representation of the message from which you're trying to branch this thread (event ID `"+branchedFromMxEvent+"` on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported.", - format: "org.matrix.custom.html", - formatted_body: "⚠️ Couldn't find a Discord representation of the message from which you're trying to branch this thread (event ID "+branchedFromMxEvent+" on Matrix), so it wasn't created. Either you ran this command on an unbridged message (one sent by this bot or one that failed to bridge due to a previous error), or this is an error on our side and should be reported." - })}; - } - catch (e){ - /**@type {string|undefined} */ - let err = e.message // see: https://docs.discord.com/developers/topics/opcodes-and-status-codes - - if (err?.includes("160004")) { - if (isFallingBack) throw e; //Discord claims that there already exists a thread for the message ran this command was ran on, but that doesn't make logical sense, as it doesn't seem like it was ran on any message. Either the Matrix client did something funny with reply/thread tags, or this is a logic error on our side. At any rate, this should be reported to OOYE for further investigation, which the user should do when encountering an „ugly error” (if they follow the „every error should be reported” directive), so this is re-thrown as-is (no stacktrace-breaking exception wrapping) to be turned into such an „ugly error” upstream. - const thread = mxUtils.getThreadRoomFromThreadEvent(branchedFromMxEvent) - return api.sendEvent(event.room_id, "m.room.message", { - ...ctx, - msgtype: "m.text", - body: "There already exists a Discord thread for the message you ran this command on" + (thread ? " - you may join its bridged room here: https://matrix.to/#/"+thread+"?"+(await mxUtils.getViaServersQuery(thread, api)).toString() : ", so a new one cannot be crated. However, it seems like that thread isn't bridged to any Matrix rooms. Please ask the space/server admins to rectify this issue by creating the bridge. (If you're said admin and you can see that said bridge already exists, but this error message is still showing up, please report that as a bug.)") - }) - } - if (err?.includes("50024")) return api.sendEvent(event.room_id, "m.room.message", { - ...ctx, - msgtype: "m.text", - body: "You cannot create threads in a Discord channel of the type, to which this Matrix room is bridged to. It could be something like a VC, or perhaps... Did you try to embed a thread inside a thread, silly?" - }) - if (err?.includes("50035")) return api.sendEvent(event.room_id, "m.room.message", { - ...ctx, - msgtype: "m.text", - body: "Specified thread name is too long - thread creation failed. Please yap a bit less in the title, the thread body is for that. ;)" - }) - - throw e //Some other error happened, one that OOYE didn't anticipate the possibility of? It should be reported to us for further investigation, which the user should do when encountering an „ugly error” (if they follow the „every error should be reported” directive), so this is re-thrown as-is (no stacktrace-breaking exception wrapping) to be turned into such an „ugly error” upstream. - } + await discord.snow.channel.createThreadWithoutMessage(channelID, {type: 11, name: words.slice(1).join(" ")}) } ) }, { @@ -378,11 +321,8 @@ const commands = [{ }] -/** - * @param {Ty.Event.Outer_M_Room_Message} event - * @returns {Promise|undefined} the executed command's in-process promise or undefined if no command execution was performed -*/ -function parseAndExecute(event) { +/** @type {CommandExecute} */ +async function execute(event) { let realBody = event.content.body while (realBody.startsWith("> ")) { const i = realBody.indexOf("\n") @@ -402,8 +342,8 @@ function parseAndExecute(event) { const command = commands.find(c => c.aliases.includes(commandName)) if (!command) return - return command.execute(event, realBody, words) + await command.execute(event, realBody, words) } -module.exports.parseAndExecute = parseAndExecute +module.exports.execute = execute module.exports.onReactionAdd = onReactionAdd diff --git a/src/matrix/mreq.js b/src/matrix/mreq.js index bb59506f..b6bc5b9d 100644 --- a/src/matrix/mreq.js +++ b/src/matrix/mreq.js @@ -9,9 +9,14 @@ const {reg} = require("./read-registration.js") const baseUrl = `${reg.ooye.server_origin}/_matrix` class MatrixServerError extends Error { - constructor(data, opts) { + /** + * @param {number} httpStatus} + */ + constructor(data, httpStatus, opts = {}) { super(data.error || data.errcode) this.data = data + /** @type {number} */ + this.httpStatus = httpStatus /** @type {string} */ this.errcode = data.errcode this.opts = opts @@ -44,11 +49,11 @@ async function _convertBody(body) { async function makeMatrixServerError(res, opts = {}) { delete opts.headers?.["Authorization"] if (res.headers.get("content-type") === "application/json") { - return new MatrixServerError(await res.json(), opts) + return new MatrixServerError(await res.json(), res.status, opts) } else if (res.headers.get("content-type")?.startsWith("text/")) { - return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, message: await res.text()}, opts) + return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, message: await res.text()}, res.status, opts) } else { - return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, content_type: res.headers.get("content-type")}, opts) + return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, content_type: res.headers.get("content-type")}, res.status, opts) } } @@ -78,12 +83,12 @@ async function mreq(method, url, bodyIn, extra = {}) { var root = JSON.parse(text) } catch (e) { delete opts.headers?.["Authorization"] - throw new MatrixServerError(text, {baseUrl, url, ...opts}) + throw new MatrixServerError(text, res.status, {baseUrl, url, ...opts}) } if (!res.ok || root.errcode) { delete opts.headers?.["Authorization"] - throw new MatrixServerError(root, {baseUrl, url, ...opts}) + throw new MatrixServerError(root, res.status, {baseUrl, url, ...opts}) } return root } diff --git a/src/matrix/read-registration.js b/src/matrix/read-registration.js index 86f99a19..e6ca53f4 100644 --- a/src/matrix/read-registration.js +++ b/src/matrix/read-registration.js @@ -17,6 +17,10 @@ function checkRegistration(reg) { assert(reg.ooye?.server_origin.match(/^https?:\/\//), "server origin must start with http or https") assert.notEqual(reg.ooye?.server_origin.slice(-1), "/", "server origin must not end in slash") assert.match(reg.url, /^https?:/, "url must start with http:// or https://") + if (!reg.receive_ephemeral) { + reg.receive_ephemeral = true + writeRegistration(reg) + } } /* c8 ignore next 4 */ @@ -50,6 +54,7 @@ function getTemplateRegistration(serverName) { ], sender_localpart: `${namespace_prefix}bot`, rate_limited: false, + receive_ephemeral: true, socket: 6693, ooye: { namespace_prefix, @@ -78,10 +83,14 @@ function readRegistration() { /** @type {import("../types").AppServiceRegistrationConfig} */ // @ts-ignore let reg = readRegistration() -fs.watch(registrationFilePath, {persistent: false}, () => { - let newReg = readRegistration() - Object.assign(reg, newReg) -}) +if (reg) { + fs.watch(registrationFilePath, {persistent: false}, () => { + let newReg = readRegistration() + if (newReg) { + Object.assign(reg, newReg) + } + }) +} module.exports.registrationFilePath = registrationFilePath module.exports.readRegistration = readRegistration diff --git a/src/matrix/utils.js b/src/matrix/utils.js index 2351a95b..fed71c97 100644 --- a/src/matrix/utils.js +++ b/src/matrix/utils.js @@ -4,7 +4,7 @@ const assert = require("assert").strict const Ty = require("../types") const {tag} = require("@cloudrac3r/html-template-tag") const passthrough = require("../passthrough") -const {db, select} = passthrough +const {db} = passthrough const {reg} = require("./read-registration") const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) @@ -250,7 +250,7 @@ function getPublicUrlForMxc(mxc) { */ function makeMxcPublic(mxc) { assert(hasher, "xxhash is not ready yet") - const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/) + const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/([a-zA-Z0-9_-]+)$/) if (!mediaParts) return undefined const serverAndMediaID = `${mediaParts[1]}/${mediaParts[2]}` @@ -385,16 +385,6 @@ async function setUserPowerCascade(spaceID, mxid, power, api) { } } -/** - * @param {undefined|string?} eventID - */ //^For some reason, „?” doesn't include Undefined and it needs to be explicitly specified -function getThreadRoomFromThreadEvent(eventID){ - if (!eventID) return eventID; - const threadID = select("event_message", "message_id", {event_id: eventID}).pluck().get() //Discord thread ID === its message ID - if (!threadID) return threadID; - return select("channel_room", "room_id", {channel_id: threadID}).pluck().get() -} - module.exports.bot = bot module.exports.BLOCK_ELEMENTS = BLOCK_ELEMENTS module.exports.eventSenderIsFromDiscord = eventSenderIsFromDiscord @@ -410,4 +400,3 @@ module.exports.removeCreatorsFromPowerLevels = removeCreatorsFromPowerLevels module.exports.getEffectivePower = getEffectivePower module.exports.setUserPower = setUserPower module.exports.setUserPowerCascade = setUserPowerCascade -module.exports.getThreadRoomFromThreadEvent = getThreadRoomFromThreadEvent diff --git a/src/matrix/utils.test.js b/src/matrix/utils.test.js index 68872522..842c5130 100644 --- a/src/matrix/utils.test.js +++ b/src/matrix/utils.test.js @@ -2,7 +2,7 @@ const {select} = require("../passthrough") const {test} = require("supertape") -const {eventSenderIsFromDiscord, getEventIDHash, MatrixStringBuilder, getViaServers, roomHasAtLeastVersion, removeCreatorsFromPowerLevels, setUserPower, getThreadRoomFromThreadEvent} = require("./utils") +const {eventSenderIsFromDiscord, getEventIDHash, MatrixStringBuilder, getViaServers, roomHasAtLeastVersion, removeCreatorsFromPowerLevels, setUserPower} = require("./utils") const util = require("util") /** @param {string[]} mxids */ @@ -417,23 +417,4 @@ test("set user power: privileged users must demote themselves", async t => { t.equal(called, 3) }) -test("getThreadRoomFromThreadEvent: real message with a thread", t => { - const room = getThreadRoomFromThreadEvent("$fdD9o7NxMA4VPexlAiIx2CB9JbsiGhJeyJgnZG7U5xg") - t.equal(room, "!FuDZhlOAtqswlyxzeR:cadence.moe") -}) - -test("getThreadRoomFromThreadEvent: real message, but without a thread", t => { - const room = getThreadRoomFromThreadEvent("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4") - const msg = "Expected null/undefined, got: "+room - if(room) t.fail(msg); - else t.pass(msg) -}) - -test("getThreadRoomFromThreadEvent: fake message", t => { - const room = getThreadRoomFromThreadEvent("$ThisEvent-IdDoesNotExistInTheDatabase4Sure") - const msg = "Expected null/undefined, got: "+room - if(room) t.fail(msg); - else t.pass(msg) -}) - module.exports.mockGetEffectivePower = mockGetEffectivePower diff --git a/src/stdin.js b/src/stdin.js index 2548d420..43f9607e 100644 --- a/src/stdin.js +++ b/src/stdin.js @@ -15,12 +15,15 @@ const mreq = sync.require("./matrix/mreq") const api = sync.require("./matrix/api") const file = sync.require("./matrix/file") const sendEvent = sync.require("./m2d/actions/send-event") +const redact = sync.require("./m2d/actions/redact") const eventDispatcher = sync.require("./d2m/event-dispatcher") const updatePins = sync.require("./d2m/actions/update-pins") const speedbump = sync.require("./d2m/actions/speedbump") const ks = sync.require("./matrix/kstate") const setPresence = sync.require("./d2m/actions/set-presence") const channelWebhook = sync.require("./m2d/actions/channel-webhook") +const dUtils = sync.require("./discord/utils") +const mxUtils = sync.require("./matrix/utils") const guildID = "112760669178241024" async function ping() { diff --git a/src/types.d.ts b/src/types.d.ts index db209ce5..f65ce59c 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -18,6 +18,7 @@ export type AppServiceRegistrationConfig = { } protocols: [string] rate_limited: boolean + receive_ephemeral: boolean socket?: string | number, ooye: { namespace_prefix: string @@ -35,6 +36,7 @@ export type AppServiceRegistrationConfig = { web_password: string time_zone?: string receive_presences: boolean + plu_ral_api_key?: string } old_bridge?: { as_token: string @@ -59,6 +61,7 @@ export type InitialAppServiceRegistrationConfig = { } protocols: [string] rate_limited: boolean + receive_ephemeral: boolean socket?: string | number, ooye: { namespace_prefix: string @@ -122,6 +125,28 @@ export type PkMessage = { sender: string } +export type PluRalWebhookMessage = { + original_id: string | null + proxy_id: string + author_id: string + channel_id: string + member_id: string + reason: string + webhook_id: string + member: PluRalMember +} + +export type PluRalMember = { + id: string + name: string + pronouns: string + bio: string + birthday: string + color: number | null + avatar_url: string + private: boolean +} + export namespace Event { export type Outer = { type: string @@ -190,12 +215,11 @@ export namespace Event { format?: "org.matrix.custom.html" formatted_body?: string, "m.relates_to"?: { - event_id?: string - is_falling_back?: boolean - "m.in_reply_to"?: { + "m.in_reply_to": { event_id: string } - rel_type?: "m.replace"|"m.thread" + rel_type?: "m.replace" + event_id?: string } } @@ -211,12 +235,11 @@ export namespace Event { info?: any "page.codeberg.everypizza.msc4193.spoiler"?: boolean "m.relates_to"?: { - event_id?: string - is_falling_back?: boolean - "m.in_reply_to"?: { + "m.in_reply_to": { event_id: string } - rel_type?: "m.replace"|"m.thread" + rel_type?: "m.replace" + event_id?: string } } @@ -248,12 +271,11 @@ export namespace Event { }, info?: any "m.relates_to"?: { - event_id?: string - is_falling_back?: boolean - "m.in_reply_to"?: { + "m.in_reply_to": { event_id: string } - rel_type?: "m.replace"|"m.thread" + rel_type?: "m.replace" + event_id?: string } } @@ -399,6 +421,14 @@ export namespace Event { rotation_period_ms?: number rotation_period_msgs?: number } + + export type Outer_M_Typing = { + content: { + user_ids: string[] + } + room_id: string + type: "m.typing" + } } export namespace R { @@ -505,8 +535,6 @@ export namespace R { export type Membership = "invite" | "knock" | "join" | "leave" | "ban" -export type JoinRule = "public" | "knock" | "invite" | "private" | "restricted" | "knock_restricted" - export type Pagination = { chunk: T[] next_batch?: string diff --git a/src/web/pug/agi-optout.pug b/src/web/pug/agi-optout.pug deleted file mode 100644 index 795e6758..00000000 --- a/src/web/pug/agi-optout.pug +++ /dev/null @@ -1,24 +0,0 @@ -extends includes/template.pug - -block body - h1.ta-center.fs-display2.fc-green-400 April Fools! - .ws7.m-auto - .s-prose.fs-body2 - p Sheesh, wouldn't that be horrible? - if guild_id - p Fake AI messages have now been #[strong.fc-green-600 deactivated for everyone in your server.] - p Hope the prank entertained you. #[a(href="https://cadence.moe/contact") Send love or hate mail here.] - - h2 What actually happened? - ul - li A secret event was added for the duration of 1st April 2026 (UTC). - li If a message matches a preset pattern, a preset response is posted to chat by an AI-ified profile of the previous author. - li It only happens at most once per hour in each server. - li I tried to design it to not interrupt any serious/sensitive topics. I am deeply sorry if that didn't work out. - li No AI generated materials have ever been used in Out Of Your Element: no code, no prose, no images, no jokes. - li It'll always deactivate itself on 2nd April, no matter what, and I'll remove the relevant code shortly after. - if guild_id - .s-prose.fl-grow1.mt16 - p If you thought it was funny, feel free to opt back in. This affects the entire server, so please be courteous. - form(method="post" action=rel(`/agi/optin?guild_id=${guild_id}`)) - button(type="submit").s-btn.s-btn__muted Opt back in diff --git a/src/web/pug/agi.pug b/src/web/pug/agi.pug deleted file mode 100644 index 029c02af..00000000 --- a/src/web/pug/agi.pug +++ /dev/null @@ -1,41 +0,0 @@ -extends includes/template.pug - -block title - title AGI in Discord - -block body - style. - .ai-gradient { - background: linear-gradient(100deg, #fb72f2, #072ea4); - color: transparent; - background-clip: text; - } - - h1.ta-center.fs-display2.ai-gradient AGI in Discord:#[br]Revolutionizing the Future of Communications - .ws7.m-auto - .s-prose.fs-body2 - p In the ever-changing and turbulent world of AI, it's crucial to always be one step ahead. - p That's why Out Of Your Element has partnered with #[strong Grimace AI] to provide you tomorrow's technology, today. - ul - li #[strong Always online:] Miss your friends when they log off? Now you can talk to facsimiles of them etched into an unfeeling machine, always and forever! - li #[strong Smarter than ever:] Pissed off when somebody says something #[em wrong] on the internet? Frustrated with having to stay up all night correcting them? With Grimace Truth (available in Pro+ Ultra plan), all information is certified true to reality, so you'll never have to worry about those frantic Google searches at 3 AM. - li #[strong Knows you better than yourself:] We aren't just training on your data; we're copying minds into our personality matrix — including yours. Do you find yourself enjoying the sound of your own voice more than anything else? Our unique simulation of You is here to help. - - h1.mt64.mb32 Frequently Asked Questions - .s-link-preview - .s-link-preview--header.fd-column - .s-link-preview--title.fs-title.pl4 How to opt out? - .s-link-preview--details.fc-red-500 - != icons.Icons.IconFire - = ` 20,000% higher search volume for this question in the last hour` - .s-link-preview--body - .s-prose - h2.fs-body3 Is this really goodbye? 😢😢😢😢😢 - p I can't convince you to stay? - p As not just a customer service representative but someone with a shared vision, I simply want you to know that everyone at Grimace AI will miss all the time that we've shared together with you. - form(method="post" action=(guild_id ? rel(`/agi/optout?guild_id=${guild_id}`) : rel("/agi/optout"))).d-flex.g4.mt16 - button(type="button").s-btn.s-btn__filled Nevermind, I'll stay :) - button(type="submit").s-btn.s-btn__danger.s-btn__outlined Opt out for 3 days - - - div(style="height: 200px") diff --git a/src/web/pug/explain.pug b/src/web/pug/explain.pug deleted file mode 100644 index d280ba55..00000000 --- a/src/web/pug/explain.pug +++ /dev/null @@ -1,5 +0,0 @@ -extends includes/template.pug - -block body - .ta-center.wmx5.p48.mx-auto#ok - p.mt24.fs-body2= msg diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index a65efade..7411a1e8 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -1,270 +1,266 @@ -extends includes/template.pug -include includes/default-roles-list.pug - -mixin badge-readonly - .s-badge.s-badge__xs.s-badge__icon.s-badge__muted - != icons.Icons.IconEyeSm - | Read-only - -mixin badge-private - .s-badge.s-badge__xs.s-badge__icon.s-badge__warning - != icons.Icons.IconLockSm - | Private - -mixin discord(channel, radio=false) - //- Previously, we passed guild.roles as the second parameter, but this doesn't quite match Discord's behaviour. See issue #42 for why this was changed. - //- Basically we just want to assign badges based on the channel overwrites, without considering the guild's base permissions. /shrug - - let permissions = dUtils.getPermissions(guild_id, [], [{id: guild_id, name: "@everyone", permissions: 1<<10 | 1<<11}], null, channel.permission_overwrites) - .s-user-card.s-user-card__small - if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel) - != icons.Icons.IconLock - else if channel.type === 5 - != icons.Icons.IconBullhorn - else if channel.type === 2 - != icons.Icons.IconPhone - else if channel.type === 11 || channel.type === 12 - != icons.Icons.IconCollection - else - include includes/hash.svg - .s-user-card--info.ws-nowrap - if radio - = channel.name - else - .s-user-card--link.fs-body1 - a(href=`https://discord.com/channels/${channel.guild_id}/${channel.id}`)= channel.name - if channel.parent_id - .s-user-card--location= discord.channels.get(channel.parent_id).name - if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel) - +badge-private - else if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.SendMessages) - +badge-readonly - -mixin matrix(row, radio=false, badge="") - .s-user-card.s-user-card__small - != icons.Icons.IconMessage - .s-user-card--info.ws-nowrap - if radio - = row.nick || row.name - else - .s-user-card--link.fs-body1 - a(href=`https://matrix.to/#/${row.room_id}`)= row.nick || row.name - if row.join_rule === "invite" - +badge-private - -block body - .s-page-title.mb24 - h1.s-page-title--header= guild.name - - .d-flex.g16(class="sm:fw-wrap") - .fl-grow1 - h2.fs-headline1 Invite a Matrix user - - form.d-grid.g-af-column.gy4.gx8.jc-start(method="post" action=rel("/api/invite") hx-post=rel("/api/invite") hx-trigger="submit" hx-swap="none" hx-on::after-request="if (event.detail.successful) this.reset()" hx-disabled-elt="input, button" hx-indicator="#invite-button") - label.s-label(for="mxid") Matrix ID - input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org" pattern="@([^:]+):([a-z0-9:\\-]+\\.[a-z0-9.:\\-]+)") - label.s-label(for="permissions") Permissions - .s-select - select#permissions(name="permissions") - option(value="default") Default - option(value="moderator") Moderator - option(value="admin") Admin - input(type="hidden" name="guild_id" value=guild_id) - .grid--row-start2 - button.s-btn.s-btn__filled#invite-button Invite - div - .s-card.d-flex.ai-center.jc-center(style="min-width: 132px; min-height: 132px;") - button.s-btn(class=space_id ? "s-btn__muted" : "s-btn__filled" hx-get=rel(`/qr?guild_id=${guild_id}`) hx-indicator="closest button" hx-swap="outerHTML" hx-disabled-elt="this") Show QR - - if space_id - h2.mt48.fs-headline1 Server settings - h3.mt32.fs-category How Matrix users join - span#privacy-level-loading - .s-card - form(hx-post=rel("/api/privacy-level") hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="input") - input(type="hidden" name="guild_id" value=guild_id) - - .s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental.d-grid.gx16.ai-center(style="grid-template-columns: auto 1fr") - input(type="radio" name="privacy_level" value="directory" id="privacy-level-directory" checked=(privacy_level === 2)) - label.d-flex.gx8.jc-center.grid--row-start3(for="privacy-level-directory") - != icons.Icons.IconPlusSm - != icons.Icons.IconInternationalSm - .fl-grow1 Directory - - input(type="radio" name="privacy_level" value="link" id="privacy-level-link" checked=(privacy_level === 1)) - label.d-flex.gx8.jc-center.grid--row-start2(for="privacy-level-link") - != icons.Icons.IconPlusSm - != icons.Icons.IconLinkSm - .fl-grow1 Link - - input(type="radio" name="privacy_level" value="invite" id="privacy-level-invite" checked=(privacy_level === 0)) - label.d-flex.gx8.jc-center.grid--row-start1(for="privacy-level-invite") - svg.svg-icon(width="14" height="14" viewBox="0 0 14 14") - != icons.Icons.IconLockSm - .fl-grow1 Invite - - p.s-description.m0 In-app direct invite from another user - p.s-description.m0 Shareable invite links, like Discord - p.s-description.m0 Publicly listed in directory, like Discord server discovery - - h3.mt32.fs-category Default roles - .s-card - form(method="post" action=rel("/api/default-roles") hx-post=rel("/api/default-roles") hx-sync="this:drop" hx-indicator="#add-role-loading" hx-target="#default-roles-list" hx-select="#default-roles-list" hx-swap="outerHTML")#default-roles - input(type="hidden" name="guild_id" value=guild_id) - .d-flex.fw-wrap.g4 - .s-tag.s-tag__md.fs-body1.s-tag__required @everyone - - +default-roles-list(guild, guild_id) - - button(type="button" popovertarget="role-add").s-btn__dropdown.s-tag.s-tag__md.fs-body1.p0 - .s-tag--dismiss.m1 - != icons.Icons.IconPlusSm - - #role-add.s-popover(popover style="display: revert").ws2.px0.py4.bs-lg.overflow-visible - .s-popover--arrow.s-popover--arrow__tc - +add-roles-menu(guild, guild_id) - p.fc-medium.mb0.mt8 Matrix users will start with these roles. If your main channels are gated by a role, use this to let Matrix users skip the gate. - - h3.mt32.fs-category Features - .s-card.d-grid.px0.g16 - form.d-flex.ai-center.g16 - #url-preview-loading.p8 - - let value = !!select("guild_space", "url_preview", {guild_id}).pluck().get() - input(type="hidden" name="guild_id" value=guild_id) - input.s-toggle-switch#url-preview(name="url_preview" type="checkbox" hx-post=rel("/api/url-preview") hx-indicator="#url-preview-loading" hx-disabled-elt="this" checked=value autocomplete="off") - label.s-label.fl-grow1(for="url-preview") - | Show Discord's URL previews on Matrix - p.s-description Shows info about links posted to chat. Discord's previews are generally better quality than Synapse's, especially for social media and videos. - - form.d-flex.ai-center.g16 - #presence-loading.p8 - - value = !!select("guild_space", "presence", {guild_id}).pluck().get() - input(type="hidden" name="guild_id" value=guild_id) - input.s-toggle-switch#presence(name="presence" type="checkbox" hx-post=rel("/api/presence") hx-indicator="#presence-loading" hx-disabled-elt="this" checked=value autocomplete="off") - label.s-label(for="presence") - | Show online statuses on Matrix - p.s-description This might cause lag on really big Discord servers. - - form.d-flex.ai-center.g16 - #webhook-profile-loading.p8 - - value = !!select("guild_space", "webhook_profile", {guild_id}).pluck().get() - input(type="hidden" name="guild_id" value=guild_id) - input.s-toggle-switch#webhook-profile(name="webhook_profile" type="checkbox" hx-post=rel("/api/webhook-profile") hx-indicator="#webhook-profile-loading" hx-disabled-elt="this" checked=value autocomplete="off") - label.s-label(for="webhook-profile") - | Create persistent Matrix sims for webhooks - p.s-description Useful when using other Discord bridges. Otherwise, not ideal, as sims will clutter the Matrix user list and will never be cleaned up. - - if space_id - h2.mt48.fs-headline1 Channel setup - - h3.mt32.fs-category Linked channels - .s-card.bs-sm.p0 - form.s-table-container(method="post" action=rel("/api/unlink")) - input(type="hidden" name="guild_id" value=guild_id) - table.s-table.s-table__bx-simple - each row in linkedChannelsWithDetails - tr - td.w40: +discord(row.channel) - td.p2: button.s-btn.s-btn__muted.s-btn__xs(name="channel_id" cx-prevent-default hx-post=rel("/api/unlink") hx-confirm="Do you want to unlink these channels?\nIt may take a moment to clean up Matrix resources." value=row.channel.id hx-indicator="this" hx-disabled-elt="this")!= icons.Icons.IconLinkSm - td: +matrix(row) - else - tr - td(colspan="3") - .s-empty-state No channels linked between Discord and Matrix yet... - - h3.fs-category.mt32 Auto-create - .s-card.d-grid.px0 - form.d-flex.ai-center.g16 - #autocreate-loading.p8 - - let value = !!select("guild_active", "autocreate", {guild_id}).pluck().get() - input(type="hidden" name="guild_id" value=guild_id) - input.s-toggle-switch#autocreate(name="autocreate" type="checkbox" hx-post=rel("/api/autocreate") hx-indicator="#autocreate-loading" hx-disabled-elt="this" checked=value autocomplete="off") - label.s-label.fl-grow1(for="autocreate") - | Create new Matrix rooms automatically - p.s-description If you want, OOYE can automatically create new Matrix rooms and link them when an unlinked Discord channel is spoken in. - - if space_id - h3.mt32.fs-category Manually link channels - form.d-flex.g16.ai-start(hx-post=rel("/api/link") hx-trigger="submit" hx-disabled-elt="input, button" hx-indicator="#link-button") - .fl-grow2.s-btn-group.fd-column.w40 - each channel in unlinkedChannels - input.s-btn--radio(type="radio" name="discord" required id=channel.id value=channel.id) - label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id) - +discord(channel, true, "Announcement") - else - .s-empty-state.p8 All Discord channels are linked. - .fl-grow1.s-btn-group.fd-column.w30 - each room in unlinkedRooms - input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id) - label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id) - +matrix(room, true) - else - .s-empty-state.p8 All Matrix rooms are linked. - input(type="hidden" name="guild_id" value=guild_id) - div - button.s-btn.s-btn__icon.s-btn__filled#link-button - != icons.Icons.IconMerge - = ` Link` - - h3.mt32.fs-category Unlink server - form.s-card.d-flex.gx16.ai-center(method="post" action=rel("/api/unlink-space")) - input(type="hidden" name="guild_id" value=guild.id) - .fl-grow1.s-prose.s-prose__sm.lh-lg - p.fc-medium. - Not using this bridge, or just made a mistake? You can unlink the whole server and all its channels.#[br] - This may take a minute to process. Please be patient and wait until the page refreshes. - div - button.s-btn.s-btn__icon.s-btn__danger.s-btn__outlined(cx-prevent-default hx-post=rel("/api/unlink-space") hx-confirm="Do you want to unlink this server and all its channels?\nIt may take a minute to clean up Matrix resources." hx-indicator="this" hx-disabled-elt="this") - != icons.Icons.IconUnsync - span.ml4= ` Unlink` - - if space_id - details.mt48 - summary Debug room list - .d-grid.grid__2.gx24 - div - h3.mt24 Channels - p Channels are read from the channel_room table and then merged with the discord.channels memory cache to make the merged list. Anything in memory cache that's not in channel_room is considered unlinked. - div - h3.mt24 Rooms - p Rooms use the same merged list as channels, based on augmented channel_room data. Then, rooms are read from the space. Anything in the space that's not merged is considered unlinked. - div - h3.mt24 Unavailable channels: Deleted from Discord - .s-card.p0 - ul.my8.ml24 - each row in removedUncachedChannels - li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`)= row.nick || row.name - h3.mt24 Unavailable channels: Wrong type - .s-card.p0 - ul.my8.ml24 - each row in removedWrongTypeChannels - li - a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name} - span | - a(href=rel(`/explain?type=${row.type}`)) Why? - h3.mt24 Unavailable channels: Discord bot can't access - .s-card.p0 - ul.my8.ml24 - each row in removedPrivateChannels - li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`)= row.name - div- // Rooms - h3.mt24 Unavailable rooms: Already linked - .s-card.p0 - ul.my8.ml24 - each row in removedLinkedRooms - li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name - h3.mt24 Unavailable rooms: Encryption not supported - .s-card.p0 - ul.my8.ml24 - each row in removedEncryptedRooms - li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name - h3.mt24 Unavailable rooms: Root space - .s-card.p0 - ul.my8.ml24 - each row in removedRootSpaceRooms - li: a(href=`https://matrix.to/#/${row.room_id}`) (#{row.room_type}) #{row.name} - h3.mt24 Unavailable rooms: Archived thread - p If you still want to link with any of these rooms (eg. you accidentally unlinked it and want to bring it back, or you're migrating from a different bridge that happens to use OOYE's prefixes), please remove the [⛓️] or [🔒⛓️] prefix in Matrix's room settings and refresh the page. - .s-card.p0 - ul.my8.ml24 - each row in removedArchivedThreadRooms - li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name +extends includes/template.pug +include includes/default-roles-list.pug + +mixin badge-readonly + .s-badge.s-badge__xs.s-badge__icon.s-badge__muted + != icons.Icons.IconEyeSm + | Read-only + +mixin badge-private + .s-badge.s-badge__xs.s-badge__icon.s-badge__warning + != icons.Icons.IconLockSm + | Private + +mixin discord(channel, radio=false) + //- Previously, we passed guild.roles as the second parameter, but this doesn't quite match Discord's behaviour. See issue #42 for why this was changed. + //- Basically we just want to assign badges based on the channel overwrites, without considering the guild's base permissions. /shrug + - let permissions = dUtils.getPermissions(guild_id, [], [{id: guild_id, name: "@everyone", permissions: 1<<10 | 1<<11}], null, channel.permission_overwrites) + .s-user-card.s-user-card__small + if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel) + != icons.Icons.IconLock + else if channel.type === 5 + != icons.Icons.IconBullhorn + else if channel.type === 2 + != icons.Icons.IconPhone + else if channel.type === 11 || channel.type === 12 + != icons.Icons.IconCollection + else + include includes/hash.svg + .s-user-card--info.ws-nowrap + if radio + = channel.name + else + .s-user-card--link.fs-body1 + a(href=`https://discord.com/channels/${channel.guild_id}/${channel.id}`)= channel.name + if channel.parent_id + .s-user-card--location= discord.channels.get(channel.parent_id).name + if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel) + +badge-private + else if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.SendMessages) + +badge-readonly + +mixin matrix(row, radio=false, badge="") + .s-user-card.s-user-card__small + != icons.Icons.IconMessage + .s-user-card--info.ws-nowrap + if radio + = row.nick || row.name + else + .s-user-card--link.fs-body1 + a(href=`https://matrix.to/#/${row.room_id}`)= row.nick || row.name + if row.join_rule === "invite" + +badge-private + +block body + .s-page-title.mb24 + h1.s-page-title--header= guild.name + + .d-flex.g16(class="sm:fw-wrap") + .fl-grow1 + h2.fs-headline1 Invite a Matrix user + + form.d-grid.g-af-column.gy4.gx8.jc-start(method="post" action=rel("/api/invite") hx-post=rel("/api/invite") hx-trigger="submit" hx-swap="none" hx-on::after-request="if (event.detail.successful) this.reset()" hx-disabled-elt="input, button" hx-indicator="#invite-button") + label.s-label(for="mxid") Matrix ID + input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org" pattern="@([^:]+):([a-z0-9:\\-]+\\.[a-z0-9.:\\-]+)") + label.s-label(for="permissions") Permissions + .s-select + select#permissions(name="permissions") + option(value="default") Default + option(value="moderator") Moderator + option(value="admin") Admin + input(type="hidden" name="guild_id" value=guild_id) + .grid--row-start2 + button.s-btn.s-btn__filled#invite-button Invite + div + .s-card.d-flex.ai-center.jc-center(style="min-width: 132px; min-height: 132px;") + button.s-btn(class=space_id ? "s-btn__muted" : "s-btn__filled" hx-get=rel(`/qr?guild_id=${guild_id}`) hx-indicator="closest button" hx-swap="outerHTML" hx-disabled-elt="this") Show QR + + if space_id + h2.mt48.fs-headline1 Server settings + h3.mt32.fs-category How Matrix users join + span#privacy-level-loading + .s-card + form(hx-post=rel("/api/privacy-level") hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="input") + input(type="hidden" name="guild_id" value=guild_id) + + .s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental.d-grid.gx16.ai-center(style="grid-template-columns: auto 1fr") + input(type="radio" name="privacy_level" value="directory" id="privacy-level-directory" checked=(privacy_level === 2)) + label.d-flex.gx8.jc-center.grid--row-start3(for="privacy-level-directory") + != icons.Icons.IconPlusSm + != icons.Icons.IconInternationalSm + .fl-grow1 Directory + + input(type="radio" name="privacy_level" value="link" id="privacy-level-link" checked=(privacy_level === 1)) + label.d-flex.gx8.jc-center.grid--row-start2(for="privacy-level-link") + != icons.Icons.IconPlusSm + != icons.Icons.IconLinkSm + .fl-grow1 Link + + input(type="radio" name="privacy_level" value="invite" id="privacy-level-invite" checked=(privacy_level === 0)) + label.d-flex.gx8.jc-center.grid--row-start1(for="privacy-level-invite") + svg.svg-icon(width="14" height="14" viewBox="0 0 14 14") + != icons.Icons.IconLockSm + .fl-grow1 Invite + + p.s-description.m0 In-app direct invite from another user + p.s-description.m0 Shareable invite links, like Discord + p.s-description.m0 Publicly listed in directory, like Discord server discovery + + h3.mt32.fs-category Default roles + .s-card + form(method="post" action=rel("/api/default-roles") hx-post=rel("/api/default-roles") hx-sync="this:drop" hx-indicator="#add-role-loading" hx-target="#default-roles-list" hx-select="#default-roles-list" hx-swap="outerHTML")#default-roles + input(type="hidden" name="guild_id" value=guild_id) + .d-flex.fw-wrap.g4 + .s-tag.s-tag__md.fs-body1.s-tag__required @everyone + + +default-roles-list(guild, guild_id) + + button(type="button" popovertarget="role-add").s-btn__dropdown.s-tag.s-tag__md.fs-body1.p0 + .s-tag--dismiss.m1 + != icons.Icons.IconPlusSm + + #role-add.s-popover(popover style="display: revert").ws2.px0.py4.bs-lg.overflow-visible + .s-popover--arrow.s-popover--arrow__tc + +add-roles-menu(guild, guild_id) + p.fc-light.mb0.mt8 Matrix users will start with these roles. If your main channels are gated by a role, use this to let Matrix users skip the gate. + + h3.mt32.fs-category Features + .s-card.d-grid.px0.g16 + form.d-flex.ai-center.g16 + #url-preview-loading.p8 + - let value = !!select("guild_space", "url_preview", {guild_id}).pluck().get() + input(type="hidden" name="guild_id" value=guild_id) + input.s-toggle-switch#url-preview(name="url_preview" type="checkbox" hx-post=rel("/api/url-preview") hx-indicator="#url-preview-loading" hx-disabled-elt="this" checked=value autocomplete="off") + label.s-label.fl-grow1(for="url-preview") + | Show Discord's URL previews on Matrix + p.s-description Shows info about links posted to chat. Discord's previews are generally better quality than Synapse's, especially for social media and videos. + + form.d-flex.ai-center.g16 + #presence-loading.p8 + - value = !!select("guild_space", "presence", {guild_id}).pluck().get() + input(type="hidden" name="guild_id" value=guild_id) + input.s-toggle-switch#presence(name="presence" type="checkbox" hx-post=rel("/api/presence") hx-indicator="#presence-loading" hx-disabled-elt="this" checked=value autocomplete="off") + label.s-label(for="presence") + | Show online statuses on Matrix + p.s-description This might cause lag on really big Discord servers. + + form.d-flex.ai-center.g16 + #webhook-profile-loading.p8 + - value = !!select("guild_space", "webhook_profile", {guild_id}).pluck().get() + input(type="hidden" name="guild_id" value=guild_id) + input.s-toggle-switch#webhook-profile(name="webhook_profile" type="checkbox" hx-post=rel("/api/webhook-profile") hx-indicator="#webhook-profile-loading" hx-disabled-elt="this" checked=value autocomplete="off") + label.s-label(for="webhook-profile") + | Create persistent Matrix sims for webhooks + p.s-description Useful when using other Discord bridges. Otherwise, not ideal, as sims will clutter the Matrix user list and will never be cleaned up. + + if space_id + h2.mt48.fs-headline1 Channel setup + + h3.mt32.fs-category Linked channels + .s-card.bs-sm.p0 + form.s-table-container(method="post" action=rel("/api/unlink")) + input(type="hidden" name="guild_id" value=guild_id) + table.s-table.s-table__bx-simple + each row in linkedChannelsWithDetails + tr + td.w40: +discord(row.channel) + td.p2: button.s-btn.s-btn__muted.s-btn__xs(name="channel_id" cx-prevent-default hx-post=rel("/api/unlink") hx-confirm="Do you want to unlink these channels?\nIt may take a moment to clean up Matrix resources." value=row.channel.id hx-indicator="this" hx-disabled-elt="this")!= icons.Icons.IconLinkSm + td: +matrix(row) + else + tr + td(colspan="3") + .s-empty-state No channels linked between Discord and Matrix yet... + + h3.fs-category.mt32 Auto-create + .s-card.d-grid.px0 + form.d-flex.ai-center.g16 + #autocreate-loading.p8 + - let value = !!select("guild_active", "autocreate", {guild_id}).pluck().get() + input(type="hidden" name="guild_id" value=guild_id) + input.s-toggle-switch#autocreate(name="autocreate" type="checkbox" hx-post=rel("/api/autocreate") hx-indicator="#autocreate-loading" hx-disabled-elt="this" checked=value autocomplete="off") + label.s-label.fl-grow1(for="autocreate") + | Create new Matrix rooms automatically + p.s-description If you want, OOYE can automatically create new Matrix rooms and link them when an unlinked Discord channel is spoken in. + + if space_id + h3.mt32.fs-category Manually link channels + form.d-flex.g16.ai-start(hx-post=rel("/api/link") hx-trigger="submit" hx-disabled-elt="input, button" hx-indicator="#link-button") + .fl-grow2.s-btn-group.fd-column.w40 + each channel in unlinkedChannels + input.s-btn--radio(type="radio" name="discord" required id=channel.id value=channel.id) + label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id) + +discord(channel, true, "Announcement") + else + .s-empty-state.p8 No Discord channels available. + .fl-grow1.s-btn-group.fd-column.w30 + each room in unlinkedRooms + input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id) + label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id) + +matrix(room, true) + else + .s-empty-state.p8 No Matrix rooms available. + input(type="hidden" name="guild_id" value=guild_id) + div + button.s-btn.s-btn__icon.s-btn__filled#link-button + != icons.Icons.IconMerge + = ` Link` + + h3.mt32.fs-category Unlink server + form.s-card.d-flex.gx16.ai-center(method="post" action=rel("/api/unlink-space")) + input(type="hidden" name="guild_id" value=guild.id) + .fl-grow1.s-prose.s-prose__sm.lh-lg + p.fc-medium. + Not using this bridge, or just made a mistake? You can unlink the whole server and all its channels.#[br] + This may take a minute to process. Please be patient and wait until the page refreshes. + div + button.s-btn.s-btn__icon.s-btn__danger.s-btn__outlined(cx-prevent-default hx-post=rel("/api/unlink-space") hx-confirm="Do you want to unlink this server and all its channels?\nIt may take a minute to clean up Matrix resources." hx-indicator="this" hx-disabled-elt="this") + != icons.Icons.IconUnsync + span.ml4= ` Unlink` + + if space_id + details.mt48 + summary Debug room list + .d-grid.grid__2.gx24 + div + h3.mt24 Channels + p Channels are read from the channel_room table and then merged with the discord.channels memory cache to make the merged list. Anything in memory cache that's not in channel_room is considered unlinked. + div + h3.mt24 Rooms + p Rooms use the same merged list as channels, based on augmented channel_room data. Then, rooms are read from the space. Anything in the space that's not merged is considered unlinked. + div + h3.mt24 Unavailable channels: Deleted from Discord + .s-card.p0 + ul.my8.ml24 + each row in removedUncachedChannels + li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`)= row.nick || row.name + h3.mt24 Unavailable channels: Wrong type + .s-card.p0 + ul.my8.ml24 + each row in removedWrongTypeChannels + li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name} + h3.mt24 Unavailable channels: Discord bot can't access + .s-card.p0 + ul.my8.ml24 + each row in removedPrivateChannels + li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`)= row.name + div- // Rooms + h3.mt24 Unavailable rooms: Already linked + .s-card.p0 + ul.my8.ml24 + each row in removedLinkedRooms + li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name + h3.mt24 Unavailable rooms: Encryption not supported + .s-card.p0 + ul.my8.ml24 + each row in removedEncryptedRooms + li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name + h3.mt24 Unavailable rooms: Wrong type + .s-card.p0 + ul.my8.ml24 + each row in removedWrongTypeRooms + li: a(href=`https://matrix.to/#/${row.room_id}`) (#{row.room_type}) #{row.name} + h3.mt24 Unavailable rooms: Archived thread + .s-card.p0 + ul.my8.ml24 + each row in removedArchivedThreadRooms + li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name diff --git a/src/web/pug/includes/template.pug b/src/web/pug/includes/template.pug index be1d005b..86680ebb 100644 --- a/src/web/pug/includes/template.pug +++ b/src/web/pug/includes/template.pug @@ -65,8 +65,7 @@ mixin define-themed-button(name, theme) doctype html html(lang="en") head - block title - title Out Of Your Element + title Out Of Your Element link(rel="stylesheet" type="text/css" href=rel("/static/stacks.min.css")) //- Please use responsibly!!!!! diff --git a/src/web/routes/agi.js b/src/web/routes/agi.js deleted file mode 100644 index f8994559..00000000 --- a/src/web/routes/agi.js +++ /dev/null @@ -1,36 +0,0 @@ -// @ts-check - -const {z} = require("zod") -const {defineEventHandler, getValidatedQuery, sendRedirect} = require("h3") -const {as, from, sync, db} = require("../../passthrough") - -/** @type {import("../pug-sync")} */ -const pugSync = sync.require("../pug-sync") - -const schema = { - opt: z.object({ - guild_id: z.string().regex(/^[0-9]+$/) - }) -} - -as.router.get("/agi", defineEventHandler(async event => { - return pugSync.render(event, "agi.pug", {}) -})) - -as.router.get("/agi/optout", defineEventHandler(async event => { - return pugSync.render(event, "agi-optout.pug", {}) -})) - -as.router.post("/agi/optout", defineEventHandler(async event => { - const parseResult = await getValidatedQuery(event, schema.opt.safeParse) - if (parseResult.success) { - db.prepare("INSERT OR IGNORE INTO agi_optout (guild_id) VALUES (?)").run(parseResult.data.guild_id) - } - return sendRedirect(event, "", 302) -})) - -as.router.post("/agi/optin", defineEventHandler(async event => { - const {guild_id} = await getValidatedQuery(event, schema.opt.parse) - db.prepare("DELETE FROM agi_optout WHERE guild_id = ?").run(guild_id) - return sendRedirect(event, `../agi?guild_id=${guild_id}`, 302) -})) diff --git a/src/web/routes/download-matrix.js b/src/web/routes/download-matrix.js index 82e2f7e6..26645207 100644 --- a/src/web/routes/download-matrix.js +++ b/src/web/routes/download-matrix.js @@ -3,6 +3,9 @@ const assert = require("assert/strict") const {defineEventHandler, getValidatedRouterParams, setResponseStatus, setResponseHeader, createError, H3Event, getValidatedQuery} = require("h3") const {z} = require("zod") +const {ReadableStream} = require("stream/web") +const {Readable} = require("stream") +const sharp = require("sharp") /** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore let hasher = null @@ -19,11 +22,27 @@ const emojiSheetConverter = sync.require("../../m2d/converters/emoji-sheet") /** @type {import("../../m2d/actions/sticker")} */ const sticker = sync.require("../../m2d/actions/sticker") +// Resizing client-side because server-side is too slow, at least with Synapse. Really need it to be fast because webhook avatars show a placeholder in the interim. +/** @type {{[presetKey: string]: (body: ReadableStream) => ReadableStream}} */ +const MEDIA_THUMBNAIL_PRESETS = { + avatar: body => + Readable.toWeb( + Readable.fromWeb(body).pipe( + sharp() + .resize({height: 210, width: 210, fit: "cover"}) // the largest display of the webhook pfp on Discord Android in screen pixels + .jpeg({force: false, quality: 90}) // File size works out to up to ~110k for a PNG, less for a JPEG + ) + ) +} + const schema = { - params: z.object({ + media: z.object({ server_name: z.string(), media_id: z.string() }), + mediaQuery: z.object({ + preset: z.enum(Object.keys(MEDIA_THUMBNAIL_PRESETS)) // list of possible thumbnail presets + }), sheet: z.object({ e: z.array(z.string()).or(z.string()) }), @@ -65,7 +84,8 @@ function verifyMediaHash(serverAndMediaID) { } as.router.get(`/download/matrix/:server_name/:media_id`, defineEventHandler(async event => { - const params = await getValidatedRouterParams(event, schema.params.parse) + const params = await getValidatedRouterParams(event, schema.media.parse) + const query = await getValidatedQuery(event, schema.mediaQuery.safeParse) verifyMediaHash(`${params.server_name}/${params.media_id}`) const api = getAPI(event) @@ -77,7 +97,17 @@ as.router.get(`/download/matrix/:server_name/:media_id`, defineEventHandler(asyn setResponseStatus(event, res.status) setResponseHeader(event, "Content-Type", contentType) setResponseHeader(event, "Transfer-Encoding", "chunked") - return res.body + + const contentDisposition = res.headers.get("content-disposition") + if (contentDisposition) { + setResponseHeader(event, "Content-Disposition", contentDisposition) + } + + if (res.ok && query.success) { + return MEDIA_THUMBNAIL_PRESETS[query.data.preset](res.body) + } else { + return res.body + } })) as.router.get(`/download/sheet`, defineEventHandler(async event => { diff --git a/src/web/routes/download-matrix.test.js b/src/web/routes/download-matrix.test.js index 610a62dc..76e8152f 100644 --- a/src/web/routes/download-matrix.test.js +++ b/src/web/routes/download-matrix.test.js @@ -30,12 +30,13 @@ test("web download matrix: works if a known attachment", async t => { api: { // @ts-ignore async getMedia(mxc, init) { - return new Response("", {status: 200, headers: {"content-type": "image/png"}}) + return new Response("", {status: 200, headers: {"content-type": "image/png", "content-disposition": `attachment; filename="wa.png"`}}) } } }) t.equal(event.node.res.statusCode, 200) t.equal(event.node.res.getHeader("content-type"), "image/png") + t.equal(event.node.res.getHeader("content-disposition"), `attachment; filename="wa.png"`) }) /** @@ -64,7 +65,7 @@ test("web sheet: single emoji", async t => { mxcDownloader: mockGetAndConvertEmoji }) t.equal(event.node.res.statusCode, 200) - t.equal(sheet.subarray(0, 90).toString("base64"), "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAPoAAAD6AG1e1JrAAALoklEQVR4nM1ZaVBU2RU+LZSIGnAvFUtcRkSk6abpbkDH") + t.equal(sheet.subarray(0, 90).toString("base64"), "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAPoAAAD6AG1e1JrAAALoklEQVRogc1ZaVBU2RU+LZSIGnAvFUtcRkSk6abpbkDH") }) test("web sheet: multiple sources", async t => { diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index 0cfec6df..70092d50 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -38,9 +38,6 @@ const schema = { }), inviteNonce: z.object({ nonce: z.string() - }), - explain: z.object({ - type: z.string() }) } @@ -56,27 +53,6 @@ function getAPI(event) { /** @type {LRUCache} nonce to guild id */ const validNonce = new LRUCache({max: 200}) -/** - * TYPING = Channels on which Discord messages can be sent. They should be bridgeable to anything other than an m.space (because if it did end up as a space, no one would be able to actually see the text messages sent there). - * SPACE = Channels on which Discord messages cannot be received. They should be bridgeable to m.space only (because not only does m.space make sending messages impossible on any sane client (thus preventing Discord-caused errors), but it also just-so-happens that both currently-existing message-unsupporting channel types (Categories and School hubs) are sort of "indexes", which fits nicely to m.space). - * MIXED = Forum-like channels. They can be bridged to both m.space and anything other than an m.space - hence the name. - * @type {Map}*/ -const linkRules = new Map([ - [0, {type: "TYPING", humanName:"Normal text channels"}], - [1, {type: "TYPING", humanName:"Normal DMs", unsupported: "OOYE won't support DMs until a good way of doing it can be figured out. Please see https://gitdab.com/cadence/out-of-your-element#caveats for more."}], - [2, {type: "TYPING", humanName:"Normal VCs"}], - [3, {type: "TYPING", humanName:"Group DMs", unsupported: "OOYE won't support DMs until a good way of doing it can be figured out. Please see https://gitdab.com/cadence/out-of-your-element#caveats for more."}], - [4, {type: "SPACE", humanName:"Categories", unsupported: "There is no concept of categories on Matrix."}], //...at least officially. In practice, some clients will render sub-spaces as categories. TODO: Bridge categories to sub-spaces. - [5, {type: "TYPING", humanName:"Announcement text channels"}], - [10, {type: "TYPING", humanName:"Announcement threads"}], - [11, {type: "TYPING", humanName:"Normal threads"}], - [12, {type: "TYPING", humanName:"Private threads"}], - [13, {type: "TYPING", humanName:"Stage VCs"}], - [14, {type: "SPACE", humanName:"School hubs", unsupported: "Bots cannot be members of school hubs. How in the sweet hell did you manage to put OOYE on one, anyway??? ~~Emma, please stop breaking Discord API in cursed ways again.~~"}], - [15, {type: "MIXED", humanName:"Normal forums"}], - [16, {type: "MIXED", humanName:"Media forums"}], -]) - /** * @param {{type: number, parent_id?: string | null, position?: number}} channel * @param {Map} channels @@ -118,9 +94,8 @@ function getPosition(channel, channels) { * @param {DiscordTypes.APIGuild} guild * @param {Ty.R.Hierarchy[]} rooms * @param {string[]} roles - * @param {string?} space */ -function getChannelRoomsLinks(guild, rooms, roles, space) { +function getChannelRoomsLinks(guild, rooms, roles) { let channelIDs = discord.guildChannelMap.get(guild.id) assert(channelIDs) @@ -137,10 +112,7 @@ function getChannelRoomsLinks(guild, rooms, roles, space) { let unlinkedChannelIDs = channelIDs.filter(c => !linkedChannelIDs.includes(c)) /** @type {DiscordTypes.APIGuildChannel[]} */ // @ts-ignore let unlinkedChannels = unlinkedChannelIDs.map(c => discord.channels.get(c)) - let removedWrongTypeChannels = dUtils.filterTo(unlinkedChannels, c => { - const rule = linkRules.get(c?.type) - return rule && !rule.unsupported - }) + let removedWrongTypeChannels = dUtils.filterTo(unlinkedChannels, c => c && [0, 5].includes(c.type)) let removedPrivateChannels = dUtils.filterTo(unlinkedChannels, c => { const permissions = dUtils.getPermissions(guild.id, roles, guild.roles, botID, c["permission_overwrites"]) return dUtils.hasSomePermissions(permissions, ["Administrator", "ViewChannel"]) @@ -150,7 +122,7 @@ function getChannelRoomsLinks(guild, rooms, roles, space) { let linkedRoomIDs = linkedChannels.map(c => c.room_id) let unlinkedRooms = [...rooms] let removedLinkedRooms = dUtils.filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id)) - let removedRootSpaceRooms = dUtils.filterTo(unlinkedRooms, r => r.room_id !== space) + let removedWrongTypeRooms = dUtils.filterTo(unlinkedRooms, r => !r.room_type) let removedEncryptedRooms = dUtils.filterTo(unlinkedRooms, r => !r.encryption && !r["im.nheko.summary.encryption"]) // https://discord.com/developers/docs/topics/threads#active-archived-threads // need to filter out linked archived threads from unlinkedRooms, will just do that by comparing against the name @@ -158,7 +130,7 @@ function getChannelRoomsLinks(guild, rooms, roles, space) { return { linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms, - removedUncachedChannels, removedWrongTypeChannels, removedPrivateChannels, removedLinkedRooms, removedRootSpaceRooms, removedArchivedThreadRooms, removedEncryptedRooms + removedUncachedChannels, removedWrongTypeChannels, removedPrivateChannels, removedLinkedRooms, removedWrongTypeRooms, removedArchivedThreadRooms, removedEncryptedRooms } } @@ -199,25 +171,17 @@ as.router.get("/guild", defineEventHandler(async event => { // Easy mode guild that hasn't been linked yet - need to remove elements that would require an existing space if (!row.space_id) { - const links = getChannelRoomsLinks(guild, [], roles, row.space_id) + const links = getChannelRoomsLinks(guild, [], roles) return pugSync.render(event, "guild.pug", {guild, guild_id, ...links, ...row}) } // Linked guild const api = getAPI(event) const rooms = await api.getFullHierarchy(row.space_id) - const links = getChannelRoomsLinks(guild, rooms, roles, row.space_id) + const links = getChannelRoomsLinks(guild, rooms, roles) return pugSync.render(event, "guild.pug", {guild, guild_id, ...links, ...row}) })) -as.router.get("/explain", defineEventHandler(async event => { - const {type} = await getValidatedQuery(event, schema.explain.parse) - const rule = linkRules.get(Number.parseInt(type)) - if (!rule) return pugSync.render(event, "explain.pug", {msg: "You cannot bridge to type-" + type + " channels because OOYE doesn't even know what they are."}) - else if (rule.unsupported) return pugSync.render(event, "explain.pug", {msg: "You cannot bridge to " + rule.humanName + " (type-" + type + " channels) because: " + rule.unsupported}) - else return pugSync.render(event, "explain.pug", {msg: "You can bridge to " + rule.humanName + " (type-" + type + " channels) just fine. Why are you even here?"}) -})) - as.router.get("/qr", defineEventHandler(async event => { const {guild_id} = await getValidatedQuery(event, schema.qr.parse) const managed = await auth.getManagedGuilds(event) @@ -303,4 +267,3 @@ as.router.post("/api/invite", defineEventHandler(async event => { module.exports._getPosition = getPosition module.exports.getInviteTargetSpaces = getInviteTargetSpaces -module.exports.linkRules = linkRules diff --git a/src/web/routes/letter-avatar.js b/src/web/routes/letter-avatar.js new file mode 100644 index 00000000..d12b004e --- /dev/null +++ b/src/web/routes/letter-avatar.js @@ -0,0 +1,117 @@ +// @ts-check + +const h3 = require("h3") +const {defineEventHandler, getValidatedQuery, setResponseHeader} = h3 +const sharp = require("sharp") +const {z} = require("zod") + +const {as} = require("../../passthrough") +const {reg} = require("../../matrix/read-registration") + +/* + Create a 300x300 avatar image consisting of a dark coloured background, and a single character in a lighter colour centered in the middle. + Note: Where dimensions are changed, font size must also be changed too to produce an identical image as before. + Simply put, 100px = 60pt for font. +*/ + +const SIZE = 300 +const POSSIBLE_HUES = 12 + +/** Helper function: To get accurate complimenting colours we need to work in HSL, then convert back to RGB at the end */ +function hslToRgb(h, s, l) { + s /= 100; + l /= 100; + + const a = s * Math.min(l, 1 - l); + + const f = n => { + const k = (n + h / 30) % 12; + return l - a * Math.max(-1, Math.min(Math.min(k - 3, 9 - k), 1)); + }; + + return { + r: Math.round(255 * f(0)), + g: Math.round(255 * f(8)), + b: Math.round(255 * f(4)) + }; +} + +/** + * Use the MXID to generate deterministic avatar colours for each user. + * Here, we use the string hash code as a hue value, with a 360 wrap modulo. + * @param {string} mxid + */ +function mxidToHue(mxid) { + // Element Classic string hasher + let hash = 0; + let i; + let chr; + if (mxid.length === 0) { + return hash; + } + for (i = 0; i < mxid.length; i++) { + chr = mxid.charCodeAt(i); + hash = ((hash << 5) - hash) + chr; + hash |= 0; + } + hash = Math.abs(hash) + return (hash % POSSIBLE_HUES) * (360 / POSSIBLE_HUES) +} + +/** + * Get first useful character in username to put in the avatar. + * @param {string} username + */ +function usernameToLetter(username) { + return (username.match(/[a-z0-9]/i)?.[0] || "#").toUpperCase() +} + +/** + * @param {string} mxid + * @param {string} username + */ +function getLetterAvatarURL(mxid, username) { + const p = new URLSearchParams({letter: usernameToLetter(username), hue: String(mxidToHue(mxid))}) + return `${reg.ooye.bridge_origin}/download/letter-avatar?${p}` +} + +const schema = { + letterAvatar: z.object({ + hue: z.coerce.number().min(0).max(360), + letter: z.string().regex(/^[A-Z0-9#]$/) + }) +} + +/** + * Produce a PNG letter-avatar from given parameters. + * @param {string} letter + * @param {number} hue + */ +as.router.get("/download/letter-avatar", defineEventHandler(async event => { + const {letter, hue} = await getValidatedQuery(event, schema.letterAvatar.parse) + + const bg_rgb = hslToRgb(hue, 65, 18); + const text_rgb = hslToRgb(hue, 70, 65); + const text_rgbahex = `#${text_rgb.r.toString(16).padStart(2, "0")}${text_rgb.g.toString(16).padStart(2, "0")}${text_rgb.b.toString(16).padStart(2, "0")}ff` + + const streamOut = sharp({ + create: { + width: SIZE, height: SIZE, channels: 4, + background: { + r: bg_rgb.r, g: bg_rgb.g, b: bg_rgb.b, alpha: 1 + } + } + }).composite([{ + input: { + text: { + text: `${letter}`, + font: "Noto Sans Bold 180", align: "center", rgba: true + } + } + }]).png() + + setResponseHeader(event, "content-type", "image/png") + return streamOut +})) + +module.exports.getLetterAvatarURL = getLetterAvatarURL diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 64afcc1d..772a19c8 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -173,9 +173,8 @@ as.router.post("/api/link", defineEventHandler(async event => { const row = from("channel_room").select("channel_id", "room_id").and("WHERE channel_id = ? OR room_id = ?").get(channel.id, parsedBody.matrix) if (row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${row.channel_id} or room ID ${parsedBody.matrix} are already bridged and cannot be reused`}) - // Check whether the room is an actual room or a space, and if it's a part of the guild's space + // Check room is part of the guild's space let foundRoom = false - let foundSpace = false /** @type {string[]?} */ let foundVia = null for await (const room of api.generateFullHierarchy(spaceID)) { @@ -187,21 +186,13 @@ as.router.post("/api/link", defineEventHandler(async event => { } // When finding a room during iteration, see if it was the requested room (to confirm that the room is in the space) - if (room.room_id === parsedBody.matrix) { + if (room.room_id === parsedBody.matrix && !room.room_type) { foundRoom = true - // And also, now that we know that the room object is our intended room - we can test for its type. - if (room.room_type && room.room_type === "m.space") foundSpace = true } if (foundRoom && foundVia) break } if (!foundRoom) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged space"}) - - // Ensure link rules are upheld - const rule = guildRoute.linkRules.get(channel.type) - if (!rule || rule.unsupported) throw createError({status:400, message: "Bad Request", data: "You cannot bridge to " + (rule ? (rule.humanName+" (type-"+channel.type+" channels)") : ("unknown-type ("+channel.type+") channels")) + " because: " + (rule ? rule.unsupported : "OOYE doesn't even know what they are.")}) - else if (foundSpace && rule.type === "TYPING") throw createError({status: 400, message: "Bad Request", data: "Matrix room cannot be of type m.space when bridging to "+rule.humanName}) - else if (!foundSpace && rule.type === "SPACE") throw createError({status: 400, message: "Bad Request", data: "Matrix room must be of type m.space when bridging to "+rule.humanName}) // Check room exists and bridge is joined try { diff --git a/src/web/routes/link.test.js b/src/web/routes/link.test.js index 0182093c..32f89bfb 100644 --- a/src/web/routes/link.test.js +++ b/src/web/routes/link.test.js @@ -73,7 +73,7 @@ test("web link space: check that OOYE is joined", async t => { api: { async joinRoom(roomID) { called++ - throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}) + throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}, 400) } } })) @@ -368,7 +368,7 @@ test("web link room: check that bridge can join room (notices lack of via and as api: { async joinRoom(roomID) { called++ - throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}) + throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}, 400) }, async *generateFullHierarchy(spaceID) { called++ @@ -402,7 +402,7 @@ test("web link room: check that bridge can join room (uses via for join attempt) async joinRoom(roomID, _, via) { called++ t.deepEqual(via, ["cadence.moe", "hashi.re"]) - throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}) + throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}, 400) }, async *generateFullHierarchy(spaceID) { called++ @@ -710,7 +710,7 @@ test("web unlink room: checks that the channel is bridged", async t => { })) t.equal(error.data, "Channel ID 665310973967597573 is not currently bridged") - db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar, last_bridged_pin_timestamp, speedbump_id, speedbump_checked, speedbump_webhook_id, guild_id, custom_topic) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(row.channel_id, row.room_id, row.name, row.nick, row.thread_parent, row.custom_avatar, row.last_bridged_pin_timestamp, row.speedbump_id, row.speedbump_checked, row.speedbump_webhook_id, row.guild_id, row.custom_topic) + db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar, last_bridged_pin_timestamp, speedbump_checked, guild_id, custom_topic) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(row.channel_id, row.room_id, row.name, row.nick, row.thread_parent, row.custom_avatar, row.last_bridged_pin_timestamp, row.speedbump_checked, row.guild_id, row.custom_topic) const new_row = db.prepare("SELECT * FROM channel_room WHERE channel_id = '665310973967597573'").get() t.deepEqual(row, new_row) }) diff --git a/src/web/routes/stats.js b/src/web/routes/stats.js new file mode 100644 index 00000000..1bfd300d --- /dev/null +++ b/src/web/routes/stats.js @@ -0,0 +1,85 @@ +// @ts-check + +const {defineEventHandler, getValidatedQuery, H3Event, setResponseHeader} = require("h3") +const {as, db, sync} = require("../../passthrough") +const {reg} = require("../../matrix/read-registration") + +/** @type {import("../../discord/utils")} */ +const dUtils = sync.require("../../discord/utils") + +// Calculation takes time and is single-threaded. I could add database indexes, but this is simpler and doesn't need storage. +const STATS_CACHE_TIME = 10 * 60 * 1000 // 10 minutes + +function getMessageCountLastDuration(duration) { + const snowflake = dUtils.timestampToSnowflakeInexact(Date.now() - duration) + return db.prepare("select count(*) from message_room where message_id >= ? and length(message_id) = ?").pluck().get(snowflake, snowflake.length) +} + +function getStats() { + const durations = [ + ["week", 7 * 24 * 60 * 60 * 1000], + ["day", 1 * 24 * 60 * 60 * 1000], + ["hour", 1 * 60 * 60 * 1000] + ] + + // console.time("get stats") + let temp = { + guilds: db.prepare("select count(*) from guild_space").pluck().get(), + channels: db.prepare("select count(*) from channel_room").pluck().get(), + messages: db.prepare("select count(*) from message_room").pluck().get(), + ...durations.reduce((a, c) => (a[`messages_last_${c[0]}`] = getMessageCountLastDuration(c[1]), a), {}), + message_sources: db.prepare("select count(*) from event_message where part = 0 group by source order by source").pluck().all(), + oldest_message: new Date(dUtils.snowflakeToTimestampExact(db.prepare("select min(message_id) from event_message where source = 0").pluck().get())), // good until 2090 + discord_users: db.prepare("select count(*) from sim").pluck().get(), + matrix_users: db.prepare("select count(distinct mxid) from member_cache where mxid not like ?").pluck().get(reg.namespaces.users[0].regex.replace(/\.\*.*/, "%")), + } + // console.timeEnd("get stats") + return temp +} + +/** @type {ReturnType} */ +let stats +let statsUpdatedAt = 0 + +function updateStatsIfOld() { + if (statsUpdatedAt < Date.now() - STATS_CACHE_TIME) { + stats = getStats() + statsUpdatedAt = Date.now() + } +} + +as.router.get("/api/stats", defineEventHandler(async event => { + updateStatsIfOld() + return { + ...stats, + oldest_message: stats.oldest_message.toISOString(), + } +})) + +as.router.get("/metrics", defineEventHandler(async event => { + updateStatsIfOld() + setResponseHeader(event, "content-type", "text/plain") + return ` +# HELP guilds Total number of guilds +# TYPE guilds gauge +ooye_guilds_total ${stats.guilds} + +# HELP channels Total number of channels +# TYPE channels gauge +ooye_channels_total ${stats.channels} + +# HELP messages_total Total number of messages sent from each side +# TYPE messages_total gauge +ooye_messages_total{type="matrix"} ${stats.message_sources[0]} +ooye_messages_total{type="discord"} ${stats.message_sources[1]} + +# HELP oldest_message_timestamp Unix timestamp of the oldest message +# TYPE oldest_message_timestamp gauge +ooye_oldest_message_timestamp_seconds ${stats.oldest_message.getTime() / 1000} + +# HELP ooye_users_total Total number of users on each side +# TYPE ooye_users_total gauge +ooye_users_total{type="matrix"} ${stats.matrix_users} +ooye_users_total{type="discord"} ${stats.discord_users} +`.trimStart() +})) diff --git a/src/web/server.js b/src/web/server.js index 85fa1cbd..e28060d8 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -125,13 +125,14 @@ as.router.get("/icon.png", defineEventHandler(async event => { pugSync.createRoute(as.router, "/ok", "ok.pug") -sync.require("./routes/agi") sync.require("./routes/download-matrix") sync.require("./routes/download-discord") sync.require("./routes/guild-settings") sync.require("./routes/guild") sync.require("./routes/info") +sync.require("./routes/letter-avatar") sync.require("./routes/link") sync.require("./routes/log-in-with-matrix") sync.require("./routes/oauth") sync.require("./routes/password") +sync.require("./routes/stats") diff --git a/start.js b/start.js index 39e8ea09..51942c67 100755 --- a/start.js +++ b/start.js @@ -31,8 +31,9 @@ sync.require("./src/m2d/event-dispatcher") ;(async () => { await migrate.migrate(db) + process.stdout.write("Connecting to Discord... ") await discord.cloud.connect() - console.log("Discord gateway started") + console.log("ok.") sync.require("./src/web/server") await power.applyPower() diff --git a/test/data.js b/test/data.js index f3092bca..eab9a63b 100644 --- a/test/data.js +++ b/test/data.js @@ -5473,6 +5473,189 @@ module.exports = { content: '-# Original Message ID: 1466556003645657118 · ' } ] + }, + pk_ping_components_v1: { + type: 23, + content: "Psst, **Red** (<@772659086046658620>), you have been pinged by <@772659086046658620>.", + mentions: [ + { + id: "772659086046658620", + username: "cadence.worm", + avatar: "466df0c98b1af1e1388f595b4c1ad1b9", + discriminator: "0", + public_flags: 0, + flags: 0, + banner: null, + accent_color: null, + global_name: "cadence", + avatar_decoration_data: null, + collectibles: null, + display_name_styles: null, + banner_color: null, + clan: { + identity_guild_id: "532245108070809601", + identity_enabled: true, + tag: "doll", + badge: "dba08126b4e810a0e096cc7cd5bc37f0" + }, + primary_guild: { + identity_guild_id: "532245108070809601", + identity_enabled: true, + tag: "doll", + badge: "dba08126b4e810a0e096cc7cd5bc37f0" + } + } + ], + mention_roles: [], + attachments: [], + embeds: [], + timestamp: "2026-03-25T07:07:02.626000+00:00", + edited_timestamp: null, + flags: 0, + components: [ + { + type: 1, + id: 1, + components: [ + { + type: 2, + id: 2, + style: 5, + label: "Jump", + url: "https://discord.com/channels/1160893336324931584/1160894080998461480/1440549403667468320" + } + ] + } + ], + id: "1486260105908457653", + channel_id: "1160894080998461480", + author: { + id: "466378653216014359", + username: "PluralKit", + avatar: "b78ef67a081737a830b60aa47d9ebcd9", + discriminator: "4020", + public_flags: 65536, + flags: 65536, + bot: true, + banner: null, + accent_color: null, + global_name: null, + avatar_decoration_data: null, + collectibles: null, + display_name_styles: null, + banner_color: null, + clan: null, + primary_guild: null + }, + pinned: false, + mention_everyone: false, + tts: false, + application_id: "466378653216014359", + interaction: { + id: "1486260103928614932", + type: 2, + name: "🔔 Ping author", + user: { + id: "772659086046658620", + username: "cadence.worm", + avatar: "466df0c98b1af1e1388f595b4c1ad1b9", + discriminator: "0", + public_flags: 0, + flags: 0, + banner: null, + accent_color: null, + global_name: "cadence", + avatar_decoration_data: null, + collectibles: null, + display_name_styles: null, + banner_color: null, + clan: { + identity_guild_id: "532245108070809601", + identity_enabled: true, + tag: "doll", + badge: "dba08126b4e810a0e096cc7cd5bc37f0" + }, + primary_guild: { + identity_guild_id: "532245108070809601", + identity_enabled: true, + tag: "doll", + badge: "dba08126b4e810a0e096cc7cd5bc37f0" + } + } + }, + webhook_id: "466378653216014359", + message_reference: { + type: 0, + channel_id: "1160894080998461480", + message_id: "1440549403667468320", + guild_id: "1160893336324931584" + }, + interaction_metadata: { + id: "1486260103928614932", + type: 2, + user: { + id: "772659086046658620", + username: "cadence.worm", + avatar: "466df0c98b1af1e1388f595b4c1ad1b9", + discriminator: "0", + public_flags: 0, + flags: 0, + banner: null, + accent_color: null, + global_name: "cadence", + avatar_decoration_data: null, + collectibles: null, + display_name_styles: null, + banner_color: null, + clan: { + identity_guild_id: "532245108070809601", + identity_enabled: true, + tag: "doll", + badge: "dba08126b4e810a0e096cc7cd5bc37f0" + }, + primary_guild: { + identity_guild_id: "532245108070809601", + identity_enabled: true, + tag: "doll", + badge: "dba08126b4e810a0e096cc7cd5bc37f0" + } + }, + authorizing_integration_owners: { "0": "1160893336324931584" }, + name: "🔔 Ping author", + command_type: 3, + target_message_id: "1440549403667468320" + }, + referenced_message: { + type: 0, + content: "test", + mentions: [], + mention_roles: [], + attachments: [], + embeds: [], + timestamp: "2025-11-19T03:49:01.948000+00:00", + edited_timestamp: null, + flags: 0, + components: [], + id: "1440549403667468320", + channel_id: "1160894080998461480", + author: { + id: "1195662438662680720", + username: "special name", + avatar: "a82347890f2739e5880cd82b8c1a708e", + discriminator: "0000", + public_flags: 0, + flags: 0, + bot: true, + global_name: null, + clan: null, + primary_guild: null + }, + pinned: false, + mention_everyone: false, + tts: false, + application_id: "466378653216014359", + webhook_id: "1195662438662680720" + } } }, message_update: { diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql index ec287a7a..16623203 100644 --- a/test/ooye-test-data.sql +++ b/test/ooye-test-data.sql @@ -96,13 +96,12 @@ WITH a (message_id, channel_id) AS (VALUES ('1401760355339862066', '112760669178241024'), ('1439351590262800565', '1438284564815548418'), ('1404133238414376971', '112760669178241024'), -('1162005314908999790', '1100319550446252084')) +('1440549403667468320', '1160894080998461480')) SELECT message_id, max(historical_room_index) as historical_room_index FROM a INNER JOIN historical_channel_room ON historical_channel_room.reference_channel_id = a.channel_id GROUP BY message_id; INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES ('$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg', 'm.room.message', 'm.text', '1126786462646550579', 0, 0, 1), ('$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4', 'm.room.message', 'm.text', '1128118177155526666', 0, 0, 0), -('$fdD9o7NxMA4VPexlAiIx2CB9JbsiGhJeyJgnZG7U5xg', 'm.room.message', 'm.text', '1162005314908999790', 0, 0, 1), ('$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10', 'm.room.message', 'm.text', '1141619794500649020', 0, 0, 1), ('$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY', 'm.room.message', 'm.text', '1141206225632112650', 0, 0, 1), ('$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA', 'm.room.message', 'm.text', '1141501302736695316', 0, 1, 1), @@ -145,7 +144,8 @@ INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part ('$7P2O_VTQNHvavX5zNJ35DV-dbJB1Ag80tGQP_JzGdhk', 'm.room.message', 'm.text', '1401760355339862066', 0, 0, 0), ('$ielAnR6geu0P1Tl5UXfrbxlIf-SV9jrNprxrGXP3v7M', 'm.room.message', 'm.image', '1439351590262800565', 0, 0, 0), ('$uUKLcTQvik5tgtTGDKuzn0Ci4zcCvSoUcYn2X7mXm9I', 'm.room.message', 'm.text', '1404133238414376971', 0, 1, 1), -('$LhmoWWvYyn5_AHkfb6FaXmLI6ZOC1kloql5P40YDmIk', 'm.room.message', 'm.notice', '1404133238414376971', 1, 0, 1); +('$LhmoWWvYyn5_AHkfb6FaXmLI6ZOC1kloql5P40YDmIk', 'm.room.message', 'm.notice', '1404133238414376971', 1, 0, 1), +('$l9FMmsEbh9K0NUReeEpWOMZYGRlUOE8yLcm6P-TYHSM', 'm.room.message', 'm.text', '1440549403667468320', 0, 0, 1); INSERT INTO file (discord_url, mxc_url) VALUES ('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'), @@ -194,6 +194,7 @@ INSERT INTO member_cache (room_id, mxid, displayname, avatar_url, power_level) V ('!TqlyQmifxGUggEmdBN:cadence.moe', '@ampflower:matrix.org', 'Ampflower 🌺', 'mxc://cadence.moe/PRfhXYBTOalvgQYtmCLeUXko', 0), ('!TqlyQmifxGUggEmdBN:cadence.moe', '@aflower:syndicated.gay', 'Rose', 'mxc://syndicated.gay/ZkBUPXCiXTjdJvONpLJmcbKP', 0), ('!TqlyQmifxGUggEmdBN:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL, 0), +('!qzDBLKlildpzrrOnFZ:cadence.moe', '@lavender.pet:queer.sh', 'lavender.pet', NULL, 0), ('!iSyXgNxQcEuXoXpsSn:pussthecat.org', '@austin:tchncs.de', 'Austin Huang', 'mxc://tchncs.de/090a2b5e07eed2f71e84edad5207221e6c8f8b8e', 0), ('!zq94fae5bVKUubZLp7:agiadn.org', '@underscore_x:agiadn.org', 'underscore_x', NULL, 100); diff --git a/test/test.js b/test/test.js index 70625a07..4cd96277 100644 --- a/test/test.js +++ b/test/test.js @@ -175,5 +175,4 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not require("../src/web/routes/log-in-with-matrix.test") require("../src/web/routes/oauth.test") require("../src/web/routes/password.test") - require("../src/agi/generator.test") })()