Compare commits

...
Sign in to create a new pull request.

61 commits

Author SHA1 Message Date
021f02f981 d->m: Try too-large PNGs as JPEG for upload
Large PNGs were almost certainly originally JPEG photos that were
converted to PNG by the user copy-pasting.
If the PNG is just supposed to be that large, you can still get the
original through the external URL option.

Had to change _removeExpiryParams to be based on URL parsing instead of
regexp because regexp didn't work with &format=jpeg on the end.
2026-09-14 00:30:03 +12:00
c71120e463 Better Discord startup checking 2026-09-11 23:27:39 +12:00
965fcab646 m->d edits now wait for original message 2026-09-11 20:07:36 +12:00
cf95f7d915 Add AI policy 2026-09-11 12:57:46 +12:00
5e9f3cd532 Add retries back to PluralKit API 2026-09-11 12:51:29 +12:00
7faa8a3c9e Remove unwise nextTick delay 2026-09-11 12:47:15 +12:00
1bb22e315a Fix m->d m.mentions for webhooks 2026-09-08 23:53:07 +12:00
82f921caee Make poll vote counters more verbose 2026-09-08 18:41:07 +12:00
70b18c8833 Guard against hopefully impossible situation 2026-09-08 18:40:54 +12:00
9b01e522cc Discord default file size limit is now 20 MB 2026-09-05 11:53:24 +12:00
c7389ff2d6 Improve d->m highlighting for toki pona community 2026-08-23 02:58:00 +12:00
cf12293d5c Allow anyone to respond to polls 2026-08-20 12:58:59 +12:00
2e84c71d96 Fix creating webhooks for matrix users 2026-08-19 22:21:46 +12:00
0d398be60a Fix logging in to Matrix on web 2026-08-19 20:42:50 +12:00
b9be8b5887 Remove mistaken logging 2026-08-19 20:40:45 +12:00
1dcd5620c4 SnowTransfer error handling is no longer pathetic 2026-08-17 17:30:06 +12:00
c0c6419bd1 Remove mx-reply earlier to skip tree walking it 2026-08-16 22:37:01 +12:00
674cd532cd Fix m->d poll questions over 45 characters 2026-08-16 22:32:10 +12:00
b3ef973568 Add noscript notice to opt-out page 2026-08-14 16:52:51 +12:00
7229206d09 Fix error when forwarding a crosspost lacks author 2026-08-14 09:51:00 +12:00
b7fa7c3e2d Map PluralKit colour to MSC4522 colour 2026-08-14 09:50:38 +12:00
db2c038f40 Support attachment spoiler flag 2026-08-11 22:05:58 +12:00
c7dbd17209 Move embed via line to footer 2026-08-10 19:43:14 +12:00
85b9e4743f Support more unnecessary room link percent-encodes 2026-08-06 16:28:50 +12:00
f42d8a80eb Update dependency 2026-08-04 14:34:21 +12:00
0034193953 Add privacy policy & opt out (requires contact) 2026-08-04 14:32:40 +12:00
63a6d37ec2 Support redirecting out of oauth 2026-08-04 14:31:33 +12:00
50d5509c07 Rename files for consistency 2026-08-04 11:43:11 +12:00
c8961a9bec Update dependencies 2026-07-30 19:31:17 +12:00
a3ce911218 Support /plu/ral webhook proxying 2026-07-30 19:24:31 +12:00
544c5206c7 Fix non-PK messages from PK users being flaky 2026-07-28 01:12:39 +12:00
b765f564b3 Remove rather than truncate links in reply preview 2026-07-27 23:45:17 +12:00
c035e49d5b Add (bot) to Matrix side displayname 2026-07-27 23:14:05 +12:00
melody
fb1e5571a9 Docs: Update melody's oci image link 2026-07-27 00:40:22 +12:00
8f65b2fa4c Responses: Combine data from multi-part messages 2026-07-24 18:07:08 +12:00
fa716cd239 Prevent typing events double-bridging 2026-07-20 21:45:44 +12:00
ed0bddaae0 Expressions http status typo 400 -> 500 2026-07-20 18:32:00 +12:00
c982ca0bb6 Fix m->d typing interval getting stuck 2026-07-20 18:02:45 +12:00
983ea0cb52 m->d typing 2026-07-18 12:06:03 +12:00
6cd36464fa Pass content-disposition header through file proxy 2026-07-16 15:01:50 +12:00
d4cd580555 Detect timeouts as server down 2026-07-15 16:37:23 +12:00
a8712b635d Only detect non-JSON responses as server down 2026-07-12 20:27:42 +12:00
f92d280494 Wait for homeserver when bridge is first started 2026-07-12 02:47:07 +12:00
414da4caf4 Queue events for later if homeserver is down 2026-07-12 02:12:53 +12:00
8779b8b1b0 Update SnowTransfer 2026-07-12 02:05:57 +12:00
51e7d8479b Add HTTP status to MatrixServerError 2026-07-12 00:33:27 +12:00
8ec7b3f67a Improve spacing around blockquotes 2026-07-09 17:51:01 +12:00
401985a861 Fix member leaves not being propagated immediately
Previously they would only be removed next time the bridge was
restarted. Now it should be done in real time.
2026-07-03 22:36:21 +12:00
cad815896b Relax mxc regexp
The spec doesn't say which characters are permitted in the opaque ID,
but in real life we at least need a hyphen. For lack of a better
reference, I copied gomuks.
2026-07-03 22:35:10 +12:00
47ccf986d8 m->d: Create filename from mime if not specified 2026-06-24 23:47:32 +12:00
73f410bcad Emoji name in messages may be uncapped 2026-06-21 20:28:44 +12:00
34fc21fa15 Fix PluralKit replies being duplicated
There was a race condition on create/update that was skipping the
remedial code due to a bad assumption about the speedbump.
2026-06-16 20:11:39 +12:00
ab051f301f Fix polls in threads 2026-06-13 20:27:48 +12:00
51d57051f6 Fix not giving speedbump info when it's bypassed 2026-06-12 18:08:43 +12:00
f7609b2040 Only speedbump users that have used PK 2026-06-06 23:38:49 +12:00
b576869764 v3.6 2026-06-04 18:07:39 +12:00
47dc0504ff Consistent font colour 2026-06-03 00:36:51 +12:00
fbade33ff0 Update language to sound more warningcore 2026-06-03 00:34:37 +12:00
e2ab9fa9bf Improve PK ping message 2026-06-03 00:02:48 +12:00
18b6efdd18 Fix editing permissions interactions not working
Co-authored-by: Cadence Ember <cadence@disroot.org>
2026-06-01 16:55:11 +12:00
313efb29d8 Fix m->d reaction deletion counting (#85)
Fixes a bug where, if multiple Matrix users had used the same reaction on a message, and then one of those Matrix users removed their reactions, the bot would forcibly remove all of that reactions. Now, we check and make sure there are no remaining reactions from Matrix before removal.

This also rewrote the retrigger system to be more generic and to use promises instead of re-entry (would lose call stack).

Co-authored-by: Cadence Ember <cadence@disroot.org>
Reviewed-on: cadence/out-of-your-element#85
2026-06-01 04:54:38 +00:00
82 changed files with 2369 additions and 694 deletions

View file

@ -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. * (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. * (1) chunk-text: It does what I want.
* (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust. * (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) discord-api-types: Bitfields needed at runtime and types needed for development.
* (0) domino: DOM implementation that's already pulled in by turndown. * (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. * (2) enquirer: Interactive prompting for the initial setup rather than forcing users to edit YAML non-interactively.

View file

@ -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.*** ***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 sim2kid: https://github.com/sim2kid/ooye-docker
- by Katharos Technology: https://github.com/katharostech/docker_ooye - by Katharos Technology: https://github.com/katharostech/docker_ooye
- by Emma: https://cgit.rory.gay/nix/OOYE-module.git/tree - by Emma: https://cgit.rory.gay/nix/OOYE-module.git/tree

View file

@ -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! I hope you enjoy Out Of Your Element!
---- # Extras
<br><br><br><br><br>
## /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 # Appendix

388
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "out-of-your-element", "name": "out-of-your-element",
"version": "3.5.1", "version": "3.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "out-of-your-element", "name": "out-of-your-element",
"version": "3.5.1", "version": "3.6.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@chriscdn/promise-semaphore": "^3.0.1", "@chriscdn/promise-semaphore": "^3.0.1",
@ -18,14 +18,15 @@
"@cloudrac3r/pngjs": "^7.0.3", "@cloudrac3r/pngjs": "^7.0.3",
"@cloudrac3r/pug": "^4.0.4", "@cloudrac3r/pug": "^4.0.4",
"@cloudrac3r/stream-type": "^1.0.0", "@cloudrac3r/stream-type": "^1.0.0",
"@cloudrac3r/turndown": "^7.1.4", "@cloudrac3r/turndown": "^7.1.5",
"@stackoverflow/stacks": "^2.5.4", "@stackoverflow/stacks": "^2.5.4",
"@stackoverflow/stacks-icons": "^6.0.2", "@stackoverflow/stacks-icons": "^6.0.2",
"ansi-colors": "^4.1.3", "ansi-colors": "^4.1.3",
"better-sqlite3": "^12.2.0", "better-sqlite3": "^12.2.0",
"chunk-text": "^2.0.1", "chunk-text": "^2.0.1",
"cloudstorm": "^0.17.1", "cloudstorm": "^0.19.0",
"discord-api-types": "^0.38.38", "denque": "^2.1.0",
"discord-api-types": "^0.38.52",
"domino": "^2.1.6", "domino": "^2.1.6",
"enquirer": "^2.4.1", "enquirer": "^2.4.1",
"entities": "^5.0.0", "entities": "^5.0.0",
@ -36,8 +37,8 @@
"lru-cache": "^11.0.2", "lru-cache": "^11.0.2",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"prettier-bytes": "^1.0.4", "prettier-bytes": "^1.0.4",
"sharp": "^0.34.5", "sharp": "^0.35.3",
"snowtransfer": "^0.17.5", "snowtransfer": "^0.19.0",
"try-to-catch": "^4.0.5", "try-to-catch": "^4.0.5",
"uqr": "^0.1.2", "uqr": "^0.1.2",
"xxhash-wasm": "^1.0.2", "xxhash-wasm": "^1.0.2",
@ -267,18 +268,18 @@
} }
}, },
"node_modules/@cloudrac3r/turndown": { "node_modules/@cloudrac3r/turndown": {
"version": "7.1.4", "version": "7.1.5",
"resolved": "https://registry.npmjs.org/@cloudrac3r/turndown/-/turndown-7.1.4.tgz", "resolved": "https://registry.npmjs.org/@cloudrac3r/turndown/-/turndown-7.1.5.tgz",
"integrity": "sha512-bQAwcvcSqBTdEHPMt+IAZWIoDh+2eRuy9TgD0FUdxVurbvj3CUHTxLfzlmsO0UTi+GHpgYqDSsVdV7kYTNq5Qg==", "integrity": "sha512-pENoG62B4UM2ecsg57cuSRDa1OV/YFYTFGg1aoQsKUdHBBglhE9I7EwEMYpS96QoM/auyglfaxjgTeqhcSGCIA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"domino": "^2.1.6" "domino": "^2.1.6"
} }
}, },
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.9.1", "version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@ -301,9 +302,9 @@
} }
}, },
"node_modules/@img/sharp-darwin-arm64": { "node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -313,19 +314,19 @@
"darwin" "darwin"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4" "@img/sharp-libvips-darwin-arm64": "1.3.2"
} }
}, },
"node_modules/@img/sharp-darwin-x64": { "node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -335,19 +336,38 @@
"darwin" "darwin"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "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": { "node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -361,9 +381,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-darwin-x64": { "node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -377,9 +397,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-arm": { "node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -393,9 +413,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-arm64": { "node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -409,9 +429,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-ppc64": { "node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -425,9 +445,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-riscv64": { "node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -441,9 +461,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-s390x": { "node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -457,9 +477,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-x64": { "node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -473,9 +493,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linuxmusl-arm64": { "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -489,9 +509,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linuxmusl-x64": { "node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -505,9 +525,9 @@
} }
}, },
"node_modules/@img/sharp-linux-arm": { "node_modules/@img/sharp-linux-arm": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -517,19 +537,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4" "@img/sharp-libvips-linux-arm": "1.3.2"
} }
}, },
"node_modules/@img/sharp-linux-arm64": { "node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -539,19 +559,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4" "@img/sharp-libvips-linux-arm64": "1.3.2"
} }
}, },
"node_modules/@img/sharp-linux-ppc64": { "node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -561,19 +581,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4" "@img/sharp-libvips-linux-ppc64": "1.3.2"
} }
}, },
"node_modules/@img/sharp-linux-riscv64": { "node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -583,19 +603,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4" "@img/sharp-libvips-linux-riscv64": "1.3.2"
} }
}, },
"node_modules/@img/sharp-linux-s390x": { "node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -605,19 +625,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4" "@img/sharp-libvips-linux-s390x": "1.3.2"
} }
}, },
"node_modules/@img/sharp-linux-x64": { "node_modules/@img/sharp-linux-x64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -627,19 +647,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4" "@img/sharp-libvips-linux-x64": "1.3.2"
} }
}, },
"node_modules/@img/sharp-linuxmusl-arm64": { "node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -649,19 +669,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4" "@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
} }
}, },
"node_modules/@img/sharp-linuxmusl-x64": { "node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -671,38 +691,54 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4" "@img/sharp-libvips-linuxmusl-x64": "1.3.2"
} }
}, },
"node_modules/@img/sharp-wasm32": { "node_modules/@img/sharp-wasm32": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/runtime": "^1.7.0" "@emnapi/runtime": "^1.11.1"
}, },
"engines": { "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": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
}, },
"node_modules/@img/sharp-win32-arm64": { "node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -712,16 +748,16 @@
"win32" "win32"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
}, },
"node_modules/@img/sharp-win32-ia32": { "node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -731,16 +767,16 @@
"win32" "win32"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
}, },
"node_modules/@img/sharp-win32-x64": { "node_modules/@img/sharp-win32-x64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -750,7 +786,7 @@
"win32" "win32"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -1003,9 +1039,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.19.19", "version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1163,16 +1199,16 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.6", "version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^4.0.2" "balanced-match": "^4.0.2"
}, },
"engines": { "engines": {
"node": "18 || 20 || >=22" "node": "20 || >=22"
} }
}, },
"node_modules/buffer": { "node_modules/buffer": {
@ -1316,13 +1352,13 @@
} }
}, },
"node_modules/cloudstorm": { "node_modules/cloudstorm": {
"version": "0.17.1", "version": "0.19.0",
"resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.17.1.tgz", "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.19.0.tgz",
"integrity": "sha512-LYUwzHagRYRd93XocOqi+HCHdzPYI9cW7Yf7pYqinxgG+Qka1OiqBKWTCcLiEuiqXaOV30kr8c6aZ/c1QcDP4Q==", "integrity": "sha512-mjuP5o2nS/CgSljajh08KjN6jYMfNBBttSPHc1YUN74f7OkmhUhv8QE8JFDXomxCfdnLNJcqBQtjRLrs8nkfVQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"discord-api-types": "^0.38.47", "discord-api-types": "^0.38.49",
"snowtransfer": "^0.17.5" "snowtransfer": "^0.19.0"
}, },
"engines": { "engines": {
"node": ">=22.0.0" "node": ">=22.0.0"
@ -1457,6 +1493,15 @@
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"license": "MIT" "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": { "node_modules/destr": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
@ -1473,9 +1518,9 @@
} }
}, },
"node_modules/discord-api-types": { "node_modules/discord-api-types": {
"version": "0.38.47", "version": "0.38.53",
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.47.tgz", "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.53.tgz",
"integrity": "sha512-XgXQodHQBAE6kfD7kMvVo30863iHX1LHSqNq6MGUTDwIFCCvHva13+rwxyxVXDqudyApMNAd32PGjgVETi5rjA==", "integrity": "sha512-HL1zz/UuZ+bbJjA/X8Kbxx9gk8v9rJAbTeWRNYKmIdjwJ7EovjlHgoJTxcLpATfNJ+AonOtMdy3Y5MVIJAAd/A==",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"scripts/actions/documentation" "scripts/actions/documentation"
@ -2433,9 +2478,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.4", "version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@ -2445,47 +2490,52 @@
} }
}, },
"node_modules/sharp": { "node_modules/sharp": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@img/colour": "^1.0.0", "@img/colour": "^1.1.0",
"detect-libc": "^2.1.2", "detect-libc": "^2.1.2",
"semver": "^7.7.3" "semver": "^7.8.5"
}, },
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-arm64": "0.35.3",
"@img/sharp-darwin-x64": "0.34.5", "@img/sharp-darwin-x64": "0.35.3",
"@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-freebsd-wasm32": "0.35.3",
"@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-darwin-arm64": "1.3.2",
"@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.3.2",
"@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.3.2",
"@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.3.2",
"@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.3.2",
"@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.3.2",
"@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.3.2",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linux-x64": "1.3.2",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
"@img/sharp-linux-arm": "0.34.5", "@img/sharp-libvips-linuxmusl-x64": "1.3.2",
"@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-arm": "0.35.3",
"@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-arm64": "0.35.3",
"@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-ppc64": "0.35.3",
"@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-riscv64": "0.35.3",
"@img/sharp-linux-x64": "0.34.5", "@img/sharp-linux-s390x": "0.35.3",
"@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linux-x64": "0.35.3",
"@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.35.3",
"@img/sharp-wasm32": "0.34.5", "@img/sharp-linuxmusl-x64": "0.35.3",
"@img/sharp-win32-arm64": "0.34.5", "@img/sharp-webcontainers-wasm32": "0.35.3",
"@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-arm64": "0.35.3",
"@img/sharp-win32-x64": "0.34.5" "@img/sharp-win32-ia32": "0.35.3",
"@img/sharp-win32-x64": "0.35.3"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
} }
}, },
"node_modules/shebang-command": { "node_modules/shebang-command": {
@ -2579,12 +2629,12 @@
} }
}, },
"node_modules/snowtransfer": { "node_modules/snowtransfer": {
"version": "0.17.7", "version": "0.19.0",
"resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.17.7.tgz", "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.19.0.tgz",
"integrity": "sha512-scbOjYezo1Ycfk21atCEkeXIISTT7R7JTHCdiZ/7m7k4XbSb6o5q8Mu2fev5IqFpNyqIVjA0d/MZQ+eP/gtwfg==", "integrity": "sha512-Vebj4FCdpUsEKAzqC1p50ol5o09CLnrffvBCpTv0FNFefvXPXwogxR8kr4zJApIRt7oWQVZZVkxAq4XnGlICjQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"discord-api-types": "^0.38.47" "discord-api-types": "^0.38.49"
}, },
"engines": { "engines": {
"node": ">=22.0.0" "node": ">=22.0.0"
@ -2809,9 +2859,9 @@
} }
}, },
"node_modules/try-to-catch": { "node_modules/try-to-catch": {
"version": "4.0.5", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/try-to-catch/-/try-to-catch-4.0.5.tgz", "resolved": "https://registry.npmjs.org/try-to-catch/-/try-to-catch-4.1.0.tgz",
"integrity": "sha512-VKBslDQsy4pGj2TMNgDdskWb7AWSi/9dPEmcNv3sdL0+aRMQTPJo6aEqlcuN0vbOwFfsE1oAXmx3bFdf6vrJFg==", "integrity": "sha512-phAezhZqLgHHTpbZYe2GgmKe8itTgEX3gfd+YeIsmllgOOpXCDPn1DkDzVWj0mGQPriJgD6AagoUEcX+M2RlGw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=22" "node": ">=22"

View file

@ -1,6 +1,6 @@
{ {
"name": "out-of-your-element", "name": "out-of-your-element",
"version": "3.5.1", "version": "3.6.0",
"description": "A bridge between Matrix and Discord", "description": "A bridge between Matrix and Discord",
"main": "index.js", "main": "index.js",
"repository": { "repository": {
@ -27,14 +27,15 @@
"@cloudrac3r/pngjs": "^7.0.3", "@cloudrac3r/pngjs": "^7.0.3",
"@cloudrac3r/pug": "^4.0.4", "@cloudrac3r/pug": "^4.0.4",
"@cloudrac3r/stream-type": "^1.0.0", "@cloudrac3r/stream-type": "^1.0.0",
"@cloudrac3r/turndown": "^7.1.4", "@cloudrac3r/turndown": "^7.1.5",
"@stackoverflow/stacks": "^2.5.4", "@stackoverflow/stacks": "^2.5.4",
"@stackoverflow/stacks-icons": "^6.0.2", "@stackoverflow/stacks-icons": "^6.0.2",
"ansi-colors": "^4.1.3", "ansi-colors": "^4.1.3",
"better-sqlite3": "^12.2.0", "better-sqlite3": "^12.2.0",
"chunk-text": "^2.0.1", "chunk-text": "^2.0.1",
"cloudstorm": "^0.17.1", "cloudstorm": "^0.19.0",
"discord-api-types": "^0.38.38", "denque": "^2.1.0",
"discord-api-types": "^0.38.52",
"domino": "^2.1.6", "domino": "^2.1.6",
"enquirer": "^2.4.1", "enquirer": "^2.4.1",
"entities": "^5.0.0", "entities": "^5.0.0",
@ -45,8 +46,8 @@
"lru-cache": "^11.0.2", "lru-cache": "^11.0.2",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"prettier-bytes": "^1.0.4", "prettier-bytes": "^1.0.4",
"sharp": "^0.34.5", "sharp": "^0.35.3",
"snowtransfer": "^0.17.5", "snowtransfer": "^0.19.0",
"try-to-catch": "^4.0.5", "try-to-catch": "^4.0.5",
"uqr": "^0.1.2", "uqr": "^0.1.2",
"xxhash-wasm": "^1.0.2", "xxhash-wasm": "^1.0.2",

View file

@ -8,6 +8,10 @@ Modern Matrix-to-Discord appservice bridge, created by [@cadence:cadence.moe](ht
![](https://cadence.moe/i/f42a3f) ![](https://cadence.moe/i/f42a3f)
## AI policy
Out Of Your Element is 100% organically cultivated ethical hand-fed code. Let's work together to conserve that!
## Why a new bridge? ## Why a new bridge?
* Modern: Supports new Discord features like replies, threads and stickers, and new Matrix features like edits, spaces and space membership. * Modern: Supports new Discord features like replies, threads and stickers, and new Matrix features like edits, spaces and space membership.

17
scripts/plu-ral-api.js Normal file
View file

@ -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.")
})()

View file

@ -359,12 +359,12 @@ function defineEchoHandler() {
console.log("✅ Emojis are ready...") console.log("✅ Emojis are ready...")
// set profile data on discord... // 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...") console.log("✅ Discord profile updated...")
// set profile data on homeserver... // 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.)") 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) await api.profileSetAvatarUrl(mxid, avatarUrl)
console.log("✅ Matrix profile updated...") console.log("✅ Matrix profile updated...")

View file

@ -163,6 +163,7 @@ async function channelToKState(channel, guild, di) {
events_default: everyoneCanSend ? 0 : READ_ONLY_ROOM_EVENTS_DEFAULT_POWER, events_default: everyoneCanSend ? 0 : READ_ONLY_ROOM_EVENTS_DEFAULT_POWER,
events: { events: {
"m.reaction": 0, "m.reaction": 0,
"org.matrix.msc3381.poll.response": 0,
"m.room.redaction": 0, // only affects redactions of own events, required to be able to un-react "m.room.redaction": 0, // only affects redactions of own events, required to be able to un-react
...pollStartPowerLevel ...pollStartPowerLevel
}, },

View file

@ -147,6 +147,7 @@ test("channel2room: read-only discord channel", async t => {
events_default: 50, // <-- it should be read-only! events_default: 50, // <-- it should be read-only!
events: { events: {
"m.reaction": 0, "m.reaction": 0,
"org.matrix.msc3381.poll.response": 0,
"m.room.redaction": 0 "m.room.redaction": 0
}, },
notifications: { notifications: {

View file

@ -8,6 +8,8 @@ const {reg} = require("../../matrix/read-registration")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {discord, sync, db, select} = passthrough const {discord, sync, db, select} = passthrough
/** @type {import("../../matrix/mreq")} */
const mreq = sync.require("../../matrix/mreq")
/** @type {import("../../matrix/api")} */ /** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api") const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/file")} */ /** @type {import("../../matrix/file")} */
@ -237,8 +239,12 @@ async function syncSpaceExpressions(data, checkBeforeSync) {
try { try {
existing = await api.getStateEvent(spaceID, "im.ponies.room_emotes", eventKey) existing = await api.getStateEvent(spaceID, "im.ponies.room_emotes", eventKey)
} catch (e) { } catch (e) {
// State event not found. This space doesn't have any existing emojis. We create a dummy empty event for comparison's sake. if (e instanceof mreq.MatrixServerError && e.httpStatus < 500) {
existing = fn([], guild) // 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 if (isDeepStrictEqual(existing, content)) return
} }

View file

@ -1,24 +1,24 @@
// @ts-check // @ts-check
const assert = require("assert").strict
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {sync, db, select, from} = passthrough const {sync, db, select, from} = passthrough
const {reg} = require("../../matrix/read-registration")
/** @type {import("../converters/edit-to-changes")} */ /** @type {import("../converters/edit-to-changes")} */
const editToChanges = sync.require("../converters/edit-to-changes") const editToChanges = sync.require("../converters/edit-to-changes")
/** @type {import("./register-pk-user")} */ /** @type {import("./speedbump")} */
const registerPkUser = sync.require("./register-pk-user") const speedbump = sync.require("./speedbump")
/** @type {import("../../matrix/api")} */ /** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api") const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/mreq")} */ /** @type {import("../../matrix/mreq")} */
const mreq = sync.require("../../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").GatewayMessageCreateDispatchData} message
* @param {import("discord-api-types/v10").APIGuild} guild * @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 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() 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 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) let {roomID, eventsToRedact, eventsToReplace, eventsToSend, senderMxid, promotions} = await editToChanges.editToChanges(message, guild, api)
if (row && row.speedbump_webhook_id === message.webhook_id) { // Sync proxy user profile (if sent by proxy)
// Handle the PluralKit public instance if (dUtils.isWebhookMessage(message)) {
if (row.speedbump_id === "466378653216014359") { senderMxid = await speedbump.getWebhookSenderId(message, guild.id, roomID)
senderMxid = await registerPkUser.syncUser(message.id, message.author, roomID, true)
}
} }
// 1. Replace all the things. // 1. Replace all the things.

View file

@ -1,8 +1,9 @@
// @ts-check // @ts-check
const assert = require("assert") const assert = require("assert").strict
const {reg} = require("../../matrix/read-registration") const {reg} = require("../../matrix/read-registration")
const Ty = require("../../types") const Ty = require("../../types")
const {scheduler} = require("timers/promises")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {sync, db, select, from} = passthrough const {sync, db, select, from} = passthrough
@ -14,14 +15,21 @@ const file = sync.require("../../matrix/file")
const registerUser = sync.require("./register-user") const registerUser = sync.require("./register-user")
/** @returns {Promise<Ty.PkMessage>} */ /** @returns {Promise<Ty.PkMessage>} */
async function fetchMessage(messageID) { async function fetchMessage(messageID, attempt = 1) {
try { try {
var res = await fetch(`https://api.pluralkit.me/v2/messages/${messageID}`) var res = await fetch(`https://api.pluralkit.me/v2/messages/${messageID}`)
} catch (networkError) { } catch (networkError) {
// Network issue, raise a more readable message // Network issue, raise a more readable message
throw new Error(`Failed to connect to PK API: ${networkError.toString()}`) throw new Error(`Failed to connect to PK API: ${networkError.toString()}`)
} }
if (!res.ok) throw new Error(`PK API returned an error: ${await res.text()}`) if (!res.ok) {
if (attempt < 2) {
await scheduler.wait(5000)
return fetchMessage(messageID, attempt + 1)
} else {
throw new Error(`PK API returned an error: ${await res.text()}`)
}
}
/** @type {any} */ /** @type {any} */
const root = await res.json() const root = await res.json()
if (!root.member) throw new Error(`PK API didn't return member data: ${JSON.stringify(root)}`) if (!root.member) throw new Error(`PK API didn't return member data: ${JSON.stringify(root)}`)
@ -103,6 +111,7 @@ async function memberToStateContent(pkMessage, author) {
// We prefer to use the member's avatar URL data since the image upload can be cached across channels, // 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. // unlike the userAvatar URL which is unique per channel, due to the webhook ID being in the URL.
const avatar = pkMessage.member.avatar_url || pkMessage.member.webhook_avatar_url || pkMessage.system.avatar_url || file.userAvatar(author) const avatar = pkMessage.member.avatar_url || pkMessage.member.webhook_avatar_url || pkMessage.system.avatar_url || file.userAvatar(author)
const color = pkMessage.member.color ?? pkMessage.system.color
const content = { const content = {
displayname: author.username, displayname: author.username,
@ -110,6 +119,12 @@ async function memberToStateContent(pkMessage, author) {
"moe.cadence.ooye.pk_member": pkMessage.member "moe.cadence.ooye.pk_member": pkMessage.member
} }
if (avatar) content.avatar_url = await file.uploadDiscordFileToMxc(avatar) if (avatar) content.avatar_url = await file.uploadDiscordFileToMxc(avatar)
if (color) {
content["eu.she-a.color"] = {
on_dark: `#${color}`,
on_light: `#${color}`
}
}
return content return content
} }
@ -132,10 +147,10 @@ async function syncUser(messageID, author, roomID, shouldActuallySync) {
try { try {
// API lookup // API lookup
var pkMessage = await fetchMessage(messageID) 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) { } catch (e) {
// Fall back to offline cache // 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 if (!senderMxid) throw e
return senderMxid return senderMxid
} }

View file

@ -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<Ty.PluRalWebhookMessage>} */
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<string>} 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<string>} 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<string>} 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

View file

@ -194,7 +194,9 @@ function memberToPowerLevel(user, member, guild, channel) {
* @param {number} powerLevel * @param {number} powerLevel
*/ */
function _hashProfileContent(content, powerLevel) { function _hashProfileContent(content, powerLevel) {
const unsignedHash = hasher.h64(`${content.displayname}\u0000${content.avatar_url}\u0000${powerLevel}`) let toHash = `${content.displayname}\u0000${content.avatar_url}\u0000${powerLevel}`
if (content["eu.she-a.color"]?.on_dark) toHash += `\u0000${content["eu.she-a.color"].on_dark}`
const unsignedHash = hasher.h64(toHash)
const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
return signedHash return signedHash
} }
@ -243,6 +245,7 @@ async function syncUser(user, member, channel, guild, roomID, interactionMetadat
async function _sendSyncUser(roomID, mxid, content, powerLevel, options) { async function _sendSyncUser(roomID, mxid, content, powerLevel, options) {
const currentHash = _hashProfileContent(content, powerLevel ?? 0) const currentHash = _hashProfileContent(content, powerLevel ?? 0)
const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get() const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get()
// console.log(roomID, mxid, existingHash, currentHash)
// only do the actual sync if the hash has changed since we last looked // only do the actual sync if the hash has changed since we last looked
const hashHasChanged = existingHash !== currentHash const hashHasChanged = existingHash !== currentHash
// always okay to add new data. for overwriting, restrict based on options.allowOverwrite, if present // always okay to add new data. for overwriting, restrict based on options.allowOverwrite, if present

View file

@ -2,7 +2,15 @@
const {EventEmitter} = require("events") const {EventEmitter} = require("events")
const passthrough = require("../../passthrough") 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 const DEBUG_RETRIGGER = false
@ -12,81 +20,140 @@ function debugRetrigger(message) {
} }
} }
const paused = new Set() const storage = new class {
const emitter = new EventEmitter() /** @private @type {Set<string>} */
paused = new Set()
/** @private @type {Map<string, ((found: Boolean) => any)[]>} id -> list of resolvers */
resolves = new Map()
/** @private @type {Map<string, ReturnType<setTimeout>>} id -> timer */
timers = new Map()
/** /**
* Due to Eventual Consistency(TM) an update/delete may arrive before the original message arrives * The purpose of storage is to store `resolve` and call it at a later time.
* (or before the it has finished being bridged to an event). * @param {string} id
* In this case, wait until the original message has finished bridging, then retrigger the passed function. * @param {(found: Boolean) => any} resolve
* @template {(...args: any[]) => any} T */
* @param {string} inputID store(id, resolve) {
* @param {T} fn debugRetrigger(`[retrigger] STORE id = ${id}`)
* @param {Parameters<T>} rest this.resolves.set(id, (this.resolves.get(id) || []).concat(resolve)) // add to list in map value
* @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 if (!this.timers.has(id)) {
*/ debugRetrigger(`[retrigger] SET TIMER id = ${id}`)
function eventNotFoundThenRetrigger(inputID, fn, ...rest) { this.timers.set(id, setTimeout(() => this.resolve(id, false), 60 * 1000).unref()) // 1 minute
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
}
} }
} }
debugRetrigger(`[retrigger] WAIT id = ${inputID}`) /** @param {string} id */
emitter.once(inputID, () => { isNotPaused(id) {
debugRetrigger(`[retrigger] TRIGGER id = ${inputID}`) return !storage.paused.has(id)
fn(...rest) }
})
// if the event never arrives, don't trigger the callback, just clean up /** @param {string} id */
setTimeout(() => { pause(id) {
if (emitter.listeners(inputID).length) { debugRetrigger(`[retrigger] PAUSE id = ${id}`)
debugRetrigger(`[retrigger] EXPIRE id = ${inputID}`) 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 if (this.resolves.has(id)) {
return true // event was not found, then retrigger 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<boolean>} if false 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<boolean>} if false 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<boolean>} if false 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. * Anything calling retrigger during the callback will be paused and retriggered after the callback resolves.
* @template T * @template T
* @param {string} messageID * @param {string} id
* @param {Promise<T>} promise * @param {Promise<T>} promise
* @returns {Promise<T>} * @returns {Promise<T>}
*/ */
async function pauseChanges(messageID, promise) { async function pauseChanges(id, promise) {
try { try {
debugRetrigger(`[retrigger] PAUSE id = ${messageID}`) storage.pause(id)
paused.add(messageID)
return await promise return await promise
} finally { } finally {
debugRetrigger(`[retrigger] RESUME id = ${messageID}`) finishedBridging(id)
paused.delete(messageID)
messageFinishedBridging(messageID)
} }
} }
/** /**
* Triggers any pending operations that were waiting on the corresponding event ID. * Triggers any pending operations that were waiting on the corresponding event ID.
* @param {string} messageID * @param {string} id
*/ */
function messageFinishedBridging(messageID) { function finishedBridging(id) {
if (emitter.listeners(messageID).length) { storage.resolve(id, true)
debugRetrigger(`[retrigger] EMIT id = ${messageID}`)
}
emitter.emit(messageID)
} }
module.exports.eventNotFoundThenRetrigger = eventNotFoundThenRetrigger module.exports.waitForMessage = waitForMessage
module.exports.messageFinishedBridging = messageFinishedBridging module.exports.waitForEvent = waitForEvent
module.exports.waitForReactionEvent = waitForReactionEvent
module.exports.pauseChanges = pauseChanges module.exports.pauseChanges = pauseChanges
module.exports.finishedBridging = finishedBridging

View file

@ -4,17 +4,16 @@ const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough") 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")} */ /** @type {import("../converters/message-to-event")} */
const messageToEvent = sync.require("../converters/message-to-event") const messageToEvent = sync.require("../converters/message-to-event")
/** @type {import("../../matrix/api")} */ /** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api") const api = sync.require("../../matrix/api")
/** @type {import("./register-user")} */ /** @type {import("./register-user")} */
const registerUser = sync.require("./register-user") const registerUser = sync.require("./register-user")
/** @type {import("./register-pk-user")} */ /** @type {import("./speedbump")} */
const registerPkUser = sync.require("./register-pk-user") const speedbump = sync.require("./speedbump")
/** @type {import("./register-webhook-user")} */
const registerWebhookUser = sync.require("./register-webhook-user")
/** @type {import("../actions/create-room")} */ /** @type {import("../actions/create-room")} */
const createRoom = sync.require("../actions/create-room") const createRoom = sync.require("../actions/create-room")
/** @type {import("../actions/poll-end")} */ /** @type {import("../actions/poll-end")} */
@ -28,24 +27,15 @@ const channelWebhook = sync.require("../../m2d/actions/channel-webhook")
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message * @param {DiscordTypes.GatewayMessageCreateDispatchData} message
* @param {DiscordTypes.APIGuildChannel} channel * @param {DiscordTypes.APIGuildChannel} channel
* @param {DiscordTypes.APIGuild} guild * @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 roomID = await createRoom.ensureRoom(message.channel_id)
const historicalRoomIndex = select("historical_channel_room", "historical_room_index", {room_id: roomID}).pluck().get() const historicalRoomIndex = select("historical_channel_room", "historical_room_index", {room_id: roomID}).pluck().get()
assert(historicalRoomIndex) assert(historicalRoomIndex)
let senderMxid = null let senderMxid = null
if (dUtils.isWebhookMessage(message)) { if (dUtils.isWebhookMessage(message)) {
const useWebhookProfile = select("guild_space", "webhook_profile", {guild_id: guild.id}).pluck().get() ?? 0 senderMxid = await speedbump.getWebhookSenderId(message, guild.id, roomID)
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)
}
} else { } else {
// not a webhook // not a webhook
if (message.author.id === discord.application.id) { if (message.author.id === discord.application.id) {
@ -60,13 +50,12 @@ async function sendMessage(message, channel, guild, row) {
const detailedResultsMessage = await pollEnd.endPoll(message) const detailedResultsMessage = await pollEnd.endPoll(message)
if (detailedResultsMessage) { if (detailedResultsMessage) {
const threadParent = select("channel_room", "thread_parent", {channel_id: message.channel_id}).pluck().get() const threadParent = select("channel_room", "thread_parent", {channel_id: message.channel_id}).pluck().get()
const channelID = threadParent ? threadParent : message.channel_id const {channelID, threadID} = dUtils.swapThreadID(message.channel_id, threadParent)
const threadID = threadParent ? message.channel_id : undefined
sentResultsMessage = await channelWebhook.sendMessageWithWebhook(channelID, detailedResultsMessage, threadID) sentResultsMessage = await channelWebhook.sendMessageWithWebhook(channelID, detailedResultsMessage, threadID)
} }
} }
const events = await messageToEvent.messageToEvent(message, guild, {}, {api, snow: discord.snow}) const events = await messageToEvent.messageToEvent(message, guild, {}, {api, snow: discord.snow, fetch})
const eventIDs = [] const eventIDs = []
if (events.length) { if (events.length) {
db.prepare("INSERT OR IGNORE INTO message_room (message_id, historical_room_index) VALUES (?, ?)").run(message.id, historicalRoomIndex) db.prepare("INSERT OR IGNORE INTO message_room (message_id, historical_room_index) VALUES (?, ?)").run(message.id, historicalRoomIndex)

View file

@ -1,14 +1,23 @@
// @ts-check // @ts-check
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough") 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 const DEBUG_SPEEDBUMP = false
function debugSpeedbump(message) { function debugSpeedbump(...args) {
if (DEBUG_SPEEDBUMP) { if (DEBUG_SPEEDBUMP) {
console.log(message) console.log(...args)
} }
} }
@ -17,7 +26,8 @@ const SPEEDBUMP_UPDATE_FREQUENCY = 2 * 60 * 60 // 2 hours
/** @type {Set<any>} */ /** @type {Set<any>} */
const KNOWN_BOTS = new 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) const now = Math.floor(Date.now() / 1000)
if (lastChecked && now - lastChecked < SPEEDBUMP_UPDATE_FREQUENCY) return if (lastChecked && now - lastChecked < SPEEDBUMP_UPDATE_FREQUENCY) return
const webhooks = await discord.snow.webhook.getChannelWebhooks(channelID) const webhooks = await discord.snow.webhook.getChannelWebhooks(channelID)
const found = webhooks.find(b => KNOWN_BOTS.has(b.application_id)) const found = webhooks.filter(b => KNOWN_BOTS.has(b.application_id))
const foundApplication = found?.application_id db.transaction(() => {
const foundWebhook = found?.id db.prepare("DELETE FROM channel_speedbump WHERE channel_id = ?").run(channelID)
db.prepare("UPDATE channel_room SET speedbump_id = ?, speedbump_webhook_id = ?, speedbump_checked = ? WHERE channel_id = ?").run(foundApplication, foundWebhook, now, 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<string, number>} 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<string, BumpingEntry>} messageID -> BumpingEntry */
const bumping = new Map() const bumping = new Map()
/** /**
* Slow down a message. After it passes the speedbump, return whether it's okay or if it's been deleted. * 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 * @param {string} messageID
* @returns whether it was deleted * @returns whether it was deleted
*/ */
async function doSpeedbump(messageID) { async function doSpeedbump(isCreate, messageID) {
let value = (bumping.get(messageID) ?? 0) + 1 const entry = bumping.get(messageID) ?? (() => {
bumping.set(messageID, value) const entry = {number: 0, hasCreate: false}
debugSpeedbump(`[speedbump] WAIT ${messageID}++ = ${value}`) bumping.set(messageID, entry)
return entry
})()
entry.number++
entry.hasCreate ||= isCreate
debugSpeedbump(`[speedbump] WAIT ${messageID}++ =`, entry)
await new Promise(resolve => setTimeout(resolve, SPEEDBUMP_SPEED)) await new Promise(resolve => setTimeout(resolve, SPEEDBUMP_SPEED))
if (!bumping.has(messageID)) { if (!bumping.has(messageID)) {
debugSpeedbump(`[speedbump] DELETED ${messageID}`) debugSpeedbump(`[speedbump] DELETED ${messageID}`)
return true return {skip: true, hasCreate: null}
} }
value = (bumping.get(messageID) ?? 0) - 1
if (value <= 0) { if (--entry.number <= 0) {
debugSpeedbump(`[speedbump] OK ${messageID}-- = ${value}`) debugSpeedbump(`[speedbump] OK ${messageID}-- =`, entry)
bumping.delete(messageID) bumping.delete(messageID)
return false return {skip: false, hasCreate: entry.hasCreate}
} else { } else {
debugSpeedbump(`[speedbump] MULTI ${messageID}-- = ${value}`) debugSpeedbump(`[speedbump] MULTI ${messageID}-- =`, entry)
bumping.set(messageID, value) return {skip: true, hasCreate: null}
return true
} }
} }
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. * 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 {boolean} isCreate
* @param {string} messageID * @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 it was deleted, and data about the channel's (not thread's) speedbump * @returns whether to skip this message, and whether the message should be created as a creation
*/ */
async function maybeDoSpeedbump(channelID, messageID) { async function maybeDoSpeedbump(isCreate, message) {
let row = select("channel_room", ["thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: channelID}).get() let rows = getSpeedbumpRows(message.channel_id)
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 (rows[0]?.thread_parent) rows = getSpeedbumpRows(rows[0].thread_parent) // webhooks belong to the channel, not the thread
if (!row?.speedbump_webhook_id) return {affected: false, row: null} // not affected, no speedbump if (!rows.length) return {skip: false} // channel not affected, no speedbump
const affected = await doSpeedbump(messageID)
return {affected, row} // maybe affected, and there is a 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,27 @@ function onMessageDelete(messageID) {
bumping.delete(messageID) bumping.delete(messageID)
} }
/**
* @param {DiscordTypes.APIMessage} message
* @param {string} guildID
* @param {string} roomID
*/
async function getWebhookSenderId(message, guildID, roomID) {
const isMatrixWebhook = select("webhook", "webhook_id", {webhook_id: message.webhook_id}).pluck().get()
if (isMatrixWebhook) return null
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.updateCache = updateCache
module.exports.doSpeedbump = doSpeedbump
module.exports.maybeDoSpeedbump = maybeDoSpeedbump module.exports.maybeDoSpeedbump = maybeDoSpeedbump
module.exports.onMessageDelete = onMessageDelete module.exports.onMessageDelete = onMessageDelete
module.exports.getWebhookSenderId = getWebhookSenderId

View file

@ -28,7 +28,7 @@ async function updatePins(channelID, roomID, convertedTimestamp) {
try { try {
var discordPins = await discord.snow.channel.getChannelPinnedMessages(channelID) var discordPins = await discord.snow.channel.getChannelPinnedMessages(channelID)
} catch (e) { } catch (e) {
if (e.message === `{"message": "Missing Access", "code": 50001}`) { if (e.message === "Missing Access") {
return // Discord sends channel pins update events even for channels that the bot can't view/get pins in, just ignore it return // Discord sends channel pins update events even for channels that the bot can't view/get pins in, just ignore it
} }
throw e throw e

View file

@ -69,8 +69,8 @@ async function editToChanges(message, guild, api) {
// Figure out what we will be replacing them with // Figure out what we will be replacing them with
const newFallbackContent = await messageToEvent.messageToEvent(message, guild, {includeEditFallbackStar: true}, {api}) const newFallbackContent = await messageToEvent.messageToEvent(message, guild, {includeEditFallbackStar: true}, {api, fetch})
const newInnerContent = await messageToEvent.messageToEvent(message, guild, {includeReplyFallback: false}, {api}) const newInnerContent = await messageToEvent.messageToEvent(message, guild, {includeReplyFallback: false}, {api, fetch})
assert.ok(newFallbackContent.length === newInnerContent.length) assert.ok(newFallbackContent.length === newInnerContent.length)
// Match the new events to the old events // Match the new events to the old events

View file

@ -343,19 +343,18 @@ test("edit2changes: generated embed", async t => {
t.deepEqual(eventsToSend, [{ t.deepEqual(eventsToSend, [{
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.notice", msgtype: "m.notice",
body: "| via hthrflwrs on cohost" body: "| ## This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO https://cohost.org/jkap/post/4794219-empty"
+ "\n| \n| ## This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO https://cohost.org/jkap/post/4794219-empty"
+ "\n| \n| 1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:" + "\n| \n| 1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:"
+ "\n| \n| * Both players draw eight cards" + "\n| \n| * Both players draw eight cards"
+ "\n| * Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand" + "\n| * Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand"
+ "\n| * Both players present their best five-or-less-card pok...", + "\n| * Both players present their best five-or-less-card pok..."
+ "\n| via hthrflwrs on cohost",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><sub>hthrflwrs on cohost</sub>` formatted_body: `<blockquote><p><strong><a href="https://cohost.org/jkap/post/4794219-empty">This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO</a></strong>`
+ `</p><p><strong><a href="https://cohost.org/jkap/post/4794219-empty">This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO</a></strong>`
+ `</p><p>1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:` + `</p><p>1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:`
+ `<br><br><ul><li>Both players draw eight cards` + `<br><br><ul><li>Both players draw eight cards`
+ `</li><li>Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand` + `</li><li>Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand`
+ `</li><li>Both players present their best five-or-less-card pok...</li></ul></p></blockquote>`, + `</li><li>Both players present their best five-or-less-card pok...</li></ul></p><sub>hthrflwrs on cohost</sub></blockquote>`,
"m.mentions": {} "m.mentions": {}
}]) }])
t.deepEqual(promotions, [{ t.deepEqual(promotions, [{

View file

@ -107,7 +107,6 @@ function tokenise(name) {
* @returns {ProcessedJoined} * @returns {ProcessedJoined}
*/ */
function processJoined(joined) { function processJoined(joined) {
joined = joined.filter(j => !userRegex.some(rx => j.mxid.match(rx)))
return { return {
mxids: joined.map(j => { mxids: joined.map(j => {
const localpart = j.mxid.match(/@([^:]*)/) const localpart = j.mxid.match(/@([^:]*)/)
@ -141,8 +140,14 @@ function findMention(pjr, maximumWrittenSection, baseOffset, prefix, content) {
/** @type {{mxid: string, scored: {score: number, matchedInputTokens: Token[]}}[]} */ /** @type {{mxid: string, scored: {score: number, matchedInputTokens: Token[]}}[]} */
let allItems = pjr.mxids.map(mxid => ({...mxid, scored: scoreLocalpart(mxid.localpart, maximumWrittenSection, mxid.displayname)})) let allItems = pjr.mxids.map(mxid => ({...mxid, scored: scoreLocalpart(mxid.localpart, maximumWrittenSection, mxid.displayname)}))
allItems = allItems.concat(pjr.names.map(name => ({...name, scored: scoreName(name.displaynameTokens, maximumWrittenSectionTokens)}))) allItems = allItems.concat(pjr.names.map(name => ({...name, scored: scoreName(name.displaynameTokens, maximumWrittenSectionTokens)})))
const best = allItems.sort((a, b) => b.scored.score - a.scored.score)[0] // Tiebreaker bonus for Matrix users
if (best.scored.score > 4) { // requires in smallest case perfect match of 2 characters, or in largest case a partial middle match of 5+ characters in a row for (const item of allItems) {
if (!userRegex.some(rx => item.mxid.match(rx))) item.scored.score += 1
}
const [best, secondBest] = allItems.sort((a, b) => b.scored.score - a.scored.score)
if (userRegex.some(rx => best.mxid.match(rx))) return // Discord user was best match, so it was just a written non-pinging @ for a Discord user. Don't highlight a Matrix user.
if (secondBest && secondBest.scored.score === best.scored.score) return // all tied up
if (best.scored.score > 5) { // requires in smallest case Matrix user tiebreaker + perfect match of 2 characters, or in largest case a partial middle match of 5+ characters in a row
// Highlight the relevant part of the message // Highlight the relevant part of the message
const start = baseOffset + best.scored.matchedInputTokens[0].index const start = baseOffset + best.scored.matchedInputTokens[0].index
const end = baseOffset + prefix.length + best.scored.matchedInputTokens.slice(-1)[0].end const end = baseOffset + prefix.length + best.scored.matchedInputTokens.slice(-1)[0].end

View file

@ -117,6 +117,47 @@ test("score name: finds match location", t => {
t.equal(message.slice(startLocation, endLocation), "evil lillith") t.equal(message.slice(startLocation, endLocation), "evil lillith")
}) })
test("score name: matches the correct jan", t => {
const names = [
"jan Kanoli",
"jan Mijawi",
"jan Mukin"
]
t.deepEqual(names.map(n => scoreName(tokenise(n), tokenise("jan Mijawi")).score), [
6,
18.4,
6
])
})
test("find mention: highlights nothing if a Discord user scored highest", t => {
const found = findMention(processJoined([{
mxid: "@jankanoli:matrix.org",
displayname: "jan Kanoli"
}, {
mxid: "@_ooye_jan_mijawi:cadence.moe",
displayname: "jan Mijawi"
}, {
mxid: "@_ooye_jan_mukin:cadence.moe",
displayname: "jan Mukin"
}]), "jan Mijawi, run this\n.def", 0, "@", "jan Mijawi, run this\n.def")
t.equal(found, undefined)
})
test("find mention: highlights nothing if all tied", t => {
const found = findMention(processJoined([{
mxid: "@jankanoli:matrix.org",
displayname: "jan Kanoli"
}, {
mxid: "@_ooye_jan_mijawi:cadence.moe",
displayname: "jan Mijawi"
}, {
mxid: "@_ooye_jan_mukin:cadence.moe",
displayname: "jan Mukin"
}]), "jan Misali, run this\n.def", 0, "@", "jan Misali, run this\n.def")
t.equal(found, undefined)
})
test("find mention: test various tiebreakers", t => { test("find mention: test various tiebreakers", t => {
const found = findMention(processJoined([{ const found = findMention(processJoined([{
mxid: "@emma:conduit.rory.gay", mxid: "@emma:conduit.rory.gay",

View file

@ -109,10 +109,11 @@ const embedTitleParser = markdown.markdownEngine.parserFor({
/** /**
* @param {{room?: boolean, user_ids?: string[]}} mentions * @param {{room?: boolean, user_ids?: string[]}} mentions
* @param {Omit<DiscordTypes.APIAttachment, "id" | "proxy_url" | "flags">} attachment * @param {Omit<DiscordTypes.APIAttachment, "id" | "proxy_url"> & {proxy_url?: string}} attachment
* @param {boolean} [alwaysLink] * @param {boolean} [alwaysLink]
* @param {{fetch?: typeof fetch}} [di]
*/ */
async function attachmentToEvent(mentions, attachment, alwaysLink) { async function attachmentToEvent(mentions, attachment, alwaysLink, di) {
const external_url = dUtils.getPublicUrlForCdn(attachment.url) const external_url = dUtils.getPublicUrlForCdn(attachment.url)
const emoji = const emoji =
attachment.content_type?.startsWith("image/jp") ? "📸" attachment.content_type?.startsWith("image/jp") ? "📸"
@ -122,7 +123,7 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) {
: attachment.content_type?.startsWith("audio/") ? "🎶" : attachment.content_type?.startsWith("audio/") ? "🎶"
: "📄" : "📄"
// no native media spoilers in Element, so we'll post a link instead, forcing it to not preview using a blockquote // no native media spoilers in Element, so we'll post a link instead, forcing it to not preview using a blockquote
if (attachment.filename.startsWith("SPOILER_")) { if (attachment.filename.startsWith("SPOILER_") || !!((attachment.flags || 0) & DiscordTypes.AttachmentFlags.IsSpoiler)) {
return { return {
$type: "m.room.message", $type: "m.room.message",
"m.mentions": mentions, "m.mentions": mentions,
@ -132,8 +133,37 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) {
formatted_body: `<blockquote>${emoji} Uploaded SPOILER file: <a href="${external_url}">${external_url}</a> (${pb(attachment.size)})</blockquote>` formatted_body: `<blockquote>${emoji} Uploaded SPOILER file: <a href="${external_url}">${external_url}</a> (${pb(attachment.size)})</blockquote>`
} }
} }
// attempt to convert large PNG image files to JPEG, since it's almost certainly a photo that was forced into PNG by the user copy-pasting.
if (
attachment.content_type === "image/png" && attachment.size > reg.ooye.max_file_size
&& !alwaysLink && attachment.proxy_url && attachment.width && attachment.height && di?.fetch
) {
const proxyUrl = new URL(attachment.proxy_url)
proxyUrl.searchParams.set("format", "jpeg")
const jpegUrl = proxyUrl.toString()
const res = await di.fetch(jpegUrl, {method: "HEAD"})
const newFilename = attachment.filename.replace(/\.png$/, ".jpg")
const newSize = Number(res.headers.get("content-length"))
if (res.ok && res.headers.get("content-type") === "image/jpeg" && !res.headers.has("content-encoding") && newSize <= reg.ooye.max_file_size) {
return {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.image",
url: await file.uploadDiscordFileToMxc(jpegUrl),
external_url,
body: attachment.description || newFilename,
filename: newFilename,
info: {
mimetype: "image/jpeg",
w: attachment.width,
h: attachment.height,
size: newSize
}
}
}
}
// for large files, always link them instead of uploading so I don't use up all the space in the content repo // for large files, always link them instead of uploading so I don't use up all the space in the content repo
else if (alwaysLink || attachment.size > reg.ooye.max_file_size) { if (alwaysLink || attachment.size > reg.ooye.max_file_size) {
return { return {
$type: "m.room.message", $type: "m.room.message",
"m.mentions": mentions, "m.mentions": mentions,
@ -142,7 +172,8 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) {
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `${emoji} Uploaded file: <a href="${external_url}">${attachment.filename}</a> (${pb(attachment.size)})` formatted_body: `${emoji} Uploaded file: <a href="${external_url}">${attachment.filename}</a> (${pb(attachment.size)})`
} }
} else if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) { }
if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) {
return { return {
$type: "m.room.message", $type: "m.room.message",
"m.mentions": mentions, "m.mentions": mentions,
@ -158,7 +189,8 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) {
size: attachment.size size: attachment.size
} }
} }
} else if (attachment.content_type?.startsWith("video/") && attachment.width && attachment.height) { }
if (attachment.content_type?.startsWith("video/") && attachment.width && attachment.height) {
return { return {
$type: "m.room.message", $type: "m.room.message",
"m.mentions": mentions, "m.mentions": mentions,
@ -174,7 +206,8 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) {
size: attachment.size size: attachment.size
} }
} }
} else if (attachment.content_type?.startsWith("audio/")) { }
if (attachment.content_type?.startsWith("audio/")) {
return { return {
$type: "m.room.message", $type: "m.room.message",
"m.mentions": mentions, "m.mentions": mentions,
@ -189,19 +222,19 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) {
duration: attachment.duration_secs && Math.round(attachment.duration_secs * 1000) duration: attachment.duration_secs && Math.round(attachment.duration_secs * 1000)
} }
} }
} else { }
return { // else
$type: "m.room.message", return {
"m.mentions": mentions, $type: "m.room.message",
msgtype: "m.file", "m.mentions": mentions,
url: await file.uploadDiscordFileToMxc(attachment.url), msgtype: "m.file",
external_url, url: await file.uploadDiscordFileToMxc(attachment.url),
body: attachment.description || attachment.filename, external_url,
filename: attachment.filename, body: attachment.description || attachment.filename,
info: { filename: attachment.filename,
mimetype: attachment.content_type, info: {
size: attachment.size mimetype: attachment.content_type,
} size: attachment.size
} }
} }
} }
@ -265,8 +298,9 @@ function getFormattedInteraction(interaction, isThinkingInteraction) {
* @param {any} newEvents merge into events * @param {any} newEvents merge into events
* @param {any} events will be modified * @param {any} events will be modified
* @param {boolean} forceSameMsgtype whether m.text may only be combined with m.text, etc * @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) let prev = events.at(-1)
for (const ne of newEvents) { 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) const isAllText = prev?.body && prev?.formatted_body && ["m.text", "m.notice"].includes(ne.msgtype) && ["m.text", "m.notice"].includes(prev?.msgtype)
@ -278,6 +312,8 @@ function mergeTextEvents(newEvents, events, forceSameMsgtype) {
rep.addLine(ne.body, ne.formatted_body) rep.addLine(ne.body, ne.formatted_body)
prev.body = rep.body prev.body = rep.body
prev.formatted_body = rep.formattedBody prev.formatted_body = rep.formattedBody
} else if (forceMerge) {
throw new Error("Unable to merge events")
} else { } else {
events.push(ne) events.push(ne)
} }
@ -292,7 +328,7 @@ function mergeTextEvents(newEvents, events, forceSameMsgtype) {
* - includeEditFallbackStar: false * - includeEditFallbackStar: false
* - alwaysReturnFormattedBody: false - formatted_body will be skipped if it is the same as body because the message is plaintext. if you want the formatted_body to be returned anyway, for example to merge it with another message, then set this to true. * - alwaysReturnFormattedBody: false - formatted_body will be skipped if it is the same as body because the message is plaintext. if you want the formatted_body to be returned anyway, for example to merge it with another message, then set this to true.
* - scanTextForMentions: true - needs to be set to false when converting forwarded messages etc which may be from a different channel that can't be scanned. * - scanTextForMentions: true - needs to be set to false when converting forwarded messages etc which may be from a different channel that can't be scanned.
* @param {{api: import("../../matrix/api"), snow?: import("snowtransfer").SnowTransfer}} di simple-as-nails dependency injection for the matrix API * @param {{api: import("../../matrix/api"), snow?: import("snowtransfer").SnowTransfer, fetch?: typeof fetch}} di simple-as-nails dependency injection for the matrix API
* @returns {Promise<{$type: string, $sender?: string, [x: string]: any}[]>} * @returns {Promise<{$type: string, $sender?: string, [x: string]: any}[]>}
*/ */
async function messageToEvent(message, guild, options = {}, di) { async function messageToEvent(message, guild, options = {}, di) {
@ -554,7 +590,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. // 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. // 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 => { await Promise.all(emojiMatches.map(match => {
const id = match[3] const id = match[3]
const name = match[2] const name = match[2]
@ -638,8 +674,7 @@ async function messageToEvent(message, guild, options = {}, di) {
html = "* " + html html = "* " + html
} }
const flags = message.flags || 0 if (((message.flags || 0) & DiscordTypes.MessageFlags.IsCrosspost) && message.author?.username) {
if (flags & DiscordTypes.MessageFlags.IsCrosspost) {
body = `[↷ ${message.author.username}]\n` + body body = `[↷ ${message.author.username}]\n` + body
html = `↷ <strong>${message.author.username}</strong><br>` + html html = `↷ <strong>${message.author.username}</strong><br>` + html
} }
@ -832,7 +867,7 @@ async function messageToEvent(message, guild, options = {}, di) {
invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]}) invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]})
} catch (e) { } catch (e) {
// Skip expired/invalid invites and events // Skip expired/invalid invites and events
if (e.message === `{"message": "Unknown Invite", "code": 10006}`) { if (e.message === "Unknown Invite") {
break break
} else { } else {
throw e throw e
@ -880,7 +915,7 @@ async function messageToEvent(message, guild, options = {}, di) {
// Then attachments // Then attachments
if (message.attachments) { if (message.attachments) {
const attachmentEvents = await Promise.all(message.attachments.map(attachment => attachmentToEvent(mentions, attachment))) const attachmentEvents = await Promise.all(message.attachments.map(attachment => attachmentToEvent(mentions, attachment, false, {fetch: di?.fetch})))
// Try to merge attachment events with the previous event // Try to merge attachment events with the previous event
// This means that if the attachments ended up as a text link, and especially if there were many of them, the events will be joined together. // This means that if the attachments ended up as a text link, and especially if there were many of them, the events will be joined together.
@ -904,7 +939,14 @@ async function messageToEvent(message, guild, options = {}, di) {
/** @type {{[k in keyof DiscordTypes.APIUnfurledMediaItem]-?: NonNullable<DiscordTypes.APIUnfurledMediaItem[k]>}} */ // @ts-ignore /** @type {{[k in keyof DiscordTypes.APIUnfurledMediaItem]-?: NonNullable<DiscordTypes.APIUnfurledMediaItem[k]>}} */ // @ts-ignore
const file = component.file const file = component.file
assert(component.name && component.size && file.content_type) assert(component.name && component.size && file.content_type)
const ev = await attachmentToEvent({}, {...file, filename: component.name, size: component.size}, true) const ev = await attachmentToEvent({}, {
filename: component.name,
content_type: file.content_type,
size: component.size,
url: file.url,
height: file.height,
width: file.width,
}, true, {fetch: di?.fetch})
stack.msb.addLine(ev.body, ev.formatted_body) stack.msb.addLine(ev.body, ev.formatted_body)
} }
else if (component.type === DiscordTypes.ComponentType.MediaGallery) { else if (component.type === DiscordTypes.ComponentType.MediaGallery) {
@ -967,7 +1009,8 @@ async function messageToEvent(message, guild, options = {}, di) {
// May only be a section accessory or in an action row (up to 5) // May only be a section accessory or in an action row (up to 5)
if (component.style === DiscordTypes.ButtonStyle.Link) { if (component.style === DiscordTypes.ButtonStyle.Link) {
assert(component.label) // required for Discord to validate link buttons assert(component.label) // required for Discord to validate link buttons
stack.msb.add(`[${component.label} ${component.url}] `, tag`<a href="${component.url}">${component.label}</a> `) const link = await transformContentMessageLinks(component.url)
stack.msb.add(`[${component.label} ${link}] `, tag`<a href="${link}">${component.label}</a> `)
} }
} }
@ -980,7 +1023,19 @@ async function messageToEvent(message, guild, options = {}, di) {
const {body, formatted_body} = stack.msb.get() const {body, formatted_body} = stack.msb.get()
if (body.trim().length) { 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)
}
} }
} }
@ -1039,15 +1094,6 @@ async function messageToEvent(message, guild, options = {}, di) {
continue continue
} }
// Provider
if (embed.provider?.name && embed.provider.name !== "Tenor") {
if (embed.provider.url) {
rep.addParagraph(`via ${embed.provider.name} ${embed.provider.url}`, tag`<sub><a href="${embed.provider.url}">${embed.provider.name}</a></sub>`)
} else {
rep.addParagraph(`via ${embed.provider.name}`, tag`<sub>${embed.provider.name}</sub>`)
}
}
// Author and URL into a paragraph // Author and URL into a paragraph
let authorNameText = embed.author?.name || "" let authorNameText = embed.author?.name || ""
if (authorNameText && embed.author?.icon_url) authorNameText = `⏺️ ${authorNameText}` // using the emoji instead of an image if (authorNameText && embed.author?.icon_url) authorNameText = `⏺️ ${authorNameText}` // using the emoji instead of an image
@ -1097,7 +1143,18 @@ async function messageToEvent(message, guild, options = {}, di) {
if (embed.video?.url) rep.addParagraph(`🎞️ ${dUtils.getPublicUrlForCdn(embed.video.url)}`) if (embed.video?.url) rep.addParagraph(`🎞️ ${dUtils.getPublicUrlForCdn(embed.video.url)}`)
if (embed.footer?.text) rep.addLine(`${embed.footer.text}`, tag`${embed.footer.text}`) // Provider and footer (moved to bottom, below author/title, to improve room preview line)
if (embed.provider?.name && embed.provider.name !== "Tenor" && embed.provider.name !== "YouTube") {
const insertableFooterText = embed.footer?.text ? `${embed.footer.text}` : ""
if (embed.provider.url) {
rep.addLine(`via ${embed.provider.name}${insertableFooterText || " " + embed.provider.url}`, tag`<sub><a href="${embed.provider.url}">${embed.provider.name}</a>${insertableFooterText}</sub>`)
} else {
rep.addLine(`via ${embed.provider.name}${insertableFooterText}`, tag`<sub>${embed.provider.name}${insertableFooterText}</sub>`)
}
} else if (embed.footer?.text) {
rep.addLine(`${embed.footer.text}`, tag`${embed.footer.text}`)
}
let {body, formatted_body: html} = rep.get() let {body, formatted_body: html} = rep.get()
body = body.split("\n").map(l => "| " + l).join("\n") body = body.split("\n").map(l => "| " + l).join("\n")
html = `<blockquote>${html}</blockquote>` html = `<blockquote>${html}</blockquote>`
@ -1144,7 +1201,7 @@ async function messageToEvent(message, guild, options = {}, di) {
} }
// Rich replies // Rich replies
if (repliedToEventRow && !repliedToEventInDifferentRoom) { if (repliedToEventRow && !repliedToEventInDifferentRoom && events[0]) {
Object.assign(events[0], { Object.assign(events[0], {
"m.relates_to": { "m.relates_to": {
"m.in_reply_to": { "m.in_reply_to": {

View file

@ -1,6 +1,7 @@
const {test} = require("supertape") const {test} = require("supertape")
const {messageToEvent} = require("./message-to-event") const {messageToEvent} = require("./message-to-event")
const data = require("../../../test/data") const data = require("../../../test/data")
const {mockGetEffectivePower} = require("../../matrix/utils.test")
test("message2event components: pk question mark output", async t => { test("message2event components: pk question mark output", async t => {
const events = await messageToEvent(data.message_with_components.pk_question_mark_response, data.guild.general, {}) 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", 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: "<blockquote><sub>❭ <a href=\"https://matrix.to/#/@_ooye_cadence:cadence.moe\">cadence</a> used <code>/🔔 Ping author</code></sub></blockquote>Psst, <strong>Red</strong> (<a href=\"https://matrix.to/#/@_ooye_cadence:cadence.moe\">@cadence.worm</a>), you have been pinged by <a href=\"https://matrix.to/#/@_ooye_cadence:cadence.moe\">@cadence.worm</a>.<br><a href=\"https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$l9FMmsEbh9K0NUReeEpWOMZYGRlUOE8yLcm6P-TYHSM?via=cadence.moe\">Jump</a> ",
"m.mentions": {}
}])
})

View file

@ -204,6 +204,34 @@ test("message2event embeds: author url without name", async t => {
}]) }])
}) })
test("message2event embeds: provider and footer", async t => {
const events = await messageToEvent(data.message_with_embeds.embed_provider_and_footer, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "https://cohost.org/jkap/post/4794219-empty",
format: "org.matrix.custom.html",
formatted_body: `<a href="https://cohost.org/jkap/post/4794219-empty">https://cohost.org/jkap/post/4794219-empty</a>`,
"m.mentions": {}
}, {
$type: "m.room.message",
msgtype: "m.notice",
body: "| ## This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO https://cohost.org/jkap/post/4794219-empty"
+ "\n| \n| 1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:"
+ "\n| \n| * Both players draw eight cards"
+ "\n| * Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand"
+ "\n| * Both players present their best five-or-less-card pok..."
+ "\n| via hthrflwrs on cohost — 29/2/2024, 2:17 PM",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><strong><a href="https://cohost.org/jkap/post/4794219-empty">This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO</a></strong>`
+ `</p><p>1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:`
+ `<br><br><ul><li>Both players draw eight cards`
+ `</li><li>Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand`
+ `</li><li>Both players present their best five-or-less-card pok...</li></ul></p><sub>hthrflwrs on cohost — 29/2/2024, 2:17 PM</sub></blockquote>`,
"m.mentions": {}
}])
})
test("message2event embeds: 4 images", async t => { test("message2event embeds: 4 images", async t => {
const events = await messageToEvent(data.message_with_embeds.four_images, data.guild.general) const events = await messageToEvent(data.message_with_embeds.four_images, data.guild.general)
t.deepEqual(events, [{ t.deepEqual(events, [{
@ -254,9 +282,7 @@ test("message2event embeds: vx image", async t => {
}, { }, {
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.notice", msgtype: "m.notice",
body: "| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix" body: "| ## Twitter https://twitter.com/tomorrowcorp/status/1760330671074287875"
+ "\n| "
+ "\n| ## Twitter https://twitter.com/tomorrowcorp/status/1760330671074287875"
+ "\n| " + "\n| "
+ "\n| ## Tomorrow Corporation (@TomorrowCorp) https://vxtwitter.com/TomorrowCorp/status/1760330671074287875" + "\n| ## Tomorrow Corporation (@TomorrowCorp) https://vxtwitter.com/TomorrowCorp/status/1760330671074287875"
+ "\n| " + "\n| "
@ -266,15 +292,16 @@ test("message2event embeds: vx image", async t => {
+ "\n| " + "\n| "
+ "\n| 💖 123 🔁 36" + "\n| 💖 123 🔁 36"
+ "\n| " + "\n| "
+ "\n| 📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg", + "\n| 📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg"
+ "\n| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><sub><a href="https://github.com/dylanpdx/BetterTwitFix">vxTwitter / fixvx</a></sub>` formatted_body: `<blockquote><p><strong><a href="https://twitter.com/tomorrowcorp/status/1760330671074287875">Twitter</a></strong>`
+ `</p><p><strong><a href="https://twitter.com/tomorrowcorp/status/1760330671074287875">Twitter</a></strong>`
+ `</p><p><strong><a href="https://vxtwitter.com/TomorrowCorp/status/1760330671074287875">Tomorrow Corporation (@TomorrowCorp)</a></strong>` + `</p><p><strong><a href="https://vxtwitter.com/TomorrowCorp/status/1760330671074287875">Tomorrow Corporation (@TomorrowCorp)</a></strong>`
+ `</p><p>Mark your calendar with a wet black stain! World of Goo 2 releases on May 23, 2024 on Nintendo Switch, Epic Games Store (Win/Mac), and <a href="http://WorldOfGoo2.com">http://WorldOfGoo2.com</a> (Win/Mac/Linux).` + `</p><p>Mark your calendar with a wet black stain! World of Goo 2 releases on May 23, 2024 on Nintendo Switch, Epic Games Store (Win/Mac), and <a href="http://WorldOfGoo2.com">http://WorldOfGoo2.com</a> (Win/Mac/Linux).`
+ `<br><br><a href="https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms">https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms</a>` + `<br><br><a href="https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms">https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms</a>`
+ `<br><br>💖 123 🔁 36` + `<br><br>💖 123 🔁 36`
+ `</p><p>📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg</p></blockquote>`, + `</p><p>📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg</p>`
+ `<sub><a href="https://github.com/dylanpdx/BetterTwitFix">vxTwitter / fixvx</a></sub></blockquote>`,
"m.mentions": {} "m.mentions": {}
}]) }])
}) })
@ -291,16 +318,16 @@ test("message2event embeds: vx video", async t => {
}, { }, {
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.notice", msgtype: "m.notice",
body: "| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix" body: "| ## McDonalds🤝@studiopierrot"
+ "\n| \n| ## McDonalds🤝@studiopierrot"
+ "\n| \n| 💖 89 🔁 21 https://twitter.com/McDonalds/status/1759971752254341417" + "\n| \n| 💖 89 🔁 21 https://twitter.com/McDonalds/status/1759971752254341417"
+ "\n| \n| ## McDonald's (@McDonalds) https://vxtwitter.com/McDonalds/status/1759971752254341417" + "\n| \n| ## McDonald's (@McDonalds) https://vxtwitter.com/McDonalds/status/1759971752254341417"
+ "\n| \n| 🎞️ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12", + "\n| \n| 🎞️ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12"
+ "\n| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><sub><a href="https://github.com/dylanpdx/BetterTwitFix">vxTwitter / fixvx</a></sub>` formatted_body: `<blockquote><p><strong><a href="https://twitter.com/McDonalds/status/1759971752254341417">McDonalds🤝@studiopierrot\n\n💖 89 🔁 21</a></strong>`
+ `</p><p><strong><a href="https://twitter.com/McDonalds/status/1759971752254341417">McDonalds🤝@studiopierrot\n\n💖 89 🔁 21</a></strong>`
+ `</p><p><strong><a href="https://vxtwitter.com/McDonalds/status/1759971752254341417">McDonald's (@McDonalds)</a></strong>` + `</p><p><strong><a href="https://vxtwitter.com/McDonalds/status/1759971752254341417">McDonald's (@McDonalds)</a></strong>`
+ `</p><p>🎞️ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12</p></blockquote>`, + `</p><p>🎞️ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12</p>`
+ `<sub><a href="https://github.com/dylanpdx/BetterTwitFix">vxTwitter / fixvx</a></sub></blockquote>`,
"m.mentions": {} "m.mentions": {}
}]) }])
}) })
@ -317,8 +344,7 @@ test("message2event embeds: youtube video", async t => {
}, { }, {
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.notice", msgtype: "m.notice",
body: "| via YouTube https://www.youtube.com" body: "| ## Happy O Funny https://www.youtube.com/channel/UCEpQ9aEb1NafpvWp5Aoizrg"
+ "\n| \n| ## Happy O Funny https://www.youtube.com/channel/UCEpQ9aEb1NafpvWp5Aoizrg"
+ "\n| \n| ## Shoebill stork clattering sounds like machine guun~!! (Japan Matsue... https://www.youtube.com/watch?v=kDMHHw8JqLE" + "\n| \n| ## Shoebill stork clattering sounds like machine guun~!! (Japan Matsue... https://www.youtube.com/watch?v=kDMHHw8JqLE"
+ "\n| \n| twitter" + "\n| \n| twitter"
+ "\n| https://twitter.com/matsuevogelpark" + "\n| https://twitter.com/matsuevogelpark"
@ -327,8 +353,7 @@ test("message2event embeds: youtube video", async t => {
+ "\n| \n| #shoebill #livingdinosaur #happyofunny #weirdcreature #weirdsoun..." + "\n| \n| #shoebill #livingdinosaur #happyofunny #weirdcreature #weirdsoun..."
+ "\n| \n| 🎞️ https://www.youtube.com/embed/kDMHHw8JqLE", + "\n| \n| 🎞️ https://www.youtube.com/embed/kDMHHw8JqLE",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><sub><a href="https://www.youtube.com">YouTube</a></sub></p>` formatted_body: `<blockquote><p><strong><a href="https://www.youtube.com/channel/UCEpQ9aEb1NafpvWp5Aoizrg">Happy O Funny</a></strong>`
+ `<p><strong><a href="https://www.youtube.com/channel/UCEpQ9aEb1NafpvWp5Aoizrg">Happy O Funny</a></strong>`
+ `</p><p><strong><a href="https://www.youtube.com/watch?v=kDMHHw8JqLE">Shoebill stork clattering sounds like machine guun~!! (Japan Matsue...</a></strong>` + `</p><p><strong><a href="https://www.youtube.com/watch?v=kDMHHw8JqLE">Shoebill stork clattering sounds like machine guun~!! (Japan Matsue...</a></strong>`
+ `</p><p>twitter<br><a href="https://twitter.com/matsuevogelpark">https://twitter.com/matsuevogelpark</a><br><br>The shoebill (Balaeniceps rex) also known as whalehead, whale-headed stork, or shoe-billed stork, is a very large stork-like bird. It derives its name from its enormous shoe-shaped bill<br>some people also called them the living dinosaur~~<br><br>#shoebill #livingdinosaur #happyofunny #weirdcreature #weirdsoun...` + `</p><p>twitter<br><a href="https://twitter.com/matsuevogelpark">https://twitter.com/matsuevogelpark</a><br><br>The shoebill (Balaeniceps rex) also known as whalehead, whale-headed stork, or shoe-billed stork, is a very large stork-like bird. It derives its name from its enormous shoe-shaped bill<br>some people also called them the living dinosaur~~<br><br>#shoebill #livingdinosaur #happyofunny #weirdcreature #weirdsoun...`
+ `</p><p>🎞️ https://www.youtube.com/embed/kDMHHw8JqLE` + `</p><p>🎞️ https://www.youtube.com/embed/kDMHHw8JqLE`

View file

@ -1103,6 +1103,45 @@ test("message2event: very large attachment is linked instead of being uploaded",
}]) }])
}) })
test("message2event: very large png is converted to jpeg for upload", async t => {
const events = await messageToEvent({
content: "",
attachments: [{
filename: "855064e9-bdae-487a-a497-c662bd510487.png",
url: "https://cdn.discordapp.com/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png",
proxy_url: "https://media.discordapp.net/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png",
content_type: "image/png",
width: 5472,
height: 3648,
size: 14967919
}]
}, data.guild.general, {}, {
async fetch(url, init) {
t.equal(url, "https://media.discordapp.net/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png?format=jpeg")
t.equal(init.method, "HEAD")
return new Response("wa", {headers: {
"Content-Type": "image/jpeg",
"Content-Length": 483085
}})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.image",
body: "855064e9-bdae-487a-a497-c662bd510487.jpg",
info: {
w: 5472,
h: 3648,
mimetype: "image/jpeg",
size: 483085
},
external_url: "https://bridge.example.org/download/discordcdn/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png",
filename: "855064e9-bdae-487a-a497-c662bd510487.jpg",
url: "mxc://cadence.moe/zXNXRvJwRDMIzZZVqHUXtGeD"
}])
})
test("message2event: multiple attachments are combined into the same event where possible", async t => { test("message2event: multiple attachments are combined into the same event where possible", async t => {
const events = await messageToEvent({ const events = await messageToEvent({
content: "hey", content: "hey",
@ -1612,7 +1651,7 @@ test("message2event: expired/invalid invites are sent as-is", async t => {
snow: { snow: {
invite: { invite: {
async getInvite() { async getInvite() {
throw new Error(`{"message": "Unknown Invite", "code": 10006}`) throw new Error(`Unknown Invite`)
} }
} }
} }

View file

@ -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 // 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. // reacted on Matrix-side. Semantically, we want to remove the reaction from EVERY Matrix user.
// Also need to clean up the database. // 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}) removals.push({eventID, mxid: null, hash})
} }
if (!lookingAtMatrixReaction && !wantToRemoveMatrixReaction) { if (!lookingAtMatrixReaction && !wantToRemoveMatrixReaction) {

View file

@ -14,6 +14,8 @@ const {sync} = passthrough
/** @type {import("./discord-packets")} */ /** @type {import("./discord-packets")} */
const discordPackets = sync.require("./discord-packets") const discordPackets = sync.require("./discord-packets")
const CONNECTION_DEBUG = false
class DiscordClient { class DiscordClient {
/** /**
* @param {string} discordToken * @param {string} discordToken
@ -24,7 +26,7 @@ class DiscordClient {
const intents = [ const intents = [
"DIRECT_MESSAGES", "DIRECT_MESSAGE_REACTIONS", "DIRECT_MESSAGE_TYPING", "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", "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") if (reg.ooye.receive_presences !== false) intents.push("GUILD_PRESENCES")
this.discordToken = discordToken this.discordToken = discordToken
@ -53,21 +55,21 @@ class DiscordClient {
this.guildChannelMap = new Map() this.guildChannelMap = new Map()
if (listen !== "no") { if (listen !== "no") {
this.cloud.on("event", message => { this.cloud.on("event", message => {
process.nextTick(() => { discordPackets.onPacket(this, message, listen)
discordPackets.onPacket(this, message, listen)
})
}) })
} }
const addEventLogger = (eventName, logName) => { if (CONNECTION_DEBUG) {
this.cloud.on(eventName, (...args) => { const addEventLogger = (eventName, logName) => {
const d = new Date().toISOString().slice(0, 19) this.cloud.on(eventName, (...args) => {
console.error(`[${d} Client ${logName}]`, ...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")
} }
} }

View file

@ -1,173 +1,256 @@
// @ts-check // @ts-check
// Discord library internals type beat const assert = require("assert").strict
const {scheduler} = require("timers/promises")
const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../passthrough") const passthrough = require("../passthrough")
const {sync, db} = passthrough const {sync} = passthrough
/** @type {import("../matrix/homeserver-status")} */
const homeserverStatus = sync.require("../matrix/homeserver-status")
const guildReadyStatus = new class {
/** @type {Set<string> | null} */
unavailableGuilds = null
_allReady = Promise.withResolvers()
const utils = {
/** /**
* @param {import("./discord-client")} client * @param {string} guildID
* @param {import("cloudstorm").IGatewayMessage} message * @returns {boolean} true if it was the last one
* @param {string} listen "full", "half", "no" - whether to set up the event listeners for OOYE to operate
*/ */
async onPacket(client, message, listen) { makeReady(guildID) {
// requiring this later so that the client is already constructed by the time event-dispatcher is loaded assert(this.unavailableGuilds)
/** @type {typeof import("./event-dispatcher")} */ if (this.unavailableGuilds.delete(guildID) && this.unavailableGuilds.size === 0) {
const eventDispatcher = sync.require("./event-dispatcher") this._allReady.resolve(null)
/** @type {import("../discord/register-interactions")} */ return true
const interactions = sync.require("../discord/register-interactions") }
return false
}
// Client internals, keep track of the state we need allReady() {
if (message.t === "READY") { return this.unavailableGuilds && this.unavailableGuilds.size === 0
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") { waitForAllReady() {
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 return this._allReady.promise
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 { * @param {import("./discord-client")} client
await eventDispatcher.checkMissedExpressions(message.d) * @param {import("cloudstorm").IGatewayMessage} message
await eventDispatcher.checkMissedMessages(client, message.d) * @param {string} listen "full", "half", "no" - whether to set up the event listeners for OOYE to operate
await eventDispatcher.checkMissedPins(client, message.d) */
await eventDispatcher.checkMissedLeaves(client, message.d) async function onPacket(client, message, listen) {
} catch (e) { // requiring this later so that the client is already constructed by the time event-dispatcher is loaded
console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:") /** @type {typeof import("./event-dispatcher")} */
console.error(e) const eventDispatcher = sync.require("./event-dispatcher")
} /** @type {import("../discord/register-interactions")} */
} const interactions = sync.require("../discord/register-interactions")
} else if (message.t === "GUILD_UPDATE") { // Client internals, keep track of the state we need
const guild = client.guilds.get(message.d.id) if (message.t === "READY") {
if (guild) { if (client.ready) return
for (const prop of Object.keys(message.d)) { client.ready = true
if (!["channels", "threads"].includes(prop)) { client.user = message.d.user
guild[prop] = message.d[prop] client.application = message.d.application
} guildReadyStatus.unavailableGuilds = new Set(message.d.guilds.filter(g => g.unavailable).map(g => g.id))
} console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`)
} process.stdout.write("Waiting for guilds to warm up... ")
interactions.registerInteractions()
} else if (message.t === "GUILD_EMOJIS_UPDATE") { } else if (message.t === "GUILD_CREATE") {
const guild = client.guilds.get(message.d.guild_id) message.d.members = message.d.members.filter(m => m.user.id === client.user.id) // only keep the bot account's member - it's needed for roles to determine private channels on web
if (guild) { client.guilds.set(message.d.id, message.d)
guild.emojis = message.d.emojis
}
} else if (message.t === "GUILD_STICKERS_UPDATE") { /*
const guild = client.guilds.get(message.d.guild_id) Info about guilds is populated one guild at a time.
if (guild) { For m->d bridging to work, the guild needs to be populated, so we need to have GUILD_CREATE for the guild.
guild.stickers = message.d.stickers 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.
The "all guilds ready" 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
*/
const firstReady = guildReadyStatus.allReady()
const lastGuildReady = guildReadyStatus.makeReady(message.d.id)
} else if (message.t === "GUILD_ROLE_CREATE" || message.t === "GUILD_ROLE_UPDATE" || message.t === "GUILD_ROLE_DELETE") { const arr = []
const guild = client.guilds.get(message.d.guild_id) client.guildChannelMap.set(message.d.id, arr)
/** Delete this in case of UPDATE or DELETE */ for (const channel of message.d.channels || []) {
const targetID = "role_id" in message.d ? message.d.role_id : message.d.role.id // @ts-ignore
/** Add this in case of CREATE or UPDATE */ channel.guild_id = message.d.id
const newRoles = [] arr.push(channel.id)
if ("role" in message.d) newRoles.push(message.d.role) client.channels.set(channel.id, channel)
if (guild) { }
const targetIndex = guild.roles.findIndex(r => r.id === targetID) for (const thread of message.d.threads || []) {
if (targetIndex !== -1) { // @ts-ignore
// Role already exists. Delete it and maybe replace it. thread.guild_id = message.d.id
guild.roles.splice(targetIndex, 1, ...newRoles) arr.push(thread.id)
client.channels.set(thread.id, thread)
}
if (listen === "full") {
try {
// Wait for guilds to be connected and homeserver to be online. If this is the last guild, a different code path is used to trigger the homeserver check.
if (lastGuildReady) {
process.stdout.write(`ok, ${client.guilds.size} available.\nConnecting to homeserver... `)
// await guildReadyStatus.waitForAllReady() - no need, we already checked this is the last guild
await homeserverStatus.homeserverStatus.waitForOnline(true)
console.log("ok.\nReplaying past events. Welcome to Out Of Your Element.")
} else { } else {
// Role doesn't already exist. await guildReadyStatus.waitForAllReady()
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) {
if (firstReady) {
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") { } else if (message.t === "GUILD_UPDATE") {
const guild = client.guilds.get(message.d.guild_id) const guild = client.guilds.get(message.d.id)
const member = guild?.members.find(m => m.user.id === message.d.user.id) if (guild) {
if (member) { // only update existing members (i.e. the bot's own member) - don't want to inflate the cache with new irrelevant ones for (const prop of Object.keys(message.d)) {
Object.assign(member, message.d) if (!["channels", "threads"].includes(prop)) {
} guild[prop] = message.d[prop]
} 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 } else if (message.t === "GUILD_EMOJIS_UPDATE") {
if (listen === "full" && message.t) { const guild = client.guilds.get(message.d.guild_id)
try { if (guild) {
if (message.t === "MESSAGE_REACTION_REMOVE" || message.t === "MESSAGE_REACTION_REMOVE_EMOJI" || message.t === "MESSAGE_REACTION_REMOVE_ALL") { guild.emojis = message.d.emojis
await eventDispatcher.onSomeReactionsRemoved(client, message.d) }
} else if (message.t === "INTERACTION_CREATE") { } else if (message.t === "GUILD_STICKERS_UPDATE") {
await interactions.dispatchInteraction(message.d) const guild = client.guilds.get(message.d.guild_id)
if (guild) {
guild.stickers = message.d.stickers
}
} else if (message.t in eventDispatcher) { } else if (message.t === "GUILD_ROLE_CREATE" || message.t === "GUILD_ROLE_UPDATE" || message.t === "GUILD_ROLE_DELETE") {
await eventDispatcher[message.t](client, message.d) const guild = client.guilds.get(message.d.guild_id)
} /** Delete this in case of UPDATE or DELETE */
} catch (e) { const targetID = "role_id" in message.d ? message.d.role_id : message.d.role.id
// Let OOYE try to handle errors too /** Add this in case of CREATE or UPDATE */
await eventDispatcher.onError(client, e, message) 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
module.exports.guildReadyStatus = guildReadyStatus

View file

@ -2,6 +2,7 @@
const assert = require("assert").strict const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const {id: botID} = require("../../addbot")
const {sync, db, select, from} = require("../passthrough") const {sync, db, select, from} = require("../passthrough")
/** @type {import("./actions/send-message")}) */ /** @type {import("./actions/send-message")}) */
@ -38,6 +39,8 @@ const removeMember = sync.require("./actions/remove-member")
const vote = sync.require("./actions/poll-vote") const vote = sync.require("./actions/poll-vote")
/** @type {import("../m2d/event-dispatcher")} */ /** @type {import("../m2d/event-dispatcher")} */
const matrixEventDispatcher = sync.require("../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")} */ /** @type {import("../discord/interactions/matrix-info")} */
const matrixInfoInteraction = sync.require("../discord/interactions/matrix-info") const matrixInfoInteraction = sync.require("../discord/interactions/matrix-info")
@ -57,7 +60,7 @@ module.exports = {
matrixEventDispatcher.printError(gatewayMessage.t, "Discord", e, gatewayMessage) matrixEventDispatcher.printError(gatewayMessage.t, "Discord", e, gatewayMessage)
const channelID = gatewayMessage.d["channel_id"] const channelID = gatewayMessage.d?.["channel_id"]
if (!channelID) return if (!channelID) return
const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get() const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
if (!roomID) return if (!roomID) return
@ -104,7 +107,7 @@ module.exports = {
try { try {
messages = await client.snow.channel.getChannelMessages(channel.id, {limit: 100}) messages = await client.snow.channel.getChannelMessages(channel.id, {limit: 100})
} catch (e) { } catch (e) {
if (e.message === `{"message": "Missing Access", "code": 50001}`) { // pathetic error handling from SnowTransfer if (e.message === "Missing Access") {
console.log(`[check missed messages] no permissions to look back in channel ${channel.name} (${channel.id})`) console.log(`[check missed messages] no permissions to look back in channel ${channel.name} (${channel.id})`)
continue // Sucks. continue // Sucks.
} else { } else {
@ -285,7 +288,6 @@ module.exports = {
if (!guildID) return // channel must have been a DM channel or something 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() 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 if (!roomID) return // channel wasn't being bridged in the first place
// @ts-ignore
await createRoom.unbridgeChannel(channel, guildID) await createRoom.unbridgeChannel(channel, guildID)
}, },
@ -295,6 +297,7 @@ module.exports = {
*/ */
async MESSAGE_CREATE(client, message) { async MESSAGE_CREATE(client, message) {
if (message.author.username === "Deleted User") return // Nothing we can do for deleted users. if (message.author.username === "Deleted User") return // Nothing we can do for deleted users.
if (select("opt_out", "user_id", {user_id: message.author.id}).pluck().get()) return // This user opted out.
const channel = client.channels.get(message.channel_id) 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. if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages.
@ -310,13 +313,12 @@ module.exports = {
if (!createRoom.existsOrAutocreatable(channel, guild.id)) return // Check that the sending-to room exists or is autocreatable 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) const {skip} = await speedbump.maybeDoSpeedbump(true, message)
if (affected) return if (skip) return
// @ts-ignore await sendMessage.sendMessage(message, channel, guild)
await sendMessage.sendMessage(message, channel, guild, row)
retrigger.messageFinishedBridging(message.id) retrigger.finishedBridging(message.id)
}, },
/** /**
@ -332,24 +334,27 @@ module.exports = {
if (dUtils.isEphemeralMessage(data)) return // Ephemeral messages are for the eyes of the receiver only! 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. // 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) const {skip, hasCreate} = await speedbump.maybeDoSpeedbump(false, data)
if (affected) return if (skip) 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
}
/** @type {DiscordTypes.GatewayMessageCreateDispatchData} */ /** @type {DiscordTypes.GatewayMessageCreateDispatchData} */
// @ts-ignore
const message = data const message = data
const channel = client.channels.get(message.channel_id) 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. 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) const guild = client.guilds.get(channel.guild_id)
assert(guild) assert(guild)
// @ts-ignore if (!hasCreate) {
await retrigger.pauseChanges(message.id, editMessage.editMessage(message, guild, row)) // 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)
}
}, },
/** /**
@ -357,6 +362,7 @@ module.exports = {
* @param {DiscordTypes.GatewayMessageReactionAddDispatchData} data * @param {DiscordTypes.GatewayMessageReactionAddDispatchData} data
*/ */
async MESSAGE_REACTION_ADD(client, data) { async MESSAGE_REACTION_ADD(client, data) {
if (select("opt_out", "user_id", {user_id: data.user_id}).pluck().get()) return // This user opted out.
if (data.user_id === client.user.id) return // m2d reactions are added by the discord bot user - do not reflect them back to matrix. if (data.user_id === client.user.id) return // m2d reactions are added by the discord bot user - do not reflect them back to matrix.
if (data.emoji.name === "❓" && select("event_message", "message_id", {message_id: data.message_id, source: 0, part: 0}).get()) { // source 0 = matrix if (data.emoji.name === "❓" && select("event_message", "message_id", {message_id: data.message_id, source: 0, part: 0}).get()) { // source 0 = matrix
const guild_id = data.guild_id ?? client.channels.get(data.channel_id)?.["guild_id"] const guild_id = data.guild_id ?? client.channels.get(data.channel_id)?.["guild_id"]
@ -375,6 +381,16 @@ module.exports = {
* @param {DiscordTypes.GatewayMessageReactionRemoveDispatchData | DiscordTypes.GatewayMessageReactionRemoveEmojiDispatchData | DiscordTypes.GatewayMessageReactionRemoveAllDispatchData} data * @param {DiscordTypes.GatewayMessageReactionRemoveDispatchData | DiscordTypes.GatewayMessageReactionRemoveEmojiDispatchData | DiscordTypes.GatewayMessageReactionRemoveAllDispatchData} data
*/ */
async onSomeReactionsRemoved(client, 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) await removeReaction.removeSomeReactions(data)
}, },
@ -384,7 +400,7 @@ module.exports = {
*/ */
async MESSAGE_DELETE(client, data) { async MESSAGE_DELETE(client, data) {
speedbump.onMessageDelete(data.id) 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) await deleteMessage.deleteMessage(data)
}, },
@ -401,6 +417,7 @@ module.exports = {
* @param {DiscordTypes.GatewayTypingStartDispatchData} data * @param {DiscordTypes.GatewayTypingStartDispatchData} data
*/ */
async TYPING_START(client, 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() const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get()
if (!roomID) return if (!roomID) return
const mxid = from("sim").join("sim_member", "mxid").where({user_id: data.user_id, room_id: roomID}).pluck("mxid").get() const mxid = from("sim").join("sim_member", "mxid").where({user_id: data.user_id, room_id: roomID}).pluck("mxid").get()
@ -432,12 +449,12 @@ module.exports = {
* @param {DiscordTypes.GatewayMessagePollVoteDispatchData} data * @param {DiscordTypes.GatewayMessagePollVoteDispatchData} data
*/ */
async MESSAGE_POLL_VOTE_ADD(client, 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) await vote.addVote(data)
}, },
async MESSAGE_POLL_VOTE_REMOVE(client, 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) await vote.removeVote(data)
}, },

View file

@ -27,7 +27,7 @@ module.exports = async function(db) {
const webhook = affectedWebhooks[counter-1] 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 => { await discord.snow.webhook.deleteWebhookToken(webhook.webhook_id, webhook.webhook_token, "Webhook token possibly compromised during 8th May 2026 outage").catch(e => {
if (e.message === `{"message": "Unknown Webhook", "code": 10015}`) { if (e.message === "Unknown Webhook") {
// OK // OK
} else { } else {
throw e throw e

View file

@ -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;

View file

@ -0,0 +1,9 @@
BEGIN TRANSACTION;
CREATE TABLE "opt_out" (
"user_id" TEXT NOT NULL,
"opted_out_at" INTEGER NOT NULL,
PRIMARY KEY("user_id")
) WITHOUT ROWID;
COMMIT;

16
src/db/orm-defs.d.ts vendored
View file

@ -18,13 +18,17 @@ export type Models = {
thread_parent: string | null thread_parent: string | null
custom_avatar: string | null custom_avatar: string | null
last_bridged_pin_timestamp: number | null last_bridged_pin_timestamp: number | null
speedbump_id: string | null
speedbump_webhook_id: string | null
speedbump_checked: number | null speedbump_checked: number | null
guild_id: string | null guild_id: string | null
custom_topic: number custom_topic: number
} }
channel_speedbump: {
channel_id: string
speedbump_webhook_id: string
speedbump_user_id: string
}
direct: { direct: {
mxid: string mxid: string
room_id: string room_id: string
@ -44,6 +48,7 @@ export type Models = {
event_subtype: string | null event_subtype: string | null
part: number part: number
reaction_part: number reaction_part: number
/** 0 = Matrix, 1 = Discord */
source: number source: number
} }
@ -110,6 +115,11 @@ export type Models = {
historical_room_index: number historical_room_index: number
} }
opt_out: {
user_id: string
opted_out_at: number
}
role_default: { role_default: {
guild_id: string guild_id: string
role_id: string role_id: string
@ -137,6 +147,8 @@ export type Models = {
user_id: string user_id: string
proxy_owner_id: string proxy_owner_id: string
displayname: string displayname: string
/** 0 = PluralKit, 1 = /plu/ral */
proxy_app: number
} }
webhook: { webhook: {

View file

@ -104,6 +104,16 @@ class From {
return r return r
} }
pluckUnsafe(col) {
/** @type {Pluck<Table, any>} */
// @ts-ignore
const r = this
r.cols = [col]
r.makeColsSafe = false
r.isPluck = true
return r
}
/** /**
* @param {string} sql * @param {string} sql
*/ */

View file

@ -68,3 +68,8 @@ test("orm: select unsafe works (to select complex column names that can't be typ
.all() .all()
t.equal(results[0].power_level, 150) 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)
})

View file

@ -55,10 +55,10 @@ async function* _interact({data, message, member, user}, {api}) {
title: "Poll", title: "Poll",
components: [{ components: [{
type: DiscordTypes.ComponentType.TextDisplay, type: DiscordTypes.ComponentType.TextDisplay,
content: `-# ${pollComponents.getMultiSelectString(pollRow.max_selections, options.length)}` content: `## ${pollRow.question_text}`
}, { }, {
type: DiscordTypes.ComponentType.Label, type: DiscordTypes.ComponentType.Label,
label: pollRow.question_text, label: pollComponents.getMultiSelectString(pollRow.max_selections, options.length),
component: { component: {
type: DiscordTypes.ComponentType.RadioGroup, type: DiscordTypes.ComponentType.RadioGroup,
custom_id: "POLL_MODAL_SELECTION", custom_id: "POLL_MODAL_SELECTION",

View file

@ -16,9 +16,9 @@ const utils = sync.require("../../matrix/utils")
* @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>} * @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
*/ */
async function* _interact({data}, {api}) { async function* _interact({data}, {api}) {
const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") 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}).get() .select("event_id", "room_id").where({message_id: data.target_id}).and("ORDER BY reaction_part").all()
if (!row) { if (!events.length) {
return yield {createInteractionResponse: { return yield {createInteractionResponse: {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: { 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<string, string[]>} */ /** @type {Map<string, Set<string>>} reaction key (the emoji) -> displaynames */
const inverted = new Map() const inverted = new Map()
for (const reaction of reactions) { for (const reaction of reactions) {
if (utils.eventSenderIsFromDiscord(reaction.sender)) continue if (utils.eventSenderIsFromDiscord(reaction.sender)) continue
const key = reaction.content["m.relates_to"].key 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 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, []) if (!inverted.has(key)) {
inverted.set(key, new Set())
}
// @ts-ignore // @ts-ignore
inverted.get(key).push(displayname) inverted.get(key).add(displayname)
} }
if (inverted.size === 0) { if (inverted.size === 0) {
@ -55,7 +61,7 @@ async function* _interact({data}, {api}) {
} }
return yield {editOriginalInteractionResponse: { 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"),
}} }}
} }

View file

@ -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 => { test("reactions: shows reactions if there are some, ignoring discord users", async t => {
let called = 1 let called = 0
const msgs = await fromAsync(_interact({ const msgs = await fromAsync(_interact({
data: { data: {
target_id: "1126786462646550579" target_id: "1126786462646550579"
@ -51,6 +51,7 @@ test("reactions: shows reactions if there are some, ignoring discord users", asy
}, { }, {
api: { api: {
async getFullRelations(roomID, eventID) { async getFullRelations(roomID, eventID) {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
t.equal(eventID, "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg") t.equal(eventID, "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg")
return [{ return [{
@ -97,3 +98,57 @@ test("reactions: shows reactions if there are some, ignoring discord users", asy
) )
t.equal(called, 1) 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)
})

View file

@ -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 // User must have EVERY permission in default_member_permissions to be able to use the command
function registerInteractions() { function registerInteractions() {
discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{ discord.snow.interaction.editApplicationCommands(id, [{
name: "Matrix info", name: "Matrix info",
contexts: [DiscordTypes.InteractionContextType.Guild], contexts: [DiscordTypes.InteractionContextType.Guild],
type: DiscordTypes.ApplicationCommandType.Message, type: DiscordTypes.ApplicationCommandType.Message,
@ -91,40 +91,32 @@ function registerInteractions() {
async function dispatchInteraction(interaction) { async function dispatchInteraction(interaction) {
const interactionId = interaction.data?.["custom_id"] || interaction.data?.["name"] const interactionId = interaction.data?.["custom_id"] || interaction.data?.["name"]
try { try {
if (interaction.type === DiscordTypes.InteractionType.MessageComponent || interaction.type === DiscordTypes.InteractionType.ModalSubmit) { if (interactionId === "Matrix info") {
// All we get is custom_id, don't know which context the button was clicked in. await matrixInfo.interact(interaction)
// So we namespace these ourselves in the custom_id. Currently the only existing namespace is POLL_. } else if (interactionId === "invite") {
if (interaction.data.custom_id.startsWith("POLL_")) { await invite.interact(interaction)
await poll.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 { } 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 { } else {
if (interactionId === "Matrix info") { throw new Error(`Unknown interaction ${interactionId}`)
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}`)
}
} }
} catch (e) { } catch (e) {
let stackLines = null let stackLines = null

View file

@ -182,6 +182,18 @@ function filterTo(xs, fn) {
return filtered 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([ const supportedPlaintextPreviewExtensions = new Set([
"4d", "4d",
"abnf", "abnf",
@ -582,4 +594,5 @@ module.exports.timestampToSnowflakeInexact = timestampToSnowflakeInexact
module.exports.getPublicUrlForCdn = getPublicUrlForCdn module.exports.getPublicUrlForCdn = getPublicUrlForCdn
module.exports.howOldUnbridgedMessage = howOldUnbridgedMessage module.exports.howOldUnbridgedMessage = howOldUnbridgedMessage
module.exports.filterTo = filterTo module.exports.filterTo = filterTo
module.exports.swapThreadID = swapThreadID
module.exports.supportedPlaintextPreviewExtensions = supportedPlaintextPreviewExtensions module.exports.supportedPlaintextPreviewExtensions = supportedPlaintextPreviewExtensions

View file

@ -17,7 +17,7 @@ const retrigger = sync.require("../../d2m/actions/retrigger")
*/ */
async function addReaction(event) { async function addReaction(event) {
// Wait until the corresponding channel and message have already been bridged // 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 // These will exist because it passed retrigger
const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") 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) 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 module.exports.addReaction = addReaction

View file

@ -45,7 +45,7 @@ async function ensureWebhook(channelID, forceCreate = false) {
async function withWebhook(channelID, callback) { async function withWebhook(channelID, callback) {
const webhook = await ensureWebhook(channelID, false) const webhook = await ensureWebhook(channelID, false)
return callback(webhook).catch(async e => { return callback(webhook).catch(async e => {
if (e.message === `{"message": "Unknown Webhook", "code": 10015}`) { // pathetic error handling from SnowTransfer if (e.message === "Unknown Webhook") {
// Our webhook is gone. Maybe somebody deleted it, or removed and re-added OOYE from the guild. // Our webhook is gone. Maybe somebody deleted it, or removed and re-added OOYE from the guild.
const newWebhook = await ensureWebhook(channelID, true) const newWebhook = await ensureWebhook(channelID, true)
return callback(newWebhook) // not caught; if the error happens again just throw it instead of looping return callback(newWebhook) // not caught; if the error happens again just throw it instead of looping

View file

@ -23,7 +23,7 @@ async function getAndConvertEmoji(mxc) {
const res = await api.getMedia(mxc, {signal: abortController.signal}) const res = await api.getMedia(mxc, {signal: abortController.signal})
if (res.status !== 200) { if (res.status !== 200) {
const root = await res.json() 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) const readable = stream.Readable.fromWeb(res.body)
return emojiSheetConverter.convertImageStream(readable, () => { return emojiSheetConverter.convertImageStream(readable, () => {

View file

@ -10,6 +10,9 @@ const utils = sync.require("../../matrix/utils")
/** @type {import("../../d2m/actions/retrigger")} */ /** @type {import("../../d2m/actions/retrigger")} */
const retrigger = sync.require("../../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 * @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) 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 * @param {Ty.Event.Outer_M_Room_Redaction} event
*/ */
@ -41,11 +59,20 @@ async function suppressEmbeds(event) {
* @param {Ty.Event.Outer_M_Room_Redaction} event * @param {Ty.Event.Outer_M_Room_Redaction} event
*/ */
async function removeReaction(event) { async function removeReaction(event) {
if (!await retrigger.waitForReactionEvent(event.redacts)) return
const hash = utils.getEventIDHash(event.redacts) const hash = utils.getEventIDHash(event.redacts)
const row = from("reaction").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") 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() .select("reference_channel_id", "message_id", "encoded_emoji").where({hashed_event_id: hash}).get()
if (!row) return 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) 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 * @param {Ty.Event.Outer_M_Room_Redaction} event
*/ */
async function handle(event) { async function handle(event) {
// If this is for removing a reaction, try it // 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 removeReaction(event) await Promise.all([
removeMessageEvent(event),
// Or, it might be for removing a message or suppressing embeds. But to do that, the message needs to be bridged first. removeReaction(event)
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)
}
} }
module.exports.handle = handle module.exports.handle = handle
module.exports.m2dDeletedReactions = m2dDeletedReactions

View file

@ -153,7 +153,7 @@ async function sendEvent(event) {
channel_id: messageResponse.channel_id, channel_id: messageResponse.channel_id,
guild_id: guild.id, guild_id: guild.id,
embeds: messageResponse.embeds embeds: messageResponse.embeds
}, guild, null) }, guild)
) )
} }
} }

View file

@ -22,7 +22,7 @@ async function getAndResizeSticker(mxc) {
const res = await api.getMedia(mxc) const res = await api.getMedia(mxc)
if (res.status !== 200) { if (res.status !== 200) {
const root = await res.json() 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) const streamIn = Readable.fromWeb(res.body)

39
src/m2d/actions/typing.js Normal file
View file

@ -0,0 +1,39 @@
// @ts-check
const {discord} = require("../../passthrough")
/** @type {Set<string>} channel ids that are currently typing */
const isTyping = new Set()
/** @type {Map<string, NodeJS.Timeout>} 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

View file

@ -16,9 +16,9 @@ async function updatePins(pins, prev) {
.select("reference_channel_id", "message_id").where({event_id}).and("ORDER BY part ASC").get() .select("reference_channel_id", "message_id").where({event_id}).and("ORDER BY part ASC").get()
if (!row) continue if (!row) continue
if (added) { 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 { } 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")
} }
} }
} }

View file

@ -1,18 +1,12 @@
// @ts-check // @ts-check
const Ty = require("../../types") const Ty = require("../../types")
const DiscordTypes = require("discord-api-types/v10")
const {Readable} = require("stream")
const assert = require("assert").strict const assert = require("assert").strict
const crypto = require("crypto")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {sync, discord, db, select} = passthrough const {sync, db, select} = passthrough
const {reg} = require("../../matrix/read-registration") /** @type {import("../../discord/utils")} */
/** @type {import("../../matrix/api")} */ const dUtils = sync.require("../../discord/utils")
const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/utils")} */
const utils = sync.require("../../matrix/utils")
/** @type {import("../converters/poll-components")} */ /** @type {import("../converters/poll-components")} */
const pollComponents = sync.require("../converters/poll-components") const pollComponents = sync.require("../converters/poll-components")
/** @type {import("./channel-webhook")} */ /** @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 poll was started on Matrix, the Discord version is using components, so we can update that to the current status
if (messageRow.source === 0) { if (messageRow.source === 0) {
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() const row = select("channel_room", ["channel_id", "thread_parent"], {room_id: event.room_id}).get()
assert(channelID) assert(row)
await webhook.editMessageWithWebhook(channelID, messageID, pollComponents.getPollComponentsFromDatabase(messageID)) const {channelID, threadID} = dUtils.swapThreadID(row.channel_id, row.thread_parent)
await webhook.editMessageWithWebhook(channelID, messageID, pollComponents.getPollComponentsFromDatabase(messageID), threadID)
} }
} }
module.exports.updateVote = updateVote module.exports.updateVote = updateVote

View file

@ -10,6 +10,7 @@ const domino = require("domino")
const assert = require("assert").strict const assert = require("assert").strict
const entities = require("entities") const entities = require("entities")
const pb = require("prettier-bytes") const pb = require("prettier-bytes")
const mimeTypes = require("mime-types")
const {tag} = require("@cloudrac3r/html-template-tag") const {tag} = require("@cloudrac3r/html-template-tag")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
@ -31,6 +32,8 @@ const setupEmojis = sync.require("../actions/setup-emojis")
const userToMxid = sync.require("../../d2m/converters/user-to-mxid") const userToMxid = sync.require("../../d2m/converters/user-to-mxid")
/** @type {import("../../web/routes/letter-avatar")} */ /** @type {import("../../web/routes/letter-avatar")} */
const letterAvatar = sync.require("../../web/routes/letter-avatar") const letterAvatar = sync.require("../../web/routes/letter-avatar")
/** @type {import("../../d2m/actions/retrigger")} */
const retrigger = sync.require("../../d2m/actions/retrigger")
/** @type {[RegExp, string][]} */ /** @type {[RegExp, string][]} */
const markdownEscapes = [ const markdownEscapes = [
@ -97,15 +100,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", { turndownService.addRule("spoiler", {
filter: function (node, options) { filter: function (node, options) {
return node.tagName === "SPAN" && node.hasAttribute("data-mx-spoiler") return node.tagName === "SPAN" && node.hasAttribute("data-mx-spoiler")
@ -392,7 +386,7 @@ function linkEndOfMessageSpriteSheet(content) {
*/ */
async function handleRoomOrMessageLinks(input, di) { async function handleRoomOrMessageLinks(input, di) {
let offset = 0 let offset = 0
for (const match of [...input.matchAll(/("?https:\/\/matrix.to\/#\/((?:#|%23|!)[^"/, ?)]+)(?:\/(\$[^"/ ?)]+))?(?:\?[^",:!? )]*?)?)(">|[,<\n )]|$)/g)]) { for (const match of [...input.matchAll(/("?https:\/\/matrix.to\/#\/((?:#|%23|!|%21)[^"/, ?)]+)(?:\/((?:\$|%24)[^"/ ?)]+))?(?:\?[^",:!? )]*?)?)(">|[,<\n )]|$)/g)]) {
assert(typeof match.index === "number") assert(typeof match.index === "number")
let [_, attributeValue, roomID, eventID, endMarker] = match let [_, attributeValue, roomID, eventID, endMarker] = match
let result let result
@ -517,7 +511,7 @@ function getFileSizeForGuild(guild) {
if (fileSizeFeature) { if (fileSizeFeature) {
return fileSizeFeature * 1024 * 1024 // discord uses big megabytes return fileSizeFeature * 1024 * 1024 // discord uses big megabytes
} else { } else {
return 10 * 1024 * 1024 // default file size is 10 MB return 20 * 1024 * 1024 // default file size is 20 MB
} }
} }
@ -622,13 +616,20 @@ async function eventToMessage(event, guild, channel, di) {
captionContent.addLine(`(Spoiler: ${fileSpoilerReason})`) 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 // File link as alternative to uploading
if (!("file" in event.content) && event.content.info?.size > getFileSizeForGuild(guild)) { if (!("file" in event.content) && event.content.info?.size > getFileSizeForGuild(guild)) {
// Upload (unencrypted) file as link, because it's too large for Discord // 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. // 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) const url = mxUtils.getPublicUrlForMxc(event.content.url)
assert(url) assert(url)
const filename = event.content.filename || event.content.body
const emoji = attachmentEmojis.has(event.content.msgtype) ? attachmentEmojis.get(event.content.msgtype) + " " : "" const emoji = attachmentEmojis.has(event.content.msgtype) ? attachmentEmojis.get(event.content.msgtype) + " " : ""
if (fileIsSpoiler) { if (fileIsSpoiler) {
captionContent.addLine(`${emoji}Uploaded SPOILER file: <${url}> (${pb(event.content.info.size)})`, tag`${emoji}<em>Uploaded <strong>SPOILER</strong> file: <span data-mx-spoiler><a href="${url} ">${filename}</a></span> (${pb(event.content.info.size)})</em>`) // the space is necessary to work around a bug in Discord's URL previewer. the preview still gets blurred in the client. captionContent.addLine(`${emoji}Uploaded SPOILER file: <${url}> (${pb(event.content.info.size)})`, tag`${emoji}<em>Uploaded <strong>SPOILER</strong> file: <span data-mx-spoiler><a href="${url} ">${filename}</a></span> (${pb(event.content.info.size)})</em>`) // the space is necessary to work around a bug in Discord's URL previewer. the preview still gets blurred in the client.
@ -637,7 +638,6 @@ async function eventToMessage(event, guild, channel, di) {
} }
} else { } else {
// Upload file as file // Upload file as file
let filename = event.content.filename || event.content.body
if (fileIsSpoiler) filename = "SPOILER_" + filename if (fileIsSpoiler) filename = "SPOILER_" + filename
if ("file" in event.content) { if ("file" in event.content) {
// Encrypted // Encrypted
@ -698,6 +698,7 @@ async function eventToMessage(event, guild, channel, di) {
// Check if we have a pointer to what was edited // Check if we have a pointer to what was edited
const originalEventId = relatesTo.event_id const originalEventId = relatesTo.event_id
if (!originalEventId) return if (!originalEventId) return
if (!await retrigger.waitForEvent(originalEventId)) return
messageIDsToEdit = select("event_message", "message_id", {event_id: originalEventId}, "ORDER BY part").pluck().all() messageIDsToEdit = select("event_message", "message_id", {event_id: originalEventId}, "ORDER BY part").pluck().all()
if (!messageIDsToEdit.length) return if (!messageIDsToEdit.length) return
@ -775,14 +776,14 @@ async function eventToMessage(event, guild, channel, di) {
repliedToEvent.content = repliedToEvent.content["m.new_content"] repliedToEvent.content = repliedToEvent.content["m.new_content"]
} }
/** @type {string} */ /** @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) const fileReplyContentAlternative = attachmentEmojis.get(repliedToEvent.content.msgtype)
let contentPreview let contentPreview
if (fileReplyContentAlternative) { if (fileReplyContentAlternative) {
contentPreview = " " + fileReplyContentAlternative contentPreview = " " + fileReplyContentAlternative
} else if (repliedToEvent.unsigned?.redacted_because) { } else if (repliedToEvent.unsigned?.redacted_because) {
contentPreview = " (in reply to a deleted message)" 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... // 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 // I'm not implementing text fallbacks for arbitrary room events. this should cover most cases
// this has never ever happened in the wild anyway // this has never ever happened in the wild anyway
@ -790,6 +791,7 @@ async function eventToMessage(event, guild, channel, di) {
contentPreview = " (channel details edited)" contentPreview = " (channel details edited)"
} else { } else {
// Generate a reply preview for a standard message // 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(/.*<\/mx-reply>/s, "") // Remove everything before replies, so just use the actual message body
repliedToContent = repliedToContent.replace(/^\s*<blockquote>.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards repliedToContent = repliedToContent.replace(/^\s*<blockquote>.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards
repliedToContent = repliedToContent.replace(/(?:\n|<br ?\/?>)+/g, " ") // Should all be on one line repliedToContent = repliedToContent.replace(/(?:\n|<br ?\/?>)+/g, " ") // Should all be on one line
@ -804,7 +806,18 @@ async function eventToMessage(event, guild, channel, di) {
const contentPreviewChunks = chunk(repliedToContent, 50) const contentPreviewChunks = chunk(repliedToContent, 50)
if (contentPreviewChunks.length) { if (contentPreviewChunks.length) {
contentPreview = ": " + contentPreviewChunks[0] 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 `<via ${u.hostname}>`
} catch (e) {
return `<url>`
}
}
return `<${url}>` // Full URL is present, escape it
})
if (contentPreviewChunks.length > 1) contentPreview = contentPreview.replace(/[,.']$/, "") + "..." if (contentPreviewChunks.length > 1) contentPreview = contentPreview.replace(/[,.']$/, "") + "..."
} else { } else {
contentPreview = "" contentPreview = ""
@ -855,7 +868,7 @@ async function eventToMessage(event, guild, channel, di) {
input = input.replace(/("https:\/\/matrix.to.*?<\/a>):?/g, "$1") input = input.replace(/("https:\/\/matrix.to.*?<\/a>):?/g, "$1")
// Element adds a bunch of <br> before </blockquote> but doesn't render them. I can't figure out how this even works in the browser, so let's just delete those. // Element adds a bunch of <br> before </blockquote> 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|<br ?\/?>\s*)*<\/blockquote>/g, "</blockquote>") input = input.replace(/(?:\n|<br ?\/?>\s*)*<\/blockquote>(?:\n|<br ?\/?>\s*)*/g, "</blockquote>")
// 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. // 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. // But I should not count it if it's between block elements.
@ -890,6 +903,12 @@ async function eventToMessage(event, guild, channel, di) {
assert(root) assert(root)
async function forEachNode(event, node) { async function forEachNode(event, node) {
for (; node; node = node.nextSibling) { for (; node; node = node.nextSibling) {
// Don't process mx-reply further
if (node.nodeType === 1 && node.tagName === "MX-REPLY") {
node.remove()
continue
}
// Check written mentions // Check written mentions
if (node.nodeType === 3 && node.nodeValue.includes("@") && !nodeIsChildOf(node, ["A", "CODE", "PRE"])) { if (node.nodeType === 3 && node.nodeValue.includes("@") && !nodeIsChildOf(node, ["A", "CODE", "PRE"])) {
const result = await checkWrittenMentions(node.nodeValue, event.sender, event.room_id, guild, di) const result = await checkWrittenMentions(node.nodeValue, event.sender, event.room_id, guild, di)
@ -1035,11 +1054,11 @@ async function eventToMessage(event, guild, channel, di) {
// Combine requested mentions with detected written mentions to get the full list // Combine requested mentions with detected written mentions to get the full list
if (Array.isArray(event.content["m.mentions"].user_ids)) { if (Array.isArray(event.content["m.mentions"].user_ids)) {
for (const mxid of event.content["m.mentions"].user_ids) { for (const mxid of event.content["m.mentions"].user_ids) {
const user_id = select("sim", "user_id", {mxid}).pluck().get() let user_id = select("sim", "user_id", {mxid}).pluck().get()
if (!user_id) continue if (!user_id) continue // only for discord users
allowedMentionsUsers.push( user_id = select("sim_proxy", "proxy_owner_id", {user_id}).pluck().get() || user_id // convert to sim proxy owner's ID
select("sim_proxy", "proxy_owner_id", {user_id}).pluck().get() || user_id if (!user_id.match(/^[0-9]+$/)) continue // mentioned sim does not have a real discord user ID (i.e. it's a webhook)
) allowedMentionsUsers.push(user_id)
} }
} }
// Specific mentions were requested, so do not parse users // Specific mentions were requested, so do not parse users

View file

@ -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: "<p>this was inspired by someone telling me</p>\n<blockquote>\n<p>betrothed is an insane vocab pull</p>\n</blockquote>\n<blockquote>\n<p>i think thats part of why im so scared of cadence</p>\n</blockquote>\n<blockquote>\n<p>if an insult comes my way, not only do i not know when, but i'll have to humiliate myself and look it up</p>\n</blockquote>"
},
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 => { /*test("event2message: whitespace is retained", async t => {
t.deepEqual( t.deepEqual(
await eventToMessage({ await eventToMessage({
@ -1294,7 +1328,7 @@ test("event2message: characters are encoded properly in code blocks", async t =>
) )
}) })
test("event2message: quotes have an appropriate amount of whitespace", async t => { test("event2message: quotes have an appropriate amount of whitespace (br following)", async t => {
t.deepEqual( t.deepEqual(
await eventToMessage({ await eventToMessage({
content: { content: {
@ -1328,6 +1362,40 @@ test("event2message: quotes have an appropriate amount of whitespace", async t =
) )
}) })
test("event2message: quotes have an appropriate amount of whitespace (p following)", async t => {
t.deepEqual(
await eventToMessage({
content: {
msgtype: "m.text",
body: "wrong body",
format: "org.matrix.custom.html",
formatted_body: "<blockquote>\n<p>short story</p>\n</blockquote>\n<p>by whose standards? LOL</p><blockquote>\n<p>I basically went all the way back to childhood (which analysts always like you to do)</p>\n</blockquote>\n<p>laughed out loud</p>"
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
origin_server_ts: 1688301929913,
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
sender: "@cadence:cadence.moe",
type: "m.room.message",
unsigned: {
age: 405299
}
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "cadence [they]",
content: "> short story\nby whose standards? LOL\n\n> I basically went all the way back to childhood (which analysts always like you to do)\nlaughed out loud",
avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90",
allowed_mentions: {
parse: ["users", "roles"]
}
}]
}
)
})
test("event2message: lists have appropriate line breaks", async t => { test("event2message: lists have appropriate line breaks", async t => {
t.deepEqual( t.deepEqual(
await eventToMessage({ await eventToMessage({
@ -1643,6 +1711,63 @@ test("event2message: rich reply to a sim user, explicitly disabling mentions in
) )
}) })
test("event2message: rich reply to a webhook with m.mentions", async t => {
t.deepEqual(
await eventToMessage({
"type": "m.room.message",
"sender": "@cadence:cadence.moe",
"content": {
"msgtype": "m.text",
"body": "> <@_ooye_webhook_spidey_bot:cadence.moe:cadence.moe> Slow news day.\n\nTesting this reply, ignore",
"format": "org.matrix.custom.html",
"formatted_body": "<mx-reply><blockquote><a href=\"https://matrix.to/#/!fGgIymcYWOqjbSRUdV:cadence.moe/$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04?via=cadence.moe&via=feather.onl\">In reply to</a> <a href=\"https://matrix.to/#/@_ooye_webhook_spidey_bot:cadence.moe:cadence.moe\">@_ooye_webhook_spidey_bot:cadence.moe:cadence.moe</a><br>Slow news day.</blockquote></mx-reply>Testing this reply, ignore",
"m.relates_to": {
"m.in_reply_to": {
"event_id": "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04"
}
},
"m.mentions": {
user_ids: ["@_ooye_webhook_spidey_bot:cadence.moe"]
}
},
"origin_server_ts": 1693029683016,
"unsigned": {
"age": 91,
"transaction_id": "m1693029682894.510"
},
"event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8",
"room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe"
}, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", {
type: "m.room.message",
content: {
msgtype: "m.text",
body: "Slow news day."
},
sender: "@_ooye_webhook_spidey_bot:cadence.moe"
})
}
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "cadence [they]",
content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 **@Spidey Bot**:"
+ " Slow news day."
+ "\nTesting this reply, ignore",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU?preset=avatar",
allowed_mentions: {
parse: ["roles"],
users: []
}
}]
}
)
})
test("event2message: rich reply to a rich reply to a multi-line message should correctly strip reply fallback", async t => { test("event2message: rich reply to a rich reply to a multi-line message should correctly strip reply fallback", async t => {
t.deepEqual( t.deepEqual(
await eventToMessage({ await eventToMessage({
@ -2049,6 +2174,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: `<mx-reply><blockquote><a href="https://matrix.to/#/!fGgIymcYWOqjbSRUdV:cadence.moe/$qmyjr-ISJtnOM5WTWLI0fT7uSlqRLgpyin2d2NCglCU?via=cadence.moe">In reply to</a> <a href="https://matrix.to/#/@_ooye_.wing.:cadence.moe">@_ooye_.wing.:cadence.moe</a><br>https://huggingface.co/blog/security-incident-july-2026</blockquote></mx-reply>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": "<https://huggingface.co/blog/security-incident-july-2026>",
"format": "org.matrix.custom.html",
"formatted_body": "<a href=\"https://huggingface.co/blog/security-incident-july-2026\">https://huggingface.co/blog/security-incident-july-2026</a>"
}
})
}
}),
{
ensureJoined: [],
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "cadence [they]",
content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1273204543739396116 <@112890272819507200>:"
+ " <via huggingface.co>..."
+ `\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 => { test("event2message: should include a reply preview when message ends with a blockquote", async t => {
t.deepEqual( t.deepEqual(
await eventToMessage({ await eventToMessage({

View file

@ -14,7 +14,7 @@ const setupEmojis = sync.require("../actions/setup-emojis")
*/ */
function getMedal(topAnswers, count) { function getMedal(topAnswers, count) {
const winningOrTied = count && topAnswers[0].count === count const winningOrTied = count && topAnswers[0].count === count
const secondOrTied = !winningOrTied && count && topAnswers[1]?.count === count && topAnswers.slice(-1)[0].count !== count const secondOrTied = !winningOrTied && count && topAnswers[1]?.count === count && topAnswers.slice(-1)[0].count !== count
const thirdOrTied = !winningOrTied && !secondOrTied && count && topAnswers[2]?.count === count && topAnswers.slice(-1)[0].count !== count const thirdOrTied = !winningOrTied && !secondOrTied && count && topAnswers[2]?.count === count && topAnswers.slice(-1)[0].count !== count
const medal = const medal =
( winningOrTied ? "🥇" ( winningOrTied ? "🥇"
@ -34,6 +34,7 @@ function optionsToComponents(isClosed, pollOptions) {
/** @type {DiscordTypes.APIMessageTopLevelComponent[]} */ /** @type {DiscordTypes.APIMessageTopLevelComponent[]} */
return pollOptions.map(option => { return pollOptions.map(option => {
const medal = getMedal(topAnswers, option.count) const medal = getMedal(topAnswers, option.count)
const label = option.count === 1 ? `${option.count} vote\u2002\u200b` : `${option.count} votes`
return { return {
type: DiscordTypes.ComponentType.Container, type: DiscordTypes.ComponentType.Container,
components: [{ components: [{
@ -45,7 +46,7 @@ function optionsToComponents(isClosed, pollOptions) {
accessory: { accessory: {
type: DiscordTypes.ComponentType.Button, type: DiscordTypes.ComponentType.Button,
style: medal === "🥇" && isClosed ? DiscordTypes.ButtonStyle.Success : DiscordTypes.ButtonStyle.Secondary, style: medal === "🥇" && isClosed ? DiscordTypes.ButtonStyle.Success : DiscordTypes.ButtonStyle.Secondary,
label: option.count.toString(), label: label,
custom_id: `POLL_OPTION#${option.matrix_option}`, custom_id: `POLL_OPTION#${option.matrix_option}`,
disabled: isClosed disabled: isClosed
} }

View file

@ -24,6 +24,8 @@ const vote = sync.require("./actions/vote")
const matrixCommandHandler = sync.require("../matrix/matrix-command-handler") const matrixCommandHandler = sync.require("../matrix/matrix-command-handler")
/** @type {import("../matrix/utils")} */ /** @type {import("../matrix/utils")} */
const utils = sync.require("../matrix/utils") const utils = sync.require("../matrix/utils")
/** @type {import("../matrix/mreq")}) */
const mreq = sync.require("../matrix/mreq")
/** @type {import("../matrix/api")}) */ /** @type {import("../matrix/api")}) */
const api = sync.require("../matrix/api") const api = sync.require("../matrix/api")
/** @type {import("../d2m/actions/create-room")} */ /** @type {import("../d2m/actions/create-room")} */
@ -32,6 +34,10 @@ const createRoom = sync.require("../d2m/actions/create-room")
const roomUpgrade = require("../matrix/room-upgrade") const roomUpgrade = require("../matrix/room-upgrade")
/** @type {import("../d2m/actions/retrigger")} */ /** @type {import("../d2m/actions/retrigger")} */
const retrigger = sync.require("../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") const {reg} = require("../matrix/read-registration")
let lastReportedEvent = 0 let lastReportedEvent = 0
@ -166,7 +172,15 @@ async function sendError(roomID, source, type, e, payload) {
key: "🔁" key: "🔁"
} }
}) })
} catch (e) {} } 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)
}
}
} }
function guard(type, fn) { function guard(type, fn) {
@ -225,7 +239,7 @@ async event => {
// @ts-ignore // @ts-ignore
await matrixCommandHandler.execute(event) await matrixCommandHandler.execute(event)
} }
retrigger.messageFinishedBridging(event.event_id) retrigger.finishedBridging(event.event_id)
await api.ackEvent(event) await api.ackEvent(event)
})) }))
@ -236,7 +250,7 @@ sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker",
async event => { async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return if (utils.eventSenderIsFromDiscord(event.sender)) return
const messageResponses = await sendEvent.sendEvent(event) const messageResponses = await sendEvent.sendEvent(event)
retrigger.messageFinishedBridging(event.event_id) retrigger.finishedBridging(event.event_id)
await api.ackEvent(event) await api.ackEvent(event)
})) }))
@ -515,6 +529,20 @@ async event => {
await createRoom.unbridgeChannel(channel, channel["guild_id"], "Encrypted rooms are not supported. This room was removed from the bridge.") 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.stringifyErrorStack = stringifyErrorStack
module.exports.cleanErrorStack = cleanErrorStack module.exports.cleanErrorStack = cleanErrorStack
module.exports.sendError = sendError module.exports.sendError = sendError

View file

@ -452,7 +452,8 @@ async function ping() {
headers: { headers: {
Authorization: `Bearer ${reg.as_token}` Authorization: `Bearer ${reg.as_token}`
}, },
body: "{}" body: "{}",
signal: AbortSignal.timeout(15e3)
}) })
const root = await res.json() const root = await res.json()
return { return {
@ -474,7 +475,7 @@ async function ping() {
async function getMedia(mxc, init = {}) { async function getMedia(mxc, init = {}) {
init = {...init} init = {...init}
const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/) const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/([a-zA-Z0-9_-]+)$/)
assert(mediaParts) assert(mediaParts)
let route = "download" let route = "download"

View file

@ -15,10 +15,15 @@ const IMAGE_SIZE = 1024
const inflight = new Map() const inflight = new Map()
/** /**
* @param {string} url * @param {string} urlString
*/ */
function _removeExpiryParams(url) { function _removeExpiryParams(urlString) {
return url.replace(/\?(?:(?:ex|is|sg|hm)=[a-f0-9]+&?)*$/, "") const url = new URL(urlString)
url.searchParams.delete("ex")
url.searchParams.delete("is")
url.searchParams.delete("sg")
url.searchParams.delete("hm")
return url.toString()
} }
/** /**

View file

@ -20,3 +20,9 @@ test("removeExpiryParams: rearranged params are removed", t => {
const result = file._removeExpiryParams(url) const result = file._removeExpiryParams(url)
t.equal(result, "https://cdn.discordapp.com/attachments/112760669178241024/1157363960518029322/image.png") t.equal(result, "https://cdn.discordapp.com/attachments/112760669178241024/1157363960518029322/image.png")
}) })
test("removeExpiryParams: works on media proxy and keeps quality", t => {
const url = "https://media.discordapp.net/attachments/1160894080998461480/1548665030898094240/855064e9-bdae-487a-a497-c662bd510487.png?ex=6aa7e234&is=6aa690b4&hm=7c153efcdd1feb1d36d0bb99955a2bfe12f26abea377fed02980521766f51620&format=jpeg"
const result = file._removeExpiryParams(url)
t.equal(result, "https://media.discordapp.net/attachments/1160894080998461480/1548665030898094240/855064e9-bdae-487a-a497-c662bd510487.png?format=jpeg")
})

View file

@ -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

View file

@ -296,7 +296,7 @@ const commands = [{
try { try {
var invite = await discord.snow.channel.createChannelInvite(channelID) var invite = await discord.snow.channel.createChannelInvite(channelID)
} catch (e) { } catch (e) {
if (e.message === `{"message": "Missing Permissions", "code": 50013}`) { if (e.message === "Missing Permissions") {
return api.sendEvent(event.room_id, "m.room.message", { return api.sendEvent(event.room_id, "m.room.message", {
...ctx, ...ctx,
msgtype: "m.text", msgtype: "m.text",

View file

@ -9,9 +9,14 @@ const {reg} = require("./read-registration.js")
const baseUrl = `${reg.ooye.server_origin}/_matrix` const baseUrl = `${reg.ooye.server_origin}/_matrix`
class MatrixServerError extends Error { class MatrixServerError extends Error {
constructor(data, opts) { /**
* @param {number} httpStatus}
*/
constructor(data, httpStatus, opts = {}) {
super(data.error || data.errcode) super(data.error || data.errcode)
this.data = data this.data = data
/** @type {number} */
this.httpStatus = httpStatus
/** @type {string} */ /** @type {string} */
this.errcode = data.errcode this.errcode = data.errcode
this.opts = opts this.opts = opts
@ -44,11 +49,11 @@ async function _convertBody(body) {
async function makeMatrixServerError(res, opts = {}) { async function makeMatrixServerError(res, opts = {}) {
delete opts.headers?.["Authorization"] delete opts.headers?.["Authorization"]
if (res.headers.get("content-type") === "application/json") { 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/")) { } 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 { } 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) var root = JSON.parse(text)
} catch (e) { } catch (e) {
delete opts.headers?.["Authorization"] 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) { if (!res.ok || root.errcode) {
delete opts.headers?.["Authorization"] delete opts.headers?.["Authorization"]
throw new MatrixServerError(root, {baseUrl, url, ...opts}) throw new MatrixServerError(root, res.status, {baseUrl, url, ...opts})
} }
return root return root
} }

View file

@ -17,6 +17,10 @@ function checkRegistration(reg) {
assert(reg.ooye?.server_origin.match(/^https?:\/\//), "server origin must start with http or https") 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.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://") 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 */ /* c8 ignore next 4 */
@ -50,6 +54,7 @@ function getTemplateRegistration(serverName) {
], ],
sender_localpart: `${namespace_prefix}bot`, sender_localpart: `${namespace_prefix}bot`,
rate_limited: false, rate_limited: false,
receive_ephemeral: true,
socket: 6693, socket: 6693,
ooye: { ooye: {
namespace_prefix, namespace_prefix,

View file

@ -250,7 +250,7 @@ function getPublicUrlForMxc(mxc) {
*/ */
function makeMxcPublic(mxc) { function makeMxcPublic(mxc) {
assert(hasher, "xxhash is not ready yet") 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 if (!mediaParts) return undefined
const serverAndMediaID = `${mediaParts[1]}/${mediaParts[2]}` const serverAndMediaID = `${mediaParts[1]}/${mediaParts[2]}`

View file

@ -15,6 +15,7 @@ const mreq = sync.require("./matrix/mreq")
const api = sync.require("./matrix/api") const api = sync.require("./matrix/api")
const file = sync.require("./matrix/file") const file = sync.require("./matrix/file")
const sendEvent = sync.require("./m2d/actions/send-event") const sendEvent = sync.require("./m2d/actions/send-event")
const redact = sync.require("./m2d/actions/redact")
const eventDispatcher = sync.require("./d2m/event-dispatcher") const eventDispatcher = sync.require("./d2m/event-dispatcher")
const updatePins = sync.require("./d2m/actions/update-pins") const updatePins = sync.require("./d2m/actions/update-pins")
const speedbump = sync.require("./d2m/actions/speedbump") const speedbump = sync.require("./d2m/actions/speedbump")
@ -22,7 +23,7 @@ const ks = sync.require("./matrix/kstate")
const setPresence = sync.require("./d2m/actions/set-presence") const setPresence = sync.require("./d2m/actions/set-presence")
const channelWebhook = sync.require("./m2d/actions/channel-webhook") const channelWebhook = sync.require("./m2d/actions/channel-webhook")
const dUtils = sync.require("./discord/utils") const dUtils = sync.require("./discord/utils")
const mUtils = sync.require("./matrix/utils") const mxUtils = sync.require("./matrix/utils")
const guildID = "112760669178241024" const guildID = "112760669178241024"
async function ping() { async function ping() {

34
src/types.d.ts vendored
View file

@ -18,6 +18,7 @@ export type AppServiceRegistrationConfig = {
} }
protocols: [string] protocols: [string]
rate_limited: boolean rate_limited: boolean
receive_ephemeral: boolean
socket?: string | number, socket?: string | number,
ooye: { ooye: {
namespace_prefix: string namespace_prefix: string
@ -35,6 +36,8 @@ export type AppServiceRegistrationConfig = {
web_password: string web_password: string
time_zone?: string time_zone?: string
receive_presences: boolean receive_presences: boolean
plu_ral_api_key?: string
bridge_admin_contact?: string
} }
old_bridge?: { old_bridge?: {
as_token: string as_token: string
@ -59,6 +62,7 @@ export type InitialAppServiceRegistrationConfig = {
} }
protocols: [string] protocols: [string]
rate_limited: boolean rate_limited: boolean
receive_ephemeral: boolean
socket?: string | number, socket?: string | number,
ooye: { ooye: {
namespace_prefix: string namespace_prefix: string
@ -122,6 +126,28 @@ export type PkMessage = {
sender: string 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 namespace Event {
export type Outer<T> = { export type Outer<T> = {
type: string type: string
@ -396,6 +422,14 @@ export namespace Event {
rotation_period_ms?: number rotation_period_ms?: number
rotation_period_msgs?: number rotation_period_msgs?: number
} }
export type Outer_M_Typing = {
content: {
user_ids: string[]
}
room_id: string
type: "m.typing"
}
} }
export namespace R { export namespace R {

View file

@ -23,7 +23,7 @@ async function getManagedGuilds(event) {
/** /**
* @param {h3.H3Event} event * @param {h3.H3Event} event
* @returns {ReturnType<typeof h3.useSession<{userID?: string, mxid?: string, managedGuilds?: string[], state?: string, selfService?: boolean, password?: string}>>} * @returns {ReturnType<typeof h3.useSession<{userID?: string, mxid?: string, managedGuilds?: string[], state?: string, selfService?: boolean, next?: string, password?: string}>>}
*/ */
function useSession(event) { function useSession(event) {
return h3.useSession(event, {password: reg.as_token, maxAge: 365 * 24 * 60 * 60}) return h3.useSession(event, {password: reg.as_token, maxAge: 365 * 24 * 60 * 60})

View file

@ -122,7 +122,7 @@ block body
#role-add.s-popover(popover style="display: revert").ws2.px0.py4.bs-lg.overflow-visible #role-add.s-popover(popover style="display: revert").ws2.px0.py4.bs-lg.overflow-visible
.s-popover--arrow.s-popover--arrow__tc .s-popover--arrow.s-popover--arrow__tc
+add-roles-menu(guild, guild_id) +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. 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 h3.mt32.fs-category Features
.s-card.d-grid.px0.g16 .s-card.d-grid.px0.g16
@ -191,14 +191,14 @@ block body
label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id) label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id)
+discord(channel, true, "Announcement") +discord(channel, true, "Announcement")
else else
.s-empty-state.p8 All Discord channels are linked. .s-empty-state.p8 No Discord channels available.
.fl-grow1.s-btn-group.fd-column.w30 .fl-grow1.s-btn-group.fd-column.w30
each room in unlinkedRooms each room in unlinkedRooms
input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id) 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) label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id)
+matrix(room, true) +matrix(room, true)
else else
.s-empty-state.p8 All Matrix rooms are linked. .s-empty-state.p8 No Matrix rooms available.
input(type="hidden" name="guild_id" value=guild_id) input(type="hidden" name="guild_id" value=guild_id)
div div
button.s-btn.s-btn__icon.s-btn__filled#link-button button.s-btn.s-btn__icon.s-btn__filled#link-button

View file

@ -157,6 +157,10 @@ html(lang="en")
//- Body //- Body
.mx-auto.w100.wmx9.py24.px8.fs-body1#content .mx-auto.w100.wmx9.py24.px8.fs-body1#content
block body block body
if reg.ooye.bridge_admin_contact
aside.fs-body0.mt48.pt8.bt.bc-black-350.d-inline-block
a(href=rel("/privacy-policy")) Privacy policy
//- Guild list popover //- Guild list popover
script. script.
document.querySelectorAll("[popovertarget]").forEach(e => { document.querySelectorAll("[popovertarget]").forEach(e => {

34
src/web/pug/opt-out.pug Normal file
View file

@ -0,0 +1,34 @@
extends includes/template.pug
block body
if !session.data.userID
.s-empty-state.wmx4.p48
!= icons.Spots.SpotKeyXL
p Please log in to access the opt-out page.
.d-flex.jc-center.g8
a.s-btn.s-btn__icon.s-btn__featured.s-btn__filled(href=rel("/oauth?next=/opt-out"))
!= icons.Icons.IconDiscord
= ` Log in with Discord`
else
.s-page-title.mb24
h1.s-page-title--header Opt Out
- let value = !select("opt_out", "user_id", {user_id: session.data.userID}).pluck().get()
noscript
.s-notice.s-notice__danger.mb16 You must enable JavaScript to use this form. Please do that now!
#opt-in-msg(hx-swap-oob="true")
if msg
.s-notice.s-notice__info.mb16= msg
else if !value
.s-notice.s-notice__warning.mb16 You are currently opted out.
.s-card.d-grid.px0.g16
form.d-flex.ai-center.g16
#opt-in-loading.p8
input.s-toggle-switch#opt-in(name="opt_in" type="checkbox" hx-post=rel("/api/opt-out") hx-indicator="#opt-in-loading" hx-disabled-elt="this" checked=value autocomplete="off" hx-swap="none")
label.s-label.fl-grow1(for="opt-in")
| Bridge my messages to Matrix
p.s-description Turn it off to opt out. This applies to all servers.

View file

@ -0,0 +1,65 @@
extends includes/template.pug
block body
.s-page-title.mb24
h1.s-page-title--header Privacy Policy
p.s-page-title--description Last updated 4 August 2026
.s-prose
h2 Summary
p Out Of Your Element bridges messages between Matrix and Discord. It does this by forwarding all the messages from one platform to the other. Your messages aren't used for any other purpose.
p Matrix and Discord will store these messages until you delete them, but Out Of Your Element doesn't keep its own copy.
h2 Important information about the Matrix network
p The public Matrix Network is a decentralised and openly federated communication network. This means that user messages are replicated on each participant's homeserver, and messages posted to a room are visible to all participants, including new joiners. Anybody can run their own homeserver, so your messages may be widely replicated onto many servers. Out Of Your Element does not control these servers.
p Out Of Your Element does endeavour to remove the corresponding data from all of these servers when you delete a particular message, opt out, or request erasure. Due to the nature of decentralised systems, deletion might not be immediate or thorough. For example, some servers could be offline and catch up later, or they may have backups of old data.
p Please keep this in mind when using chatrooms with Out Of Your Element, or any other bridge software.
h2 What data is collected
p Data provided by you in the chatroom: Your messages, edits, uploaded files, username, profile picture, emojis, reactions, commands used, online indicator, typing indicator, and generally any other information written in chat will be copied to the other platform. This happens in all chatrooms where you see the Out Of Your Element bot.
p There is also a non-identifiable #[a(href=rel("/api/stats")) counter of how many people and chatrooms are active].
h2 What data isn't collected
p No tracking here. Out Of Your Element #[strong does not] automatically access or share your IP address, device fingerprint, location, read receipts, or your real-world identity.
h2 How long data is stored for
p Out Of Your Element doesn't store your message content directly. It only stores non-identifiable metadata, such as message IDs and room IDs.
p However, the messages are stored forever by Discord and Matrix, or until you delete them. Your messages will remain in the chat even if you leave the chat later.
p Your online indicator and typing indicator are only stored briefly to show your real-time status. They can't be viewed as history.
h2 Who can access this data
ul
li Anybody participating in the chatroom (on either platform) can see your messages attributed to your username
li Discord, Inc., and their hosting providers, can access that too
li People running a Matrix homeserver in the room, and their hosting providers, can access that too (see #[a(href="https://matrix.org/legal/privacy-notice/") Matrix.org privacy policy])
li Your data isn't shared with any other third parties (no analytics or monitoring)
h2 How to control access to your data
p To remove data in a specific message, you can delete that message. Deletions are bridged across all platforms. Discord and Matrix will discard the message.
p However, we can't stop people in the chatroom from independently saving or sharing a screenshot of your messages.
h3 Broad opt-out for Discord account holders
p If you don't want any future messages to be bridged across platforms, #[a(href=rel("/opt-out")) opt out (of your element) here]. Keep in mind this could make conversations confusing because people will see different things.
p Your old messages will stick around, but if you want those deleted as well, please contact the operator !{reg.ooye.bridge_admin_contact} to request erasure.
h2 Questions
p Out Of Your Element is open source, freely available software that anyone can run. The person running this version you're using is probably a different person from the software author.
p For questions about #[em your] data and how #[em this] version handles it, please contact the operator !{reg.ooye.bridge_admin_contact} to discuss.
p For general questions and clarifications about how the software handles data more broadly, please contact #[a(href="https://cadence.moe/contact") the author of the Out Of Your Element software.]

View file

@ -98,6 +98,11 @@ as.router.get(`/download/matrix/:server_name/:media_id`, defineEventHandler(asyn
setResponseHeader(event, "Content-Type", contentType) setResponseHeader(event, "Content-Type", contentType)
setResponseHeader(event, "Transfer-Encoding", "chunked") setResponseHeader(event, "Transfer-Encoding", "chunked")
const contentDisposition = res.headers.get("content-disposition")
if (contentDisposition) {
setResponseHeader(event, "Content-Disposition", contentDisposition)
}
if (res.ok && query.success) { if (res.ok && query.success) {
return MEDIA_THUMBNAIL_PRESETS[query.data.preset](res.body) return MEDIA_THUMBNAIL_PRESETS[query.data.preset](res.body)
} else { } else {

View file

@ -30,12 +30,13 @@ test("web download matrix: works if a known attachment", async t => {
api: { api: {
// @ts-ignore // @ts-ignore
async getMedia(mxc, init) { 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.statusCode, 200)
t.equal(event.node.res.getHeader("content-type"), "image/png") 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 mxcDownloader: mockGetAndConvertEmoji
}) })
t.equal(event.node.res.statusCode, 200) 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 => { test("web sheet: multiple sources", async t => {

View file

@ -158,13 +158,13 @@ as.router.get("/guild", defineEventHandler(async event => {
// Permission problems // Permission problems
if (!guild_id || !guild || !managed.has(guild_id) || !row) { if (!guild_id || !guild || !managed.has(guild_id) || !row) {
return pugSync.render(event, "guild_access_denied.pug", {guild_id, row}) return pugSync.render(event, "guild-access-denied.pug", {guild_id, row})
} }
// Self-service guild that hasn't been linked yet - needs a special page encouraging the link flow // Self-service guild that hasn't been linked yet - needs a special page encouraging the link flow
if (!row.space_id && row.autocreate === 0) { if (!row.space_id && row.autocreate === 0) {
const spaces = session.data.mxid ? getInviteTargetSpaces(session.data.mxid) : [] const spaces = session.data.mxid ? getInviteTargetSpaces(session.data.mxid) : []
return pugSync.render(event, "guild_not_linked.pug", {guild, guild_id, spaces}) return pugSync.render(event, "guild-not-linked.pug", {guild, guild_id, spaces})
} }
const roles = guild.members?.find(m => m.user.id === botID)?.roles || [] const roles = guild.members?.find(m => m.user.id === botID)?.roles || []
@ -191,7 +191,7 @@ as.router.get("/qr", defineEventHandler(async event => {
// Permission problems // Permission problems
if (!guild_id || !guild || !managed.has(guild_id) || !row) { if (!guild_id || !guild || !managed.has(guild_id) || !row) {
return pugSync.render(event, "guild_access_denied.pug", {guild_id, row}) return pugSync.render(event, "guild-access-denied.pug", {guild_id, row})
} }
const nonce = randomUUID() const nonce = randomUUID()

View file

@ -73,7 +73,7 @@ test("web link space: check that OOYE is joined", async t => {
api: { api: {
async joinRoom(roomID) { async joinRoom(roomID) {
called++ 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: { api: {
async joinRoom(roomID) { async joinRoom(roomID) {
called++ 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) { async *generateFullHierarchy(spaceID) {
called++ called++
@ -402,7 +402,7 @@ test("web link room: check that bridge can join room (uses via for join attempt)
async joinRoom(roomID, _, via) { async joinRoom(roomID, _, via) {
called++ called++
t.deepEqual(via, ["cadence.moe", "hashi.re"]) 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) { async *generateFullHierarchy(spaceID) {
called++ 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") 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() const new_row = db.prepare("SELECT * FROM channel_room WHERE channel_id = '665310973967597573'").get()
t.deepEqual(row, new_row) t.deepEqual(row, new_row)
}) })

View file

@ -17,7 +17,8 @@ const redirect_uri = `${reg.ooye.bridge_origin}/oauth`
const schema = { const schema = {
first: z.object({ first: z.object({
action: z.string().optional() action: z.string().optional(),
next: z.string().optional()
}), }),
code: z.object({ code: z.object({
state: z.string(), state: z.string(),
@ -53,10 +54,10 @@ function getOauth2Token(event) {
as.router.get("/oauth", defineEventHandler(async event => { as.router.get("/oauth", defineEventHandler(async event => {
const session = await auth.useSession(event) const session = await auth.useSession(event)
const parsedFirstQuery = await getValidatedQuery(event, schema.first.safeParse)
let scope = "guilds" let scope = "guilds"
if (!reg.ooye.web_password || reg.ooye.web_password === session.data.password) { if (!reg.ooye.web_password || reg.ooye.web_password === session.data.password) {
const parsedFirstQuery = await getValidatedQuery(event, schema.first.safeParse)
if (parsedFirstQuery.data?.action === "add") { if (parsedFirstQuery.data?.action === "add") {
scope = "bot+guilds" scope = "bot+guilds"
await session.update({selfService: false}) await session.update({selfService: false})
@ -69,6 +70,9 @@ as.router.get("/oauth", defineEventHandler(async event => {
async function tryAgain() { async function tryAgain() {
const newState = randomUUID() const newState = randomUUID()
await session.update({state: newState}) await session.update({state: newState})
if (parsedFirstQuery.data?.next) {
await session.update({next: parsedFirstQuery.data?.next})
}
return sendRedirect(event, `https://discord.com/oauth2/authorize?client_id=${id}&scope=${scope}&permissions=${permissions}&response_type=code&redirect_uri=${redirect_uri}&state=${newState}`) return sendRedirect(event, `https://discord.com/oauth2/authorize?client_id=${id}&scope=${scope}&permissions=${permissions}&response_type=code&redirect_uri=${redirect_uri}&state=${newState}`)
} }
@ -86,8 +90,10 @@ as.router.get("/oauth", defineEventHandler(async event => {
const client = getClient(event)(parsedToken.access_token) const client = getClient(event)(parsedToken.access_token)
const guilds = await client.user.getGuilds() const guilds = await client.user.getGuilds()
var managedGuilds = guilds.filter(g => BigInt(g.permissions) & DiscordTypes.PermissionFlagsBits.ManageGuild).map(g => g.id) const managedGuilds = guilds.filter(g => BigInt(g.permissions) & DiscordTypes.PermissionFlagsBits.ManageGuild).map(g => g.id)
await session.update({managedGuilds, userID, state: undefined})
const savedNext = session.data.next
await session.update({managedGuilds, userID, state: undefined, next: undefined})
// Set auto-create for the guild // Set auto-create for the guild
// @ts-ignore // @ts-ignore
@ -100,5 +106,5 @@ as.router.get("/oauth", defineEventHandler(async event => {
return sendRedirect(event, getRelativePath(event.path, `/guild?guild_id=${parsedQuery.data.guild_id}`), 302) return sendRedirect(event, getRelativePath(event.path, `/guild?guild_id=${parsedQuery.data.guild_id}`), 302)
} }
return sendRedirect(event, getRelativePath(event.path, "/"), 302) return sendRedirect(event, getRelativePath(event.path, savedNext || "/"), 302)
})) }))

43
src/web/routes/opt-out.js Normal file
View file

@ -0,0 +1,43 @@
// @ts-check
const assert = require("assert/strict")
const {z} = require("zod")
const {defineEventHandler, createError, readValidatedBody, getRequestHeader, setResponseHeader, sendRedirect, H3Event} = require("h3")
const {as, db, sync, select, discord} = require("../../passthrough")
/** @type {import("../auth")} */
const auth = sync.require("../auth")
/** @type {import("../pug-sync")} */
const pugSync = sync.require("../pug-sync")
const schema = {
optOut: z.object({
opt_in: z.string().optional() // switch is reversed, you turn it on to opt in
})
}
as.router.post("/api/opt-out", defineEventHandler(async event => {
// CSRF prevention for a more important endpoint
if (!getRequestHeader(event, "HX-Request")) {
throw createError({status: 403, message: "Forbidden", data: "JavaScript is required for the opt-out form."})
}
const session = await auth.useSession(event)
if (!session.data.userID) {
throw createError({status: 401, message: "Unauthorised", data: "Log in first."})
}
const parsedBody = await readValidatedBody(event, schema.optOut.parse)
const isOptIn = !!parsedBody.opt_in
if (isOptIn) {
db.prepare("DELETE FROM opt_out WHERE user_id = ?").run(session.data.userID)
var msg = "You have opted in."
} else {
db.prepare("INSERT OR IGNORE INTO opt_out (user_id, opted_out_at) VALUES (?, ?)").run(session.data.userID, Date.now())
var msg = "You have opted out."
}
return sendRedirect(event, `../opt-out?${new URLSearchParams({msg})}`, 302)
}))

View file

@ -123,7 +123,11 @@ as.router.get("/icon.png", defineEventHandler(async event => {
// Routes // Routes
pugSync.createRoute(as.router, "/ok", "ok.pug") if (reg.reg.ooye.bridge_admin_contact) {
pugSync.createRoute(as.router, "/ok", "ok.pug")
}
pugSync.createRoute(as.router, "/opt-out", "opt-out.pug")
pugSync.createRoute(as.router, "/privacy-policy", "privacy-policy.pug")
sync.require("./routes/download-matrix") sync.require("./routes/download-matrix")
sync.require("./routes/download-discord") sync.require("./routes/download-discord")
@ -134,5 +138,6 @@ sync.require("./routes/letter-avatar")
sync.require("./routes/link") sync.require("./routes/link")
sync.require("./routes/log-in-with-matrix") sync.require("./routes/log-in-with-matrix")
sync.require("./routes/oauth") sync.require("./routes/oauth")
sync.require("./routes/opt-out")
sync.require("./routes/password") sync.require("./routes/password")
sync.require("./routes/stats") sync.require("./routes/stats")

View file

@ -14,6 +14,10 @@ const sync = new HeatSync({watchFunction: fs.watchFile})
Object.assign(passthrough, {sync, db}) Object.assign(passthrough, {sync, db})
const orm = sync.require("./src/db/orm")
passthrough.from = orm.from
passthrough.select = orm.select
const DiscordClient = require("./src/d2m/discord-client") const DiscordClient = require("./src/d2m/discord-client")
const discord = new DiscordClient(reg.ooye.discord_token) const discord = new DiscordClient(reg.ooye.discord_token)
@ -22,17 +26,14 @@ passthrough.discord = discord
const {as} = require("./src/matrix/appservice") const {as} = require("./src/matrix/appservice")
passthrough.as = as passthrough.as = as
const orm = sync.require("./src/db/orm")
passthrough.from = orm.from
passthrough.select = orm.select
const power = require("./src/matrix/power.js") const power = require("./src/matrix/power.js")
sync.require("./src/m2d/event-dispatcher") sync.require("./src/m2d/event-dispatcher")
;(async () => { ;(async () => {
await migrate.migrate(db) await migrate.migrate(db)
process.stdout.write("Connecting to Discord... ")
await discord.cloud.connect() await discord.cloud.connect()
console.log("Discord gateway started") console.log("ok.")
sync.require("./src/web/server") sync.require("./src/web/server")
await power.applyPower() await power.applyPower()

View file

@ -144,6 +144,7 @@ module.exports = {
events_default: 0, events_default: 0,
events: { events: {
"m.reaction": 0, "m.reaction": 0,
"org.matrix.msc3381.poll.response": 0,
"m.room.redaction": 0 "m.room.redaction": 0
}, },
users: { users: {
@ -5296,6 +5297,60 @@ module.exports = {
} }
} }
] ]
},
embed_provider_and_footer: {
type: 0,
content: "https://cohost.org/jkap/post/4794219-empty",
author: {
id: "113340068197859328",
username: "kumaccino",
avatar: "b48302623a12bc7c59a71328f72ccb39",
discriminator: "0",
public_flags: 128,
premium_type: 0,
flags: 128,
banner: null,
accent_color: null,
global_name: "kumaccino",
avatar_decoration_data: null,
banner_color: null
},
attachments: [],
channel_id: "112760669178241024",
embeds: [
{
color: 8594767,
description: "1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:\n\n * Both players draw eight cards\n * Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand\n * Both players present their best five-or-less-card pok...",
provider: {
name: "hthrflwrs on cohost"
},
footer: {
text: "29/2/2024, 2:17 PM"
},
thumbnail: {
height: 1587,
placeholder: "GpoKP5BJZphshnhwmmmYlmh3l7+m+mwJ",
placeholder_version: 1,
proxy_url: "https://images-ext-2.discordapp.net/external/9vTXIzlXU4wyUZvWfmlmQkck8nGLUL-A090W4lWsZ48/https/staging.cohostcdn.org/avatar/292-6b64b03c-4ada-42f6-8452-109275bfe68d-profile.png",
url: "https://staging.cohostcdn.org/avatar/292-6b64b03c-4ada-42f6-8452-109275bfe68d-profile.png",
width: 1644
},
title: "This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO",
type: "link",
url: "https://cohost.org/jkap/post/4794219-empty"
}
],
guild_id: "112760669178241024",
id: "1210387798297682020",
mentions: [],
mention_roles: [],
pinned: false,
mention_everyone: false,
tts: false,
timestamp: "2024-02-21T18:15:43.353000+00:00",
edited_timestamp: null,
flags: 0,
components: []
} }
}, },
message_with_components: { message_with_components: {
@ -5473,6 +5528,189 @@ module.exports = {
content: '-# Original Message ID: 1466556003645657118 · <t:1769724599:f>' content: '-# Original Message ID: 1466556003645657118 · <t:1769724599:f>'
} }
] ]
},
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: { message_update: {

View file

@ -41,8 +41,8 @@ INSERT INTO sim (user_id, username, sim_name, mxid) VALUES
('772659086046658620', 'cadence.worm', 'cadence', '@_ooye_cadence:cadence.moe'), ('772659086046658620', 'cadence.worm', 'cadence', '@_ooye_cadence:cadence.moe'),
('196188877885538304', 'ampflower', 'ampflower', '@_ooye_ampflower:cadence.moe'), ('196188877885538304', 'ampflower', 'ampflower', '@_ooye_ampflower:cadence.moe'),
('1458668878107381800', 'Evil Lillith (she/her)', 'evil_lillith_sheher', '@_ooye_evil_lillith_sheher:cadence.moe'), ('1458668878107381800', 'Evil Lillith (she/her)', 'evil_lillith_sheher', '@_ooye_evil_lillith_sheher:cadence.moe'),
('197126718400626689', 'infinidoge1337', 'infinidoge1337', '@_ooye_infinidoge1337:cadence.moe'); ('197126718400626689', 'infinidoge1337', 'infinidoge1337', '@_ooye_infinidoge1337:cadence.moe'),
('webhook_spidey_bot', 'Spidey Bot', 'webhook_spidey_bot', '@_ooye_webhook_spidey_bot:cadence.moe');
INSERT INTO sim_member (mxid, room_id, hashed_profile_content) VALUES INSERT INTO sim_member (mxid, room_id, hashed_profile_content) VALUES
('@_ooye_bojack_horseman:cadence.moe', '!hYnGGlPHlbujVVfktC:cadence.moe', NULL), ('@_ooye_bojack_horseman:cadence.moe', '!hYnGGlPHlbujVVfktC:cadence.moe', NULL),
@ -95,7 +95,8 @@ WITH a (message_id, channel_id) AS (VALUES
('1381212840957972480', '112760669178241024'), ('1381212840957972480', '112760669178241024'),
('1401760355339862066', '112760669178241024'), ('1401760355339862066', '112760669178241024'),
('1439351590262800565', '1438284564815548418'), ('1439351590262800565', '1438284564815548418'),
('1404133238414376971', '112760669178241024')) ('1404133238414376971', '112760669178241024'),
('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; 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 INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES
@ -143,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), ('$7P2O_VTQNHvavX5zNJ35DV-dbJB1Ag80tGQP_JzGdhk', 'm.room.message', 'm.text', '1401760355339862066', 0, 0, 0),
('$ielAnR6geu0P1Tl5UXfrbxlIf-SV9jrNprxrGXP3v7M', 'm.room.message', 'm.image', '1439351590262800565', 0, 0, 0), ('$ielAnR6geu0P1Tl5UXfrbxlIf-SV9jrNprxrGXP3v7M', 'm.room.message', 'm.image', '1439351590262800565', 0, 0, 0),
('$uUKLcTQvik5tgtTGDKuzn0Ci4zcCvSoUcYn2X7mXm9I', 'm.room.message', 'm.text', '1404133238414376971', 0, 1, 1), ('$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 INSERT INTO file (discord_url, mxc_url) VALUES
('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'), ('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'),
@ -166,7 +168,8 @@ INSERT INTO file (discord_url, mxc_url) VALUES
('https://cdn.discordapp.com/attachments/122155380120748034/1174514575220158545/the.yml', 'mxc://cadence.moe/HnQIYQmmlIKwOQsbFsIGpzPP'), ('https://cdn.discordapp.com/attachments/122155380120748034/1174514575220158545/the.yml', 'mxc://cadence.moe/HnQIYQmmlIKwOQsbFsIGpzPP'),
('https://cdn.discordapp.com/attachments/112760669178241024/1296237494987133070/100km.gif', 'mxc://cadence.moe/qDAotmebTfEIfsAIVCEZptLh'), ('https://cdn.discordapp.com/attachments/112760669178241024/1296237494987133070/100km.gif', 'mxc://cadence.moe/qDAotmebTfEIfsAIVCEZptLh'),
('https://cdn.discordapp.com/attachments/123/456/my_enemies.txt', 'mxc://cadence.moe/y89EOTRp2lbeOkgdsEleGOge'), ('https://cdn.discordapp.com/attachments/123/456/my_enemies.txt', 'mxc://cadence.moe/y89EOTRp2lbeOkgdsEleGOge'),
('https://cdn.discordapp.com/emojis/1254940125948022915.webp', 'mxc://cadence.moe/bvVJFgOIyNcAknKCbmaHDktG'); ('https://cdn.discordapp.com/emojis/1254940125948022915.webp', 'mxc://cadence.moe/bvVJFgOIyNcAknKCbmaHDktG'),
('https://media.discordapp.net/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png?format=jpeg', 'mxc://cadence.moe/zXNXRvJwRDMIzZZVqHUXtGeD');
INSERT INTO emoji (emoji_id, name, animated, mxc_url) VALUES INSERT INTO emoji (emoji_id, name, animated, mxc_url) VALUES
('230201364309868544', 'hippo', 0, 'mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC'), ('230201364309868544', 'hippo', 0, 'mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC'),