diff --git a/.gitignore b/.gitignore
index e533dce5..c38dd88f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,8 @@ config.js
registration.yaml
ooye.db*
events.db*
+backfill.db*
+custom-webroot
# Automatically generated
node_modules
diff --git a/addbot.js b/addbot.js
index ef1cc63e..f0e850ce 100755
--- a/addbot.js
+++ b/addbot.js
@@ -1,12 +1,20 @@
#!/usr/bin/env node
// @ts-check
+const DiscordTypes = require("discord-api-types/v10")
+
const {reg} = require("./src/matrix/read-registration")
const token = reg.ooye.discord_token
const id = Buffer.from(token.split(".")[0], "base64").toString()
+const permissions =
+( DiscordTypes.PermissionFlagsBits.ManageWebhooks
+| DiscordTypes.PermissionFlagsBits.ManageGuildExpressions
+| DiscordTypes.PermissionFlagsBits.ManageMessages
+| DiscordTypes.PermissionFlagsBits.PinMessages
+| DiscordTypes.PermissionFlagsBits.UseExternalEmojis)
function addbot() {
- return `Open this link to add the bot to a Discord server:\nhttps://discord.com/oauth2/authorize?client_id=${id}&scope=bot&permissions=1610883072 `
+ return `Open this link to add the bot to a Discord server:\nhttps://discord.com/oauth2/authorize?client_id=${id}&scope=bot&permissions=${permissions} `
}
/* c8 ignore next 3 */
@@ -16,3 +24,4 @@ if (process.argv.find(a => a.endsWith("addbot") || a.endsWith("addbot.js"))) {
module.exports.id = id
module.exports.addbot = addbot
+module.exports.permissions = permissions
diff --git a/docs/developer-orientation.md b/docs/developer-orientation.md
index 94a420ca..0f5e0c01 100644
--- a/docs/developer-orientation.md
+++ b/docs/developer-orientation.md
@@ -9,7 +9,7 @@
Using WeatherStack as a thin layer between the bridge application and the Discord API lets us control exactly what data is cached in memory. Only necessary information is cached. For example, member data, user data, message content, and past edits are never stored in memory. This keeps the memory usage low and also prevents it ballooning in size over the bridge's runtime.
-The bridge uses a small SQLite database to store relationships like which Discord messages correspond to which Matrix messages. This is so the bridge knows what to edit when some message is edited on Discord. Using `without rowid` on the database tables stores the index and the data in the same B-tree. Since Matrix and Discord's internal IDs are quite long, this vastly reduces storage space because those IDs do not have to be stored twice separately. Some event IDs and URLs are actually stored as xxhash integers to reduce storage requirements even more. On my personal instance of OOYE, every 300,000 messages (representing a year of conversations) requires 47.3 MB of storage space in the SQLite database.
+The bridge uses a small SQLite database to store relationships like which Discord messages correspond to which Matrix messages. This is so the bridge knows what to edit when some message is edited on Discord. Using `without rowid` on the database tables stores the index and the data in the same B-tree. Since Matrix and Discord's internal IDs are quite long, this vastly reduces storage space because those IDs do not have to be stored twice separately. Some event IDs and URLs are actually stored as xxhash integers to reduce storage requirements even more. On my personal instance of OOYE, every 300,000 messages (representing a year of conversations) requires 40.6 MB of storage space in the SQLite database.
Only necessary data and columns are queried from the database. We only contact the homeserver API if the database doesn't contain what we need.
@@ -89,15 +89,14 @@ Whether you read those or not, I'm more than happy to help you 1-on-1 with codin
# Dependency justification
-Total transitive production dependencies: 137
+Total transitive production dependencies: 144
### 🦕
-* (31) better-sqlite3: SQLite is the best database, and this is the best library for it.
-* (27) @cloudrac3r/pug: Language for dynamic web pages. This is my fork. (I released code that hadn't made it to npm, and removed the heavy pug-filters feature.)
-* (16) stream-mime-type@1: This seems like the best option. Version 1 is used because version 2 is ESM-only.
-* (9) h3: Web server. OOYE needs this for the appservice listener, authmedia proxy, self-service, and more.
-* (11) sharp: Image resizing and compositing. OOYE needs this for the emoji sprite sheets.
+* (35) better-sqlite3: SQLite is the best database, and this is the best library for it.
+* (29) sharp: Image resizing and compositing. OOYE needs this for the emoji sprite sheets. It has libvips prebuilts for each platform.
+* (26) @cloudrac3r/pug: Language for dynamic web pages. This is my fork. (I released code that hadn't made it to npm, and removed the heavy pug-filters feature.)
+* (9) h3: Web server. OOYE needs this for the web UI, appservice listener, authmedia proxy, and more.
### 🪱
@@ -108,19 +107,21 @@ Total transitive production dependencies: 137
* (0) @cloudrac3r/in-your-element: This is my Matrix Appservice API library. It depends on h3 and zod, which are already pulled in by OOYE.
* (0) @cloudrac3r/mixin-deep: This is my fork. (It fixes a bug in regular mixin-deep.)
* (0) @cloudrac3r/pngjs: Lottie stickers are converted to bitmaps with the vendored Rlottie WASM build, then the bitmaps are converted to PNG with pngjs.
+* (0) @cloudrac3r/stream-type: Determine type of Matrix files that don't specify it in info. Switched from stream-mime-type to this.
* (0) @cloudrac3r/turndown: This HTML-to-Markdown converter looked the most suitable. I forked it to change the escaping logic to match the way Discord works.
* (3) @stackoverflow/stacks: Stack Overflow design language and icons.
* (0) ansi-colors: Helps with interactive prompting for the initial setup, and it's already pulled in by enquirer.
* (1) chunk-text: It does what I want.
* (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust.
+* (0) denque: Much faster than using arrays as a queue.
* (0) discord-api-types: Bitfields needed at runtime and types needed for development.
* (0) domino: DOM implementation that's already pulled in by turndown.
-* (1) 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.
* (0) entities: Looks fine. No dependencies.
* (0) get-relative-path: Looks fine. No dependencies.
* (1) heatsync: Module hot-reloader that I trust.
-* (1) js-yaml: Will be removed in the future after registration.yaml is converted to JSON.
* (0) lru-cache: For holding unused nonce in memory and letting them be overwritten later if never used.
+* (1) mime-types: List of mime type mappings. Needed to serve static files.
* (0) prettier-bytes: It does what I want and has no dependencies.
* (0) snowtransfer: Discord API library with bring-your-own-caching that I trust.
* (0) try-to-catch: Not strictly necessary, but it's already pulled in by supertape, so I may as well.
diff --git a/docs/docker.md b/docs/docker.md
new file mode 100644
index 00000000..04b9cf4c
--- /dev/null
+++ b/docs/docker.md
@@ -0,0 +1,76 @@
+# Docker policy
+
+**Out Of Your Element has no official support for Docker. There are no official files or images. If you choose to run Out Of Your Element in Docker, you must disclose this when asking for support. I may refuse to provide support/advice at any time. I may refuse to acknowledge issue reports.**
+
+This also goes for Podman, Nix, and other similar technology that upends a program's understanding of what it's running on.
+
+## What I recommend
+
+I recommend [following the official setup guide,](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/get-started.md) which does not use Docker.
+
+Ultimately, though, do what makes you happy. I can't stop you from doing what you want. As long as you read this page and understand my perspective, that's good enough for me.
+
+## Why I advise against Docker
+
+When misconfigured, Docker has terrible impacts. It can cause messages to go missing or even permanent data loss. These have happened to people.
+
+Docker also makes it much harder for me to advise on debugging because it puts barriers between you and useful debugging tools, such as stdin, the database file, a shell, and the inspector. It's also not clear which version of the source code is running in the container, as there are many pieces of Docker (builder, container, image) that can cache old data, often making it so you didn't actually update when you thought you did. This has happened to people.
+
+## Why I don't provide a good configuration myself
+
+It is not possible for Docker to be correctly configured by default. The defaults are broken and will cause data loss.
+
+It is also not possible for me to provide a correct configuration for everyone. Even if I provided a correct image, the YAMLs and command-line arguments must be written by individual end users. Incorrect YAMLs and command-line arguments may cause connection issues or permanent data loss.
+
+## Why I don't provide assistance if you run OOYE in Docker
+
+Problems you encounter, especially with the initial setup, are much more likely to be caused by nuances in your Docker setup than problems in my code. Therefore, my code is not responsible for the problem. The cause of the problem is different code that I can't advise on.
+
+Also, if you reported an issue and I asked for additional information to help find the cause, you might be unable to provide it because of the debugging barriers discussed above.
+
+## Why I don't provide Docker resources
+
+I create OOYE unpaid in my spare time because I enjoy the process. I find great enjoyment in creating code and none at all in creating infrastructure.
+
+## Why you're probably fine without Docker
+
+### If you care about system footprint
+
+OOYE was designed to be simple and courteous:
+
+* It only creates files in its working directory
+* It does not require any other processes to be running (e.g., no dependency on a Postgres process)
+* It only requires node/npm executables in PATH, which you can store in any folder if you don't want to use your package manager
+
+### If you care about ease of setup
+
+In my opinion, the [official setup process](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/get-started.md) is straightforward. After installing prerequisites (Node.js and the repo clone), the rest of the process interactively guides you through providing necessary information. Your input is checked for correctness so the bridge will definitely work when you run it.
+
+I find this easier than the usual Docker workflow of pasting values into a YAML and rolling the dice on whether it will start up or not.
+
+### If you care about security in the case of compromise/RCE
+
+There are no known vulnerabilities in dependencies. I [carefully selected simple, light dependencies](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/developer-orientation.md#dependency-justification) to reduce attack surface area.
+
+For defense in depth, I suggest running OOYE as a different user.
+
+### If you want to see all the processes when you run docker ps
+
+Well, you got me there.
+
+## Unofficial, independent, community-provided container setups
+
+I acknowledge the demand for using OOYE in a container, so I will still point you in the right direction.
+
+I had no hand in creating these and have not used or tested them whatsoever. I make no assurance that these will work reliably, or even at all. If you use these, you must do so with the understanding that if you run into any problems, **you must ask for support from the author of that setup, not from me, because you're running their code, not mine.**
+
+***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/oci-images/out-of-your-element
+- by sim2kid: https://github.com/sim2kid/ooye-docker
+- by Katharos Technology: https://github.com/katharostech/docker_ooye
+- by Emma: https://cgit.rory.gay/nix/OOYE-module.git/tree
+
+## Making your own Docker setup
+
+If you decide to make your own, I may provide advice or indicate problems at my discretion. You acknowledge that I am not required to provide evidence of problems I indicate, nor solutions to them. You acknowledge that it is not possible for me to exhaustively indicate every problem, so I cannot indicate correctness. Even if I have provided advice to an unofficial, independent, community-provided setup, I do not endorse it.
diff --git a/docs/get-started.md b/docs/get-started.md
index ae31f5f7..fa67678a 100644
--- a/docs/get-started.md
+++ b/docs/get-started.md
@@ -1,5 +1,7 @@
# Setup
+If you want Docker, [please read this first.](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/docker.md)
+
If you get stuck, you're welcome to message [#out-of-your-element:cadence.moe](https://matrix.to/#/#out-of-your-element:cadence.moe) or [@cadence:cadence.moe](https://matrix.to/#/@cadence:cadence.moe) to ask for help setting up OOYE!
You'll need:
@@ -54,8 +56,16 @@ After that, to get into the rooms on your Matrix account, use the invite form on
I hope you enjoy Out Of Your Element!
-----
-
+# Extras
+
+## /plu/ral support
+
+To support /plu/ral webhook proxying, OOYE needs to call the /plu/ral API authenticated. I can't provide an API key for everyone to share, so you have to make your own. Here's how:
+
+1. Open the [/plu/ral app on Discord](https://discord.com/discovery/applications/1291501048493768784) and "Add App" -> "Add to My Apps (use everywhere)"
+2. Go to any text channel on Discord and use the `/api` command from /plu/ral.
+3. Create a new application named Out Of Your Element. Copy the displayed token.
+3. Run `node scripts/plu-ral-api.js` and paste the token.
# Appendix
@@ -106,3 +116,9 @@ bridge.cadence.moe {
reverse_proxy 127.0.0.1:6693
}
```
+
+## Example reverse proxy for traefik
+
+Note: Out Of Your Element has no official Docker support. This guide is for using traefik when OOYE is ***not*** in a container.
+
+See [third-party/reverse-proxy-traefik.md](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/third-party/reverse-proxy-traefik.md)
diff --git a/docs/img/poll-star-avatar.png b/docs/img/poll-star-avatar.png
new file mode 100644
index 00000000..a4355557
Binary files /dev/null and b/docs/img/poll-star-avatar.png differ
diff --git a/docs/img/poll_win.png b/docs/img/poll_win.png
new file mode 100644
index 00000000..61c85900
Binary files /dev/null and b/docs/img/poll_win.png differ
diff --git a/docs/room-upgrades.md b/docs/room-upgrades.md
new file mode 100644
index 00000000..49308fd0
--- /dev/null
+++ b/docs/room-upgrades.md
@@ -0,0 +1,80 @@
+# Room upgrades
+
+"Upgrading" a room is supposed to create a new room and then tries to set it up exactly like the old one. So it copies name, topic, power levels, space membership, etc. The old room is marked as old with an `m.room.tombstone` event, its power levels are adjusted to make it harder to send messages, and a hyperlink to the new room is added.
+
+## What happens?
+
+A room upgrade is triggered by a POST request to `/_matrix/client/v3/rooms/{roomId}/upgrade`. The upgrade process is done by the server, and involves multiple events across multiple rooms. Since this is server-specific, what will _actually_ happen depends on the server's implementation, but the spec says it does this:
+
+1. Checks that the user has permission to send `m.room.tombstone` events in the room.
+2. Creates a replacement room with a `m.room.create` event containing a predecessor field, the applicable `room_version`, and a `type` field which is copied from the predecessor room. If no `type` is set on the previous room, no `type` is specified on the new room’s create event either.
+3. Replicates transferable state events to the new room. The exact details for what is transferred is left as an implementation detail, however the recommended state events to transfer are:
+ * `m.room.server_acl`
+ * `m.room.encryption`
+ * `m.room.name`
+ * `m.room.avatar`
+ * `m.room.topic`
+ * `m.room.guest_access`
+ * `m.room.history_visibility`
+ * `m.room.join_rules`
+ * `m.room.power_levels`
+
+ (Membership can't be transferred by the server.)
+
+4. Moves any local aliases to the new room.
+5. Sends a `m.room.tombstone` event to the old room to indicate that it is not intended to be used any further.
+6. If possible, the power levels in the old room should also be modified to prevent sending of events and inviting new users. For example, setting `events_default` and `invite` to the greater of `50` and `users_default + 1`.
+
+### Synapse additionally:
+
+1. Copies its `m.space.child` events (if it was a space).
+ * This is good for OOYE, because it automatically tries to join new rooms when they're added to a registered space.
+2. Copies bans.
+3. Un/publishes to the public room directory as applicable.
+4. Copies user tags and push rules.
+
+Conduwuit does not do those!
+
+### Element additionally:
+
+1. May invite all users from the old room to the new room, depending on if the checkbox is checked in the dialog.
+2. Update parent spaces to remove the old room and add the new room.
+
+Cinny does not do those! The new room is totally detached! The hyperlink from the old room (and the moved alias by server) is the only way to find it!
+
+* This is probably still okay for OOYE? Since the join rules are preserved, and if they were `restricted`, OOYE is able to join via the tombstone hyperlink. Then, after it joins, it's already PL 100 since the power levels are preserved. It's very bad if the join rules were `invite`, but OOYE never sets this join rule - it's either `restricted` or `public`.
+
+### Other clients
+
+Nheko doesn't support room upgrades at all. Cinyy, NeoChat and FluffyChat just call the API and don't do anything. FluffyChat invites all joined/invited users to the new room if the join rule is restricted.
+
+### Notable things that don't happen at all:
+
+* Add `m.space.parent` pointing to the space it was in (if it was a room in a space).
+
+## What should OOYE do?
+
+### Ideal case (Element, Synapse)
+
+The new room is added to the space and OOYE autojoins it. It already has the correct power levels and join rules.
+
+OOYE still needs to do this:
+
+1. Un/set `m.room.parent` in the rooms.
+2. Update `channel_room` and `historical_channel_room` tables.
+
+### Not ideal case (everyone else)
+
+OOYE should:
+
+1. Join the room by following the hyperlink from the tombstone, if able
+ * If not able, somebody messed with the join rules. Send a PM to the user who upgraded - the new room's creator - asking for an invite.
+2. Wait for join.
+3. Un/set `m.space.child` events on the space.
+4. Un/set `m.room.parent` in the rooms.
+5. Update `channel_room` and `historical_channel_room` tables.
+6. Un/publish to the room directory.
+
+### It's actually fine to do all the steps always
+
+Even by blindly following the entire list, each step is a no-op or atomic, so it doesn't matter if Element is also trying to do them.
diff --git a/docs/third-party/reverse-proxy-traefik.md b/docs/third-party/reverse-proxy-traefik.md
new file mode 100644
index 00000000..8b06a9be
--- /dev/null
+++ b/docs/third-party/reverse-proxy-traefik.md
@@ -0,0 +1,113 @@
+> This guide was written by @bgtlover:stealthy.club, a community contributor. The author of Out Of Your Element hopes it will be useful, but cannot say whether the information is accurate or complete.
+
+## Example reverse proxy configuration with traefik
+
+Note: This guide describes setting up the reverse proxy configuration when OOYE is ***not*** in a Docker container.
+
+Because traefik is generally used in Docker, this guide assumes the user already has it configured properly. However, given that Docker is very complex and the smallest mistakes can cascade in catastrophic, not immediately observable, and unpredictable ways, a fairly complete setup will be reproduced. Therefore, system administrators are advised to diff this sample setup against theirs rather than copy it wholesale.
+
+### Note on variable substitution
+
+Variables will be denoted as `{{var}}`. This syntax has been chosen because that's also how YAML substitution works. The values that fit each variable will be explained after the code block containing the placeholder.
+
+### Base compose configuration for traefik
+
+This file defines the traefik service stack. It's responsible for mounting volumes correctly, declaring ports that should be opened on the host side, and the external traefik network (created manually).
+
+In compose.yml, put the following:
+
+```yaml
+services:
+ traefik:
+ image: "traefik:latest"
+ restart: always
+ command:
+ - "--configFile=/etc/traefik/static_config.yml"
+ ports:
+ - "80:80" #http
+ - "443:443" #https
+ networks:
+ - traefik
+ volumes:
+ - ./letsencrypt:/letsencrypt
+ - /etc/localtime:/etc/localtime:ro
+ - /var/run/docker.sock:/var/run/docker.sock:ro
+ - ./static_config.yml:/etc/traefik/static_config.yml
+ - ./config:/etc/traefik/config
+networks:
+ traefik:
+ external: true
+```
+
+### Static traefik configuration
+
+The static traefik configuration is used to define base traefik behavior, for example entry points, access and runtime logs, a file or directory for per-service configuration, etc.
+
+In static_config.yml, put the following:
+
+```yaml
+api:
+ dashboard: true
+
+providers:
+ docker:
+ endpoint: "unix:///var/run/docker.sock"
+ exposedByDefault: false
+ network: "traefik"
+ file:
+ directory: /etc/traefik/config/
+ watch: true
+
+entryPoints:
+ web-secure:
+ address: ":443"
+ asDefault: true
+ http3: {}
+ http:
+ tls:
+ certResolver: default
+ web:
+ address: ":80"
+ http:
+ redirections:
+ entryPoint:
+ to: web-secure
+
+certificatesResolvers:
+ default:
+ acme:
+ email: {{email}}
+ storage: "/letsencrypt/acme.json"
+ tlsChallenge: {}
+
+```
+
+Replace `{{email}}` with a valid email address.
+
+### Out of your element traefik dynamic configuration
+
+Traefik's dynamic configuration files configure proxy behaviors on a per-application level.
+
+In config/out-of-your-element.yml, put the following:
+
+```yaml
+http:
+ routers:
+ out-of-your-element:
+ rule: Host(`bridge.stealthy.club`)
+ service: out-of-your-element-service
+ services:
+ out-of-your-element-service:
+ loadBalancer:
+ servers:
+ - url: "http://{{ip}}:{{port}}"
+
+```
+
+The `{{port}}` is 6693 unless you changed it during Out Of Your Element's first time setup.
+
+Replace `{{ip}}` with the ***external*** IP of your server.
+
+Make sure the port is allowed through your firewall if applicable.
+
+For context, the external IP is required because of Docker networking. Because Docker modifies the host-side iptables firewall and creates virtual interfaces for its networks, and because the networking inside containers is configured such that localhost points to the IP of the container instead of the actual host, placing localhost in the url field above would make the traefik container establish an HTTP connection to itself, which would cause a bad gateway error.
diff --git a/docs/user-guide.md b/docs/user-guide.md
index d3608068..d1beea15 100644
--- a/docs/user-guide.md
+++ b/docs/user-guide.md
@@ -84,7 +84,7 @@ Discord display names for normal users are limited to 32 characters. For webhook
If the bridge software was restarted, it will attempt to catch up on messages missed while it was offline.
-From Discord, for any given channel, if fewer than 50 messages were missed in that given channel, the bridge will catch up and transfer all of them to Matrix. If more than 50 messages were missed in that given channel, the bridge will only bridge the latest message. Happenings that aren't messages, such as edits and reactions to prior messages, might be missed during catch-up.
+From Discord, for any given channel, if fewer than 100 messages were missed in that given channel, the bridge will catch up and transfer all of them to Matrix. If more than 100 messages were missed in that given channel, the bridge will only bridge the latest message. Happenings that aren't messages, such as edits and reactions to prior messages, might be missed during catch-up.
From Matrix, all events should be bridged to Discord.
diff --git a/jsconfig.json b/jsconfig.json
new file mode 100644
index 00000000..65a9b504
--- /dev/null
+++ b/jsconfig.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "target": "es2024",
+ "module": "nodenext",
+ "lib": ["ESNext"],
+ "strict": true,
+ "noImplicitAny": false,
+ "useUnknownInCatchVariables": false
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index aa7822f7..986e6cae 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,43 +1,45 @@
{
"name": "out-of-your-element",
- "version": "3.1.0",
+ "version": "3.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "out-of-your-element",
- "version": "3.1.0",
+ "version": "3.6.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@chriscdn/promise-semaphore": "^3.0.1",
- "@cloudrac3r/discord-markdown": "^2.6.6",
+ "@cloudrac3r/discord-markdown": "^2.7.0",
"@cloudrac3r/giframe": "^0.4.3",
"@cloudrac3r/html-template-tag": "^5.0.1",
"@cloudrac3r/in-your-element": "^1.1.1",
"@cloudrac3r/mixin-deep": "^3.0.1",
"@cloudrac3r/pngjs": "^7.0.3",
"@cloudrac3r/pug": "^4.0.4",
- "@cloudrac3r/turndown": "^7.1.4",
+ "@cloudrac3r/stream-type": "^1.0.0",
+ "@cloudrac3r/turndown": "^7.1.5",
"@stackoverflow/stacks": "^2.5.4",
"@stackoverflow/stacks-icons": "^6.0.2",
"ansi-colors": "^4.1.3",
"better-sqlite3": "^12.2.0",
"chunk-text": "^2.0.1",
- "cloudstorm": "^0.14.0",
- "discord-api-types": "^0.38.19",
+ "cloudstorm": "^0.19.0",
+ "denque": "^2.1.0",
+ "discord-api-types": "^0.38.52",
"domino": "^2.1.6",
"enquirer": "^2.4.1",
"entities": "^5.0.0",
"get-relative-path": "^1.0.2",
- "h3": "^1.15.1",
+ "h3": "^1.15.10",
"heatsync": "^2.7.2",
"htmx.org": "^2.0.4",
"lru-cache": "^11.0.2",
+ "mime-types": "^2.1.35",
"prettier-bytes": "^1.0.4",
- "sharp": "^0.33.4",
- "snowtransfer": "^0.15.0",
- "stream-mime-type": "^1.0.2",
- "try-to-catch": "^3.0.1",
+ "sharp": "^0.35.3",
+ "snowtransfer": "^0.19.0",
+ "try-to-catch": "^4.0.5",
"uqr": "^0.1.2",
"xxhash-wasm": "^1.0.2",
"zod": "^4.0.17"
@@ -45,49 +47,39 @@
"devDependencies": {
"@cloudrac3r/tap-dot": "^2.0.3",
"@types/node": "^22.17.1",
- "c8": "^10.1.2",
+ "c8": "^11.0.0",
"cross-env": "^7.0.3",
- "supertape": "^11.3.0"
+ "supertape": "^13.2.0"
},
"engines": {
- "node": ">=20"
- }
- },
- "../tap-dot": {
- "name": "@cloudrac3r/tap-dot",
- "version": "2.0.0",
- "extraneous": true,
- "license": "MIT",
- "dependencies": {
- "@cloudrac3r/tap-out": "^3.2.3",
- "ansi-colors": "^4.1.3"
- },
- "bin": {
- "tap-dot": "bin/dot"
+ "node": ">=22"
}
},
"node_modules/@babel/helper-string-parser": {
- "version": "7.24.8",
- "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz",
- "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz",
- "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
- "version": "7.25.6",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.6.tgz",
- "integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==",
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
+ "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+ "license": "MIT",
"dependencies": {
- "@babel/types": "^7.25.6"
+ "@babel/types": "^7.29.0"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -97,13 +89,13 @@
}
},
"node_modules/@babel/types": {
- "version": "7.25.6",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.6.tgz",
- "integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==",
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "license": "MIT",
"dependencies": {
- "@babel/helper-string-parser": "^7.24.8",
- "@babel/helper-validator-identifier": "^7.24.7",
- "to-fast-properties": "^2.0.0"
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
@@ -114,120 +106,34 @@
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@chriscdn/promise-semaphore": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/@chriscdn/promise-semaphore/-/promise-semaphore-3.1.1.tgz",
- "integrity": "sha512-ALLLLYlPfd/QZLptcVi6HQRK1zaCDWZoqYYw+axLmCatFs4gVTSZ5nqlyxwFe4qwR/K84HvOMa9hxda881FqMA==",
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@chriscdn/promise-semaphore/-/promise-semaphore-3.1.3.tgz",
+ "integrity": "sha512-EAmwIbH1L2CNsJWloXBG4Kv89H7IUsjYFQnGnmus3OX70LcD5Uu5A7sohPx3O0Ks9UQWEgcr5n2IfxBSuYvOeg==",
"license": "MIT"
},
"node_modules/@cloudcmd/stub": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/@cloudcmd/stub/-/stub-4.0.1.tgz",
- "integrity": "sha512-7x7tVxJZOdQowHv/VKwHLo9aoNNoVRc6PdKYqyKcDHX+xrF78jSXnqEWrOplnD/gF+tCnyFafu1Is+lFfWCILw==",
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@cloudcmd/stub/-/stub-5.0.0.tgz",
+ "integrity": "sha512-jLC05CmcvEKDFXWf95UZGgqyJePhP3kh6/5ZXm7BAB42hv72RIx9LsYMhqGXlPtXjShV5KioOHri6QGnWMzHwQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "chalk": "^4.0.0",
- "jest-diff": "^27.0.6",
- "strip-ansi": "^6.0.0"
+ "jest-diff": "^30.2.0"
},
"engines": {
- "node": ">=16"
- }
- },
- "node_modules/@cloudcmd/stub/node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "dev": true,
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/@cloudcmd/stub/node_modules/ansi-styles": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
- "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
- "dev": true,
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
- "node_modules/@cloudcmd/stub/node_modules/diff-sequences": {
- "version": "27.5.1",
- "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz",
- "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==",
- "dev": true,
- "engines": {
- "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
- }
- },
- "node_modules/@cloudcmd/stub/node_modules/jest-diff": {
- "version": "27.5.1",
- "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz",
- "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==",
- "dev": true,
- "dependencies": {
- "chalk": "^4.0.0",
- "diff-sequences": "^27.5.1",
- "jest-get-type": "^27.5.1",
- "pretty-format": "^27.5.1"
- },
- "engines": {
- "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
- }
- },
- "node_modules/@cloudcmd/stub/node_modules/jest-get-type": {
- "version": "27.5.1",
- "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz",
- "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==",
- "dev": true,
- "engines": {
- "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
- }
- },
- "node_modules/@cloudcmd/stub/node_modules/pretty-format": {
- "version": "27.5.1",
- "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
- "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
- "dev": true,
- "dependencies": {
- "ansi-regex": "^5.0.1",
- "ansi-styles": "^5.0.0",
- "react-is": "^17.0.1"
- },
- "engines": {
- "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
- }
- },
- "node_modules/@cloudcmd/stub/node_modules/react-is": {
- "version": "17.0.2",
- "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
- "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
- "dev": true
- },
- "node_modules/@cloudcmd/stub/node_modules/strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
+ "node": ">=22"
}
},
"node_modules/@cloudrac3r/discord-markdown": {
- "version": "2.6.6",
- "resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.6.6.tgz",
- "integrity": "sha512-4FNO7WmACPvcTrQjeLQLr9WRuP7JDUVUGFrRJvmAjiMs2UlUAsShfSRuU2SCqz3QqmX8vyJ06wy2hkjTTyRtbw==",
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.7.0.tgz",
+ "integrity": "sha512-1iR9tKI2WJe8UNB+4VSh7D8m6RP7ugByuf8RNWyJwyhIrSlqQ8ljY1BKXodSvDg7seZkf7B7V2t5FfK7UpTw/A==",
"license": "MIT",
"dependencies": {
"simple-markdown": "^0.7.3"
@@ -236,20 +142,22 @@
"node_modules/@cloudrac3r/giframe": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@cloudrac3r/giframe/-/giframe-0.4.3.tgz",
- "integrity": "sha512-LKuRfdHrhvgPP0heYdlVRecswk/kYaC3fI+X+GQmnkJE36uN1E2dg5l5QdLoukliH7g8S2hgDYk0jsR7sJf8Dg=="
+ "integrity": "sha512-LKuRfdHrhvgPP0heYdlVRecswk/kYaC3fI+X+GQmnkJE36uN1E2dg5l5QdLoukliH7g8S2hgDYk0jsR7sJf8Dg==",
+ "license": "MIT"
},
"node_modules/@cloudrac3r/html-template-tag": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@cloudrac3r/html-template-tag/-/html-template-tag-5.0.1.tgz",
"integrity": "sha512-aH+ZdWJf53E63bVb2FiSnpM81qtF2ZNVbrXjrHcfnofyV/GTYJjZHnmPYC2FgXxJ+I8+bZP3DiwYzj7zXYoekw==",
+ "license": "MIT",
"dependencies": {
"html-es6cape": "^2.0.0"
}
},
"node_modules/@cloudrac3r/in-your-element": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@cloudrac3r/in-your-element/-/in-your-element-1.1.1.tgz",
- "integrity": "sha512-AKp9vnSDA9wzJl4O3C/LA8jgI5m1r0M3MRBQGHcVVL22SrrZMdcy+kWjlZWK343KVLOkuTAISA2D+Jb/zyZS6A==",
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@cloudrac3r/in-your-element/-/in-your-element-1.1.2.tgz",
+ "integrity": "sha512-adFZel24sGHpTI1vgJdBN5twcdu6QmPFlO8qAJt49KO6N8mwDcbUC2GPqH5pGerXNv1Lpq0eXsNLm+ytKrOTaQ==",
"license": "AGPL-3.0-or-later",
"dependencies": {
"h3": "^1.12.0",
@@ -272,6 +180,7 @@
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@cloudrac3r/pngjs/-/pngjs-7.0.3.tgz",
"integrity": "sha512-Aghuja9XAIqBPmY2jk8dKZSyK90gImxA4hJeEYYAWkZO34bf+zliUAvGBygoBZA0EgXSmfxewVchL+9y3w+rDw==",
+ "license": "MIT",
"engines": {
"node": ">=14.19.0"
}
@@ -280,6 +189,7 @@
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@cloudrac3r/pug/-/pug-4.0.4.tgz",
"integrity": "sha512-RZhxM/WfSHT0n39URlwDdugBfGfwEWmr+w+mCyiT9jaiqCjeZPpXkps/cWLA1XRLo7fzq0+9THtGzVKXS487/A==",
+ "license": "MIT",
"dependencies": {
"@cloudrac3r/pug-code-gen": "3.0.5",
"@cloudrac3r/pug-lexer": "5.0.3",
@@ -294,6 +204,7 @@
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@cloudrac3r/pug-code-gen/-/pug-code-gen-3.0.5.tgz",
"integrity": "sha512-dKKpy3i9YlVa3lBgu5Jds513c7AtzmmsR2/lGhY2NOODSpIiTcbWLw1obA9YEmmH1tAJny+J6ePYN1N1RgjjQA==",
+ "license": "MIT",
"dependencies": {
"constantinople": "^4.0.1",
"doctypes": "^1.1.0",
@@ -309,12 +220,22 @@
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/@cloudrac3r/pug-lexer/-/pug-lexer-5.0.3.tgz",
"integrity": "sha512-ym4g4q+l9IC2H1wXCDnF79AQZ48xtxO675JOT316e17W2wHWtgRccXpT6DkBAaRDZycmkGzSxID1S15T2lZj+g==",
+ "license": "MIT",
"dependencies": {
"character-parser": "^4.0.0",
"is-expression": "^4.0.0",
"pug-error": "^2.1.0"
}
},
+ "node_modules/@cloudrac3r/stream-type": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@cloudrac3r/stream-type/-/stream-type-1.0.0.tgz",
+ "integrity": "sha512-orfdUaeDT00fkELxAab+pJNZWwis+KijJEWw+cUWOD2VqqQWriL04W5DOPN0dlsJvn4VoyBe6cYGrzsJ5YPcOw==",
+ "license": "AGPL-3.0-only",
+ "engines": {
+ "node": ">=22.6.0"
+ }
+ },
"node_modules/@cloudrac3r/tap-dot": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@cloudrac3r/tap-dot/-/tap-dot-2.0.3.tgz",
@@ -347,17 +268,18 @@
}
},
"node_modules/@cloudrac3r/turndown": {
- "version": "7.1.4",
- "resolved": "https://registry.npmjs.org/@cloudrac3r/turndown/-/turndown-7.1.4.tgz",
- "integrity": "sha512-bQAwcvcSqBTdEHPMt+IAZWIoDh+2eRuy9TgD0FUdxVurbvj3CUHTxLfzlmsO0UTi+GHpgYqDSsVdV7kYTNq5Qg==",
+ "version": "7.1.5",
+ "resolved": "https://registry.npmjs.org/@cloudrac3r/turndown/-/turndown-7.1.5.tgz",
+ "integrity": "sha512-pENoG62B4UM2ecsg57cuSRDa1OV/YFYTFGg1aoQsKUdHBBglhE9I7EwEMYpS96QoM/auyglfaxjgTeqhcSGCIA==",
+ "license": "MIT",
"dependencies": {
"domino": "^2.1.6"
}
},
"node_modules/@emnapi/runtime": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.2.0.tgz",
- "integrity": "sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==",
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -367,12 +289,22 @@
"node_modules/@hotwired/stimulus": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.2.tgz",
- "integrity": "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A=="
+ "integrity": "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A==",
+ "license": "MIT"
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
},
"node_modules/@img/sharp-darwin-arm64": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
- "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
+ "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
"cpu": [
"arm64"
],
@@ -382,19 +314,19 @@
"darwin"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-darwin-arm64": "1.0.4"
+ "@img/sharp-libvips-darwin-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-darwin-x64": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
- "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
+ "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
"cpu": [
"x64"
],
@@ -404,19 +336,38 @@
"darwin"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-darwin-x64": "1.0.4"
+ "@img/sharp-libvips-darwin-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-freebsd-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
+ "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
- "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
+ "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
"cpu": [
"arm64"
],
@@ -430,9 +381,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
- "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
+ "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
"cpu": [
"x64"
],
@@ -446,9 +397,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
- "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
+ "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
"cpu": [
"arm"
],
@@ -462,9 +413,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
- "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
+ "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
"cpu": [
"arm64"
],
@@ -477,10 +428,42 @@
"url": "https://opencollective.com/libvips"
}
},
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
+ "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
+ "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
"node_modules/@img/sharp-libvips-linux-s390x": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
- "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
+ "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
"cpu": [
"s390x"
],
@@ -494,9 +477,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
- "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
+ "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
"cpu": [
"x64"
],
@@ -510,9 +493,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
- "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
+ "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
"cpu": [
"arm64"
],
@@ -526,9 +509,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
- "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
+ "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
"cpu": [
"x64"
],
@@ -542,9 +525,9 @@
}
},
"node_modules/@img/sharp-linux-arm": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
- "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
+ "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
"cpu": [
"arm"
],
@@ -554,19 +537,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-arm": "1.0.5"
+ "@img/sharp-libvips-linux-arm": "1.3.2"
}
},
"node_modules/@img/sharp-linux-arm64": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
- "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
+ "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
"cpu": [
"arm64"
],
@@ -576,19 +559,63 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-arm64": "1.0.4"
+ "@img/sharp-libvips-linux-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
+ "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
+ "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-s390x": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
- "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
+ "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
"cpu": [
"s390x"
],
@@ -598,19 +625,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-s390x": "1.0.4"
+ "@img/sharp-libvips-linux-s390x": "1.3.2"
}
},
"node_modules/@img/sharp-linux-x64": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
- "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
+ "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
"cpu": [
"x64"
],
@@ -620,19 +647,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-x64": "1.0.4"
+ "@img/sharp-libvips-linux-x64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
- "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
+ "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
"cpu": [
"arm64"
],
@@ -642,19 +669,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
- "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
+ "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
"cpu": [
"x64"
],
@@ -664,38 +691,73 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-x64": "1.0.4"
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2"
}
},
"node_modules/@img/sharp-wasm32": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
- "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
- "cpu": [
- "wasm32"
- ],
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
+ "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
- "@emnapi/runtime": "^1.2.0"
+ "@emnapi/runtime": "^1.11.1"
},
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-webcontainers-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
+ "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
+ "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
- "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
+ "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
"cpu": [
"ia32"
],
@@ -705,16 +767,16 @@
"win32"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
- "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
+ "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
"cpu": [
"x64"
],
@@ -724,122 +786,26 @@
"win32"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
- "node_modules/@isaacs/balanced-match": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
- "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "20 || >=22"
- }
- },
- "node_modules/@isaacs/brace-expansion": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
- "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@isaacs/balanced-match": "^4.0.1"
- },
- "engines": {
- "node": "20 || >=22"
- }
- },
- "node_modules/@isaacs/cliui": {
- "version": "8.0.2",
- "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
- "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "string-width": "^5.1.2",
- "string-width-cjs": "npm:string-width@^4.2.0",
- "strip-ansi": "^7.0.1",
- "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
- "wrap-ansi": "^8.1.0",
- "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@isaacs/cliui/node_modules/ansi-styles": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
- "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
- "node_modules/@isaacs/cliui/node_modules/emoji-regex": {
- "version": "9.2.2",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
- "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@isaacs/cliui/node_modules/string-width": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
- "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "eastasianwidth": "^0.2.0",
- "emoji-regex": "^9.2.2",
- "strip-ansi": "^7.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/@isaacs/cliui/node_modules/wrap-ansi": {
- "version": "8.1.0",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
- "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-styles": "^6.1.0",
- "string-width": "^5.0.1",
- "strip-ansi": "^7.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
- }
- },
"node_modules/@istanbuljs/schema": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
"integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@jest/diff-sequences": {
- "version": "30.0.1",
- "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz",
- "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==",
+ "version": "30.3.0",
+ "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz",
+ "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -847,9 +813,9 @@
}
},
"node_modules/@jest/get-type": {
- "version": "30.0.1",
- "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.0.1.tgz",
- "integrity": "sha512-AyYdemXCptSRFirI5EPazNxyPwAL0jXt3zceFjaj8NFiKP9pOi0bfXonf6qkf82z2t3QWPeLCWWw4stPBzctLw==",
+ "version": "30.1.0",
+ "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz",
+ "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -870,62 +836,55 @@
}
},
"node_modules/@jridgewell/resolve-uri": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz",
- "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==",
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.4.15",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
- "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==",
- "dev": true
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.19",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.19.tgz",
- "integrity": "sha512-kf37QtfW+Hwx/buWGMPcR60iF9ziHa6r/CZJIHbmcm4+0qrXiVdxegAH0F6yddEVQ7zdkjcGCgCzUu+BcbhQxw==",
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
- "node_modules/@pkgjs/parseargs": {
- "version": "0.11.0",
- "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
- "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "engines": {
- "node": ">=14"
- }
- },
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
+ "license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@putout/cli-keypress": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/@putout/cli-keypress/-/cli-keypress-3.0.0.tgz",
- "integrity": "sha512-RwODGTbcWNaulEPvVPdxH/vnddf5dE627G3s8gyou3kexa6zQerQHvbKFX0wywNdA3HD2O/9STPv/r5mjXFUgw==",
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@putout/cli-keypress/-/cli-keypress-4.0.0.tgz",
+ "integrity": "sha512-76zmDjUycBt/CHkOZADP2KMdXWud3n8c1Wb4By/LWpbpykM8G9+pC7UeWAMo9CFDp/s3OXYee2UVtACf1+oZsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"ci-info": "^4.0.0",
- "fullstore": "^3.0.0"
+ "fullstore": "^4.0.0"
},
"engines": {
- "node": ">=20"
+ "node": ">=22"
}
},
"node_modules/@putout/cli-validate-args": {
@@ -933,6 +892,7 @@
"resolved": "https://registry.npmjs.org/@putout/cli-validate-args/-/cli-validate-args-2.0.0.tgz",
"integrity": "sha512-/tl1XiBog6XMb1T9kalFerYU86sYsl6EtrlvGI5RVtlHOQdEEJAIPRxmX4vnKG3uoY5aVEkJOWzbPM5tsncmFQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"fastest-levenshtein": "^1.0.12",
"just-kebab-case": "^4.2.0"
@@ -942,94 +902,82 @@
}
},
"node_modules/@sinclair/typebox": {
- "version": "0.34.38",
- "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.38.tgz",
- "integrity": "sha512-HpkxMmc2XmZKhvaKIZZThlHmx1L0I/V1hWK1NubtlFnr6ZqdiOpV72TKudZUNQjZNsyDBay72qFEhEvb+bcwcA==",
+ "version": "0.34.48",
+ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz",
+ "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==",
"dev": true,
"license": "MIT"
},
"node_modules/@stackoverflow/stacks": {
- "version": "2.8.4",
- "resolved": "https://registry.npmjs.org/@stackoverflow/stacks/-/stacks-2.8.4.tgz",
- "integrity": "sha512-FfA7Bw7a0AQrMw3/bG6G4BUrZ698F7Cdk6HkR9T7jdaufORkiX5d16wI4j4b5Sqm1FwkaZAF+ZSKLL1w0tAsew==",
- "license": "MIT",
+ "version": "2.8.6",
+ "resolved": "https://registry.npmjs.org/@stackoverflow/stacks/-/stacks-2.8.6.tgz",
+ "integrity": "sha512-pR0vMDBA5rNV5Cb/McG+2F1nG68fZk3UiNwuOvQrP82WoQy41k9PsBjRjD7wHDVVZyDrWHLb/WnosXX7uK3IdA==",
"dependencies": {
"@hotwired/stimulus": "^3.2.2",
"@popperjs/core": "^2.11.8"
}
},
"node_modules/@stackoverflow/stacks-icons": {
- "version": "6.6.1",
- "resolved": "https://registry.npmjs.org/@stackoverflow/stacks-icons/-/stacks-icons-6.6.1.tgz",
- "integrity": "sha512-upa2jajYTKAHfILFbPWMsml0nlh4fbIEb2V9SS0txjOJEoZE2oBnNJXbg29vShp7Nyn1VwrMjaraX63WkKT07w==",
+ "version": "6.9.0",
+ "resolved": "https://registry.npmjs.org/@stackoverflow/stacks-icons/-/stacks-icons-6.9.0.tgz",
+ "integrity": "sha512-SFlcnSrH0b0/SsDBhCypYANyUwJs8hyuZzpo53l4iUUm30FGRbBWNfcaWwBW/skRjpHvlFYaUXwt66ZI6zT+lg==",
"license": "MIT"
},
"node_modules/@supertape/engine-loader": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/@supertape/engine-loader/-/engine-loader-2.0.0.tgz",
- "integrity": "sha512-1G2MmfZnSxx546omLPAVNgvG/iqOQZGiXHnjJ2JXKvuf2lpPdDRnNm5eLl81lvEG473zE9neX979TzeFcr3Dxw==",
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@supertape/engine-loader/-/engine-loader-4.0.0.tgz",
+ "integrity": "sha512-2HFza8zaCGIC3Inaf3TEkWn3wvCkg+JPRWuSGrX+LM+j5OUptq6XtHnPeH037iEITTIDMie7OKlvfhZcFONGcw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "try-catch": "^3.0.0"
+ "try-catch": "^4.0.2"
},
"engines": {
- "node": ">=16"
+ "node": ">=22"
}
},
"node_modules/@supertape/formatter-fail": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/@supertape/formatter-fail/-/formatter-fail-4.0.0.tgz",
- "integrity": "sha512-+isArOXmGkIqH14PQoq2WhJmSwO8rzpQnhurVMuBmC+kYB96R95kRdjo/KO9d9yP1KoSjum0kX94s0SwqlZ8yA==",
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@supertape/formatter-fail/-/formatter-fail-5.0.0.tgz",
+ "integrity": "sha512-nwE9c07hSFwoIf2Mex9PgWSe0f7PXbCbaPqL2oK3VWewNNRdGYBJv2VkPj0oAldvV86gTgQgS1b1k84XfthUCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@supertape/formatter-tap": "^4.0.0",
- "fullstore": "^3.0.0"
+ "fullstore": "^4.0.0"
},
"engines": {
- "node": ">=20"
+ "node": ">=22"
}
},
"node_modules/@supertape/formatter-json-lines": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/@supertape/formatter-json-lines/-/formatter-json-lines-2.0.1.tgz",
- "integrity": "sha512-9LWOCu4yOF9orf4QJseS8lP3hXkYn24qn57VqYt/3r2aRJv4vWTPfaL1ot5JRHCZs0qXrV1sqPmN6E05rRLDYA==",
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@supertape/formatter-json-lines/-/formatter-json-lines-3.0.0.tgz",
+ "integrity": "sha512-xk/Tl/J4rKVUroYyNCJEqmw78+xxBfToGi49G0oRPbfWvQgzvFzvqb97jyuet9rXEe5hJ6cztE/l/oMC1n4eig==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "fullstore": "^3.0.0"
+ "fullstore": "^4.0.0"
},
"engines": {
- "node": ">=16"
+ "node": ">=22"
}
},
"node_modules/@supertape/formatter-progress-bar": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/@supertape/formatter-progress-bar/-/formatter-progress-bar-7.0.0.tgz",
- "integrity": "sha512-JDCT86hFJkoaqE/KS8BQsRaYiy3ipMpf0j+o+vwQMcFYm0mgG35JwbotBMUQM7LFifh68bTqU4xuewy7kUS1EA==",
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/@supertape/formatter-progress-bar/-/formatter-progress-bar-8.0.0.tgz",
+ "integrity": "sha512-ZGhKcQgMY4aUqfaMENoUUJ48xkLshlN2icJWeAV4nyRIu1RWU36qpVqcV95SEm+XKScYn/5EZtg9rhleaM9AEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^5.3.0",
"ci-info": "^4.0.0",
"cli-progress": "^3.8.2",
- "fullstore": "^3.0.0",
+ "fullstore": "^4.0.0",
"once": "^1.4.0"
},
"engines": {
- "node": ">=20"
- }
- },
- "node_modules/@supertape/formatter-progress-bar/node_modules/chalk": {
- "version": "5.5.0",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.5.0.tgz",
- "integrity": "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^12.17.0 || ^14.13 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/chalk/chalk?sponsor=1"
+ "node": ">=22"
}
},
"node_modules/@supertape/formatter-short": {
@@ -1053,93 +1001,67 @@
}
},
"node_modules/@supertape/formatter-time": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/@supertape/formatter-time/-/formatter-time-2.0.0.tgz",
- "integrity": "sha512-5UPvVHwpg5ZJmz0nII2f5rBFqNdMxHQnBybetmhgkSDIZHb+3NTPz/VrDggZERWOGxmIf4NKebaA+BWHTBQMeA==",
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@supertape/formatter-time/-/formatter-time-3.0.0.tgz",
+ "integrity": "sha512-+A0uSQPdVSYCEwHSgdnWmn9tq4C+Dg9rj0ky05uHvrHhfPktA/XKlBocU8qpFMN+HllquWuKhZXeuwd18raQBw==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^5.3.0",
"ci-info": "^4.0.0",
"cli-progress": "^3.8.2",
- "fullstore": "^3.0.0",
+ "fullstore": "^4.0.0",
"once": "^1.4.0",
"timer-node": "^5.0.7"
},
"engines": {
- "node": ">=20"
- }
- },
- "node_modules/@supertape/formatter-time/node_modules/chalk": {
- "version": "5.5.0",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.5.0.tgz",
- "integrity": "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^12.17.0 || ^14.13 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/chalk/chalk?sponsor=1"
+ "node": ">=22"
}
},
"node_modules/@supertape/operator-stub": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/@supertape/operator-stub/-/operator-stub-3.1.0.tgz",
- "integrity": "sha512-jzC56u1k+3DLRo854+J6v/DP/4SjRV2mAqfR6qzsyaAocC9OFe7NHYQQMmlJ4cUJwgFjUh7AVnjFfC0Z0XuH+g==",
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@supertape/operator-stub/-/operator-stub-4.0.0.tgz",
+ "integrity": "sha512-t+LAKOA92m1pidzaXYzRHMAffYqqk19QOkMEbarP57/Sav90x9Q3ndvH6kRwa3HQhU2N7SuZrc21zh7vSwIOKA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@cloudcmd/stub": "^4.0.0"
+ "@cloudcmd/stub": "^5.0.0"
},
"engines": {
- "node": ">=16"
+ "node": ">=22"
}
},
- "node_modules/@tokenizer/token": {
- "version": "0.3.0",
- "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
- "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="
- },
"node_modules/@types/istanbul-lib-coverage": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz",
- "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==",
- "dev": true
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
+ "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@types/node": {
- "version": "22.18.0",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.0.tgz",
- "integrity": "sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==",
+ "version": "22.20.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
+ "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
- "node_modules/@types/prop-types": {
- "version": "15.7.11",
- "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz",
- "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng=="
- },
"node_modules/@types/react": {
- "version": "18.2.55",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.55.tgz",
- "integrity": "sha512-Y2Tz5P4yz23brwm2d7jNon39qoAtMMmalOQv6+fEFt1mT+FcM3D841wDpoUvFXhaYenuROCy3FZYqdTjM7qVyA==",
+ "version": "19.2.14",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
+ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
+ "license": "MIT",
"dependencies": {
- "@types/prop-types": "*",
- "@types/scheduler": "*",
- "csstype": "^3.0.2"
+ "csstype": "^3.2.2"
}
},
- "node_modules/@types/scheduler": {
- "version": "0.16.8",
- "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
- "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A=="
- },
"node_modules/acorn": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
"integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
+ "license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
@@ -1151,32 +1073,28 @@
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
"integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
+ "license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/ansi-regex": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
- "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
- "dev": true,
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
"engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ "node": ">=8"
}
},
"node_modules/ansi-styles": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
- "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
- "dependencies": {
- "color-convert": "^2.0.1"
- },
+ "license": "MIT",
"engines": {
- "node": ">=8"
+ "node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
@@ -1187,19 +1105,22 @@
"resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz",
"integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"printable-characters": "^1.0.42"
}
},
"node_modules/assert-never": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.3.0.tgz",
- "integrity": "sha512-9Z3vxQ+berkL/JJo0dK+EY3Lp0s3NtSnP3VCLsh5HDcZPrh0M+KQRK5sWhUeyPPH+/RCxZqOxLMR+YC6vlviEQ=="
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.4.0.tgz",
+ "integrity": "sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==",
+ "license": "MIT"
},
"node_modules/babel-walk": {
"version": "3.0.0-canary-5",
"resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz",
"integrity": "sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==",
+ "license": "MIT",
"dependencies": {
"@babel/types": "^7.9.6"
},
@@ -1208,16 +1129,20 @@
}
},
"node_modules/backtracker": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/backtracker/-/backtracker-4.0.0.tgz",
- "integrity": "sha512-XG2ldN+WDRq9niJMnoZDjLLUnhDOQGhFZc6qZQotN59xj8oOa4KXSCu6YyZQawPqi6gG3HilGFt91zT6Hbdh1w==",
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/backtracker/-/backtracker-5.0.0.tgz",
+ "integrity": "sha512-2rY1s1iMlF1FVb4jpIMxTeGE+KRppuVvPyU61q7gvap1MWVahToUI8WUqy+v3L37iip5a4mJOTRBZxNRbTv4bg==",
"license": "MIT"
},
"node_modules/balanced-match": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
- "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
- "dev": true
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
},
"node_modules/base64-js": {
"version": "1.5.1",
@@ -1236,12 +1161,13 @@
"type": "consulting",
"url": "https://feross.org/support"
}
- ]
+ ],
+ "license": "MIT"
},
"node_modules/better-sqlite3": {
- "version": "12.2.0",
- "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.2.0.tgz",
- "integrity": "sha512-eGbYq2CT+tos1fBwLQ/tkBt9J5M3JEHjku4hbvQUePCckkvVf14xWj+1m7dGoK81M/fOjFT7yM9UMeKT/+vFLQ==",
+ "version": "12.10.0",
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz",
+ "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -1249,13 +1175,14 @@
"prebuild-install": "^7.1.1"
},
"engines": {
- "node": "20.x || 22.x || 23.x || 24.x"
+ "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
@@ -1264,13 +1191,27 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
- "node_modules/bl/node_modules/buffer": {
+ "node_modules/brace-expansion": {
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
@@ -1288,39 +1229,18 @@
"url": "https://feross.org/support"
}
],
+ "license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
- "node_modules/bl/node_modules/readable-stream": {
- "version": "3.6.2",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
- "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
- "dependencies": {
- "inherits": "^2.0.3",
- "string_decoder": "^1.1.1",
- "util-deprecate": "^1.0.1"
- },
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0"
- }
- },
"node_modules/c8": {
- "version": "10.1.3",
- "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz",
- "integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==",
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/c8/-/c8-11.0.0.tgz",
+ "integrity": "sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.1",
"@istanbuljs/schema": "^0.1.3",
@@ -1329,7 +1249,7 @@
"istanbul-lib-coverage": "^3.2.0",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.1.6",
- "test-exclude": "^7.0.1",
+ "test-exclude": "^8.0.0",
"v8-to-istanbul": "^9.0.0",
"yargs": "^17.7.2",
"yargs-parser": "^21.1.1"
@@ -1338,7 +1258,7 @@
"c8": "bin/c8.js"
},
"engines": {
- "node": ">=18"
+ "node": "20 || >=22"
},
"peerDependencies": {
"monocart-coverage-reports": "^2"
@@ -1350,16 +1270,13 @@
}
},
"node_modules/chalk": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
- "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
+ "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"dev": true,
- "dependencies": {
- "ansi-styles": "^4.1.0",
- "supports-color": "^7.1.0"
- },
+ "license": "MIT",
"engines": {
- "node": ">=10"
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
@@ -1368,17 +1285,20 @@
"node_modules/character-parser": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/character-parser/-/character-parser-4.0.0.tgz",
- "integrity": "sha512-jWburCrDpd+aPopB7esjh/gLyZoHZS4C2xwwJlkTPyhhJdXG+FCG0P4qCOInvOd9yhiuAEJYlZsUMQ0JSK4ykw=="
+ "integrity": "sha512-jWburCrDpd+aPopB7esjh/gLyZoHZS4C2xwwJlkTPyhhJdXG+FCG0P4qCOInvOd9yhiuAEJYlZsUMQ0JSK4ykw==",
+ "license": "MIT"
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
- "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "license": "ISC"
},
"node_modules/chunk-text": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/chunk-text/-/chunk-text-2.0.1.tgz",
"integrity": "sha512-ER6TSpe2DT4wjOVOKJ3FFAYv7wE77HA/Ztz88Peiv3lq/2oVMsItYJJsVVI0xNZM8cdImOOTNqlw+LQz7gYdJg==",
+ "license": "MIT",
"dependencies": {
"runes": "^0.4.3"
},
@@ -1388,9 +1308,9 @@
}
},
"node_modules/ci-info": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz",
- "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==",
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz",
+ "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==",
"dev": true,
"funding": [
{
@@ -1421,6 +1341,7 @@
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
@@ -1430,56 +1351,25 @@
"node": ">=12"
}
},
- "node_modules/cliui/node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "dev": true,
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/cliui/node_modules/strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/cloudstorm": {
- "version": "0.14.1",
- "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.14.1.tgz",
- "integrity": "sha512-x95WCKg818E1rE1Ru45NPD3RoIq0pg3WxwvF0GE7Eq07pAeLcjSRqM1lUmbmfjdOqZrWdSRYA1NETVZ8QhVrIA==",
+ "version": "0.19.0",
+ "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.19.0.tgz",
+ "integrity": "sha512-mjuP5o2nS/CgSljajh08KjN6jYMfNBBttSPHc1YUN74f7OkmhUhv8QE8JFDXomxCfdnLNJcqBQtjRLrs8nkfVQ==",
"license": "MIT",
"dependencies": {
- "discord-api-types": "^0.38.21",
- "snowtransfer": "^0.15.0"
+ "discord-api-types": "^0.38.49",
+ "snowtransfer": "^0.19.0"
},
"engines": {
"node": ">=22.0.0"
}
},
- "node_modules/color": {
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
- "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
- "dependencies": {
- "color-convert": "^2.0.1",
- "color-string": "^1.9.0"
- },
- "engines": {
- "node": ">=12.5.0"
- }
- },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
@@ -1490,36 +1380,31 @@
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
- "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
- },
- "node_modules/color-string": {
- "version": "1.9.1",
- "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
- "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
- "dependencies": {
- "color-name": "^1.0.0",
- "simple-swizzle": "^0.2.2"
- }
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/constantinople": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/constantinople/-/constantinople-4.0.1.tgz",
"integrity": "sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==",
+ "license": "MIT",
"dependencies": {
"@babel/parser": "^7.6.0",
"@babel/types": "^7.6.1"
}
},
"node_modules/convert-source-map": {
- "version": "1.9.0",
- "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
- "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
- "dev": true
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/cookie-es": {
- "version": "1.2.2",
- "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.2.tgz",
- "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==",
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz",
+ "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==",
"license": "MIT"
},
"node_modules/cross-env": {
@@ -1527,6 +1412,7 @@
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.1"
},
@@ -1545,6 +1431,7 @@
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
@@ -1564,20 +1451,23 @@
}
},
"node_modules/csstype": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
- "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
},
"node_modules/data-uri-to-buffer": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz",
"integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
@@ -1592,14 +1482,25 @@
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/defu": {
- "version": "6.1.4",
- "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
- "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="
+ "version": "6.1.7",
+ "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
+ "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
+ "license": "MIT"
+ },
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
},
"node_modules/destr": {
"version": "2.0.5",
@@ -1608,17 +1509,18 @@
"license": "MIT"
},
"node_modules/detect-libc": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz",
- "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==",
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/discord-api-types": {
- "version": "0.38.22",
- "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.22.tgz",
- "integrity": "sha512-2gnYrgXN3yTlv2cKBISI/A8btZwsSZLwKpIQXeI1cS8a7W7wP3sFVQOm3mPuuinTD8jJCKGPGNH399zE7Un1kA==",
+ "version": "0.38.53",
+ "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.53.tgz",
+ "integrity": "sha512-HL1zz/UuZ+bbJjA/X8Kbxx9gk8v9rJAbTeWRNYKmIdjwJ7EovjlHgoJTxcLpATfNJ+AonOtMdy3Y5MVIJAAd/A==",
"license": "MIT",
"workspaces": [
"scripts/actions/documentation"
@@ -1627,30 +1529,27 @@
"node_modules/doctypes": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/doctypes/-/doctypes-1.1.0.tgz",
- "integrity": "sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ=="
+ "integrity": "sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==",
+ "license": "MIT"
},
"node_modules/domino": {
- "version": "2.1.6",
- "resolved": "https://registry.npmjs.org/domino/-/domino-2.1.6.tgz",
- "integrity": "sha512-3VdM/SXBZX2omc9JF9nOPCtDaYQ67BGp5CoLpIQlO2KCAPETs8TcDHacF26jXadGbvUteZzRTeos2fhID5+ucQ=="
- },
- "node_modules/eastasianwidth": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
- "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
- "dev": true,
- "license": "MIT"
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/domino/-/domino-2.1.7.tgz",
+ "integrity": "sha512-3rcXhx0ixJV2nj8J0tljzejTF73A35LVVdnTQu79UAqTBFEgYPMgGtykMuu/BDqaOZphATku1ddRUn/RtqUHYQ==",
+ "license": "BSD-2-Clause"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/end-of-stream": {
- "version": "1.4.4",
- "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
- "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
@@ -1659,6 +1558,7 @@
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz",
"integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==",
+ "license": "MIT",
"dependencies": {
"ansi-colors": "^4.1.1",
"strip-ansi": "^6.0.1"
@@ -1667,29 +1567,11 @@
"node": ">=8.6"
}
},
- "node_modules/enquirer/node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/enquirer/node_modules/strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/entities": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-5.0.0.tgz",
"integrity": "sha512-BeJFvFRJddxobhvEdm5GqHzRV/X+ACeuw0/BuuxsCh1EUZcAIz8+kYmBp/LrQuloy6K1f3a0M7+IhmZ7QnkISA==",
+ "license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
@@ -1698,10 +1580,11 @@
}
},
"node_modules/escalade": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
- "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=6"
}
@@ -1710,6 +1593,7 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+ "license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
@@ -1719,36 +1603,23 @@
"resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz",
"integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">= 4.9.1"
}
},
- "node_modules/file-type": {
- "version": "16.5.4",
- "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz",
- "integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==",
- "dependencies": {
- "readable-web-to-node-stream": "^3.0.0",
- "strtok3": "^6.2.4",
- "token-types": "^4.1.1"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sindresorhus/file-type?sponsor=1"
- }
- },
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
- "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "license": "MIT"
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"locate-path": "^6.0.0",
"path-exists": "^4.0.0"
@@ -1761,10 +1632,11 @@
}
},
"node_modules/flatted": {
- "version": "3.3.1",
- "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz",
- "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
- "dev": true
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
+ "dev": true,
+ "license": "ISC"
},
"node_modules/foreground-child": {
"version": "3.3.1",
@@ -1786,15 +1658,17 @@
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
- "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="
+ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+ "license": "MIT"
},
"node_modules/fullstore": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/fullstore/-/fullstore-3.0.0.tgz",
- "integrity": "sha512-EEIdG+HWpyygWRwSLIZy+x4u0xtghjHNfhQb0mI5825Mmjq6oFESFUY0hoZigEgd3KH8GX+ZOCK9wgmOiS7VBQ==",
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/fullstore/-/fullstore-4.0.2.tgz",
+ "integrity": "sha512-syOev4kA0lZy4VkfBJZ99ZL4cIiSgiKt0G8SpP0kla1tpM1c+V/jBOVY/OqqGtR2XLVcM83SjFPFC3R2YIwqjQ==",
"dev": true,
+ "license": "MIT",
"engines": {
- "node": ">=4"
+ "node": ">=22"
}
},
"node_modules/function-bind": {
@@ -1802,6 +1676,7 @@
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
+ "license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@@ -1811,6 +1686,7 @@
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
+ "license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
@@ -1818,13 +1694,15 @@
"node_modules/get-relative-path": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/get-relative-path/-/get-relative-path-1.0.2.tgz",
- "integrity": "sha512-dGkopYfmB4sXMTcZslq5SojEYakpdCSj/SVSHLhv7D6RBHzvDtd/3Q8lTEOAhVKxPPeAHu/YYkENbbz3PaH+8w=="
+ "integrity": "sha512-dGkopYfmB4sXMTcZslq5SojEYakpdCSj/SVSHLhv7D6RBHzvDtd/3Q8lTEOAhVKxPPeAHu/YYkENbbz3PaH+8w==",
+ "license": "MIT"
},
"node_modules/get-source": {
"version": "2.0.12",
"resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz",
"integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==",
"dev": true,
+ "license": "Unlicense",
"dependencies": {
"data-uri-to-buffer": "^2.0.0",
"source-map": "^0.6.1"
@@ -1833,43 +1711,41 @@
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
- "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+ "license": "MIT"
},
"node_modules/glob": {
- "version": "10.4.5",
- "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
- "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
+ "version": "13.0.6",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
+ "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"dev": true,
- "license": "ISC",
+ "license": "BlueOak-1.0.0",
"dependencies": {
- "foreground-child": "^3.1.0",
- "jackspeak": "^3.1.2",
- "minimatch": "^9.0.4",
- "minipass": "^7.1.2",
- "package-json-from-dist": "^1.0.0",
- "path-scurry": "^1.11.1"
+ "minimatch": "^10.2.2",
+ "minipass": "^7.1.3",
+ "path-scurry": "^2.0.2"
},
- "bin": {
- "glob": "dist/esm/bin.mjs"
+ "engines": {
+ "node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/h3": {
- "version": "1.15.4",
- "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.4.tgz",
- "integrity": "sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ==",
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz",
+ "integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==",
"license": "MIT",
"dependencies": {
- "cookie-es": "^1.2.2",
+ "cookie-es": "^1.2.3",
"crossws": "^0.3.5",
- "defu": "^6.1.4",
+ "defu": "^6.1.6",
"destr": "^2.0.5",
"iron-webcrypto": "^1.2.1",
- "node-mock-http": "^1.0.2",
+ "node-mock-http": "^1.0.4",
"radix3": "^1.1.2",
- "ufo": "^1.6.1",
+ "ufo": "^1.6.3",
"uncrypto": "^0.1.3"
}
},
@@ -1878,15 +1754,17 @@
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/hasown": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz",
- "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==",
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
@@ -1895,12 +1773,12 @@
}
},
"node_modules/heatsync": {
- "version": "2.8.2",
- "resolved": "https://registry.npmjs.org/heatsync/-/heatsync-2.8.2.tgz",
- "integrity": "sha512-zO5ivWP1NYoYmngdqVxzeQGX2Q68rfLkXKbO8Dhcguj5eS2eBDVpcWPh3+KCQagM7xYP5QVzvrUryWDu4mt6Eg==",
+ "version": "2.8.3",
+ "resolved": "https://registry.npmjs.org/heatsync/-/heatsync-2.8.3.tgz",
+ "integrity": "sha512-9pVRC3BZD1NZ0EYnU5akjoO10+s/aJc04QqUxgtBqAYUeberV8st0ctWH7selEnyU8OEAUKZhBCFxmH7MvCQQQ==",
"license": "MIT",
"dependencies": {
- "backtracker": "^4.0.0"
+ "backtracker": "^5.0.0"
},
"engines": {
"node": ">=14.6.0"
@@ -1909,18 +1787,20 @@
"node_modules/html-es6cape": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-es6cape/-/html-es6cape-2.0.2.tgz",
- "integrity": "sha512-utzhH8rq2VABdW1LsPdv5tmxeMNOtP83If0jKCa79xPBgLWfcMvdf9K+EZoxJ5P7KioCxTs6WBnSDWLQHJ2lWA=="
+ "integrity": "sha512-utzhH8rq2VABdW1LsPdv5tmxeMNOtP83If0jKCa79xPBgLWfcMvdf9K+EZoxJ5P7KioCxTs6WBnSDWLQHJ2lWA==",
+ "license": "MIT"
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/htmx.org": {
- "version": "2.0.6",
- "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.6.tgz",
- "integrity": "sha512-7ythjYneGSk3yCHgtCnQeaoF+D+o7U2LF37WU3O0JYv3gTZSicdEFiI/Ai/NJyC5ZpYJWMpUb11OC5Lr6AfAqA==",
+ "version": "2.0.10",
+ "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.10.tgz",
+ "integrity": "sha512-kdeJe7ZVwaS6QMz/ebBIVtZdpwen6L0OQ5GOhPV9MKBb196TCZeZu4yA7ZIQsaLKv7EpXz+So7KSXNuHXhj7Cw==",
"license": "0BSD"
},
"node_modules/ieee754": {
@@ -1940,17 +1820,20 @@
"type": "consulting",
"url": "https://feross.org/support"
}
- ]
+ ],
+ "license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
- "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
- "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "license": "ISC"
},
"node_modules/iron-webcrypto": {
"version": "1.2.1",
@@ -1961,18 +1844,17 @@
"url": "https://github.com/sponsors/brc-dd"
}
},
- "node_modules/is-arrayish": {
- "version": "0.3.2",
- "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
- "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="
- },
"node_modules/is-core-module": {
- "version": "2.13.1",
- "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz",
- "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==",
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "hasown": "^2.0.0"
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -1982,6 +1864,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-expression/-/is-expression-4.0.0.tgz",
"integrity": "sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==",
+ "license": "MIT",
"dependencies": {
"acorn": "^7.1.1",
"object-assign": "^4.1.1"
@@ -1992,6 +1875,7 @@
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=8"
}
@@ -2000,13 +1884,15 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
- "dev": true
+ "dev": true,
+ "license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz",
- "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==",
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
+ "license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
@@ -2016,6 +1902,7 @@
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
+ "license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
@@ -2026,10 +1913,11 @@
}
},
"node_modules/istanbul-reports": {
- "version": "3.1.6",
- "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz",
- "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==",
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
+ "license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
@@ -2038,47 +1926,65 @@
"node": ">=8"
}
},
- "node_modules/jackspeak": {
- "version": "3.4.3",
- "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
- "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "dependencies": {
- "@isaacs/cliui": "^8.0.2"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- },
- "optionalDependencies": {
- "@pkgjs/parseargs": "^0.11.0"
- }
- },
"node_modules/jest-diff": {
- "version": "30.0.5",
- "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.0.5.tgz",
- "integrity": "sha512-1UIqE9PoEKaHcIKvq2vbibrCog4Y8G0zmOxgQUVEiTqwR5hJVMCoDsN1vFvI5JvwD37hjueZ1C4l2FyGnfpE0A==",
+ "version": "30.3.0",
+ "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz",
+ "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@jest/diff-sequences": "30.0.1",
- "@jest/get-type": "30.0.1",
+ "@jest/diff-sequences": "30.3.0",
+ "@jest/get-type": "30.1.0",
"chalk": "^4.1.2",
- "pretty-format": "30.0.5"
+ "pretty-format": "30.3.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
+ "node_modules/jest-diff/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/jest-diff/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
"node_modules/js-stringify": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz",
- "integrity": "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g=="
+ "integrity": "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==",
+ "license": "MIT"
},
"node_modules/json-with-bigint": {
- "version": "3.4.4",
- "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.4.4.tgz",
- "integrity": "sha512-AhpYAAaZsPjU7smaBomDt1SOQshi9rEm6BlTbfVwsG1vNmeHKtEedJi62sHZzJTyKNtwzmNnrsd55kjwJ7054A==",
+ "version": "3.5.8",
+ "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz",
+ "integrity": "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==",
"dev": true,
"license": "MIT"
},
@@ -2086,13 +1992,22 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/just-kebab-case/-/just-kebab-case-4.2.0.tgz",
"integrity": "sha512-p2BdO7o4BI+pMun3J+dhaOfYan5JsZrw9wjshRjkWY9+p+u+kKSMhNWYnot2yHDR9CSahZ9iT3dcqJ+V72qHMw==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/just-snake-case": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/just-snake-case/-/just-snake-case-3.2.0.tgz",
+ "integrity": "sha512-iugHP9bSE0jOq3BzN0W0rdu/OOkFirPe8FtUw6v9y37UlbUDgJ1x4xiGNfUhI6mV9dc/paaifyiyn+F+mrm8gw==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"p-locate": "^5.0.0"
},
@@ -2104,10 +2019,10 @@
}
},
"node_modules/lru-cache": {
- "version": "11.1.0",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz",
- "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==",
- "license": "ISC",
+ "version": "11.5.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz",
+ "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==",
+ "license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
@@ -2117,6 +2032,7 @@
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
@@ -2131,6 +2047,7 @@
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
"engines": {
"node": ">= 0.6"
}
@@ -2139,6 +2056,7 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
@@ -2150,6 +2068,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "license": "MIT",
"engines": {
"node": ">=10"
},
@@ -2158,16 +2077,16 @@
}
},
"node_modules/minimatch": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "version": "10.2.4",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
+ "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"dev": true,
- "license": "ISC",
+ "license": "BlueOak-1.0.0",
"dependencies": {
- "brace-expansion": "^2.0.1"
+ "brace-expansion": "^5.0.2"
},
"engines": {
- "node": ">=16 || 14 >=14.17"
+ "node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@@ -2177,15 +2096,17 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minipass": {
- "version": "7.1.2",
- "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
- "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
+ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
+ "license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
@@ -2193,17 +2114,20 @@
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
- "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+ "license": "MIT"
},
"node_modules/napi-build-utils": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz",
- "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg=="
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+ "license": "MIT"
},
"node_modules/node-abi": {
- "version": "3.40.0",
- "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.40.0.tgz",
- "integrity": "sha512-zNy02qivjjRosswoYmPi8hIKJRr8MpQyeKT6qlcq/OnOgA3Rhoae+IYOqsM9V5+JnHWmxKnWOT2GxvtqdtOCXA==",
+ "version": "3.89.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
+ "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
+ "license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
@@ -2212,15 +2136,16 @@
}
},
"node_modules/node-mock-http": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.2.tgz",
- "integrity": "sha512-zWaamgDUdo9SSLw47we78+zYw/bDr5gH8pH7oRRs8V3KmBtu8GLgGIbV2p/gRPd3LWpEOpjQj7X1FOU3VFMJ8g==",
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.4.tgz",
+ "integrity": "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==",
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
"engines": {
"node": ">=0.10.0"
}
@@ -2229,6 +2154,7 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
"dependencies": {
"wrappy": "1"
}
@@ -2238,6 +2164,7 @@
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"yocto-queue": "^0.1.0"
},
@@ -2253,6 +2180,7 @@
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"p-limit": "^3.0.2"
},
@@ -2263,17 +2191,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/package-json-from-dist": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz",
- "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==",
- "dev": true
- },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=8"
}
@@ -2283,6 +2206,7 @@
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=8"
}
@@ -2291,54 +2215,39 @@
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/path-scurry": {
- "version": "1.11.1",
- "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
- "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
+ "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
- "lru-cache": "^10.2.0",
- "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+ "lru-cache": "^11.0.0",
+ "minipass": "^7.1.2"
},
"engines": {
- "node": ">=16 || 14 >=14.18"
+ "node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
- "node_modules/path-scurry/node_modules/lru-cache": {
- "version": "10.4.3",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
- "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
- "dev": true
- },
- "node_modules/peek-readable": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz",
- "integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==",
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Borewit"
- }
- },
"node_modules/prebuild-install": {
- "version": "7.1.1",
- "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz",
- "integrity": "sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==",
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
+ "license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
- "napi-build-utils": "^1.0.1",
+ "napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
@@ -2356,12 +2265,13 @@
"node_modules/prettier-bytes": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/prettier-bytes/-/prettier-bytes-1.0.4.tgz",
- "integrity": "sha512-dLbWOa4xBn+qeWeIF60qRoB6Pk2jX5P3DIVgOQyMyvBpu931Q+8dXz8X0snJiFkQdohDDLnZQECjzsAj75hgZQ=="
+ "integrity": "sha512-dLbWOa4xBn+qeWeIF60qRoB6Pk2jX5P3DIVgOQyMyvBpu931Q+8dXz8X0snJiFkQdohDDLnZQECjzsAj75hgZQ==",
+ "license": "ISC"
},
"node_modules/pretty-format": {
- "version": "30.0.5",
- "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz",
- "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==",
+ "version": "30.3.0",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz",
+ "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2373,29 +2283,18 @@
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
- "node_modules/pretty-format/node_modules/ansi-styles": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
- "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
"node_modules/printable-characters": {
"version": "1.0.42",
"resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz",
"integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==",
- "dev": true
+ "dev": true,
+ "license": "Unlicense"
},
"node_modules/pug-attrs": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pug-attrs/-/pug-attrs-3.0.0.tgz",
"integrity": "sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==",
+ "license": "MIT",
"dependencies": {
"constantinople": "^4.0.1",
"js-stringify": "^1.0.2",
@@ -2405,12 +2304,14 @@
"node_modules/pug-error": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pug-error/-/pug-error-2.1.0.tgz",
- "integrity": "sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg=="
+ "integrity": "sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==",
+ "license": "MIT"
},
"node_modules/pug-linker": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/pug-linker/-/pug-linker-4.0.0.tgz",
"integrity": "sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==",
+ "license": "MIT",
"dependencies": {
"pug-error": "^2.0.0",
"pug-walk": "^2.0.0"
@@ -2420,6 +2321,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pug-load/-/pug-load-3.0.0.tgz",
"integrity": "sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==",
+ "license": "MIT",
"dependencies": {
"object-assign": "^4.1.1",
"pug-walk": "^2.0.0"
@@ -2429,6 +2331,7 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/pug-parser/-/pug-parser-6.0.0.tgz",
"integrity": "sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==",
+ "license": "MIT",
"dependencies": {
"pug-error": "^2.0.0",
"token-stream": "1.0.0"
@@ -2437,12 +2340,14 @@
"node_modules/pug-runtime": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/pug-runtime/-/pug-runtime-3.0.1.tgz",
- "integrity": "sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg=="
+ "integrity": "sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg==",
+ "license": "MIT"
},
"node_modules/pug-strip-comments": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/pug-strip-comments/-/pug-strip-comments-2.0.0.tgz",
"integrity": "sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==",
+ "license": "MIT",
"dependencies": {
"pug-error": "^2.0.0"
}
@@ -2450,12 +2355,14 @@
"node_modules/pug-walk": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/pug-walk/-/pug-walk-2.0.0.tgz",
- "integrity": "sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ=="
+ "integrity": "sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==",
+ "license": "MIT"
},
"node_modules/pump": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
- "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
@@ -2471,6 +2378,7 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
@@ -2495,25 +2403,11 @@
"dev": true,
"license": "MIT"
},
- "node_modules/readable-web-to-node-stream": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz",
- "integrity": "sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==",
- "dependencies": {
- "readable-stream": "^3.6.0"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Borewit"
- }
- },
- "node_modules/readable-web-to-node-stream/node_modules/readable-stream": {
+ "node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
@@ -2528,23 +2422,28 @@
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve": {
- "version": "1.22.8",
- "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
- "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==",
+ "version": "1.22.11",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
+ "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "is-core-module": "^2.13.0",
+ "is-core-module": "^2.16.1",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
+ "engines": {
+ "node": ">= 0.4"
+ },
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@@ -2553,6 +2452,7 @@
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/runes/-/runes-0.4.3.tgz",
"integrity": "sha512-K6p9y4ZyL9wPzA+PMDloNQPfoDGTiFYDvdlXznyGKgD10BJpcAosvATKrExRKOrNLgD8E7Um7WGW0lxsnOuNLg==",
+ "license": "MIT",
"engines": {
"node": ">=4.0.0"
}
@@ -2574,12 +2474,14 @@
"type": "consulting",
"url": "https://feross.org/support"
}
- ]
+ ],
+ "license": "MIT"
},
"node_modules/semver": {
- "version": "7.6.3",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
- "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
@@ -2588,42 +2490,52 @@
}
},
"node_modules/sharp": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
- "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
- "hasInstallScript": true,
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
+ "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"license": "Apache-2.0",
"dependencies": {
- "color": "^4.2.3",
- "detect-libc": "^2.0.3",
- "semver": "^7.6.3"
+ "@img/colour": "^1.1.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.8.5"
},
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-darwin-arm64": "0.33.5",
- "@img/sharp-darwin-x64": "0.33.5",
- "@img/sharp-libvips-darwin-arm64": "1.0.4",
- "@img/sharp-libvips-darwin-x64": "1.0.4",
- "@img/sharp-libvips-linux-arm": "1.0.5",
- "@img/sharp-libvips-linux-arm64": "1.0.4",
- "@img/sharp-libvips-linux-s390x": "1.0.4",
- "@img/sharp-libvips-linux-x64": "1.0.4",
- "@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
- "@img/sharp-libvips-linuxmusl-x64": "1.0.4",
- "@img/sharp-linux-arm": "0.33.5",
- "@img/sharp-linux-arm64": "0.33.5",
- "@img/sharp-linux-s390x": "0.33.5",
- "@img/sharp-linux-x64": "0.33.5",
- "@img/sharp-linuxmusl-arm64": "0.33.5",
- "@img/sharp-linuxmusl-x64": "0.33.5",
- "@img/sharp-wasm32": "0.33.5",
- "@img/sharp-win32-ia32": "0.33.5",
- "@img/sharp-win32-x64": "0.33.5"
+ "@img/sharp-darwin-arm64": "0.35.3",
+ "@img/sharp-darwin-x64": "0.35.3",
+ "@img/sharp-freebsd-wasm32": "0.35.3",
+ "@img/sharp-libvips-darwin-arm64": "1.3.2",
+ "@img/sharp-libvips-darwin-x64": "1.3.2",
+ "@img/sharp-libvips-linux-arm": "1.3.2",
+ "@img/sharp-libvips-linux-arm64": "1.3.2",
+ "@img/sharp-libvips-linux-ppc64": "1.3.2",
+ "@img/sharp-libvips-linux-riscv64": "1.3.2",
+ "@img/sharp-libvips-linux-s390x": "1.3.2",
+ "@img/sharp-libvips-linux-x64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2",
+ "@img/sharp-linux-arm": "0.35.3",
+ "@img/sharp-linux-arm64": "0.35.3",
+ "@img/sharp-linux-ppc64": "0.35.3",
+ "@img/sharp-linux-riscv64": "0.35.3",
+ "@img/sharp-linux-s390x": "0.35.3",
+ "@img/sharp-linux-x64": "0.35.3",
+ "@img/sharp-linuxmusl-arm64": "0.35.3",
+ "@img/sharp-linuxmusl-x64": "0.35.3",
+ "@img/sharp-webcontainers-wasm32": "0.35.3",
+ "@img/sharp-win32-arm64": "0.35.3",
+ "@img/sharp-win32-ia32": "0.35.3",
+ "@img/sharp-win32-x64": "0.35.3"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
}
},
"node_modules/shebang-command": {
@@ -2631,6 +2543,7 @@
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
@@ -2643,6 +2556,7 @@
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=8"
}
@@ -2652,6 +2566,7 @@
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
+ "license": "ISC",
"engines": {
"node": ">=14"
},
@@ -2676,7 +2591,8 @@
"type": "consulting",
"url": "https://feross.org/support"
}
- ]
+ ],
+ "license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
@@ -2696,6 +2612,7 @@
"url": "https://feross.org/support"
}
],
+ "license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
@@ -2706,28 +2623,21 @@
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/simple-markdown/-/simple-markdown-0.7.3.tgz",
"integrity": "sha512-uGXIc13NGpqfPeFJIt/7SHHxd6HekEJYtsdoCM06mEBPL9fQH/pSD7LRM6PZ7CKchpSvxKL4tvwMamqAaNDAyg==",
+ "license": "MIT",
"dependencies": {
"@types/react": ">=16.0.0"
}
},
- "node_modules/simple-swizzle": {
- "version": "0.2.2",
- "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
- "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
- "dependencies": {
- "is-arrayish": "^0.3.1"
- }
- },
"node_modules/snowtransfer": {
- "version": "0.15.0",
- "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.15.0.tgz",
- "integrity": "sha512-kEDGKtFiH5nSkHsDZonEUuDx99lUasJoZ7AGrgvE8HzVG59vjvqc//C+pjWj4DuJqTj4Q+Z1L/M/MYNim8F2VA==",
+ "version": "0.19.0",
+ "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.19.0.tgz",
+ "integrity": "sha512-Vebj4FCdpUsEKAzqC1p50ol5o09CLnrffvBCpTv0FNFefvXPXwogxR8kr4zJApIRt7oWQVZZVkxAq4XnGlICjQ==",
"license": "MIT",
"dependencies": {
- "discord-api-types": "^0.38.21"
+ "discord-api-types": "^0.38.49"
},
"engines": {
- "node": ">=16.15.0"
+ "node": ">=22.0.0"
}
},
"node_modules/source-map": {
@@ -2735,6 +2645,7 @@
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
+ "license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
@@ -2750,63 +2661,26 @@
}
},
"node_modules/stacktracey": {
- "version": "2.1.8",
- "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.1.8.tgz",
- "integrity": "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw==",
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.2.0.tgz",
+ "integrity": "sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==",
"dev": true,
+ "license": "Unlicense",
"dependencies": {
"as-table": "^1.0.36",
"get-source": "^2.0.12"
}
},
- "node_modules/stream-head": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/stream-head/-/stream-head-2.0.2.tgz",
- "integrity": "sha512-aRkUMcmgbDl2Yjd5LqsB1LKB58Ot3JZ4ffuFMkFuvkPQT5X5XFMr4YK2dctApc+d3o52CXU1KUFisYaF/4zjAQ==",
- "dependencies": {
- "through2": "4.0.2"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/stream-mime-type": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/stream-mime-type/-/stream-mime-type-1.0.2.tgz",
- "integrity": "sha512-80GzRn7JICPDEPBhSyqJjbztqX66+3DpkuUUcgDHtRBQlZRTkbCz0BsISggUl7AnyinJk9zyHVnd2lftlZXDdg==",
- "dependencies": {
- "file-type": "^16.0.1",
- "mime-types": "^2.1.27",
- "stream-head": "^2.0.1"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "dev": true,
- "dependencies": {
- "emoji-regex": "^8.0.0",
- "is-fullwidth-code-point": "^3.0.0",
- "strip-ansi": "^6.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/string-width-cjs": {
- "name": "string-width",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
@@ -2821,71 +2695,10 @@
"node": ">=8"
}
},
- "node_modules/string-width-cjs/node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/string-width-cjs/node_modules/strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/string-width/node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "dev": true,
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/string-width/node_modules/strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/strip-ansi": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
- "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
- "dev": true,
- "dependencies": {
- "ansi-regex": "^6.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/strip-ansi?sponsor=1"
- }
- },
- "node_modules/strip-ansi-cjs": {
- "name": "strip-ansi",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -2894,151 +2707,53 @@
"node": ">=8"
}
},
- "node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
- "node_modules/strtok3": {
- "version": "6.3.0",
- "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz",
- "integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==",
- "dependencies": {
- "@tokenizer/token": "^0.3.0",
- "peek-readable": "^4.1.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Borewit"
- }
- },
"node_modules/supertape": {
- "version": "11.3.0",
- "resolved": "https://registry.npmjs.org/supertape/-/supertape-11.3.0.tgz",
- "integrity": "sha512-2LP36xLtxsb3bBYrfvWIilhWpA/vs7/vIgElpsqEhZZ0vcOAMlhMIxH6eHAl5u9KcxGD28IrJrw8lREqeMtZeQ==",
+ "version": "13.2.0",
+ "resolved": "https://registry.npmjs.org/supertape/-/supertape-13.2.0.tgz",
+ "integrity": "sha512-UoxZnyoMOdSJHvbcmD8i28MaGXsA7I0cJ0jr8anT4CkmfaE9M1y5mt9EoXyzfC8UdnQZwXOnJLUwqyKLAeUOug==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@cloudcmd/stub": "^4.0.0",
- "@putout/cli-keypress": "^3.0.0",
+ "@cloudcmd/stub": "^5.0.0",
+ "@putout/cli-keypress": "^4.0.0",
"@putout/cli-validate-args": "^2.0.0",
- "@supertape/engine-loader": "^2.0.0",
- "@supertape/formatter-fail": "^4.0.0",
- "@supertape/formatter-json-lines": "^2.0.0",
- "@supertape/formatter-progress-bar": "^7.0.0",
+ "@supertape/engine-loader": "^4.0.0",
+ "@supertape/formatter-fail": "^5.0.0",
+ "@supertape/formatter-json-lines": "^3.0.0",
+ "@supertape/formatter-progress-bar": "^8.0.0",
"@supertape/formatter-short": "^3.0.0",
"@supertape/formatter-tap": "^4.0.0",
- "@supertape/formatter-time": "^2.0.0",
- "@supertape/operator-stub": "^3.0.0",
+ "@supertape/formatter-time": "^3.0.0",
+ "@supertape/operator-stub": "^4.0.0",
"cli-progress": "^3.8.2",
"flatted": "^3.3.1",
- "fullstore": "^3.0.0",
- "glob": "^11.0.1",
+ "fullstore": "^4.0.0",
+ "glob": "^13.0.0",
"jest-diff": "^30.0.3",
"json-with-bigint": "^3.4.4",
+ "just-snake-case": "^3.2.0",
"once": "^1.4.0",
"resolve": "^1.17.0",
"stacktracey": "^2.1.7",
- "strip-ansi": "^7.0.0",
- "try-to-catch": "^3.0.0",
+ "try-to-catch": "^4.0.0",
"wraptile": "^3.0.0",
"yargs-parser": "^22.0.0"
},
"bin": {
- "supertape": "bin/tracer.mjs",
- "tape": "bin/tracer.mjs"
+ "supertape": "bin/tracer.js",
+ "tape": "bin/tracer.js"
},
"engines": {
- "node": ">=20"
- }
- },
- "node_modules/supertape/node_modules/glob": {
- "version": "11.0.3",
- "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz",
- "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "foreground-child": "^3.3.1",
- "jackspeak": "^4.1.1",
- "minimatch": "^10.0.3",
- "minipass": "^7.1.2",
- "package-json-from-dist": "^1.0.0",
- "path-scurry": "^2.0.0"
- },
- "bin": {
- "glob": "dist/esm/bin.mjs"
- },
- "engines": {
- "node": "20 || >=22"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/supertape/node_modules/jackspeak": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz",
- "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "dependencies": {
- "@isaacs/cliui": "^8.0.2"
- },
- "engines": {
- "node": "20 || >=22"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/supertape/node_modules/minimatch": {
- "version": "10.0.3",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz",
- "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "@isaacs/brace-expansion": "^5.0.0"
- },
- "engines": {
- "node": "20 || >=22"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/supertape/node_modules/path-scurry": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz",
- "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "dependencies": {
- "lru-cache": "^11.0.0",
- "minipass": "^7.1.2"
- },
- "engines": {
- "node": "20 || >=22"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
+ "node": ">=22"
}
},
"node_modules/supertape/node_modules/yargs-parser": {
@@ -3056,6 +2771,7 @@
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
@@ -3068,6 +2784,7 @@
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">= 0.4"
},
@@ -3076,9 +2793,9 @@
}
},
"node_modules/tar-fs": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
- "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==",
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
+ "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
@@ -3091,6 +2808,7 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
@@ -3102,52 +2820,19 @@
"node": ">=6"
}
},
- "node_modules/tar-stream/node_modules/readable-stream": {
- "version": "3.6.2",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
- "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
- "dependencies": {
- "inherits": "^2.0.3",
- "string_decoder": "^1.1.1",
- "util-deprecate": "^1.0.1"
- },
- "engines": {
- "node": ">= 6"
- }
- },
"node_modules/test-exclude": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz",
- "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==",
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz",
+ "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
- "glob": "^10.4.1",
- "minimatch": "^9.0.4"
+ "glob": "^13.0.6",
+ "minimatch": "^10.2.2"
},
"engines": {
- "node": ">=18"
- }
- },
- "node_modules/through2": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz",
- "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==",
- "dependencies": {
- "readable-stream": "3"
- }
- },
- "node_modules/through2/node_modules/readable-stream": {
- "version": "3.6.2",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
- "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
- "dependencies": {
- "inherits": "^2.0.3",
- "string_decoder": "^1.1.1",
- "util-deprecate": "^1.0.1"
- },
- "engines": {
- "node": ">= 6"
+ "node": "20 || >=22"
}
},
"node_modules/timer-node": {
@@ -3157,56 +2842,35 @@
"dev": true,
"license": "MIT"
},
- "node_modules/to-fast-properties": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
- "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
- "engines": {
- "node": ">=4"
- }
- },
"node_modules/token-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/token-stream/-/token-stream-1.0.0.tgz",
- "integrity": "sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg=="
- },
- "node_modules/token-types": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz",
- "integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==",
- "dependencies": {
- "@tokenizer/token": "^0.3.0",
- "ieee754": "^1.2.1"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Borewit"
- }
+ "integrity": "sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==",
+ "license": "MIT"
},
"node_modules/try-catch": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/try-catch/-/try-catch-3.0.1.tgz",
- "integrity": "sha512-91yfXw1rr/P6oLpHSyHDOHm0vloVvUoo9FVdw8YwY05QjJQG9OT0LUxe2VRAzmHG+0CUOmI3nhxDUMLxDN/NEQ==",
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/try-catch/-/try-catch-4.0.9.tgz",
+ "integrity": "sha512-tEWGmsfqZ9NBzvDOGbACxu+AaXajM6+RtmIM6wCIkFD6lCa3/UvjNuWjCRoOjn8qTKuZlQmrMh8vSTBMQcceew==",
"dev": true,
+ "license": "MIT",
"engines": {
- "node": ">=6"
+ "node": ">=22"
}
},
"node_modules/try-to-catch": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/try-to-catch/-/try-to-catch-3.0.1.tgz",
- "integrity": "sha512-hOY83V84Hx/1sCzDSaJA+Xz2IIQOHRvjxzt+F0OjbQGPZ6yLPLArMA0gw/484MlfUkQbCpKYMLX3VDCAjWKfzQ==",
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/try-to-catch/-/try-to-catch-4.1.0.tgz",
+ "integrity": "sha512-phAezhZqLgHHTpbZYe2GgmKe8itTgEX3gfd+YeIsmllgOOpXCDPn1DkDzVWj0mGQPriJgD6AagoUEcX+M2RlGw==",
+ "license": "MIT",
"engines": {
- "node": ">=6"
+ "node": ">=22"
}
},
"node_modules/tslib": {
- "version": "2.6.3",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz",
- "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==",
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
@@ -3214,6 +2878,7 @@
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
@@ -3222,9 +2887,9 @@
}
},
"node_modules/ufo": {
- "version": "1.6.1",
- "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz",
- "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==",
+ "version": "1.6.3",
+ "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
+ "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
"license": "MIT"
},
"node_modules/uncrypto": {
@@ -3241,25 +2906,27 @@
"license": "MIT"
},
"node_modules/uqr": {
- "version": "0.1.2",
- "resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.2.tgz",
- "integrity": "sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==",
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.3.tgz",
+ "integrity": "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==",
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
- "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
},
"node_modules/v8-to-istanbul": {
- "version": "9.1.0",
- "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz",
- "integrity": "sha512-6z3GW9x8G1gd+JIIgQQQxXuiJtCXeAjp6RaPEPLv62mH3iPHPxV6W3robxtCzNErRo6ZwTmzWhsbNvjyEBKzKA==",
+ "version": "9.3.0",
+ "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
+ "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.12",
"@types/istanbul-lib-coverage": "^2.0.1",
- "convert-source-map": "^1.6.0"
+ "convert-source-map": "^2.0.0"
},
"engines": {
"node": ">=10.12.0"
@@ -3269,6 +2936,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
+ "license": "MIT",
"engines": {
"node": ">=0.10.0"
}
@@ -3278,6 +2946,7 @@
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
@@ -3292,6 +2961,7 @@
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/with/-/with-7.0.2.tgz",
"integrity": "sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==",
+ "license": "MIT",
"dependencies": {
"@babel/parser": "^7.9.6",
"@babel/types": "^7.9.6",
@@ -3303,24 +2973,6 @@
}
},
"node_modules/wrap-ansi": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
- "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
- "dev": true,
- "dependencies": {
- "ansi-styles": "^4.0.0",
- "string-width": "^4.1.0",
- "strip-ansi": "^6.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
- }
- },
- "node_modules/wrap-ansi-cjs": {
- "name": "wrap-ansi",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
@@ -3338,71 +2990,47 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
- "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "node_modules/wrap-ansi/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "ansi-regex": "^5.0.1"
+ "color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
- }
- },
- "node_modules/wrap-ansi/node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "dev": true,
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/wrap-ansi/node_modules/strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
- "dependencies": {
- "ansi-regex": "^5.0.1"
},
- "engines": {
- "node": ">=8"
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
- "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
},
"node_modules/wraptile": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/wraptile/-/wraptile-3.0.0.tgz",
"integrity": "sha512-23LJhkIw940uTcDFyJZmNyO0z8lEINOTGCr4vR5YCG3urkdXwduRIhivBm9wKaVynLHYvxoHHYbKsDiafCLp6w==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/xxhash-wasm": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz",
- "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="
+ "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==",
+ "license": "MIT"
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
+ "license": "ISC",
"engines": {
"node": ">=10"
}
@@ -3412,6 +3040,7 @@
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
@@ -3430,6 +3059,7 @@
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
+ "license": "ISC",
"engines": {
"node": ">=12"
}
@@ -3439,6 +3069,7 @@
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=10"
},
@@ -3447,9 +3078,9 @@
}
},
"node_modules/zod": {
- "version": "4.1.5",
- "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.5.tgz",
- "integrity": "sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg==",
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
diff --git a/package.json b/package.json
index 2fb21f24..06b2a495 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "out-of-your-element",
- "version": "3.1.0",
+ "version": "3.6.0",
"description": "A bridge between Matrix and Discord",
"main": "index.js",
"repository": {
@@ -12,58 +12,62 @@
"discord",
"bridge"
],
- "author": "Cadence, PapiOphidian",
+ "author": "Cadence",
"license": "AGPL-3.0-or-later",
"engines": {
- "node": ">=20"
+ "node": ">=22"
},
"dependencies": {
"@chriscdn/promise-semaphore": "^3.0.1",
- "@cloudrac3r/discord-markdown": "^2.6.6",
+ "@cloudrac3r/discord-markdown": "^2.7.0",
"@cloudrac3r/giframe": "^0.4.3",
"@cloudrac3r/html-template-tag": "^5.0.1",
"@cloudrac3r/in-your-element": "^1.1.1",
"@cloudrac3r/mixin-deep": "^3.0.1",
"@cloudrac3r/pngjs": "^7.0.3",
"@cloudrac3r/pug": "^4.0.4",
- "@cloudrac3r/turndown": "^7.1.4",
+ "@cloudrac3r/stream-type": "^1.0.0",
+ "@cloudrac3r/turndown": "^7.1.5",
"@stackoverflow/stacks": "^2.5.4",
"@stackoverflow/stacks-icons": "^6.0.2",
"ansi-colors": "^4.1.3",
"better-sqlite3": "^12.2.0",
"chunk-text": "^2.0.1",
- "cloudstorm": "^0.14.0",
- "discord-api-types": "^0.38.19",
+ "cloudstorm": "^0.19.0",
+ "denque": "^2.1.0",
+ "discord-api-types": "^0.38.52",
"domino": "^2.1.6",
"enquirer": "^2.4.1",
"entities": "^5.0.0",
"get-relative-path": "^1.0.2",
- "h3": "^1.15.1",
+ "h3": "^1.15.10",
"heatsync": "^2.7.2",
"htmx.org": "^2.0.4",
"lru-cache": "^11.0.2",
+ "mime-types": "^2.1.35",
"prettier-bytes": "^1.0.4",
- "sharp": "^0.33.4",
- "snowtransfer": "^0.15.0",
- "stream-mime-type": "^1.0.2",
- "try-to-catch": "^3.0.1",
+ "sharp": "^0.35.3",
+ "snowtransfer": "^0.19.0",
+ "try-to-catch": "^4.0.5",
"uqr": "^0.1.2",
"xxhash-wasm": "^1.0.2",
"zod": "^4.0.17"
},
+ "overrides": {
+ "glob@<11.1": "^12"
+ },
"devDependencies": {
"@cloudrac3r/tap-dot": "^2.0.3",
"@types/node": "^22.17.1",
- "c8": "^10.1.2",
+ "c8": "^11.0.0",
"cross-env": "^7.0.3",
- "supertape": "^11.3.0"
+ "supertape": "^13.2.0"
},
"scripts": {
"start": "node --enable-source-maps start.js",
"setup": "node --enable-source-maps scripts/setup.js",
"addbot": "node addbot.js",
"test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js | tap-dot",
- "test-slow": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js -- --slow | tap-dot",
- "cover": "c8 -o test/coverage --skip-full -x db/migrations -x src/matrix/file.js -x src/matrix/api.js -x src/d2m/converters/rlottie-wasm.js -r html -r text supertape --no-check-assertions-count --format fail --no-worker test/test.js -- --slow"
+ "cover": "c8 -o test/coverage --skip-full -x db/migrations -x src/m2d/event-dispatcher.js -x src/matrix/file.js -x src/matrix/api.js -x src/d2m/converters/rlottie-wasm.js -r html -r text supertape --no-check-assertions-count --format fail --no-worker test/test.js -- --slow"
}
}
diff --git a/readme.md b/readme.md
index 8c9fc90c..e8a8e7e7 100644
--- a/readme.md
+++ b/readme.md
@@ -38,6 +38,7 @@ For more information about features, [see the user guide.](https://gitdab.com/ca
* This bridge is not designed for puppetting.
* Direct Messaging is not supported until I figure out a good way of doing it.
+* Encrypted messages are not supported. Decryption is often unreliable on Matrix, and your messages end up in plaintext on Discord anyway, so there's not much advantage.
## Get started!
diff --git a/scripts/backfill.js b/scripts/backfill.js
index 12d9da3d..941e8035 100644
--- a/scripts/backfill.js
+++ b/scripts/backfill.js
@@ -10,7 +10,6 @@ if (!channelID) {
process.exit(1)
}
-const assert = require("assert/strict")
const sqlite = require("better-sqlite3")
const backfill = new sqlite("scripts/backfill.db")
backfill.prepare("CREATE TABLE IF NOT EXISTS backfill (channel_id TEXT NOT NULL, message_id INTEGER NOT NULL, PRIMARY KEY (channel_id, message_id))").run()
@@ -29,18 +28,17 @@ const DiscordClient = require("../src/d2m/discord-client")
const discord = new DiscordClient(reg.ooye.discord_token, "half")
passthrough.discord = discord
+const {as} = require("../src/matrix/appservice")
+passthrough.as = as
+
const orm = sync.require("../src/db/orm")
passthrough.from = orm.from
passthrough.select = orm.select
/** @type {import("../src/d2m/event-dispatcher")}*/
const eventDispatcher = sync.require("../src/d2m/event-dispatcher")
-
-const roomID = passthrough.select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
-if (!roomID) {
- console.error("Please choose a channel that's already bridged.")
- process.exit(1)
-}
+/** @type {import("../src/d2m/actions/create-room")} */
+const createRoom = sync.require("../src/d2m/actions/create-room")
;(async () => {
await discord.cloud.connect()
@@ -57,23 +55,29 @@ async function event(event) {
if (!channel) return
const guild_id = event.d.id
- let last = backfill.prepare("SELECT cast(max(message_id) as TEXT) FROM backfill WHERE channel_id = ?").pluck().get(channelID) || "0"
- console.log(`OK, processing messages for #${channel.name}, continuing from ${last}`)
+ try {
+ await createRoom.syncRoom(channelID)
+ let last = backfill.prepare("SELECT cast(max(message_id) as TEXT) FROM backfill WHERE channel_id = ?").pluck().get(channelID) || "0"
+ console.log(`OK, processing messages for #${channel.name}, continuing from ${last}`)
- while (last) {
- const messages = await discord.snow.channel.getChannelMessages(channelID, {limit: 50, after: String(last)})
- messages.reverse() // More recent messages come first -> More recent messages come last
- for (const message of messages) {
- const simulatedGatewayDispatchData = {
- guild_id,
- backfill: true,
- ...message
+ while (last) {
+ const messages = await discord.snow.channel.getChannelMessages(channelID, {limit: 50, after: String(last)})
+ messages.reverse() // More recent messages come first -> More recent messages come last
+ for (const message of messages) {
+ const simulatedGatewayDispatchData = {
+ guild_id,
+ backfill: true,
+ ...message
+ }
+ await eventDispatcher.MESSAGE_CREATE(discord, simulatedGatewayDispatchData)
+ preparedInsert.run(channelID, message.id)
}
- await eventDispatcher.onMessageCreate(discord, simulatedGatewayDispatchData)
- preparedInsert.run(channelID, message.id)
+ last = messages.at(-1)?.id
}
- last = messages.at(-1)?.id
- }
- process.exit()
+ process.exit()
+ } catch (e) {
+ console.error(e)
+ process.exit(1) // won't exit automatically on thrown error due to living discord connection, so manual exit is necessary
+ }
}
diff --git a/scripts/estimate-size.js b/scripts/estimate-size.js
new file mode 100644
index 00000000..341abc07
--- /dev/null
+++ b/scripts/estimate-size.js
@@ -0,0 +1,65 @@
+// @ts-check
+
+const pb = require("prettier-bytes")
+const sqlite = require("better-sqlite3")
+const HeatSync = require("heatsync")
+
+const {reg} = require("../src/matrix/read-registration")
+const passthrough = require("../src/passthrough")
+
+const sync = new HeatSync({watchFS: false})
+Object.assign(passthrough, {reg, sync})
+
+const DiscordClient = require("../src/d2m/discord-client")
+
+const discord = new DiscordClient(reg.ooye.discord_token, "no")
+passthrough.discord = discord
+
+const db = new sqlite("ooye.db")
+passthrough.db = db
+
+const api = require("../src/matrix/api")
+
+const {room: roomID} = require("minimist")(process.argv.slice(2), {string: ["room"]})
+if (!roomID) {
+ console.error("Usage: ./scripts/estimate-size.js --room=")
+ process.exit(1)
+}
+
+const {channel_id, guild_id} = db.prepare("SELECT channel_id, guild_id FROM channel_room WHERE room_id = ?").get(roomID)
+
+const max = 1000
+
+;(async () => {
+ let total = 0
+ let size = 0
+ let from
+
+ while (total < max) {
+ const events = await api.getEvents(roomID, "b", {limit: 1000, from})
+ total += events.chunk.length
+ from = events.end
+ console.log(`Fetched ${total} events so far`)
+
+ for (const e of events.chunk) {
+ if (e.content?.info?.size) {
+ size += e.content.info.size
+ }
+ }
+
+ if (events.chunk.length === 0 || !events.end) break
+ }
+
+ console.log(`Total size of uploads: ${pb(size)}`)
+
+ const searchResults = await discord.snow.requestHandler.request(`/guilds/${guild_id}/messages/search`, {
+ channel_id,
+ offset: "0",
+ limit: "1"
+ }, "get", "json")
+
+ const totalAllTime = searchResults.total_results
+ const fractionCounted = total / totalAllTime
+ console.log(`That counts for ${(fractionCounted*100).toFixed(2)}% of the history on Discord (${totalAllTime.toLocaleString()} messages)`)
+ console.log(`The size of uploads for the whole history would be approx: ${pb(Math.floor(size/total*totalAllTime))}`)
+})()
diff --git a/scripts/migrate-from-old-bridge.js b/scripts/migrate-from-old-bridge.js
index 36cf884d..1842c16b 100755
--- a/scripts/migrate-from-old-bridge.js
+++ b/scripts/migrate-from-old-bridge.js
@@ -37,7 +37,9 @@ const createRoom = sync.require("../d2m/actions/create-room")
/** @type {import("../src/matrix/mreq")} */
const mreq = sync.require("../matrix/mreq")
/** @type {import("../src/matrix/api")} */
-const api = sync.require("../matrix/api")
+const api = sync.require("../src/matrix/api")
+/** @type {import("../src/matrix/utils")} */
+const utils = sync.require("../src/matrix/utils")
const sema = new Semaphore()
@@ -80,16 +82,8 @@ async function migrateGuild(guild) {
// Step 2: (Using old bridge access token) Join the new bridge to the old rooms and give it PL 100
console.log(`-- Joining channel ${channel.name}...`)
await mreq.withAccessToken(oldAT, async () => {
- try {
- await api.inviteToRoom(roomID, newBridgeMxid)
- } catch (e) {
- if (e.message.includes("is already in the room")) {
- // Great!
- } else {
- throw e
- }
- }
- await api.setUserPower(roomID, newBridgeMxid, 100)
+ await api.inviteToRoom(roomID, newBridgeMxid)
+ await utils.setUserPower(roomID, newBridgeMxid, 100, api)
})
await api.joinRoom(roomID)
diff --git a/scripts/plu-ral-api.js b/scripts/plu-ral-api.js
new file mode 100644
index 00000000..d3468a3c
--- /dev/null
+++ b/scripts/plu-ral-api.js
@@ -0,0 +1,17 @@
+// @ts-check
+
+const {reg, writeRegistration} = require("../src/matrix/read-registration")
+const {prompt} = require("enquirer")
+
+;(async () => {
+ /** @type {{api_key: string}} */
+ const apiKeyResponse = await prompt({
+ type: "text",
+ name: "api_key",
+ message: "Paste your personal /plu/ral API key"
+ })
+
+ reg.ooye.plu_ral_api_key = apiKeyResponse.api_key
+ writeRegistration(reg)
+ console.log("Saved. This change should be applied instantly.")
+})()
diff --git a/scripts/remove-old-bridged-users.js b/scripts/remove-old-bridged-users.js
index d8910bd0..756a492b 100644
--- a/scripts/remove-old-bridged-users.js
+++ b/scripts/remove-old-bridged-users.js
@@ -10,6 +10,7 @@ const passthrough = require("../src/passthrough")
Object.assign(passthrough, {db, sync})
const api = require("../src/matrix/api")
+const utils = require("../src/matrix/utils")
const mreq = require("../src/matrix/mreq")
const rooms = db.prepare("select room_id from channel_room").pluck().all()
@@ -25,7 +26,7 @@ const rooms = db.prepare("select room_id from channel_room").pluck().all()
await api.leaveRoom(roomID, mxid)
}
}
- await api.setUserPower(roomID, "@_discord_bot:cadence.moe", 0)
+ await utils.setUserPower(roomID, "@_discord_bot:cadence.moe", 0, api)
await api.leaveRoom(roomID)
} catch (e) {
if (e.message.includes("Appservice not in room")) {
diff --git a/scripts/remove-uncached-bridged-users.js b/scripts/remove-uncached-bridged-users.js
new file mode 100644
index 00000000..b3ceb8a4
--- /dev/null
+++ b/scripts/remove-uncached-bridged-users.js
@@ -0,0 +1,36 @@
+// @ts-check
+
+const HeatSync = require("heatsync")
+const sync = new HeatSync({watchFS: false})
+
+const sqlite = require("better-sqlite3")
+const db = new sqlite("ooye.db", {fileMustExist: true})
+
+const passthrough = require("../src/passthrough")
+Object.assign(passthrough, {db, sync})
+
+const api = require("../src/matrix/api")
+const utils = require("../src/matrix/utils")
+const {reg} = require("../src/matrix/read-registration")
+
+const rooms = db.prepare("select room_id, name, nick from channel_room").all()
+
+;(async () => {
+ // Search for members starting with @_ooye_ and kick them if they are not in sim_member cache
+ for (const room of rooms) {
+ try {
+ const members = await api.getJoinedMembers(room.room_id)
+ for (const mxid of Object.keys(members.joined)) {
+ if (!mxid.startsWith("@" + reg.sender_localpart) && utils.eventSenderIsFromDiscord(mxid) && !db.prepare("select mxid from sim_member where mxid = ? and room_id = ?").get(mxid, room.room_id)) {
+ await api.leaveRoom(room.room_id, mxid)
+ }
+ }
+ } catch (e) {
+ if (e.message.includes("Appservice not in room")) {
+ // ok
+ } else {
+ throw e
+ }
+ }
+ }
+})()
diff --git a/scripts/reset-web-password.js b/scripts/reset-web-password.js
new file mode 100644
index 00000000..7c3a1a20
--- /dev/null
+++ b/scripts/reset-web-password.js
@@ -0,0 +1,17 @@
+// @ts-check
+
+const {reg, writeRegistration, registrationFilePath} = require("../src/matrix/read-registration")
+const {prompt} = require("enquirer")
+
+;(async () => {
+ /** @type {{web_password: string}} */
+ const passwordResponse = await prompt({
+ type: "text",
+ name: "web_password",
+ message: "Choose a simple password (optional)"
+ })
+
+ reg.ooye.web_password = passwordResponse.web_password
+ writeRegistration(reg)
+ console.log("Saved. This change should be applied instantly.")
+})()
diff --git a/scripts/setup.js b/scripts/setup.js
index 6bff2938..191ca3e3 100644
--- a/scripts/setup.js
+++ b/scripts/setup.js
@@ -1,6 +1,7 @@
#!/usr/bin/env node
// @ts-check
+const Ty = require("../src/types")
const assert = require("assert").strict
const fs = require("fs")
const sqlite = require("better-sqlite3")
@@ -17,22 +18,6 @@ const {SnowTransfer} = require("snowtransfer")
const DiscordTypes = require("discord-api-types/v10")
const {createApp, defineEventHandler, toNodeListener} = require("h3")
-// Move database file if it's still in the old location
-if (fs.existsSync("db")) {
- if (fs.existsSync("db/ooye.db")) {
- fs.renameSync("db/ooye.db", "ooye.db")
- }
- const files = fs.readdirSync("db")
- if (files.length) {
- console.error("The db folder is deprecated and must be removed. Your ooye.db database file has already been moved to the root of the repo. You must manually move or delete the remaining files:")
- for (const file of files) {
- console.error(file)
- }
- process.exit(1)
- }
- fs.rmSync("db", {recursive: true})
-}
-
const passthrough = require("../src/passthrough")
const db = new sqlite("ooye.db")
const migrate = require("../src/db/migrate")
@@ -50,11 +35,6 @@ let {reg, getTemplateRegistration, writeRegistration, readRegistration, checkReg
const {setupEmojis} = require("../src/m2d/actions/setup-emojis")
-function die(message) {
- console.error(message)
- process.exit(1)
-}
-
async function suggestWellKnown(serverUrlPrompt, url, otherwise) {
try {
var json = await fetch(`${url}/.well-known/matrix/client`).then(res => res.json())
@@ -80,6 +60,7 @@ async function validateHomeserverOrigin(serverUrlPrompt, url) {
return e.message
}
try {
+ /** @type {any} */
var json = await res.json()
if (!Array.isArray(json?.versions) || !json.versions.includes("v1.11")) {
return `OOYE needs Matrix version v1.11, but ${url} doesn't support this`
@@ -106,7 +87,7 @@ function defineEchoHandler() {
type: "input",
name: "server_name",
message: "Homeserver name",
- validate: serverName => !!serverName.match(/[a-z][a-z.]+[a-z]/)
+ validate: serverName => !!serverName.match(/[a-z0-9][.a-z0-9-]+[a-z]/)
})
console.log("What is the URL of your homeserver?")
@@ -120,16 +101,28 @@ function defineEchoHandler() {
/** @type {string} */ // @ts-ignore
const serverOrigin = await serverOriginPrompt.run()
+ console.log("OOYE has its own web server. It needs to be accessible on the public internet.")
+ console.log("What port would you like OOYE to use? You can connect your reverse proxy to this port later.")
+ /** @type {{socket: string | number}} */
+ const portResponse = await prompt({
+ type: "input",
+ name: "socket",
+ message: "Web server port",
+ initial: "6693"
+ })
+ portResponse.socket = +portResponse.socket || portResponse.socket // convert to number if numeric
+
const app = createApp()
app.use(defineEchoHandler())
const server = createServer(toNodeListener(app))
- await server.listen(6693)
+ await server.listen(portResponse.socket)
- console.log("OOYE has its own web server. It needs to be accessible on the public internet.")
- console.log("You need to enter a public URL where you will be able to host this web server.")
- console.log("OOYE listens on localhost:6693, so you will probably have to set up a reverse proxy.")
+ console.log("Now you need to enter a public URL that OOYE's web server will live on.")
+ console.log("Set up your reverse proxy so that this URL accesses OOYE.")
console.log("Examples: https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/get-started.md#appendix")
- console.log("Now listening on port 6693. Feel free to send some test requests.")
+ if (typeof portResponse.socket === "number") {
+ console.log(`Now listening on http://localhost:${portResponse.socket}. Feel free to send some test requests.`)
+ }
/** @type {{bridge_origin: string}} */
const bridgeOriginResponse = await prompt({
type: "input",
@@ -176,17 +169,21 @@ function defineEchoHandler() {
}
})
- const mandatoryIntentFlags = DiscordTypes.ApplicationFlags.GatewayMessageContent | DiscordTypes.ApplicationFlags.GatewayMessageContentLimited
- if (!(client.flags & mandatoryIntentFlags)) {
+ const intentFlagPossibilities = [
+ DiscordTypes.ApplicationFlags.GatewayMessageContent | DiscordTypes.ApplicationFlags.GatewayPresence | DiscordTypes.ApplicationFlags.GatewayGuildMembers,
+ DiscordTypes.ApplicationFlags.GatewayMessageContentLimited | DiscordTypes.ApplicationFlags.GatewayPresenceLimited | DiscordTypes.ApplicationFlags.GatewayGuildMembersLimited
+ ]
+ const intentFlagMask = intentFlagPossibilities.reduce((a, c) => a | c, 0)
+ if (!intentFlagPossibilities.includes(client.flags & intentFlagMask)) {
console.log(`On that same page, scroll down to Privileged Gateway Intents and enable all switches.`)
await prompt({
type: "invisible",
name: "intents",
message: "Press Enter when you've enabled them",
- validate: async token => {
+ validate: async () => {
process.stdout.write(magenta("checking, please wait..."))
client = await snow.requestHandler.request(`/applications/@me`, {}, "get", "json")
- if (client.flags & mandatoryIntentFlags) {
+ if (intentFlagPossibilities.includes(client.flags & intentFlagMask)) {
return true
} else {
return "Switches have not been enabled yet"
@@ -212,7 +209,7 @@ function defineEchoHandler() {
type: "invisible",
name: "description",
message: "Press Enter to acknowledge",
- validate: async token => {
+ validate: async () => {
process.stdout.write(magenta("checking, please wait..."))
client = await snow.requestHandler.request(`/applications/@me`, {}, "get", "json")
if (client.description?.match(/out.of.your.element/i)) {
@@ -229,7 +226,8 @@ function defineEchoHandler() {
const clientSecretResponse = await prompt({
type: "input",
name: "discord_client_secret",
- message: "Client secret"
+ message: "Client secret",
+ validate: secret => !!secret
})
const expectedUri = `${bridgeOriginResponse.bridge_origin}/oauth`
@@ -239,7 +237,7 @@ function defineEchoHandler() {
type: "invisible",
name: "redirect_uri",
message: "Press Enter when you've added it",
- validate: async token => {
+ validate: async () => {
process.stdout.write(magenta("checking, please wait..."))
client = await snow.requestHandler.request(`/applications/@me`, {}, "get", "json")
if (client.redirect_uris.includes(expectedUri)) {
@@ -255,6 +253,7 @@ function defineEchoHandler() {
reg = {
...template,
url: bridgeOriginResponse.bridge_origin,
+ ...portResponse,
ooye: {
...template.ooye,
...bridgeOriginResponse,
@@ -287,14 +286,15 @@ function defineEchoHandler() {
console.log()
// Done with user prompts, reg is now guaranteed to be valid
+ const mreq = require("../src/matrix/mreq")
const api = require("../src/matrix/api")
- const file = require("../src/matrix/file")
const DiscordClient = require("../src/d2m/discord-client")
const discord = new DiscordClient(reg.ooye.discord_token, "no")
passthrough.discord = discord
const {as} = require("../src/matrix/appservice")
as.router.use("/**", defineEchoHandler())
+ await as.listen()
console.log("⏳ Waiting for you to register the file with your homeserver... (Ctrl+C to cancel)")
process.once("SIGINT", () => {
@@ -344,7 +344,13 @@ function defineEchoHandler() {
await api.register(reg.sender_localpart)
// upload initial images...
- const avatarUrl = await file.uploadDiscordFileToMxc("https://cadence.moe/friends/out_of_your_element.png")
+ const avatarBuffer = await fs.promises.readFile(join(__dirname, "..", "docs", "img", "icon.png"), null)
+ /** @type {Ty.R.FileUploaded} */
+ const root = await mreq.mreq("POST", "/media/v3/upload", avatarBuffer, {
+ headers: {"Content-Type": "image/png"}
+ })
+ const avatarUrl = root.content_uri
+ assert(avatarUrl)
console.log("✅ Matrix appservice login works...")
@@ -353,13 +359,12 @@ function defineEchoHandler() {
console.log("✅ Emojis are ready...")
// set profile data on discord...
- const avatarImageBuffer = await fetch("https://cadence.moe/friends/out_of_your_element.png").then(res => res.arrayBuffer())
- await discord.snow.user.updateSelf({avatar: "data:image/png;base64," + Buffer.from(avatarImageBuffer).toString("base64")})
+ await discord.snow.user.editSelf({avatar: "data:image/png;base64," + avatarBuffer.toString("base64")})
console.log("✅ Discord profile updated...")
// set profile data on homeserver...
console.log("⏩ Updating Matrix profile... (If you've joined lots of rooms, this is slow. Please allow at least 30 seconds.)")
- await api.profileSetDisplayname(mxid, "Out Of Your Element")
+ await api.profileSetDisplayname(mxid, "Out Of Your Element (bot)")
await api.profileSetAvatarUrl(mxid, avatarUrl)
console.log("✅ Matrix profile updated...")
diff --git a/scripts/start-server.js b/scripts/start-server.js
index 0d4753ac..44edbcb9 100755
--- a/scripts/start-server.js
+++ b/scripts/start-server.js
@@ -34,5 +34,9 @@ passthrough.select = orm.select
console.log("Discord gateway started")
sync.require("../src/web/server")
+ discord.cloud.once("ready", () => {
+ as.listen()
+ })
+
require("../src/stdin")
})()
diff --git a/scripts/text-probability.js b/scripts/text-probability.js
new file mode 100644
index 00000000..cc934057
--- /dev/null
+++ b/scripts/text-probability.js
@@ -0,0 +1,65 @@
+// @ts-check
+
+const Ty = require("../src/types")
+const fs = require("fs")
+const domino = require("domino")
+const repl = require("repl")
+
+const pres = (() => {
+ const pres = []
+ for (const file of process.argv.slice(2)) {
+ const data = JSON.parse(fs.readFileSync(file, "utf8"))
+ /** @type {Ty.Event.Outer<{msgtype?: string}>[]} */
+ const events = data.messages
+ for (const event of events) {
+ if (event.type !== "m.room.message" || event.content.msgtype !== "m.text") continue
+ /** @type {Ty.Event.M_Room_Message} */ // @ts-ignore
+ const content = event.content
+ if (content.format !== "org.matrix.custom.html") continue
+ if (!content.formatted_body) continue
+
+ const document = domino.createDocument(content.formatted_body)
+ // @ts-ignore
+ for (const pre of document.querySelectorAll("pre").cache) {
+ const content = pre.textContent
+ if (content.length < 100) continue
+ pres.push(content)
+ }
+ }
+ }
+ return pres
+})()
+
+// @ts-ignore
+global.gc()
+
+/** @param {string} text */
+function probablyFixedWidthIntended(text) {
+ // if internal spaces are used, seems like they want a fixed-width font
+ if (text.match(/[^ ] {3,}[^ ]/)) return true
+ // if characters from Unicode General_Category "Symbol, other" are used, seems like they're doing ascii art and they want a fixed-width font
+ if (text.match(/\p{So}/v)) return true
+ // check start of line indentation
+ let indents = new Set()
+ for (const line of text.trimEnd().split("\n")) {
+ indents.add(line.match(/^ */)?.[0].length || 0)
+ // if there are more than 3 different indents (counting 0) then it's code
+ if (indents.size >= 3) return true
+ }
+ // if everything is indented then it's code
+ if (!indents.has(0)) return true
+ // if there is a high proportion of symbols then it's code (this filter works remarkably well on its own)
+ if ([...text.matchAll(/[\\`~;+|<>%$@*&"'=(){}[\]_^]|\.[a-zA-Z]|[a-z][A-Z]/g)].length / text.length >= 0.04) return true
+ return false
+}
+
+Object.assign(repl.start().context, {pres, probablyFixedWidthIntended})
+
+/*
+if it has a lot of symbols then it's code
+if it has >=3 levels of indentation then it's code
+if it is all indented then it's code
+if it has many spaces in a row in the middle then it's ascii art
+if it has many non-latin characters then it's language
+-> except if they are ascii art characters e.g. ⣿⣿⡇⢸⣿⠃ then it's ascii art
+*/
diff --git a/src/d2m/actions/add-reaction.js b/src/d2m/actions/add-reaction.js
index 8d86e5fa..476f8ddd 100644
--- a/src/d2m/actions/add-reaction.js
+++ b/src/d2m/actions/add-reaction.js
@@ -21,7 +21,7 @@ async function addReaction(data) {
const user = data.member?.user
assert.ok(user && user.username)
- const parentID = select("event_message", "event_id", {message_id: data.message_id, reaction_part: 0}).pluck().get()
+ const parentID = select("event_message", "event_id", {message_id: data.message_id}, "ORDER BY reaction_part").pluck().get()
if (!parentID) return // Nothing can be done if the parent message was never bridged.
assert.equal(typeof parentID, "string")
diff --git a/src/d2m/actions/announce-thread.js b/src/d2m/actions/announce-thread.js
index 324c7a5b..c8cbf9de 100644
--- a/src/d2m/actions/announce-thread.js
+++ b/src/d2m/actions/announce-thread.js
@@ -1,6 +1,6 @@
// @ts-check
-const assert = require("assert")
+const assert = require("assert").strict
const passthrough = require("../../passthrough")
const {discord, sync, db, select} = passthrough
diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js
index ff5782dc..7f110ad6 100644
--- a/src/d2m/actions/create-room.js
+++ b/src/d2m/actions/create-room.js
@@ -17,8 +17,8 @@ const mreq = sync.require("../../matrix/mreq")
const ks = sync.require("../../matrix/kstate")
/** @type {import("../../discord/utils")} */
const dUtils = sync.require("../../discord/utils")
-/** @type {import("../../m2d/converters/utils")} */
-const mUtils = sync.require("../../m2d/converters/utils")
+/** @type {import("../../matrix/utils")} */
+const mUtils = sync.require("../../matrix/utils")
/** @type {import("./create-space")} */
const createSpace = sync.require("./create-space")
@@ -77,7 +77,7 @@ function convertNameAndTopic(channel, guild, customName) {
* Async because it may create the guild and/or upload the guild icon to mxc.
* @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel
* @param {DiscordTypes.APIGuild} guild
- * @param {{api: {getStateEvent: typeof api.getStateEvent}}} di simple-as-nails dependency injection for the matrix API
+ * @param {{api: {getStateEvent: typeof api.getStateEvent, getStateEventOuter: typeof api.getStateEventOuter}}} di simple-as-nails dependency injection for the matrix API
*/
async function channelToKState(channel, guild, di) {
// @ts-ignore
@@ -122,19 +122,31 @@ async function channelToKState(channel, guild, di) {
join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]}
}
- const everyonePermissions = dUtils.getPermissions([], guild.roles, undefined, channel.permission_overwrites)
+ const everyonePermissions = dUtils.getDefaultPermissions(guild, channel.permission_overwrites)
const everyoneCanSend = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendMessages)
- const everyoneCanMentionEveryone = dUtils.hasAllPermissions(everyonePermissions, ["MentionEveryone"])
+ const everyoneCanMentionEveryone = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.MentionEveryone)
+
+ const pollStartPowerLevel = {}
+ const everyoneCanCreatePolls = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendPolls)
+ if (everyoneCanSend && !everyoneCanCreatePolls) pollStartPowerLevel["org.matrix.msc3381.poll.start"] = 10
+
+ const spacePowerDetails = await mUtils.getEffectivePower(guildSpaceID, [], di.api)
+ spacePowerDetails.powerLevels.users ??= {}
+ const spaceCreatorsAndFounders = spacePowerDetails.allCreators
+ .concat(Object.entries(spacePowerDetails.powerLevels.users).filter(([, power]) => power >= spacePowerDetails.tombstone).map(([mxid]) => mxid))
const globalAdmins = select("member_power", ["mxid", "power_level"], {room_id: "*"}).all()
const globalAdminPower = globalAdmins.reduce((a, c) => (a[c.mxid] = c.power_level, a), {})
- /** @type {Ty.Event.M_Power_Levels} */
- const spacePowerEvent = await di.api.getStateEvent(guildSpaceID, "m.room.power_levels", "")
- const spacePower = spacePowerEvent.users
+ const additionalCreators = select("member_power", "mxid", {room_id: "*"}, "AND power_level > 100").pluck().all().concat(spaceCreatorsAndFounders)
+ const creationContent = {}
+ creationContent.additional_creators = additionalCreators
+
+ if (channel.type === DiscordTypes.ChannelType.GuildForum) creationContent.type = "m.space"
/** @type {any} */
const channelKState = {
+ "m.room.create/": creationContent,
"m.room.name/": {name: convertedName},
"m.room.topic/": {topic: convertedTopic},
"m.room.avatar/": avatarEventContent,
@@ -151,17 +163,16 @@ async function channelToKState(channel, guild, di) {
events_default: everyoneCanSend ? 0 : READ_ONLY_ROOM_EVENTS_DEFAULT_POWER,
events: {
"m.reaction": 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
},
notifications: {
room: everyoneCanMentionEveryone ? 0 : 20
},
- users: {...spacePower, ...globalAdminPower}
- },
- "chat.schildi.hide_ui/read_receipts": {
+ users: {...spacePowerDetails.powerLevels.users, ...globalAdminPower}
},
[`uk.half-shot.bridge/moe.cadence.ooye://discord/${guild.id}/${channel.id}`]: {
- bridgebot: `@${reg.sender_localpart}:${reg.ooye.server_name}`,
+ bridgebot: mUtils.bot,
protocol: {
id: "discord",
displayname: "Discord"
@@ -182,6 +193,16 @@ async function channelToKState(channel, guild, di) {
// Don't overwrite room topic if the topic has been customised
if (hasCustomTopic) delete channelKState["m.room.topic/"]
+ // Make voice channels be a Matrix voice room (MSC3417)
+ if (channel.type === DiscordTypes.ChannelType.GuildVoice) {
+ creationContent.type = "org.matrix.msc3417.call"
+ channelKState["org.matrix.msc3401.call/"] = {
+ "m.intent": "m.room",
+ "m.type": "m.voice",
+ "m.name": customName || channel.name
+ }
+ }
+
// Don't add a space parent if it's self service
// (The person setting up self-service has already put it in their preferred space to be able to get this far.)
const autocreate = select("guild_active", "autocreate", {guild_id: guild.id}).pluck().get()
@@ -195,7 +216,7 @@ async function channelToKState(channel, guild, di) {
/**
* Create a bridge room, store the relationship in the database, and add it to the guild's space.
* @param {DiscordTypes.APIGuildTextChannel} channel
- * @param guild
+ * @param {DiscordTypes.APIGuild} guild
* @param {string} spaceID
* @param {any} kstate
* @param {number} privacyLevel
@@ -205,9 +226,6 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) {
let threadParent = null
if (channel.type === DiscordTypes.ChannelType.PublicThread) threadParent = channel.parent_id
- let spaceCreationContent = {}
- if (channel.type === DiscordTypes.ChannelType.GuildForum) spaceCreationContent = {creation_content: {type: "m.space"}}
-
// Name and topic can be done earlier in room creation rather than in initial_state
// https://spec.matrix.org/latest/client-server-api/#creation
const name = kstate["m.room.name/"].name
@@ -217,7 +235,7 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) {
delete kstate["m.room.topic/"]
assert(topic)
- const roomID = await postApplyPowerLevels(kstate, async kstate => {
+ const roomCreate = await postApplyPowerLevels(kstate, async kstate => {
const roomID = await api.createRoom({
name,
topic,
@@ -225,13 +243,20 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) {
visibility: PRIVACY_ENUMS.VISIBILITY[privacyLevel],
invite: [],
initial_state: await ks.kstateToState(kstate),
- ...spaceCreationContent
+ creation_content: ks.kstateToCreationContent(kstate)
})
- db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent) VALUES (?, ?, ?, NULL, ?)").run(channel.id, roomID, channel.name, threadParent)
+ /** @type {Ty.Event.StateOuter} */
+ const roomCreate = await api.getStateEventOuter(roomID, "m.room.create", "")
- return roomID
+ db.transaction(() => {
+ db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, guild_id) VALUES (?, ?, ?, NULL, ?, ?)").run(channel.id, roomID, channel.name, threadParent, guild.id)
+ db.prepare("INSERT INTO historical_channel_room (reference_channel_id, room_id, upgraded_timestamp) VALUES (?, ?, 0)").run(channel.id, roomID)
+ })()
+
+ return roomCreate
})
+ const roomID = roomCreate.room_id
// Put the newly created child into the space
await _syncSpaceMember(channel, spaceID, roomID, guild.id)
@@ -241,31 +266,35 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) {
/**
* Handling power levels separately. The spec doesn't specify what happens, Dendrite differs,
- * and Synapse does an absolutely insane *shallow merge* of what I provide on top of what it creates.
+ * and Synapse does a very poorly thought out *shallow merge* of what I provide on top of what it creates.
* We don't want the `events` key to be overridden completely.
* https://github.com/matrix-org/synapse/blob/develop/synapse/handlers/room.py#L1170-L1210
* https://github.com/matrix-org/matrix-spec/issues/492
* @param {any} kstate
- * @param {(_: any) => Promise} callback must return room ID
- * @returns {Promise} room ID
+ * @param {(_: any) => Promise>} callback must return room ID and room version
+ * @returns {Promise>} room ID
*/
async function postApplyPowerLevels(kstate, callback) {
const powerLevelContent = kstate["m.room.power_levels/"]
const kstateWithoutPowerLevels = {...kstate}
delete kstateWithoutPowerLevels["m.room.power_levels/"]
- delete kstateWithoutPowerLevels["chat.schildi.hide_ui/read_receipts"]
- /** @type {string} */
- const roomID = await callback(kstateWithoutPowerLevels)
+ const roomCreate = await callback(kstateWithoutPowerLevels)
+ const roomID = roomCreate.room_id
// Now *really* apply the power level overrides on top of what Synapse *really* set
if (powerLevelContent) {
- const newRoomKState = await ks.roomToKState(roomID)
- const newRoomPowerLevelsDiff = ks.diffKState(newRoomKState, {"m.room.power_levels/": powerLevelContent})
- await ks.applyKStateDiffToRoom(roomID, newRoomPowerLevelsDiff)
+ mUtils.removeCreatorsFromPowerLevels(roomCreate, powerLevelContent)
+
+ const originalPowerLevels = await api.getStateEvent(roomID, "m.room.power_levels", "")
+ const powerLevelsDiff = ks.diffKState(
+ {"m.room.power_levels/": originalPowerLevels, "m.room.create/": roomCreate.content, "m.room.create/outer": roomCreate},
+ {"m.room.power_levels/": powerLevelContent}
+ )
+ await ks.applyKStateDiffToRoom(roomID, powerLevelsDiff)
}
- return roomID
+ return roomCreate
}
/**
@@ -358,7 +387,7 @@ function assertExistsOrAutocreatable(channel, guildID) {
* @returns {Promise} room ID
*/
async function _syncRoom(channelID, shouldActuallySync) {
- /** @ts-ignore @type {DiscordTypes.APIGuildChannel} */
+ /** @ts-ignore @type {DiscordTypes.APIGuildTextChannel} */
const channel = discord.channels.get(channelID)
assert.ok(channel)
const guild = channelToGuild(channel)
@@ -389,18 +418,19 @@ async function _syncRoom(channelID, shouldActuallySync) {
console.log(`[room sync] to matrix: ${channel.name}`)
const {spaceID, channelKState} = await channelToKState(channel, guild, {api}) // calling this in both branches because we don't want to calculate this if not syncing
+ await ks.kstateUploadMxc(channelKState) // pre-upload icons before diffing
// sync channel state to room
const roomKState = await ks.roomToKState(roomID)
- if (+roomKState["m.room.create/"].room_version <= 8) {
- // join_rule `restricted` is not available in room version < 8 and not working properly in version == 8
+ if (!mUtils.roomHasAtLeastVersion(roomKState["m.room.create/"].room_version, 9)) {
+ // join_rule `restricted` is not available in room version < 8 and not working properly in version == 8, so require version 9
// read more: https://spec.matrix.org/v1.8/rooms/v9/
// we have to use `public` instead, otherwise the room will be unjoinable.
channelKState["m.room.join_rules/"] = {join_rule: "public"}
}
const roomDiff = ks.diffKState(roomKState, channelKState)
const roomApply = ks.applyKStateDiffToRoom(roomID, roomDiff)
- db.prepare("UPDATE channel_room SET name = ? WHERE room_id = ?").run(channel.name, roomID)
+ db.prepare("UPDATE channel_room SET name = ? WHERE channel_id = ?").run(channel.name, channel.id)
// sync room as space member
const spaceApply = _syncSpaceMember(channel, spaceID, roomID, guild.id)
@@ -419,22 +449,15 @@ function syncRoom(channelID) {
return _syncRoom(channelID, true)
}
-async function unbridgeChannel(channelID) {
- /** @ts-ignore @type {DiscordTypes.APIGuildChannel} */
- const channel = discord.channels.get(channelID)
- assert.ok(channel)
- assert.ok(channel.guild_id)
- return unbridgeDeletedChannel(channel, channel.guild_id)
-}
-
/**
* @param {{id: string, topic?: string?}} channel channel-ish (just needs an id, topic is optional)
* @param {string} guildID
+ * @param {string} messageBeforeLeave
*/
-async function unbridgeDeletedChannel(channel, guildID) {
+async function unbridgeChannel(channel, guildID, messageBeforeLeave = "This room was removed from the bridge.") {
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
assert.ok(roomID)
- const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").get()
+ const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").where({guild_id: guildID}).get()
assert.ok(row)
let botInRoom = true
@@ -458,32 +481,30 @@ async function unbridgeDeletedChannel(channel, guildID) {
// delete webhook on discord
const webhook = select("webhook", ["webhook_id", "webhook_token"], {channel_id: channel.id}).get()
if (webhook) {
- await discord.snow.webhook.deleteWebhook(webhook.webhook_id, webhook.webhook_token)
+ await discord.snow.webhook.deleteWebhook(webhook.webhook_id, webhook.webhook_token).catch(() => {})
db.prepare("DELETE FROM webhook WHERE channel_id = ?").run(channel.id)
}
// delete room from database
db.prepare("DELETE FROM member_cache WHERE room_id = ?").run(roomID)
- db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channel.id) // cascades to most other tables, like messages
+ db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channel.id) // cascades to most other tables, like messages and historical rooms
if (!botInRoom) return
- // demote admins in room
- /** @type {Ty.Event.M_Power_Levels} */
- const powerLevelContent = await api.getStateEvent(roomID, "m.room.power_levels", "")
- powerLevelContent.users ??= {}
- const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}`
- for (const mxid of Object.keys(powerLevelContent.users)) {
- if (powerLevelContent.users[mxid] >= 100 && mUtils.eventSenderIsFromDiscord(mxid) && mxid !== bot) {
- delete powerLevelContent.users[mxid]
- await api.sendState(roomID, "m.room.power_levels", "", powerLevelContent, mxid)
+ // demote discord sim admins in room
+ const {powerLevels, allCreators} = await mUtils.getEffectivePower(roomID, [], api)
+ const powerLevelsUsers = (powerLevels.users ||= {})
+ for (const mxid of Object.keys(powerLevelsUsers)) {
+ if (powerLevelsUsers[mxid] >= (powerLevels.state_default ?? 50) && !allCreators.includes(mxid) && mUtils.eventSenderIsFromDiscord(mxid) && mxid !== mUtils.bot) {
+ delete powerLevelsUsers[mxid]
+ await api.sendState(roomID, "m.room.power_levels", "", powerLevels, mxid) // done individually because each user must demote themselves
}
}
// send a notification in the room
await api.sendEvent(roomID, "m.room.message", {
msgtype: "m.notice",
- body: "⚠️ This room was removed from the bridge."
+ body: `⚠️ ${messageBeforeLeave}`
})
// if it is an easy mode room, clean up the room from the managed space and make it clear it's not being bridged
@@ -498,7 +519,7 @@ async function unbridgeDeletedChannel(channel, guildID) {
// (the room can be used with less clutter and the member list makes sense if it's bridged somewhere else)
if (row.autocreate === 0) {
// remove sim members
- const members = db.prepare("SELECT mxid FROM sim_member WHERE room_id = ? AND mxid <> ?").pluck().all(roomID, bot)
+ const members = db.prepare("SELECT mxid FROM sim_member WHERE room_id = ? AND mxid <> ?").pluck().all(roomID, mUtils.bot)
const preparedDelete = db.prepare("DELETE FROM sim_member WHERE room_id = ? AND mxid = ?")
for (const mxid of members) {
await api.leaveRoom(roomID, mxid)
@@ -507,6 +528,7 @@ async function unbridgeDeletedChannel(channel, guildID) {
}
// leave room
+ await mUtils.setUserPower(roomID, mUtils.bot, 0, api)
await api.leaveRoom(roomID)
}
@@ -568,7 +590,7 @@ module.exports.createAllForGuild = createAllForGuild
module.exports.channelToKState = channelToKState
module.exports.postApplyPowerLevels = postApplyPowerLevels
module.exports._convertNameAndTopic = convertNameAndTopic
+module.exports._syncSpaceMember = _syncSpaceMember
module.exports.unbridgeChannel = unbridgeChannel
-module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel
module.exports.existsOrAutocreatable = existsOrAutocreatable
module.exports.assertExistsOrAutocreatable = assertExistsOrAutocreatable
diff --git a/src/d2m/actions/create-room.test.js b/src/d2m/actions/create-room.test.js
index e653744a..c9e098bb 100644
--- a/src/d2m/actions/create-room.test.js
+++ b/src/d2m/actions/create-room.test.js
@@ -9,19 +9,44 @@ const testData = require("../../../test/data")
const passthrough = require("../../passthrough")
const {db} = passthrough
+function mockAPI(t) {
+ let called = 0
+ return {
+ getCalled() {
+ return called
+ },
+ async getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
+ called++
+ t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe")
+ t.equal(type, "m.room.power_levels")
+ t.equal(key, "")
+ return {users: {"@example:matrix.org": 50}, events: {"m.room.tombstone": 100}}
+ },
+ async getStateEventOuter(roomID, type, key) {
+ called++
+ t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe")
+ t.equal(type, "m.room.create")
+ t.equal(key, "")
+ return {
+ type: "m.room.create",
+ state_key: "",
+ content: {
+ room_version: "11"
+ },
+ event_id: "$create",
+ origin_server_ts: 0,
+ room_id: "!jjmvBegULiLucuWEHU:cadence.moe",
+ sender: "@_ooye_bot:cadence.moe"
+ }
+ }
+ }
+}
test("channel2room: discoverable privacy room", async t => {
- let called = 0
- async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
- called++
- t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe")
- t.equal(type, "m.room.power_levels")
- t.equal(key, "")
- return {users: {"@example:matrix.org": 50}}
- }
+ const api = mockAPI(t)
db.prepare("UPDATE guild_space SET privacy_level = 2").run()
t.deepEqual(
- kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)),
+ kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api}).then(x => x.channelKState)),
Object.assign({}, testData.room.general, {
"m.room.guest_access/": {guest_access: "forbidden"},
"m.room.join_rules/": {join_rule: "public"},
@@ -29,58 +54,37 @@ test("channel2room: discoverable privacy room", async t => {
"m.room.power_levels/": mixin({users: {"@example:matrix.org": 50}}, testData.room.general["m.room.power_levels/"])
})
)
- t.equal(called, 1)
+ t.equal(api.getCalled(), 2)
})
test("channel2room: linkable privacy room", async t => {
- let called = 0
- async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
- called++
- t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe")
- t.equal(type, "m.room.power_levels")
- t.equal(key, "")
- return {users: {"@example:matrix.org": 50}}
- }
+ const api = mockAPI(t)
db.prepare("UPDATE guild_space SET privacy_level = 1").run()
t.deepEqual(
- kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)),
+ kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api}).then(x => x.channelKState)),
Object.assign({}, testData.room.general, {
"m.room.guest_access/": {guest_access: "forbidden"},
"m.room.join_rules/": {join_rule: "public"},
"m.room.power_levels/": mixin({users: {"@example:matrix.org": 50}}, testData.room.general["m.room.power_levels/"])
})
)
- t.equal(called, 1)
+ t.equal(api.getCalled(), 2)
})
test("channel2room: invite-only privacy room", async t => {
- let called = 0
- async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
- called++
- t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe")
- t.equal(type, "m.room.power_levels")
- t.equal(key, "")
- return {users: {"@example:matrix.org": 50}}
- }
+ const api = mockAPI(t)
db.prepare("UPDATE guild_space SET privacy_level = 0").run()
t.deepEqual(
- kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)),
+ kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api}).then(x => x.channelKState)),
Object.assign({}, testData.room.general, {
"m.room.power_levels/": mixin({users: {"@example:matrix.org": 50}}, testData.room.general["m.room.power_levels/"])
})
)
- t.equal(called, 1)
+ t.equal(api.getCalled(), 2)
})
test("channel2room: room where limited people can mention everyone", async t => {
- let called = 0
- async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
- called++
- t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe")
- t.equal(type, "m.room.power_levels")
- t.equal(key, "")
- return {users: {"@example:matrix.org": 50}}
- }
+ const api = mockAPI(t)
const limitedGuild = mixin({}, testData.guild.general)
limitedGuild.roles[0].permissions = (BigInt(limitedGuild.roles[0].permissions) - 131072n).toString()
const limitedRoom = mixin({}, testData.room.general, {"m.room.power_levels/": {
@@ -88,43 +92,31 @@ test("channel2room: room where limited people can mention everyone", async t =>
users: {"@example:matrix.org": 50}
}})
t.deepEqual(
- kstateStripConditionals(await channelToKState(testData.channel.general, limitedGuild, {api: {getStateEvent}}).then(x => x.channelKState)),
+ kstateStripConditionals(await channelToKState(testData.channel.general, limitedGuild, {api}).then(x => x.channelKState)),
limitedRoom
)
- t.equal(called, 1)
+ t.equal(api.getCalled(), 2)
})
test("channel2room: matrix room that already has a custom topic set", async t => {
- let called = 0
- async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
- called++
- t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe")
- t.equal(type, "m.room.power_levels")
- t.equal(key, "")
- return {}
- }
+ const api = mockAPI(t)
db.prepare("UPDATE channel_room SET custom_topic = 1 WHERE channel_id = ?").run(testData.channel.general.id)
- const expected = mixin({}, testData.room.general, {"m.room.power_levels/": {notifications: {room: 20}}})
+ const expected = mixin({}, testData.room.general, {"m.room.power_levels/": {notifications: {room: 20}, users: {"@example:matrix.org": 50}}})
// @ts-ignore
delete expected["m.room.topic/"]
t.deepEqual(
- kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)),
+ kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api}).then(x => x.channelKState)),
expected
)
- t.equal(called, 1)
+ t.equal(api.getCalled(), 2)
})
test("channel2room: read-only discord channel", async t => {
- let called = 0
- async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
- called++
- t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe")
- t.equal(type, "m.room.power_levels")
- t.equal(key, "")
- return {}
- }
+ const api = mockAPI(t)
const expected = {
- "chat.schildi.hide_ui/read_receipts": {},
+ "m.room.create/": {
+ additional_creators: ["@test_auto_invite:example.org"],
+ },
"m.room.avatar/": {
url: {
$url: "/icons/112760669178241024/a_f83622e09ead74f0c5c527fe241f8f8c.png?size=1024",
@@ -161,7 +153,8 @@ test("channel2room: read-only discord channel", async t => {
room: 20,
},
users: {
- "@test_auto_invite:example.org": 100,
+ "@test_auto_invite:example.org": 150,
+ "@example:matrix.org": 50
},
},
"m.space.parent/!jjmvBegULiLucuWEHU:cadence.moe": {
@@ -191,10 +184,21 @@ test("channel2room: read-only discord channel", async t => {
}
}
t.deepEqual(
- kstateStripConditionals(await channelToKState(testData.channel.updates, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)),
+ kstateStripConditionals(await channelToKState(testData.channel.updates, testData.guild.general, {api}).then(x => x.channelKState)),
expected
)
- t.equal(called, 1)
+ t.equal(api.getCalled(), 2)
+})
+
+test("channel2room: voice channel", async t => {
+ const api = mockAPI(t)
+ const state = kstateStripConditionals(await channelToKState(testData.channel.voice, testData.guild.general, {api}).then(x => x.channelKState))
+ t.equal(state["m.room.create/"].type, "org.matrix.msc3417.call")
+ t.deepEqual(state["org.matrix.msc3401.call/"], {
+ "m.intent": "m.room",
+ "m.name": "🍞丨[8user] Piece",
+ "m.type": "m.voice"
+ })
})
test("convertNameAndTopic: custom name and topic", t => {
diff --git a/src/d2m/actions/create-space.js b/src/d2m/actions/create-space.js
index 8bce3adc..9f5d7e30 100644
--- a/src/d2m/actions/create-space.js
+++ b/src/d2m/actions/create-space.js
@@ -8,6 +8,8 @@ const {reg} = require("../../matrix/read-registration")
const passthrough = require("../../passthrough")
const {discord, sync, db, select} = passthrough
+/** @type {import("../../matrix/mreq")} */
+const mreq = sync.require("../../matrix/mreq")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/file")} */
@@ -35,8 +37,8 @@ async function createSpace(guild, kstate) {
const enablePresenceByDefault = +(memberCount < 50) // scary! all active users in a presence-enabled guild will be pinging the server every <30 seconds to stay online
const globalAdmins = select("member_power", "mxid", {room_id: "*"}).pluck().all()
- const roomID = await createRoom.postApplyPowerLevels(kstate, async kstate => {
- return api.createRoom({
+ const roomCreate = await createRoom.postApplyPowerLevels(kstate, async kstate => {
+ const roomID = await api.createRoom({
name,
preset: createRoom.PRIVACY_ENUMS.PRESET[createRoom.DEFAULT_PRIVACY_LEVEL], // New spaces will have to use the default privacy level; we obviously can't look up the existing entry
visibility: createRoom.PRIVACY_ENUMS.VISIBILITY[createRoom.DEFAULT_PRIVACY_LEVEL],
@@ -46,12 +48,14 @@ async function createSpace(guild, kstate) {
},
invite: globalAdmins,
topic,
- creation_content: {
- type: "m.space"
- },
- initial_state: await ks.kstateToState(kstate)
+ initial_state: await ks.kstateToState(kstate),
+ creation_content: ks.kstateToCreationContent(kstate)
})
+ const roomCreate = await api.getStateEventOuter(roomID, "m.room.create", "")
+ return roomCreate
})
+ const roomID = roomCreate.room_id
+
db.prepare("INSERT INTO guild_space (guild_id, space_id, presence) VALUES (?, ?, ?)").run(guild.id, roomID, enablePresenceByDefault)
return roomID
}
@@ -63,7 +67,13 @@ async function createSpace(guild, kstate) {
async function guildToKState(guild, privacyLevel) {
assert.equal(typeof privacyLevel, "number")
const globalAdmins = select("member_power", ["mxid", "power_level"], {room_id: "*"}).all()
+ const additionalCreators = select("member_power", "mxid", {room_id: "*"}, "AND power_level > 100").pluck().all()
+
const guildKState = {
+ "m.room.create/": {
+ type: "m.space",
+ additional_creators: additionalCreators
+ },
"m.room.name/": {name: guild.name},
"m.room.avatar/": {
$if: guild.icon,
@@ -116,6 +126,8 @@ async function _syncSpace(guild, shouldActuallySync) {
console.log(`[space sync] to matrix: ${guild.name}`)
const guildKState = await guildToKState(guild, privacy_level) // calling this in both branches because we don't want to calculate this if not syncing
+ ks.kstateStripConditionals(guildKState) // pre-upload icons before diffing
+ await ks.kstateUploadMxc(guildKState)
// sync guild state to space
const spaceKState = await ks.roomToKState(spaceID)
@@ -177,6 +189,8 @@ async function syncSpaceFully(guildID) {
console.log(`[space sync] to matrix: ${guild.name}`)
const guildKState = await guildToKState(guild, privacy_level)
+ ks.kstateStripConditionals(guildKState) // pre-upload icons before diffing
+ await ks.kstateUploadMxc(guildKState)
// sync guild state to space
const spaceKState = await ks.roomToKState(spaceID)
@@ -191,7 +205,7 @@ async function syncSpaceFully(guildID) {
if (discord.channels.has(channelID)) {
await createRoom.syncRoom(channelID)
} else {
- await createRoom.unbridgeDeletedChannel({id: channelID}, guildID)
+ await createRoom.unbridgeChannel({id: channelID}, guildID)
}
}
@@ -217,14 +231,20 @@ async function syncSpaceExpressions(data, checkBeforeSync) {
*/
async function update(spaceID, key, eventKey, fn) {
if (!(key in data) || !data[key].length) return
- const content = await fn(data[key])
+ const guild = discord.guilds.get(data.guild_id)
+ assert(guild)
+ const content = await fn(data[key], guild)
if (checkBeforeSync) {
let existing
try {
existing = await api.getStateEvent(spaceID, "im.ponies.room_emotes", eventKey)
} catch (e) {
- // State event not found. This space doesn't have any existing emojis. We create a dummy empty event for comparison's sake.
- existing = fn([])
+ if (e instanceof mreq.MatrixServerError && e.httpStatus < 500) {
+ // State event not found. This space doesn't have any existing emojis. We create a dummy empty event for comparison's sake.
+ existing = fn([], guild)
+ } else {
+ throw e
+ }
}
if (isDeepStrictEqual(existing, content)) return
}
diff --git a/src/d2m/actions/create-space.test.js b/src/d2m/actions/create-space.test.js
index cb4d90a4..fc6eba41 100644
--- a/src/d2m/actions/create-space.test.js
+++ b/src/d2m/actions/create-space.test.js
@@ -13,6 +13,10 @@ test("guild2space: can generate kstate for a guild, passing privacy level 0", as
t.deepEqual(
await kstateUploadMxc(kstateStripConditionals(await guildToKState(testData.guild.general, 0))),
{
+ "m.room.create/": {
+ additional_creators: ["@test_auto_invite:example.org"],
+ type: "m.space"
+ },
"m.room.avatar/": {
url: "mxc://cadence.moe/zKXGZhmImMHuGQZWJEFKJbsF"
},
@@ -30,7 +34,7 @@ test("guild2space: can generate kstate for a guild, passing privacy level 0", as
},
"m.room.power_levels/": {
users: {
- "@test_auto_invite:example.org": 100
+ "@test_auto_invite:example.org": 150
},
},
}
diff --git a/src/d2m/actions/delete-message.js b/src/d2m/actions/delete-message.js
index e9e0b088..39b9fc84 100644
--- a/src/d2m/actions/delete-message.js
+++ b/src/d2m/actions/delete-message.js
@@ -14,11 +14,13 @@ async function deleteMessage(data) {
const row = select("channel_room", ["room_id", "speedbump_checked", "thread_parent"], {channel_id: data.channel_id}).get()
if (!row) return
- const eventsToRedact = select("event_message", "event_id", {message_id: data.id}).pluck().all()
- db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(data.id)
- for (const eventID of eventsToRedact) {
+ // Assume we can redact from tombstoned rooms.
+ const eventsToRedact = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
+ .select("event_id", "room_id").where({message_id: data.id}).all()
+ db.prepare("DELETE FROM message_room WHERE message_id = ?").run(data.id)
+ for (const {event_id, room_id} of eventsToRedact) {
// Unfortunately, we can't specify a sender to do the redaction as, unless we find out that info via the audit logs
- await api.redactEvent(row.room_id, eventID)
+ await api.redactEvent(room_id, event_id)
}
await speedbump.updateCache(row.thread_parent || data.channel_id, row.speedbump_checked)
@@ -28,15 +30,17 @@ async function deleteMessage(data) {
* @param {import("discord-api-types/v10").GatewayMessageDeleteBulkDispatchData} data
*/
async function deleteMessageBulk(data) {
- const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get()
- if (!roomID) return
+ const row = select("channel_room", "room_id", {channel_id: data.channel_id}).get()
+ if (!row) return
const sids = JSON.stringify(data.ids)
- const eventsToRedact = from("event_message").pluck("event_id").and("WHERE message_id IN (SELECT value FROM json_each(?))").all(sids)
- db.prepare("DELETE FROM message_channel WHERE message_id IN (SELECT value FROM json_each(?))").run(sids)
- for (const eventID of eventsToRedact) {
+ // Assume we can redact from tombstoned rooms.
+ const eventsToRedact = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
+ .select("event_id", "room_id").and("WHERE message_id IN (SELECT value FROM json_each(?))").all(sids)
+ db.prepare("DELETE FROM message_room WHERE message_id IN (SELECT value FROM json_each(?))").run(sids)
+ for (const {event_id, room_id} of eventsToRedact) {
// Awaiting will make it go slower, but since this could be a long-running operation either way, we want to leave rate limit capacity for other operations
- await api.redactEvent(roomID, eventID)
+ await api.redactEvent(room_id, event_id)
}
}
diff --git a/src/d2m/actions/edit-message.js b/src/d2m/actions/edit-message.js
index 1afcb35e..31f36273 100644
--- a/src/d2m/actions/edit-message.js
+++ b/src/d2m/actions/edit-message.js
@@ -1,29 +1,35 @@
// @ts-check
-const assert = require("assert").strict
-
const passthrough = require("../../passthrough")
-const {sync, db, select} = passthrough
+const {sync, db, select, from} = passthrough
+const {reg} = require("../../matrix/read-registration")
/** @type {import("../converters/edit-to-changes")} */
const editToChanges = sync.require("../converters/edit-to-changes")
-/** @type {import("./register-pk-user")} */
-const registerPkUser = sync.require("./register-pk-user")
+/** @type {import("./speedbump")} */
+const speedbump = sync.require("./speedbump")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
+/** @type {import("../../matrix/mreq")} */
+const mreq = sync.require("../../matrix/mreq")
+/** @type {import("../../discord/utils")} */
+const dUtils = sync.require("../../discord/utils")
/**
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
* @param {import("discord-api-types/v10").APIGuild} guild
- * @param {{speedbump_id: string, speedbump_webhook_id: string} | null} row data about the webhook which is proxying messages in this channel
*/
-async function editMessage(message, guild, row) {
+async function editMessage(message, guild) {
+ const historicalRoomOfMessage = from("message_room").join("historical_channel_room", "historical_room_index").where({message_id: message.id}).select("room_id").get()
+ const currentRoom = from("channel_room").join("historical_channel_room", "room_id").where({channel_id: message.channel_id}).select("room_id", "historical_room_index").get()
+ if (!currentRoom) return
+
+ if (historicalRoomOfMessage && historicalRoomOfMessage.room_id !== currentRoom.room_id) return // tombstoned rooms should not have new events (including edits) sent to them
+
let {roomID, eventsToRedact, eventsToReplace, eventsToSend, senderMxid, promotions} = await editToChanges.editToChanges(message, guild, api)
- if (row && row.speedbump_webhook_id === message.webhook_id) {
- // Handle the PluralKit public instance
- if (row.speedbump_id === "466378653216014359") {
- senderMxid = await registerPkUser.syncUser(message.id, message.author, roomID, false)
- }
+ // Sync proxy user profile (if sent by proxy)
+ if (dUtils.isWebhookMessage(message)) {
+ senderMxid = await speedbump.getWebhookSenderId(message, guild.id, roomID)
}
// 1. Replace all the things.
@@ -59,7 +65,7 @@ async function editMessage(message, guild, row) {
// 4. Send all the things.
if (eventsToSend.length) {
- db.prepare("INSERT OR IGNORE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id)
+ db.prepare("INSERT OR IGNORE INTO message_room (message_id, historical_room_index) VALUES (?, ?)").run(message.id, currentRoom.historical_room_index)
}
for (const content of eventsToSend) {
const eventType = content.$type
@@ -70,8 +76,17 @@ async function editMessage(message, guild, row) {
const part = sendNewEventParts.has("part") && eventsToSend[0] === content ? 0 : 1
const reactionPart = sendNewEventParts.has("reaction_part") && eventsToSend[eventsToSend.length - 1] === content ? 0 : 1
- const eventID = await api.sendEvent(roomID, eventType, contentWithoutType, senderMxid)
- db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 1)").run(eventID, eventType, content.msgtype || null, message.id, part, reactionPart) // source 1 = discord
+
+ try {
+ const eventID = await api.sendEvent(roomID, eventType, contentWithoutType, senderMxid)
+ db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 1)").run(eventID, eventType, content.msgtype || null, message.id, part, reactionPart) // source 1 = discord
+ } catch (e) {
+ if (e instanceof mreq.MatrixServerError && e.errcode === "M_FORBIDDEN") {
+ // sending user doesn't have permission to update message, e.g. because Discord generated an embed in a read-only room
+ } else {
+ throw e
+ }
+ }
}
}
diff --git a/src/d2m/actions/expression.js b/src/d2m/actions/expression.js
index fd75aa5b..0f714c60 100644
--- a/src/d2m/actions/expression.js
+++ b/src/d2m/actions/expression.js
@@ -9,11 +9,12 @@ const file = sync.require("../../matrix/file")
/**
* @param {DiscordTypes.APIEmoji[]} emojis
+ * @param {DiscordTypes.APIGuild} guild
*/
-async function emojisToState(emojis) {
+async function emojisToState(emojis, guild) {
const result = {
pack: {
- display_name: "Discord Emojis",
+ display_name: `${guild.name} (Discord Emojis)`,
usage: ["emoticon"] // we'll see...
},
images: {
@@ -24,7 +25,7 @@ async function emojisToState(emojis) {
file.uploadDiscordFileToMxc(file.emoji(emoji.id, emoji.animated)).then(url => {
result.images[emoji.name] = {
info: {
- mimetype: emoji.animated ? "image/gif" : "image/png"
+ mimetype: "image/webp"
},
url
}
@@ -33,7 +34,10 @@ async function emojisToState(emojis) {
if (e.data?.errcode === "M_TOO_LARGE") { // Very unlikely to happen. Only possible for 3x-series emojis uploaded shortly after animated emojis were introduced, when there was no 256 KB size limit.
return
}
- console.error(`Trying to handle emoji ${emoji.name} (${emoji.id}), but...`)
+ e["emoji"] = {
+ name: emoji.name,
+ id: emoji.id
+ }
throw e
})
))
@@ -42,11 +46,12 @@ async function emojisToState(emojis) {
/**
* @param {DiscordTypes.APISticker[]} stickers
+ * @param {DiscordTypes.APIGuild} guild
*/
-async function stickersToState(stickers) {
+async function stickersToState(stickers, guild) {
const result = {
pack: {
- display_name: "Discord Stickers",
+ display_name: `${guild.name} (Discord Stickers)`,
usage: ["sticker"] // we'll see...
},
images: {
diff --git a/src/d2m/actions/poll-end.js b/src/d2m/actions/poll-end.js
new file mode 100644
index 00000000..8ede9e2f
--- /dev/null
+++ b/src/d2m/actions/poll-end.js
@@ -0,0 +1,122 @@
+// @ts-check
+
+const assert = require("assert").strict
+const DiscordTypes = require("discord-api-types/v10")
+const {isDeepStrictEqual} = require("util")
+
+const passthrough = require("../../passthrough")
+const {discord, sync, db, select, from} = passthrough
+const {reg} = require("../../matrix/read-registration")
+/** @type {import("./poll-vote")} */
+const vote = sync.require("../actions/poll-vote")
+/** @type {import("../../discord/interactions/poll-responses")} */
+const pollResponses = sync.require("../../discord/interactions/poll-responses")
+
+/**
+ * @file This handles, in the following order:
+ * * verifying Matrix-side votes are accurate for a poll originating on Discord, sending missed votes to Matrix if necessary
+ * * sending a message to Discord if a vote in that poll has been cast on Matrix
+ * This does *not* handle bridging of poll closures on Discord to Matrix; that takes place in converters/message-to-event.js.
+ */
+
+/**
+ * @param {string} channelID
+ * @param {string} messageID
+ * @param {string} answerID
+ * @returns {Promise}
+ */
+async function getAllVotesOnAnswer(channelID, messageID, answerID) {
+ const limit = 100
+ /** @type {DiscordTypes.RESTGetAPIPollAnswerVotersResult["users"]} */
+ let voteUsers = []
+ let after = undefined
+ while (true) {
+ const curVotes = await discord.snow.channel.getPollAnswerVoters(channelID, messageID, answerID, {after: after, limit})
+ voteUsers = voteUsers.concat(curVotes.users)
+ if (curVotes.users.length >= limit) { // Loop again for the next page.
+ // @ts-ignore - stupid
+ after = curVotes.users.at(-1).id
+ } else { // Reached the end.
+ return voteUsers
+ }
+ }
+}
+
+/**
+ * @param {typeof import("../../../test/data.js")["poll_close"]} closeMessage
+*/
+async function endPoll(closeMessage) {
+ const pollCloseObject = closeMessage.embeds[0]
+
+ const pollMessageID = closeMessage.message_reference.message_id
+ const pollEventID = select("event_message", "event_id", {message_id: pollMessageID, event_type: "org.matrix.msc3381.poll.start"}).pluck().get()
+ if (!pollEventID) return // Nothing we can send Discord-side if we don't have the original poll. We will still send a results message Matrix-side.
+
+ const discordPollOptions = select("poll_option", "discord_option", {message_id: pollMessageID}).pluck().all()
+ assert(discordPollOptions.every(x => typeof x === "string")) // This poll originated on Discord so it will have Discord option IDs
+
+ // If the closure came from Discord, we want to fetch all the votes there again and bridge over any that got lost to Matrix before posting the results.
+ // Database reads are cheap, and API calls are expensive, so we will only query Discord when the totals don't match.
+
+ const totalVotes = +pollCloseObject.fields.find(element => element.name === "total_votes").value // We could do [2], but best not to rely on the ordering staying consistent.
+
+ const databaseVotes = select("poll_vote", ["discord_or_matrix_user_id", "matrix_option"], {message_id: pollMessageID}, " AND discord_or_matrix_user_id NOT LIKE '@%'").all()
+
+ if (databaseVotes.length !== totalVotes) { // Matching length should be sufficient for most cases.
+ let voteUsers = [...new Set(databaseVotes.map(vote => vote.discord_or_matrix_user_id))] // Unique array of all users we have votes for in the database.
+
+ // Main design challenge here: we get the data by *answer*, but we need to send it to Matrix by *user*.
+
+ /** @type {{user: DiscordTypes.APIUser, matrixOptionVotes: string[]}[]} This will be our new array of answers */
+ const updatedAnswers = []
+
+ for (const discordPollOption of discordPollOptions) {
+ const optionUsers = await getAllVotesOnAnswer(closeMessage.channel_id, pollMessageID, discordPollOption) // Array of user IDs who voted for the option we're testing.
+ for (const user of optionUsers) {
+ const userLocation = updatedAnswers.findIndex(answer => answer.user.id === user.id)
+ const matrixOption = select("poll_option", "matrix_option", {message_id: pollMessageID, discord_option: discordPollOption}).pluck().get()
+ assert(matrixOption)
+ if (userLocation === -1) { // We haven't seen this user yet, so we need to add them.
+ updatedAnswers.push({user, matrixOptionVotes: [matrixOption]}) // toString as this is what we store and get from the database and send to Matrix.
+ } else { // This user already voted for another option on the poll.
+ updatedAnswers[userLocation].matrixOptionVotes.push(matrixOption)
+ }
+ }
+ }
+
+ // Check for inconsistencies in what was cached in database vs final confirmed poll answers
+ // If different, sync the final confirmed answers to Matrix-side to make it accurate there too
+
+ await Promise.all(updatedAnswers.map(async answer => {
+ voteUsers = voteUsers.filter(item => item !== answer.user.id) // Remove any users we have updated answers for from voteUsers. The only remaining entries in this array will be users who voted, but then removed their votes before the poll ended.
+ const cachedAnswers = select("poll_vote", "matrix_option", {discord_or_matrix_user_id: answer.user.id, message_id: pollMessageID}).pluck().all()
+ if (!isDeepStrictEqual(new Set(cachedAnswers), new Set(answer.matrixOptionVotes))) {
+ db.transaction(() => {
+ db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ?").run(answer.user.id, pollMessageID) // Delete existing stored votes.
+ for (const matrixOption of answer.matrixOptionVotes) {
+ db.prepare("INSERT INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(answer.user.id, pollMessageID, matrixOption)
+ }
+ })()
+ await vote.sendVotes(answer.user, closeMessage.channel_id, pollMessageID, pollEventID)
+ }
+ }))
+
+ await Promise.all(voteUsers.map(async user_id => { // Remove these votes.
+ db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ?").run(user_id, pollMessageID)
+ await vote.sendVotes(user_id, closeMessage.channel_id, pollMessageID, pollEventID)
+ }))
+ }
+
+ const {combinedVotes, messageString} = pollResponses.getCombinedResults(pollMessageID, true)
+
+ if (combinedVotes !== totalVotes) { // This means some votes were cast on Matrix. Now that we've corrected the vote totals, we can get the results again and post them to Discord.
+ return {
+ username: "Total results including Matrix votes",
+ avatar_url: `${reg.ooye.bridge_origin}/download/file/poll-star-avatar.png`,
+ content: messageString,
+ flags: DiscordTypes.MessageFlags.SuppressEmbeds
+ }
+ }
+}
+
+module.exports.endPoll = endPoll
diff --git a/src/d2m/actions/poll-vote.js b/src/d2m/actions/poll-vote.js
new file mode 100644
index 00000000..66918fea
--- /dev/null
+++ b/src/d2m/actions/poll-vote.js
@@ -0,0 +1,100 @@
+// @ts-check
+
+const assert = require("assert").strict
+const DiscordTypes = require("discord-api-types/v10")
+const {Semaphore} = require("@chriscdn/promise-semaphore")
+const {scheduler} = require("timers/promises")
+
+const passthrough = require("../../passthrough")
+const {discord, sync, db, select, from} = passthrough
+/** @type {import("../../matrix/api")} */
+const api = sync.require("../../matrix/api")
+/** @type {import("./register-user")} */
+const registerUser = sync.require("./register-user")
+
+const inFlightPollSema = new Semaphore()
+
+/**
+ * @param {import("discord-api-types/v10").GatewayMessagePollVoteAddDispatch["d"]} data
+ */
+async function addVote(data) {
+ const pollEventID = from("event_message").join("poll_option", "message_id").pluck("event_id").where({message_id: data.message_id, event_type: "org.matrix.msc3381.poll.start"}).get() // Currently Discord doesn't allow sending a poll with anything else, but we bridge it after all other content so reaction_part: 0 is the part that will have the poll.
+ if (!pollEventID) return // Nothing can be done if the parent message was never bridged.
+
+ let realAnswer = select("poll_option", "matrix_option", {message_id: data.message_id, discord_option: data.answer_id.toString()}).pluck().get() // Discord answer IDs don't match those on Matrix-created polls.
+ assert(realAnswer)
+ db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(data.user_id, data.message_id, realAnswer)
+ return debounceSendVotes(data, pollEventID)
+}
+
+/**
+ * @param {import("discord-api-types/v10").GatewayMessagePollVoteRemoveDispatch["d"]} data
+ */
+async function removeVote(data) {
+ const pollEventID = from("event_message").join("poll_option", "message_id").pluck("event_id").where({message_id: data.message_id, event_type: "org.matrix.msc3381.poll.start"}).get()
+ if (!pollEventID) return
+
+ let realAnswer = select("poll_option", "matrix_option", {message_id: data.message_id, discord_option: data.answer_id.toString()}).pluck().get() // Discord answer IDs don't match those on Matrix-created polls.
+ assert(realAnswer)
+ db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ? AND matrix_option = ?").run(data.user_id, data.message_id, realAnswer)
+ return debounceSendVotes(data, pollEventID)
+}
+
+/**
+ * Multiple-choice polls send all the votes at the same time. This debounces and sends the combined votes.
+ * In the meantime, the combined votes are assembled in the `poll_vote` database table by the above functions.
+ * @param {import("discord-api-types/v10").GatewayMessagePollVoteAddDispatch["d"]} data
+ * @param {string} pollEventID
+ * @return {Promise} event ID of Matrix vote
+ */
+async function debounceSendVotes(data, pollEventID) {
+ return await inFlightPollSema.request(async () => {
+ await scheduler.wait(1000) // Wait for votes to be collected
+
+ const user = await discord.snow.user.getUser(data.user_id) // Gateway event doesn't give us the object, only the ID.
+ return await sendVotes(user, data.channel_id, data.message_id, pollEventID)
+ }, `${data.user_id}/${data.message_id}`)
+}
+
+/**
+ * @param {DiscordTypes.APIUser | string} userOrID
+ * @param {string} channelID
+ * @param {string} pollMessageID
+ * @param {string} pollEventID
+ */
+async function sendVotes(userOrID, channelID, pollMessageID, pollEventID) {
+ const latestRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
+ const matchingRoomID = from("message_room").join("historical_channel_room", "historical_room_index").where({message_id: pollMessageID}).pluck("room_id").get()
+ if (!latestRoomID || latestRoomID !== matchingRoomID) { // room upgrade mid-poll??
+ db.prepare("UPDATE poll SET is_closed = 1 WHERE message_id = ?").run(pollMessageID)
+ return
+ }
+
+ let userID, senderMxid
+ if (typeof userOrID === "string") { // just a string when double-checking a vote removal - good thing the unvoter is already here from having voted
+ userID = userOrID
+ senderMxid = from("sim").join("sim_member", "mxid").where({user_id: userOrID, room_id: matchingRoomID}).pluck("mxid").get()
+ if (!senderMxid) return
+ } else { // sent in full when double-checking adding a vote, so we can properly ensure joined
+ userID = userOrID.id
+ senderMxid = await registerUser.ensureSimJoined(userOrID, matchingRoomID)
+ }
+
+ const answersArray = select("poll_vote", "matrix_option", {discord_or_matrix_user_id: userID, message_id: pollMessageID}).pluck().all()
+ const eventID = await api.sendEvent(matchingRoomID, "org.matrix.msc3381.poll.response", {
+ "m.relates_to": {
+ rel_type: "m.reference",
+ event_id: pollEventID,
+ },
+ "org.matrix.msc3381.poll.response": {
+ answers: answersArray
+ }
+ }, senderMxid)
+
+ return eventID
+}
+
+module.exports.addVote = addVote
+module.exports.removeVote = removeVote
+module.exports.debounceSendVotes = debounceSendVotes
+module.exports.sendVotes = sendVotes
diff --git a/src/d2m/actions/register-pk-user.js b/src/d2m/actions/register-pk-user.js
index b5e44e56..4946d4fe 100644
--- a/src/d2m/actions/register-pk-user.js
+++ b/src/d2m/actions/register-pk-user.js
@@ -1,6 +1,6 @@
// @ts-check
-const assert = require("assert")
+const assert = require("assert").strict
const {reg} = require("../../matrix/read-registration")
const Ty = require("../../types")
@@ -13,13 +13,6 @@ const file = sync.require("../../matrix/file")
/** @type {import("./register-user")} */
const registerUser = sync.require("./register-user")
-/**
- * @typedef WebhookAuthor Discord API message->author. A webhook as an author.
- * @prop {string} username
- * @prop {string?} avatar
- * @prop {string} id
- */
-
/** @returns {Promise} */
async function fetchMessage(messageID) {
try {
@@ -29,6 +22,7 @@ async function fetchMessage(messageID) {
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()}`)
+ /** @type {any} */
const root = await res.json()
if (!root.member) throw new Error(`PK API didn't return member data: ${JSON.stringify(root)}`)
return root
@@ -93,16 +87,8 @@ async function ensureSimJoined(pkMessage, roomID) {
// Ensure joined
const existing = select("sim_member", "mxid", {room_id: roomID, mxid}).pluck().get()
if (!existing) {
- try {
- await api.inviteToRoom(roomID, mxid)
- await api.joinRoom(roomID, mxid)
- } catch (e) {
- if (e.message.includes("is already in the room.")) {
- // Sweet!
- } else {
- throw e
- }
- }
+ 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
@@ -111,7 +97,7 @@ async function ensureSimJoined(pkMessage, roomID) {
/**
* Generate profile data based on webhook displayname and configured avatar.
* @param {Ty.PkMessage} pkMessage
- * @param {WebhookAuthor} author
+ * @param {Ty.WebhookAuthor} author
*/
async function memberToStateContent(pkMessage, author) {
// We prefer to use the member's avatar URL data since the image upload can be cached across channels,
@@ -137,7 +123,7 @@ async function memberToStateContent(pkMessage, author) {
* 5. Compare against the previously known state content, which is helpfully stored in the database
* 6. If the state content has changed, send it to Matrix and update it in the database for next time
* @param {string} messageID to call API with
- * @param {WebhookAuthor} author for profile data
+ * @param {Ty.WebhookAuthor} author for profile data
* @param {string} roomID room to join member to
* @param {boolean} shouldActuallySync whether to actually sync updated user data or just ensure it's joined
* @returns {Promise} mxid of the updated sim
@@ -146,10 +132,10 @@ async function syncUser(messageID, author, roomID, shouldActuallySync) {
try {
// API lookup
var pkMessage = await fetchMessage(messageID)
- db.prepare("INSERT OR IGNORE INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES (?, ?, ?)").run(pkMessage.member.uuid, pkMessage.sender, author.username)
+ db.prepare("REPLACE INTO sim_proxy (user_id, proxy_owner_id, displayname, proxy_app) VALUES (?, ?, ?, 0)").run(pkMessage.member.uuid, pkMessage.sender, author.username)
} catch (e) {
// Fall back to offline cache
- const senderMxid = from("sim_proxy").join("sim", "user_id").join("sim_member", "mxid").where({displayname: author.username, room_id: roomID}).pluck("mxid").get()
+ const senderMxid = from("sim_proxy").join("sim", "user_id").join("sim_member", "mxid").where({displayname: author.username, room_id: roomID, proxy_app: 0}).pluck("mxid").get()
if (!senderMxid) throw e
return senderMxid
}
@@ -158,16 +144,9 @@ async function syncUser(messageID, author, roomID, shouldActuallySync) {
const mxid = await ensureSimJoined(pkMessage, roomID)
if (shouldActuallySync) {
- // Build current profile data
+ // Build current profile data and sync if the hash has changed
const content = await memberToStateContent(pkMessage, author)
- const currentHash = registerUser._hashProfileContent(content, 0)
- const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get()
-
- // Only do the actual sync if the hash has changed since we last looked
- if (existingHash !== currentHash) {
- await api.sendState(roomID, "m.room.member", mxid, content, mxid)
- db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid)
- }
+ await registerUser._sendSyncUser(roomID, mxid, content, null)
}
return mxid
diff --git a/src/d2m/actions/register-plu-ral-user.js b/src/d2m/actions/register-plu-ral-user.js
new file mode 100644
index 00000000..04fa28cd
--- /dev/null
+++ b/src/d2m/actions/register-plu-ral-user.js
@@ -0,0 +1,170 @@
+// @ts-check
+
+const assert = require("assert").strict
+const {reg} = require("../../matrix/read-registration")
+const Ty = require("../../types")
+
+const passthrough = require("../../passthrough")
+const {sync, db, select, from} = passthrough
+/** @type {import("../../matrix/api")} */
+const api = sync.require("../../matrix/api")
+/** @type {import("../../matrix/file")} */
+const file = sync.require("../../matrix/file")
+/** @type {import("./register-user")} */
+const registerUser = sync.require("./register-user")
+
+/** @returns {Promise} */
+async function fetchMessage(channelID, messageID) {
+ assert(reg.ooye.plu_ral_api_key)
+ try {
+ var res = await fetch(`https://api.plural.gg/messages/${channelID}/${messageID}?member=true`, {
+ headers: {
+ Authorization: reg.ooye.plu_ral_api_key
+ }
+ })
+ } catch (networkError) {
+ // Network issue, raise a more readable message
+ throw new Error(`Failed to connect to /plu/ral API: ${networkError.toString()}`)
+ }
+ if (!res.ok) throw new Error(`/plu/ral API returned an error: ${await res.text()}`)
+ /** @type {any} */
+ const root = await res.json()
+ if (!root.member) throw new Error(`/plu/ral API didn't return member data: ${JSON.stringify(root)}`)
+ return root
+}
+
+/**
+ * Using the same sim names and fake user IDs for /plu/ral members, since unlike PluralKit they don't have a short and a long ID.
+ * @param {Ty.PluRalWebhookMessage} pluRalMessage
+ */
+function getSimName(pluRalMessage) {
+ return `_pl_${pluRalMessage.member_id}`
+}
+
+/**
+ * A sim is an account that is being simulated by the bridge to copy events from the other side.
+ * @param {Ty.PluRalWebhookMessage} pluRalMessage
+ * @returns mxid
+ */
+async function createSim(pluRalMessage) {
+ // Choose sim name
+ const simName = getSimName(pluRalMessage)
+ const localpart = reg.ooye.namespace_prefix + simName
+ const mxid = `@${localpart}:${reg.ooye.server_name}`
+
+ // Save chosen name in the database forever
+ db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?)").run(simName, simName, simName, mxid)
+
+ // Register matrix user with that name
+ try {
+ await api.register(localpart)
+ } catch (e) {
+ // If user creation fails, manually undo the database change. Still isn't perfect, but should help.
+ // (I would prefer a transaction, but it's not safe to leave transactions open across event loop ticks.)
+ db.prepare("DELETE FROM sim WHERE user_id = ?").run(simName)
+ throw e
+ }
+ return mxid
+}
+
+/**
+ * Ensure a sim is registered for the user.
+ * If there is already a sim, use that one. If there isn't one yet, register a new sim.
+ * @param {Ty.PluRalWebhookMessage} pluRalMessage
+ * @returns {Promise} mxid
+ */
+async function ensureSim(pluRalMessage) {
+ let mxid = null
+ const existing = select("sim", "mxid", {user_id: getSimName(pluRalMessage)}).pluck().get()
+ if (existing) {
+ mxid = existing
+ } else {
+ mxid = await createSim(pluRalMessage)
+ }
+ return mxid
+}
+
+/**
+ * Ensure a sim is registered for the user and is joined to the room.
+ * @param {Ty.PluRalWebhookMessage} pluRalMessage
+ * @param {string} roomID
+ * @returns {Promise} mxid
+ */
+async function ensureSimJoined(pluRalMessage, roomID) {
+ // Ensure room ID is really an ID, not an alias
+ assert.ok(roomID[0] === "!")
+
+ // Ensure user
+ const mxid = await ensureSim(pluRalMessage)
+
+ // Ensure joined
+ const existing = select("sim_member", "mxid", {room_id: roomID, mxid}).pluck().get()
+ if (!existing) {
+ await api.inviteToRoom(roomID, mxid)
+ await api.joinRoom(roomID, mxid)
+ db.prepare("INSERT OR IGNORE INTO sim_member (room_id, mxid) VALUES (?, ?)").run(roomID, mxid)
+ }
+ return mxid
+}
+
+/**
+ * Generate profile data based on webhook displayname and configured avatar.
+ * @param {Ty.PluRalWebhookMessage} pluRalMessage
+ * @param {Ty.WebhookAuthor} author
+ */
+async function memberToStateContent(pluRalMessage, author) {
+ // We prefer to use the member's avatar URL data since the image upload can be cached across channels,
+ // unlike the userAvatar URL which is unique per channel, due to the webhook ID being in the URL.
+ const avatar = pluRalMessage.member.avatar_url || file.userAvatar(author)
+
+ const content = {
+ displayname: author.username,
+ membership: "join",
+ "moe.cadence.ooye.plu_ral_member": pluRalMessage.member
+ }
+ if (avatar) content.avatar_url = await file.uploadDiscordFileToMxc(avatar)
+
+ return content
+}
+
+/**
+ * Sync profile data for a sim user. This function follows the following process:
+ * 1. Look up data about proxy user from API
+ * 2. If this fails, try to use previously cached data (won't sync)
+ * 3. Create and join the sim to the room if needed
+ * 4. Make an object of what the new room member state content would be, including uploading the profile picture if it hasn't been done before
+ * 5. Compare against the previously known state content, which is helpfully stored in the database
+ * 6. If the state content has changed, send it to Matrix and update it in the database for next time
+ * @param {string} channelID to call API with
+ * @param {string} messageID to call API with
+ * @param {Ty.WebhookAuthor} author for profile data
+ * @param {string} roomID room to join member to
+ * @param {boolean} shouldActuallySync whether to actually sync updated user data or just ensure it's joined
+ * @returns {Promise} mxid of the updated sim
+ */
+async function syncUser(channelID, messageID, author, roomID, shouldActuallySync) {
+ try {
+ // API lookup
+ var pluRalMessage = await fetchMessage(channelID, messageID)
+ const simName = getSimName(pluRalMessage)
+ db.prepare("REPLACE INTO sim_proxy (user_id, proxy_owner_id, displayname, proxy_app) VALUES (?, ?, ?, 1)").run(simName, pluRalMessage.author_id, author.username)
+ } catch (e) {
+ // Fall back to offline cache
+ const senderMxid = from("sim_proxy").join("sim", "user_id").join("sim_member", "mxid").where({displayname: author.username, room_id: roomID, proxy_app: 1}).pluck("mxid").get()
+ if (!senderMxid) throw e
+ return senderMxid
+ }
+
+ // Create and join the sim to the room if needed
+ const mxid = await ensureSimJoined(pluRalMessage, roomID)
+
+ if (shouldActuallySync) {
+ // Build current profile data and sync if the hash has changed
+ const content = await memberToStateContent(pluRalMessage, author)
+ await registerUser._sendSyncUser(roomID, mxid, content, null)
+ }
+
+ return mxid
+}
+
+module.exports.syncUser = syncUser
diff --git a/src/d2m/actions/register-user.js b/src/d2m/actions/register-user.js
index 674853a2..d475e54d 100644
--- a/src/d2m/actions/register-user.js
+++ b/src/d2m/actions/register-user.js
@@ -4,16 +4,17 @@ const assert = require("assert").strict
const {reg} = require("../../matrix/read-registration")
const DiscordTypes = require("discord-api-types/v10")
const Ty = require("../../types")
-const mixin = require("@cloudrac3r/mixin-deep")
const passthrough = require("../../passthrough")
-const {discord, sync, db, select} = passthrough
+const {discord, sync, db, from, select} = passthrough
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/file")} */
const file = sync.require("../../matrix/file")
/** @type {import("../../discord/utils")} */
-const utils = sync.require("../../discord/utils")
+const dUtils = sync.require("../../discord/utils")
+/** @type {import("../../matrix/utils")} */
+const mxUtils = sync.require("../../matrix/utils")
/** @type {import("../converters/user-to-mxid")} */
const userToMxid = sync.require("../converters/user-to-mxid")
/** @type {import("./create-room")} */
@@ -23,6 +24,8 @@ let hasher = null
// @ts-ignore
require("xxhash-wasm")().then(h => hasher = h)
+const supportsMsc4069 = api.versions().then(v => !!v?.unstable_features?.["org.matrix.msc4069"]).catch(() => false)
+
/**
* A sim is an account that is being simulated by the bridge to copy events from the other side.
* @param {DiscordTypes.APIUser} user
@@ -83,21 +86,30 @@ async function ensureSimJoined(user, roomID) {
// Ensure joined
const existing = select("sim_member", "mxid", {room_id: roomID, mxid}).pluck().get()
if (!existing) {
- try {
- await api.inviteToRoom(roomID, mxid)
- await api.joinRoom(roomID, mxid)
- } catch (e) {
- if (e.message.includes("is already in the room.")) {
- // Sweet!
- } else {
- throw e
- }
- }
+ 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
}
+/**
+ * @param {DiscordTypes.APIUser} user
+ */
+async function userToGlobalProfile(user) {
+ const globalProfile = {}
+
+ globalProfile.displayname = user.username
+ if (user.global_name) globalProfile.displayname = user.global_name
+
+ if (user.avatar) {
+ const avatarPath = file.userAvatar(user) // the user avatar only
+ globalProfile.avatar_url = await file.uploadDiscordFileToMxc(avatarPath)
+ }
+
+ return globalProfile
+}
+
/**
* @param {DiscordTypes.APIUser} user
* @param {Omit | undefined} member
@@ -141,8 +153,8 @@ async function memberToStateContent(user, member, guildID) {
function memberToPowerLevel(user, member, guild, channel) {
if (!member) return 0
- const permissions = utils.getPermissions(member.roles, guild.roles, user.id, channel.permission_overwrites)
- const everyonePermissions = utils.getPermissions([], guild.roles, undefined, channel.permission_overwrites)
+ const permissions = dUtils.getPermissions(guild.id, member.roles, guild.roles, user.id, channel.permission_overwrites)
+ const everyonePermissions = dUtils.getDefaultPermissions(guild, channel.permission_overwrites)
/*
* PL 100 = Administrator = People who can brick the room. RATIONALE:
* - Administrator.
@@ -151,7 +163,7 @@ function memberToPowerLevel(user, member, guild, channel) {
* - Manage Channels: People who can manage the channel can delete it.
* (Setting sim users to PL 100 is safe because even though we can't demote the sims we can use code to make the sims demote themselves.)
*/
- if (guild.owner_id === user.id || utils.hasSomePermissions(permissions, ["Administrator", "ManageWebhooks", "ManageGuild", "ManageChannels"])) return 100
+ if (guild.owner_id === user.id || dUtils.hasSomePermissions(permissions, ["Administrator", "ManageWebhooks", "ManageGuild", "ManageChannels"])) return 100
/*
* PL 50 = Moderator = People who can manage people and messages in many ways. RATIONALE:
* - Manage Messages: Can moderate by pinning or deleting the conversation.
@@ -161,15 +173,19 @@ function memberToPowerLevel(user, member, guild, channel) {
* - Mute Members & Deafen Members: Can moderate by silencing disruptive people in ways they can't undo.
* - Moderate Members.
*/
- if (utils.hasSomePermissions(permissions, ["ManageMessages", "ManageNicknames", "ManageThreads", "KickMembers", "BanMembers", "MuteMembers", "DeafenMembers", "ModerateMembers"])) return 50
+ if (dUtils.hasSomePermissions(permissions, ["ManageMessages", "ManageNicknames", "ManageThreads", "KickMembers", "BanMembers", "MuteMembers", "DeafenMembers", "ModerateMembers"])) return 50
/* PL 50 = if room is read-only but the user has been specially allowed to send messages */
- const everyoneCanSend = utils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendMessages)
- const userCanSend = utils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.SendMessages)
+ const everyoneCanSend = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendMessages)
+ const userCanSend = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.SendMessages)
if (!everyoneCanSend && userCanSend) return createRoom.READ_ONLY_ROOM_EVENTS_DEFAULT_POWER
/* PL 20 = Mention Everyone for technical reasons. */
- const everyoneCanMentionEveryone = utils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.MentionEveryone)
- const userCanMentionEveryone = utils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.MentionEveryone)
+ const everyoneCanMentionEveryone = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.MentionEveryone)
+ const userCanMentionEveryone = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.MentionEveryone)
if (!everyoneCanMentionEveryone && userCanMentionEveryone) return 20
+ /* PL 10 = Create Polls for technical reasons. */
+ const everyoneCanCreatePolls = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendPolls)
+ const userCanCreatePolls = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.SendPolls)
+ if (!everyoneCanCreatePolls && userCanCreatePolls) return 10
return 0
}
@@ -190,39 +206,72 @@ function _hashProfileContent(content, powerLevel) {
* 3. Calculate the power level the user should get based on their Discord permissions
* 4. Compare against the previously known state content, which is helpfully stored in the database
* 5. If the state content or power level have changed, send them to Matrix and update them in the database for next time
+ * 6. If the sim is for a user-installed app, check which user it was added by
* @param {DiscordTypes.APIUser} user
* @param {Omit | undefined} member
* @param {DiscordTypes.APIGuildChannel} channel
* @param {DiscordTypes.APIGuild} guild
* @param {string} roomID
+ * @param {DiscordTypes.APIMessageInteractionMetadata} [interactionMetadata]
* @returns {Promise} mxid of the updated sim
*/
-async function syncUser(user, member, channel, guild, roomID) {
+async function syncUser(user, member, channel, guild, roomID, interactionMetadata) {
const mxid = await ensureSimJoined(user, roomID)
const content = await memberToStateContent(user, member, guild.id)
const powerLevel = memberToPowerLevel(user, member, guild, channel)
- const currentHash = _hashProfileContent(content, powerLevel)
+ await _sendSyncUser(roomID, mxid, content, powerLevel, {
+ // do not overwrite pre-existing data if we already have data and `member` is not accessible, because this would replace good data with bad data
+ allowOverwrite: !!member,
+ globalProfile: await userToGlobalProfile(user)
+ })
+
+ const appInstalledByUser = user.bot && interactionMetadata?.authorizing_integration_owners?.[DiscordTypes.ApplicationIntegrationType.UserInstall]
+ if (appInstalledByUser) {
+ db.prepare("INSERT OR IGNORE INTO app_user_install (app_bot_id, user_id, guild_id) VALUES (?, ?, ?)").run(user.id, appInstalledByUser, guild.id)
+ }
+
+ return mxid
+}
+
+/**
+ * @param {string} roomID
+ * @param {string} mxid
+ * @param {{displayname: string, avatar_url?: string}} content
+ * @param {number | null} powerLevel
+ * @param {{allowOverwrite?: boolean, globalProfile?: {displayname: string, avatar_url?: string}}} [options]
+ */
+async function _sendSyncUser(roomID, mxid, content, powerLevel, options) {
+ const currentHash = _hashProfileContent(content, powerLevel ?? 0)
const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get()
// only do the actual sync if the hash has changed since we last looked
const hashHasChanged = existingHash !== currentHash
- // however, do not overwrite pre-existing data if we already have data and `member` is not accessible, because this would replace good data with bad data
- const wouldOverwritePreExisting = existingHash && !member
- if (hashHasChanged && !wouldOverwritePreExisting) {
+ // always okay to add new data. for overwriting, restrict based on options.allowOverwrite, if present
+ const overwriteOkay = !existingHash || (options?.allowOverwrite ?? true)
+ if (hashHasChanged && overwriteOkay) {
+ const actions = []
// Update room member state
- await api.sendState(roomID, "m.room.member", mxid, content, mxid)
+ actions.push(api.sendState(roomID, "m.room.member", mxid, content, mxid))
// Update power levels
- await api.setUserPower(roomID, mxid, powerLevel)
+ if (powerLevel != null) {
+ actions.push(mxUtils.setUserPower(roomID, mxid, powerLevel, api))
+ }
+ // Update global profile (if supported by server)
+ if (await supportsMsc4069) {
+ actions.push(api.profileSetDisplayname(mxid, options?.globalProfile?.displayname || content.displayname, true))
+ actions.push(api.profileSetAvatarUrl(mxid, options?.globalProfile?.avatar_url || content.avatar_url, true))
+ }
+ await Promise.all(actions)
// Update cached hash
db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid)
}
- return mxid
}
/**
* @param {string} roomID
*/
async function syncAllUsersInRoom(roomID) {
- const mxids = select("sim_member", "mxid", {room_id: roomID}).pluck().all()
+ const users = from("sim_member").join("sim", "mxid")
+ .where({room_id: roomID}).and("and user_id not like '%-%' and user_id not like '%\\_%' escape '\\'").pluck("user_id").all()
const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get()
assert.ok(typeof channelID === "string")
@@ -234,10 +283,7 @@ async function syncAllUsersInRoom(roomID) {
/** @ts-ignore @type {DiscordTypes.APIGuild} */
const guild = discord.guilds.get(guildID)
- for (const mxid of mxids) {
- const userID = select("sim", "user_id", {mxid}).pluck().get()
- assert.ok(typeof userID === "string")
-
+ for (const userID of users) {
/** @ts-ignore @type {Required} */
const member = await discord.snow.guild.getGuildMember(guildID, userID)
/** @ts-ignore @type {Required} user */
@@ -254,5 +300,7 @@ module.exports._hashProfileContent = _hashProfileContent
module.exports.ensureSim = ensureSim
module.exports.ensureSimJoined = ensureSimJoined
module.exports.syncUser = syncUser
+module.exports._sendSyncUser = _sendSyncUser
module.exports.syncAllUsersInRoom = syncAllUsersInRoom
module.exports._memberToPowerLevel = memberToPowerLevel
+module.exports.supportsMsc4069 = supportsMsc4069
diff --git a/src/d2m/actions/register-webhook-user.js b/src/d2m/actions/register-webhook-user.js
new file mode 100644
index 00000000..145eeb8f
--- /dev/null
+++ b/src/d2m/actions/register-webhook-user.js
@@ -0,0 +1,131 @@
+// @ts-check
+
+const assert = require("assert")
+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")
+/** @type {import("../converters/user-to-mxid")} */
+const userToMxid = sync.require("../converters/user-to-mxid")
+
+/**
+ * A sim is an account that is being simulated by the bridge to copy events from the other side.
+ * @param {string} fakeUserID
+ * @param {Ty.WebhookAuthor} author
+ * @returns mxid
+ */
+async function createSim(fakeUserID, author) {
+ // Choose sim name
+ const simName = userToMxid.webhookAuthorToSimName(author)
+ 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(fakeUserID, author.username, 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(fakeUserID)
+ 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 {string} fakeUserID
+ * @param {Ty.WebhookAuthor} author
+ * @returns {Promise} mxid
+ */
+async function ensureSim(fakeUserID, author) {
+ let mxid = null
+ const existing = select("sim", "mxid", {user_id: fakeUserID}).pluck().get()
+ if (existing) {
+ mxid = existing
+ } else {
+ mxid = await createSim(fakeUserID, author)
+ }
+ return mxid
+}
+
+/**
+ * Ensure a sim is registered for the user and is joined to the room.
+ * @param {string} fakeUserID
+ * @param {Ty.WebhookAuthor} author
+ * @param {string} roomID
+ * @returns {Promise} mxid
+ */
+async function ensureSimJoined(fakeUserID, author, roomID) {
+ // Ensure room ID is really an ID, not an alias
+ assert.ok(roomID[0] === "!")
+
+ // Ensure user
+ const mxid = await ensureSim(fakeUserID, author)
+
+ // 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.WebhookAuthor} author
+ */
+async function authorToStateContent(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 = file.userAvatar(author)
+
+ const content = {
+ displayname: author.username,
+ membership: "join",
+ }
+ if (avatar) content.avatar_url = await file.uploadDiscordFileToMxc(avatar)
+
+ return content
+}
+
+/**
+ * Sync profile data for a sim webhook user. This function follows the following process:
+ * 1. Create and join the sim to the room if needed
+ * 2. Make an object of what the new room member state content would be, including uploading the profile picture if it hasn't been done before
+ * 3. Compare against the previously known state content, which is helpfully stored in the database
+ * 4. If the state content has changed, send it to Matrix and update it in the database for next time
+ * @param {Ty.WebhookAuthor} author for profile data
+ * @param {string} roomID room to join member to
+ * @param {boolean} shouldActuallySync whether to actually sync updated user data or just ensure it's joined
+ * @returns {Promise} mxid of the updated sim
+ */
+async function syncUser(author, roomID, shouldActuallySync) {
+ const fakeUserID = userToMxid.webhookAuthorToFakeUserID(author)
+
+ // Create and join the sim to the room if needed
+ const mxid = await ensureSimJoined(fakeUserID, author, roomID)
+
+ if (shouldActuallySync) {
+ // Build current profile data and sync if the hash has changed
+ const content = await authorToStateContent(author)
+ await registerUser._sendSyncUser(roomID, mxid, content, null)
+ }
+
+ return mxid
+}
+
+module.exports.syncUser = syncUser
diff --git a/src/d2m/actions/remove-member.js b/src/d2m/actions/remove-member.js
new file mode 100644
index 00000000..56ac750e
--- /dev/null
+++ b/src/d2m/actions/remove-member.js
@@ -0,0 +1,37 @@
+// @ts-check
+
+const passthrough = require("../../passthrough")
+const {sync, db, select, from} = passthrough
+/** @type {import("../../matrix/api")} */
+const api = sync.require("../../matrix/api")
+/** @type {import("../converters/remove-member-mxids")} */
+const removeMemberMxids = sync.require("../converters/remove-member-mxids")
+
+/**
+ * @param {string} userID discord user ID that left
+ * @param {string} guildID discord guild ID that they left
+ */
+async function removeMember(userID, guildID) {
+ const {userAppDeletions, membership} = removeMemberMxids.removeMemberMxids(userID, guildID)
+ db.transaction(() => {
+ for (const d of userAppDeletions) {
+ db.prepare("DELETE FROM app_user_install WHERE guild_id = ? and user_id = ?").run(guildID, d)
+ }
+ })()
+ for (const m of membership) {
+ try {
+ await api.leaveRoom(m.room_id, m.mxid)
+ } catch (e) {
+ if (String(e).includes("not in room")) {
+ // no further action needed
+ } else {
+ throw e
+ }
+ }
+ // Update cache to say that the member isn't in the room any more
+ // You'd think this would happen automatically when the leave event arrives at Matrix's event dispatcher, but that isn't 100% reliable.
+ db.prepare("DELETE FROM sim_member WHERE room_id = ? AND mxid = ?").run(m.room_id, m.mxid)
+ }
+}
+
+module.exports.removeMember = removeMember
diff --git a/src/d2m/actions/remove-reaction.js b/src/d2m/actions/remove-reaction.js
index 06c4b59d..af7fd6ab 100644
--- a/src/d2m/actions/remove-reaction.js
+++ b/src/d2m/actions/remove-reaction.js
@@ -4,7 +4,7 @@ const Ty = require("../../types")
const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough")
-const {discord, sync, db, select} = passthrough
+const {discord, sync, db, from, select} = passthrough
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("../converters/emoji-to-key")} */
@@ -18,12 +18,15 @@ const converter = sync.require("../converters/remove-reaction")
* @param {DiscordTypes.GatewayMessageReactionRemoveDispatchData | DiscordTypes.GatewayMessageReactionRemoveEmojiDispatchData | DiscordTypes.GatewayMessageReactionRemoveAllDispatchData} data
*/
async function removeSomeReactions(data) {
- const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get()
- if (!roomID) return
- const eventIDForMessage = select("event_message", "event_id", {message_id: data.message_id, reaction_part: 0}).pluck().get()
- if (!eventIDForMessage) return
+ const row = select("channel_room", "room_id", {channel_id: data.channel_id}).get()
+ if (!row) return
- const reactions = await api.getFullRelations(roomID, eventIDForMessage, "m.annotation")
+ const eventReactedTo = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
+ .where({message_id: data.message_id}).and("ORDER BY reaction_part").select("event_id", "room_id").get()
+ if (!eventReactedTo) return
+
+ // Due to server restrictions, all relations (i.e. reactions) have to be in the same room as the original event.
+ const reactions = await api.getFullRelations(eventReactedTo.room_id, eventReactedTo.event_id, "m.annotation")
// Run the proper strategy and any strategy-specific database changes
const removals = await
@@ -33,7 +36,7 @@ async function removeSomeReactions(data) {
// Redact the events and delete individual stored events in the database
for (const removal of removals) {
- await api.redactEvent(roomID, removal.eventID, removal.mxid)
+ await api.redactEvent(eventReactedTo.room_id, removal.eventID, removal.mxid)
if (removal.hash) db.prepare("DELETE FROM reaction WHERE hashed_event_id = ?").run(removal.hash)
}
}
diff --git a/src/d2m/actions/retrigger.js b/src/d2m/actions/retrigger.js
index aa79a798..43f400dd 100644
--- a/src/d2m/actions/retrigger.js
+++ b/src/d2m/actions/retrigger.js
@@ -2,7 +2,15 @@
const {EventEmitter} = require("events")
const passthrough = require("../../passthrough")
-const {select} = passthrough
+const {select, sync, from} = passthrough
+/** @type {import("../../matrix/utils")} */
+const utils = sync.require("../../matrix/utils")
+
+/*
+ Due to Eventual Consistency(TM) an update/delete may arrive before the original message arrives
+ (or before the it has finished being bridged to an event).
+ In this case, wait until the original message has finished bridging, then retrigger the passed function.
+*/
const DEBUG_RETRIGGER = false
@@ -12,73 +20,140 @@ function debugRetrigger(message) {
}
}
-const paused = new Set()
-const emitter = new EventEmitter()
+const storage = new class {
+ /** @private @type {Set} */
+ paused = new Set()
+ /** @private @type {Map any)[]>} id -> list of resolvers */
+ resolves = new Map()
+ /** @private @type {Map>} id -> timer */
+ timers = new Map()
-/**
- * Due to Eventual Consistency(TM) an update/delete may arrive before the original message arrives
- * (or before the it has finished being bridged to an event).
- * In this case, wait until the original message has finished bridging, then retrigger the passed function.
- * @template {(...args: any[]) => Promise} T
- * @param {string} messageID
- * @param {T} fn
- * @param {Parameters} rest
- * @returns {boolean} false if the event was found and the function will be ignored, true if the event was not found and the function will be retriggered
- */
-function eventNotFoundThenRetrigger(messageID, fn, ...rest) {
- if (!paused.has(messageID)) {
- const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get()
- if (eventID) {
- debugRetrigger(`[retrigger] OK mid <-> eid = ${messageID} <-> ${eventID}`)
- return false // event was found so don't retrigger
+ /**
+ * The purpose of storage is to store `resolve` and call it at a later time.
+ * @param {string} id
+ * @param {(found: Boolean) => any} resolve
+ */
+ store(id, resolve) {
+ debugRetrigger(`[retrigger] STORE id = ${id}`)
+ this.resolves.set(id, (this.resolves.get(id) || []).concat(resolve)) // add to list in map value
+ if (!this.timers.has(id)) {
+ debugRetrigger(`[retrigger] SET TIMER id = ${id}`)
+ this.timers.set(id, setTimeout(() => this.resolve(id, false), 60 * 1000).unref()) // 1 minute
}
}
+
+ /** @param {string} id */
+ isNotPaused(id) {
+ return !storage.paused.has(id)
+ }
- debugRetrigger(`[retrigger] WAIT mid = ${messageID}`)
- emitter.once(messageID, () => {
- debugRetrigger(`[retrigger] TRIGGER mid = ${messageID}`)
- fn(...rest)
- })
- // if the event never arrives, don't trigger the callback, just clean up
- setTimeout(() => {
- if (emitter.listeners(messageID).length) {
- debugRetrigger(`[retrigger] EXPIRE mid = ${messageID}`)
+ /** @param {string} id */
+ pause(id) {
+ debugRetrigger(`[retrigger] PAUSE id = ${id}`)
+ this.paused.add(id)
+ }
+
+ /**
+ * Go through `resolves` storage and resolve them all. (Also resets timer/paused.)
+ * @param {string} id
+ * @param {boolean} value
+ */
+ resolve(id, value) {
+ if (this.paused.has(id)) {
+ debugRetrigger(`[retrigger] RESUME id = ${id}`)
+ this.paused.delete(id)
}
- emitter.removeAllListeners(messageID)
- }, 60 * 1000) // 1 minute
- return true // event was not found, then retrigger
+
+ if (this.resolves.has(id)) {
+ debugRetrigger(`[retrigger] RESOLVE ${value} id = ${id}`)
+ const fns = this.resolves.get(id) || []
+ this.resolves.delete(id)
+ for (const fn of fns) {
+ fn(value)
+ }
+ }
+
+ if (this.timers.has(id)) {
+ clearTimeout(this.timers.get(id))
+ this.timers.delete(id)
+ }
+ }
+}
+
+/**
+ * @param {string} id
+ * @param {(found: Boolean) => any} resolve
+ * @param {boolean} existsInDatabase
+ */
+function waitFor(id, resolve, existsInDatabase) {
+ if (existsInDatabase && storage.isNotPaused(id)) { // if event already exists and isn't paused then resolve immediately
+ debugRetrigger(`[retrigger] EXISTS id = ${id}`)
+ return resolve(true)
+ }
+
+ // doesn't exist. wait for it to exist. storage will resolve true if it exists or false if it timed out
+ return storage.store(id, resolve)
+}
+
+const GET_EVENT_PREPARED = from("event_message").select("event_id").and("WHERE event_id = ?").prepare().raw()
+/**
+ * @param {string} eventID
+ * @returns {Promise} if true then the message did not arrive
+ */
+function waitForEvent(eventID) {
+ const {promise, resolve} = Promise.withResolvers()
+ waitFor(eventID, resolve, !!GET_EVENT_PREPARED.get(eventID))
+ return promise
+}
+
+const GET_MESSAGE_PREPARED = from("event_message").select("message_id").and("WHERE message_id = ?").prepare().raw()
+/**
+ * @param {string} messageID
+ * @returns {Promise} if true then the message did not arrive
+ */
+function waitForMessage(messageID) {
+ const {promise, resolve} = Promise.withResolvers()
+ waitFor(messageID, resolve, !!GET_MESSAGE_PREPARED.get(messageID))
+ return promise
+}
+
+const GET_REACTION_EVENT_PREPARED = from("reaction").select("hashed_event_id").and("WHERE hashed_event_id = ?").prepare().raw()
+/**
+ * @param {string} eventID
+ * @returns {Promise} if true then the message did not arrive
+ */
+function waitForReactionEvent(eventID) {
+ const {promise, resolve} = Promise.withResolvers()
+ waitFor(eventID, resolve, !!GET_REACTION_EVENT_PREPARED.get(utils.getEventIDHash(eventID)))
+ return promise
}
/**
* Anything calling retrigger during the callback will be paused and retriggered after the callback resolves.
* @template T
- * @param {string} messageID
+ * @param {string} id
* @param {Promise} promise
* @returns {Promise}
*/
-async function pauseChanges(messageID, promise) {
+async function pauseChanges(id, promise) {
try {
- debugRetrigger(`[retrigger] PAUSE mid = ${messageID}`)
- paused.add(messageID)
+ storage.pause(id)
return await promise
} finally {
- debugRetrigger(`[retrigger] RESUME mid = ${messageID}`)
- paused.delete(messageID)
- messageFinishedBridging(messageID)
+ finishedBridging(id)
}
}
/**
* Triggers any pending operations that were waiting on the corresponding event ID.
- * @param {string} messageID
+ * @param {string} id
*/
-function messageFinishedBridging(messageID) {
- if (emitter.listeners(messageID).length) {
- debugRetrigger(`[retrigger] EMIT mid = ${messageID}`)
- }
- emitter.emit(messageID)
+function finishedBridging(id) {
+ storage.resolve(id, true)
}
-module.exports.eventNotFoundThenRetrigger = eventNotFoundThenRetrigger
-module.exports.messageFinishedBridging = messageFinishedBridging
+module.exports.waitForMessage = waitForMessage
+module.exports.waitForEvent = waitForEvent
+module.exports.waitForReactionEvent = waitForReactionEvent
module.exports.pauseChanges = pauseChanges
+module.exports.finishedBridging = finishedBridging
\ No newline at end of file
diff --git a/src/d2m/actions/send-message.js b/src/d2m/actions/send-message.js
index b1cb6801..41ef70d7 100644
--- a/src/d2m/actions/send-message.js
+++ b/src/d2m/actions/send-message.js
@@ -4,48 +4,63 @@ const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough")
-const { discord, sync, db } = passthrough
+const {discord, sync, db, select, from} = passthrough
+const {reg} = require("../../matrix/read-registration")
/** @type {import("../converters/message-to-event")} */
const messageToEvent = sync.require("../converters/message-to-event")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("./register-user")} */
const registerUser = sync.require("./register-user")
-/** @type {import("./register-pk-user")} */
-const registerPkUser = sync.require("./register-pk-user")
+/** @type {import("./speedbump")} */
+const speedbump = sync.require("./speedbump")
/** @type {import("../actions/create-room")} */
const createRoom = sync.require("../actions/create-room")
+/** @type {import("../actions/poll-end")} */
+const pollEnd = sync.require("../actions/poll-end")
/** @type {import("../../discord/utils")} */
const dUtils = sync.require("../../discord/utils")
+/** @type {import("../../m2d/actions/channel-webhook")} */
+const channelWebhook = sync.require("../../m2d/actions/channel-webhook")
/**
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message
* @param {DiscordTypes.APIGuildChannel} channel
* @param {DiscordTypes.APIGuild} guild
- * @param {{speedbump_id: string, speedbump_webhook_id: string} | null} row data about the webhook which is proxying messages in this channel
*/
-async function sendMessage(message, channel, guild, row) {
+async function sendMessage(message, channel, guild) {
const roomID = await createRoom.ensureRoom(message.channel_id)
+ const historicalRoomIndex = select("historical_channel_room", "historical_room_index", {room_id: roomID}).pluck().get()
+ assert(historicalRoomIndex)
let senderMxid = null
- if (!dUtils.isWebhookMessage(message)) {
+ if (dUtils.isWebhookMessage(message)) {
+ senderMxid = await speedbump.getWebhookSenderId(message, guild.id, roomID)
+ } else {
+ // not a webhook
if (message.author.id === discord.application.id) {
// no need to sync the bot's own user
} else {
- senderMxid = await registerUser.syncUser(message.author, message.member, channel, guild, roomID)
+ senderMxid = await registerUser.syncUser(message.author, message.member, channel, guild, roomID, message.interaction_metadata)
}
- } else 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)
+ }
+
+ let sentResultsMessage
+ if (message.type === DiscordTypes.MessageType.PollResult) { // ensure all Discord-side votes were pushed to Matrix before a poll is closed
+ const detailedResultsMessage = await pollEnd.endPoll(message)
+ if (detailedResultsMessage) {
+ const threadParent = select("channel_room", "thread_parent", {channel_id: message.channel_id}).pluck().get()
+ const {channelID, threadID} = dUtils.swapThreadID(message.channel_id, threadParent)
+ sentResultsMessage = await channelWebhook.sendMessageWithWebhook(channelID, detailedResultsMessage, threadID)
}
}
const events = await messageToEvent.messageToEvent(message, guild, {}, {api, snow: discord.snow})
const eventIDs = []
if (events.length) {
- db.prepare("INSERT OR IGNORE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id)
- if (senderMxid) api.sendTyping(roomID, false, senderMxid).catch(() => {})
+ db.prepare("INSERT OR IGNORE INTO message_room (message_id, historical_room_index) VALUES (?, ?)").run(message.id, historicalRoomIndex)
+ const typingMxid = from("sim").join("sim_member", "mxid").where({user_id: message.author.id, room_id: roomID}).pluck("mxid").get()
+ if (typingMxid) api.sendTyping(roomID, false, typingMxid).catch(() => {})
}
for (const event of events) {
const part = event === events[0] ? 0 : 1
@@ -60,7 +75,19 @@ async function sendMessage(message, channel, guild, row) {
const useTimestamp = message["backfill"] ? new Date(message.timestamp).getTime() : undefined
const eventID = await api.sendEvent(roomID, eventType, eventWithoutType, senderMxid, useTimestamp)
- db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 1)").run(eventID, eventType, event.msgtype || null, message.id, part, reactionPart) // source 1 = discord
+ eventIDs.push(eventID)
+
+ try {
+ db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 1)").run(eventID, eventType, event.msgtype || null, message.id, part, reactionPart) // source 1 = discord
+ } catch (e) {
+ // check if we got rugpulled
+ if (!select("message_room", "message_id", {message_id: message.id}).get()) {
+ for (const eventID of eventIDs) {
+ await api.redactEvent(roomID, eventID)
+ }
+ return []
+ }
+ }
// The primary event is part = 0 and has the most important and distinct information. It is used to provide reply previews, be pinned, and possibly future uses.
// The first event is chosen to be the primary part because it is usually the message text content and is more likely to be distinct.
@@ -68,7 +95,35 @@ async function sendMessage(message, channel, guild, row) {
// The last event gets reaction_part = 0. Reactions are managed there because reactions are supposed to appear at the bottom.
- eventIDs.push(eventID)
+
+ if (eventType === "org.matrix.msc3381.poll.start") {
+ db.transaction(() => {
+ db.prepare("INSERT INTO poll (message_id, max_selections, question_text, is_closed) VALUES (?, ?, ?, 0)").run(
+ message.id,
+ event["org.matrix.msc3381.poll.start"].max_selections,
+ event["org.matrix.msc3381.poll.start"].question["org.matrix.msc1767.text"]
+ )
+ for (const [index, option] of Object.entries(event["org.matrix.msc3381.poll.start"].answers)) {
+ db.prepare("INSERT INTO poll_option (message_id, matrix_option, discord_option, option_text, seq) VALUES (?, ?, ?, ?, ?)").run(
+ message.id,
+ option.id,
+ option.id,
+ option["org.matrix.msc1767.text"],
+ index
+ )
+ }
+ })()
+ }
+
+ // part/reaction_part consistency for polls
+ if (sentResultsMessage) {
+ db.transaction(() => {
+ db.prepare("INSERT OR IGNORE INTO message_room (message_id, historical_room_index) VALUES (?, ?)").run(sentResultsMessage.id, historicalRoomIndex)
+ db.prepare("UPDATE event_message SET reaction_part = 1 WHERE event_id = ?").run(eventID)
+ // part = 1, reaction_part = 0, source = 0 as the results are "from Matrix" and doing otherwise breaks things when that message gets updated by Discord (it just does that sometimes)
+ db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 0)").run(eventID, eventType, event.msgtype || null, sentResultsMessage.id, 1, 0)
+ })()
+ }
}
return eventIDs
diff --git a/src/d2m/actions/set-presence.js b/src/d2m/actions/set-presence.js
index f26668f3..0a310385 100644
--- a/src/d2m/actions/set-presence.js
+++ b/src/d2m/actions/set-presence.js
@@ -1,5 +1,7 @@
// @ts-check
+const assert = require("assert").strict
+
const passthrough = require("../../passthrough")
const {sync, select} = passthrough
/** @type {import("../../matrix/api")} */
@@ -26,7 +28,7 @@ const presenceLoopInterval = 28e3
// Cache the list of enabled guilds rather than accessing it like multiple times per second when any user changes presence
const guildPresenceSetting = new class {
- /** @private @type {Set} */ guilds
+ /** @private @type {Set} */ guilds = new Set()
constructor() {
this.update()
}
@@ -40,7 +42,7 @@ const guildPresenceSetting = new class {
class Presence extends sync.reloadClassMethods(() => Presence) {
/** @type {string} */ userID
- /** @type {{presence: "online" | "offline" | "unavailable", status_msg?: string}} */ data
+ /** @type {{presence: "online" | "offline" | "unavailable", status_msg?: string} | undefined} */ data
/** @private @type {?string | undefined} */ mxid
/** @private @type {number} */ delay = Math.random()
@@ -66,6 +68,7 @@ class Presence extends sync.reloadClassMethods(() => Presence) {
// I haven't tried, but I assume Synapse explodes if you try to update too many presences at the same time.
// This random delay will space them out over the whole 28 second cycle.
setTimeout(() => {
+ assert(this.data)
api.setPresence(this.data, mxid).catch(() => {})
}, this.delay * presenceLoopInterval).unref()
}
diff --git a/src/d2m/actions/speedbump.js b/src/d2m/actions/speedbump.js
index 7c3109b1..88cea075 100644
--- a/src/d2m/actions/speedbump.js
+++ b/src/d2m/actions/speedbump.js
@@ -1,15 +1,33 @@
// @ts-check
const DiscordTypes = require("discord-api-types/v10")
+
const passthrough = require("../../passthrough")
-const {discord, select, db} = passthrough
+const {discord, from, select, db, sync} = passthrough
+
+const {reg} = require("../../matrix/read-registration")
+/** @type {import("./register-pk-user")} */
+const registerPkUser = sync.require("./register-pk-user")
+/** @type {import("./register-plu-ral-user")} */
+const registerPluRalUser = sync.require("./register-plu-ral-user")
+/** @type {import("./register-webhook-user")} */
+const registerWebhookUser = sync.require("./register-webhook-user")
+
+const DEBUG_SPEEDBUMP = false
+
+function debugSpeedbump(...args) {
+ if (DEBUG_SPEEDBUMP) {
+ console.log(...args)
+ }
+}
const SPEEDBUMP_SPEED = 4000 // 4 seconds delay
const SPEEDBUMP_UPDATE_FREQUENCY = 2 * 60 * 60 // 2 hours
/** @type {Set} */
const KNOWN_BOTS = new Set([
- "466378653216014359" // PluralKit
+ "466378653216014359", // PluralKit
+ "1291501048493768784", // /plu/ral
])
/**
@@ -21,38 +39,81 @@ async function updateCache(channelID, lastChecked) {
const now = Math.floor(Date.now() / 1000)
if (lastChecked && now - lastChecked < SPEEDBUMP_UPDATE_FREQUENCY) return
const webhooks = await discord.snow.webhook.getChannelWebhooks(channelID)
- const found = webhooks.find(b => KNOWN_BOTS.has(b.application_id))
- const foundApplication = found?.application_id
- const foundWebhook = found?.id
- db.prepare("UPDATE channel_room SET speedbump_id = ?, speedbump_webhook_id = ?, speedbump_checked = ? WHERE channel_id = ?").run(foundApplication, foundWebhook, now, channelID)
+ const found = webhooks.filter(b => KNOWN_BOTS.has(b.application_id))
+ db.transaction(() => {
+ db.prepare("DELETE FROM channel_speedbump WHERE channel_id = ?").run(channelID)
+ for (const webhook of found) {
+ db.prepare("INSERT INTO channel_speedbump (channel_id, speedbump_webhook_id, speedbump_user_id) VALUES (?, ?, ?)").run(channelID, webhook.id, webhook.application_id)
+ }
+ db.prepare("UPDATE channel_room SET speedbump_checked = ? WHERE channel_id = ?").run(now, channelID)
+ })()
}
-/** @type {Set} set of messageID */
-const bumping = new Set()
+/**
+ * @typedef BumpingEntry
+ * @prop {number} number number of gateway events currently bumping for this message ID
+ * @prop {boolean} hasCreate whether there was a message create within the events currently bumping
+ */
+
+/** @type {Map} messageID -> BumpingEntry */
+const bumping = new Map()
/**
* Slow down a message. After it passes the speedbump, return whether it's okay or if it's been deleted.
+ * @param {boolean} isCreate
* @param {string} messageID
* @returns whether it was deleted
*/
-async function doSpeedbump(messageID) {
- bumping.add(messageID)
+async function doSpeedbump(isCreate, messageID) {
+ const entry = bumping.get(messageID) ?? (() => {
+ const entry = {number: 0, hasCreate: false}
+ bumping.set(messageID, entry)
+ return entry
+ })()
+
+ entry.number++
+ entry.hasCreate ||= isCreate
+
+ debugSpeedbump(`[speedbump] WAIT ${messageID}++ =`, entry)
+
await new Promise(resolve => setTimeout(resolve, SPEEDBUMP_SPEED))
- return !bumping.delete(messageID)
+
+ if (!bumping.has(messageID)) {
+ debugSpeedbump(`[speedbump] DELETED ${messageID}`)
+ return {skip: true, hasCreate: null}
+ }
+
+ if (--entry.number <= 0) {
+ debugSpeedbump(`[speedbump] OK ${messageID}-- =`, entry)
+ bumping.delete(messageID)
+ return {skip: false, hasCreate: entry.hasCreate}
+ } else {
+ debugSpeedbump(`[speedbump] MULTI ${messageID}-- =`, entry)
+ return {skip: true, hasCreate: null}
+ }
+}
+
+function getSpeedbumpRows(channelID) {
+ return from("channel_room").join("channel_speedbump", "channel_id").select("thread_parent", "speedbump_user_id", "speedbump_webhook_id").where({channel_id: channelID}).all()
}
/**
* Check whether to slow down a message, and do it. After it passes the speedbump, return whether it's okay or if it's been deleted.
- * @param {string} channelID
- * @param {string} messageID
- * @returns whether it was deleted, and data about the channel's (not thread's) speedbump
+ * @param {boolean} isCreate
+ * @param {{id: string, channel_id: string, author: {id: string}, backfill?: boolean}} message uses the ID to identify, and the userID to only slow down the message when the user has used PK before
+ * @returns whether to skip this message, and whether the message should be created as a creation
*/
-async function maybeDoSpeedbump(channelID, messageID) {
- let row = select("channel_room", ["thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: channelID}).get()
- if (row?.thread_parent) row = select("channel_room", ["thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: row.thread_parent}).get() // webhooks belong to the channel, not the thread
- if (!row?.speedbump_webhook_id) return {affected: false, row: null} // not affected, no speedbump
- const affected = await doSpeedbump(messageID)
- return {affected, row} // maybe affected, and there is a speedbump
+async function maybeDoSpeedbump(isCreate, message) {
+ let rows = getSpeedbumpRows(message.channel_id)
+ if (rows[0]?.thread_parent) rows = getSpeedbumpRows(rows[0].thread_parent) // webhooks belong to the channel, not the thread
+ if (!rows.length) return {skip: false} // channel not affected, no speedbump
+
+ if (message.backfill) return {skip: false} // don't slow messages during backfill
+ if (rows.some(r => r.speedbump_webhook_id === message.author.id)) return {skip: false} // shortcut
+ const userHasProxy = select("sim_proxy", "user_id", {proxy_owner_id: message.author.id}).pluck().get()
+ if (!userHasProxy) return {skip: false} // user has not used PK before, no speedbump
+ const {skip, hasCreate} = await doSpeedbump(isCreate, message.id)
+ return {skip, hasCreate} // maybe affected, and there is a speedbump
}
/**
@@ -62,7 +123,25 @@ function onMessageDelete(messageID) {
bumping.delete(messageID)
}
+/**
+ * @param {DiscordTypes.APIMessage} message
+ * @param {string} guildID
+ * @param {string} roomID
+ */
+async function getWebhookSenderId(message, guildID, roomID) {
+ const speedbumpUserID = select("channel_speedbump", "speedbump_user_id", {channel_id: message.channel_id, speedbump_webhook_id: message.webhook_id}).pluck().get()
+ const useWebhookProfile = select("guild_space", "webhook_profile", {guild_id: guildID}).pluck().get() ?? 0
+ if (speedbumpUserID === "466378653216014359") { // PluralKit public instance
+ return await registerPkUser.syncUser(message.id, message.author, roomID, true)
+ } else if (speedbumpUserID === "1291501048493768784" && reg.ooye.plu_ral_api_key) { // /plu/ral public instance
+ return await registerPluRalUser.syncUser(message.channel_id, message.id, message.author, roomID, true)
+ } else if (useWebhookProfile) {
+ return await registerWebhookUser.syncUser(message.author, roomID, true)
+ }
+ return null
+}
+
module.exports.updateCache = updateCache
-module.exports.doSpeedbump = doSpeedbump
module.exports.maybeDoSpeedbump = maybeDoSpeedbump
module.exports.onMessageDelete = onMessageDelete
+module.exports.getWebhookSenderId = getWebhookSenderId
diff --git a/src/d2m/actions/update-pins.js b/src/d2m/actions/update-pins.js
index 15febaad..56c9642f 100644
--- a/src/d2m/actions/update-pins.js
+++ b/src/d2m/actions/update-pins.js
@@ -34,7 +34,7 @@ async function updatePins(channelID, roomID, convertedTimestamp) {
throw e
}
- const kstate = await ks.roomToKState(roomID)
+ const kstate = await ks.roomToKState(roomID, [["m.room.pinned_events", ""]])
const pinned = pinsToList.pinsToList(discordPins, kstate)
const diff = ks.diffKState(kstate, {"m.room.pinned_events/": {pinned}})
diff --git a/src/d2m/converters/edit-to-changes.js b/src/d2m/converters/edit-to-changes.js
index c615a3fc..61f32902 100644
--- a/src/d2m/converters/edit-to-changes.js
+++ b/src/d2m/converters/edit-to-changes.js
@@ -6,8 +6,10 @@ const passthrough = require("../../passthrough")
const {sync, select, from} = passthrough
/** @type {import("./message-to-event")} */
const messageToEvent = sync.require("../converters/message-to-event")
-/** @type {import("../../m2d/converters/utils")} */
-const utils = sync.require("../../m2d/converters/utils")
+/** @type {import("../../discord/utils")} */
+const dUtils = sync.require("../../discord/utils")
+/** @type {import("../../matrix/utils")} */
+const mxUtils = sync.require("../../matrix/utils")
function eventCanBeEdited(ev) {
// Discord does not allow files, images, attachments, or videos to be edited.
@@ -18,6 +20,10 @@ function eventCanBeEdited(ev) {
if (ev.old.event_type === "m.sticker") {
return false
}
+ // Discord does not allow the data of polls to be edited, they may only be responded to.
+ if (ev.old.event_type === "org.matrix.msc3381.poll.start" || ev.old.event_type === "org.matrix.msc3381.poll.end") {
+ return false
+ }
// Anything else is fair game.
return true
}
@@ -32,17 +38,21 @@ function eventIsText(ev) {
* @param {import("../../matrix/api")} api simple-as-nails dependency injection for the matrix API
*/
async function editToChanges(message, guild, api) {
- // If it is a user edit, allow deleting old messages (e.g. they might have removed text from an image).
- // If it is the system adding a generated embed to a message, don't delete old messages since the system only sends partial data.
- // Since an update in August 2024, the system always provides the full data of message updates. I'll leave in the old code since it won't cause problems.
-
- const isGeneratedEmbed = !("content" in message)
-
// Figure out what events we will be replacing
const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
assert(roomID)
- const oldEventRows = select("event_message", ["event_id", "event_type", "event_subtype", "part", "reaction_part"], {message_id: message.id}).all()
+ const oldEventRows = select("event_message", ["event_id", "event_type", "event_subtype", "part", "reaction_part", "source"], {message_id: message.id}).all()
+
+ // If it is a user edit, allow deleting old messages (e.g. they might have removed text from an image).
+ // If it is the system adding a generated embed to a message, don't delete old messages since the system only sends partial data.
+ // Since an update in August 2024, the system always provides the full data of message updates.
+ // Now, this code path is only used by generated embeds for messages that were originally sent from Matrix.
+
+ const originallyFromMatrix = oldEventRows.some(r => r.source === 0)
+ const mightBeGeneratedEmbed = !("content" in message) || originallyFromMatrix
+
+ // Figure out who to send as
/** @type {string?} Null if we don't have a sender in the room, which will happen if it's a webhook's message. The bridge bot will do the edit instead. */
let senderMxid = null
@@ -52,7 +62,7 @@ async function editToChanges(message, guild, api) {
// Should be a system generated embed. We want the embed to be sent by the same user who sent the message, so that the messages get grouped in most clients.
const eventID = oldEventRows[0].event_id // a calling function should have already checked that there is at least one message to edit
const event = await api.getEvent(roomID, eventID)
- if (utils.eventSenderIsFromDiscord(event.sender)) {
+ if (mxUtils.eventSenderIsFromDiscord(event.sender)) {
senderMxid = event.sender
}
}
@@ -71,13 +81,25 @@ async function editToChanges(message, guild, api) {
+ The events must have the same subtype.
Events will therefore be divided into four categories:
*/
- /** 1. Events that are matched, and should be edited by sending another m.replace event */
+ /**
+ * 1. Events that are matched, and should be edited by sending another m.replace event
+ * @type {{old: typeof oldEventRows[0], oldMentions?: any, newFallbackContent: typeof newFallbackContent[0], newInnerContent: typeof newInnerContent[0]}[]}
+ */
let eventsToReplace = []
- /** 2. Events that are present in the old version only, and should be blanked or redacted */
+ /**
+ * 2. Events that are present in the old version only, and should be blanked or redacted
+ * @type {{old: typeof oldEventRows[0]}[]}
+ */
let eventsToRedact = []
- /** 3. Events that are present in the new version only, and should be sent as new, with references back to the context */
+ /**
+ * 3. Events that are present in the new version only, and should be sent as new, with references back to the context
+ * @type {typeof newInnerContent}
+ */
let eventsToSend = []
- /** 4. Events that are matched and have definitely not changed, so they don't need to be edited or replaced at all. */
+ /**
+ * 4. Events that are matched and have definitely not changed, so they don't need to be edited or replaced at all.
+ * @type {(typeof eventsToRedact[0] | typeof eventsToReplace[0])[]}
+ */
let unchangedEvents = []
function shift() {
@@ -114,16 +136,33 @@ async function editToChanges(message, guild, api) {
eventsToRedact = oldEventRows.map(e => ({old: e}))
// If this is a generated embed update, only allow the embeds to be updated, since the system only sends data about events. Ignore changes to other things.
- if (isGeneratedEmbed) {
- unchangedEvents.push(...eventsToRedact.filter(e => e.old.event_subtype !== "m.notice")) // Move them from eventsToRedact to unchangedEvents.
- eventsToRedact = eventsToRedact.filter(e => e.old.event_subtype === "m.notice")
+ // This also prevents Matrix events that were re-subtyped during conversion (e.g. large image -> text link) from being mistakenly included.
+ if (mightBeGeneratedEmbed) {
+ unchangedEvents = unchangedEvents.concat(
+ dUtils.filterTo(eventsToRedact, e => e.old.event_subtype === "m.notice" && e.old.source === 1), // Move everything except embeds from eventsToRedact to unchangedEvents.
+ dUtils.filterTo(eventsToReplace, e => e.old.event_subtype === "m.notice" && e.old.source === 1) // Move everything except embeds from eventsToReplace to unchangedEvents.
+ )
+ eventsToSend = eventsToSend.filter(e => e.msgtype === "m.notice") // Don't send new events that aren't the embed.
+ }
+
+ // Don't post new generated embeds for messages if it's been a while since the message was sent. Detached embeds look weird.
+ const messageQuiteOld = message.timestamp && new Date(message.timestamp).getTime() < Date.now() - 30 * 1000 // older than 30 seconds ago
+ // Don't send anything new at all if it's been longer since the message was sent. Detached messages are just inappropriate.
+ const messageReallyOld = message.timestamp && new Date(message.timestamp).getTime() < Date.now() - 2 * 60 * 1000 // older than 2 minutes ago
+ // Don't post new generated embeds for messages if the setting was disabled.
+ const embedsEnabled = select("guild_space", "url_preview", {guild_id: guild?.id}).pluck().get() ?? 1
+ // Bots may rely on embeds to send new content, so the rules may be more lax for them.
+ const botEmbedsApproved = message.author?.bot && !originallyFromMatrix
+ if (messageReallyOld) {
+ eventsToSend = [] // Only allow edits to change and delete, but not send new.
+ } else if ((messageQuiteOld || !embedsEnabled) && !botEmbedsApproved) {
+ eventsToSend = eventsToSend.filter(e => e.msgtype !== "m.notice") // Only send events that aren't embeds.
}
// Now, everything in eventsToSend and eventsToRedact is a real change, but everything in eventsToReplace might not have actually changed!
// (Example: a MESSAGE_UPDATE for a text+image message - Discord does not allow the image to be changed, but the text might have been.)
// So we'll remove entries from eventsToReplace that *definitely* cannot have changed. (This is category 4 mentioned above.) Everything remaining *may* have changed.
- unchangedEvents.push(...eventsToReplace.filter(ev => !eventCanBeEdited(ev))) // Move them from eventsToRedact to unchangedEvents.
- eventsToReplace = eventsToReplace.filter(eventCanBeEdited)
+ unchangedEvents = unchangedEvents.concat(dUtils.filterTo(eventsToReplace, ev => eventCanBeEdited(ev))) // Move them from eventsToReplace to unchangedEvents.
// Now, everything in eventsToReplace has the potential to have changed, but did it actually?
// (Example: if a URL preview was generated or updated, the message text won't have changed.)
@@ -132,6 +171,7 @@ async function editToChanges(message, guild, api) {
const event = eventsToReplace[i]
if (!eventIsText(event)) continue // not text, can't analyse
const oldEvent = await api.getEvent(roomID, eventsToReplace[i].old.event_id)
+ eventsToReplace[i].oldMentions = oldEvent.content["m.mentions"]
const oldEventBodyWithoutQuotedReply = oldEvent.content.body?.replace(/^(>.*\n)*\n*/sm, "")
if (oldEventBodyWithoutQuotedReply !== event.newInnerContent.body) continue // event changed, must replace it
// Move it from eventsToRedact to unchangedEvents.
@@ -179,23 +219,38 @@ async function editToChanges(message, guild, api) {
}
// Removing unnecessary properties before returning
- eventsToRedact = eventsToRedact.map(e => e.old.event_id)
- eventsToReplace = eventsToReplace.map(e => ({oldID: e.old.event_id, newContent: makeReplacementEventContent(e.old.event_id, e.newFallbackContent, e.newInnerContent)}))
-
- return {roomID, eventsToReplace, eventsToRedact, eventsToSend, senderMxid, promotions}
+ return {
+ roomID,
+ eventsToReplace: eventsToReplace.map(e => ({oldID: e.old.event_id, newContent: makeReplacementEventContent(e.old.event_id, e.oldMentions, e.newFallbackContent, e.newInnerContent)})),
+ eventsToRedact: eventsToRedact.map(e => e.old.event_id),
+ eventsToSend,
+ senderMxid,
+ promotions
+ }
}
/**
* @template T
* @param {string} oldID
+ * @param {any} oldMentions
* @param {T} newFallbackContent
* @param {T} newInnerContent
* @returns {import("../../types").Event.ReplacementContent} content
*/
-function makeReplacementEventContent(oldID, newFallbackContent, newInnerContent) {
+function makeReplacementEventContent(oldID, oldMentions, newFallbackContent, newInnerContent) {
+ const mentions = {}
+ const newMentionUsers = new Set(newFallbackContent["m.mentions"]?.user_ids || [])
+ const oldMentionUsers = new Set(oldMentions?.user_ids || [])
+ const mentionDiff = newMentionUsers.difference(oldMentionUsers)
+ if (mentionDiff.size) {
+ mentions.user_ids = [...mentionDiff.values()]
+ }
+ if (newFallbackContent["m.mentions"]?.room && !oldMentions?.room) {
+ mentions.room = true
+ }
const content = {
...newFallbackContent,
- "m.mentions": {},
+ "m.mentions": mentions,
"m.new_content": {
...newInnerContent
},
diff --git a/src/d2m/converters/edit-to-changes.test.js b/src/d2m/converters/edit-to-changes.test.js
index 30549c78..b78ee9e0 100644
--- a/src/d2m/converters/edit-to-changes.test.js
+++ b/src/d2m/converters/edit-to-changes.test.js
@@ -42,7 +42,14 @@ test("edit2changes: bot response", async t => {
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.bot_response, data.guild.general, {
getEvent(roomID, eventID) {
t.equal(eventID, "$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY")
- return {content: {body: "dummy"}}
+ return {
+ content: {
+ "m.mentions": {
+ user_ids: ["@cadence:cadence.moe"],
+ },
+ body: "dummy"
+ }
+ }
},
async getJoinedMembers(roomID) {
t.equal(roomID, "!hYnGGlPHlbujVVfktC:cadence.moe")
@@ -73,7 +80,7 @@ test("edit2changes: bot response", async t => {
msgtype: "m.text",
body: "* :ae_botrac4r: @cadence asked ````, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.",
format: "org.matrix.custom.html",
- formatted_body: '* @cadence asked , I respond: Stop drinking paint. (No)
Hit to reroll.',
+ formatted_body: '* @cadence asked , I respond: Stop drinking paint. (No)
Hit to reroll.',
"m.mentions": {
// Client-Server API spec 11.37.7: Copy Discord's behaviour by not re-notifying anyone that an *edit occurred*
},
@@ -82,7 +89,7 @@ test("edit2changes: bot response", async t => {
msgtype: "m.text",
body: ":ae_botrac4r: @cadence asked ````, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.",
format: "org.matrix.custom.html",
- formatted_body: ' @cadence asked , I respond: Stop drinking paint. (No)
Hit to reroll.',
+ formatted_body: '@cadence asked , I respond: Stop drinking paint. (No)
Hit to reroll.',
"m.mentions": {
// Client-Server API spec 11.37.7: This should contain the mentions for the final version of the event
"user_ids": ["@cadence:cadence.moe"]
@@ -181,12 +188,7 @@ test("edit2changes: edit of reply to skull webp attachment with content", async
newContent: {
$type: "m.room.message",
msgtype: "m.text",
- body: "> Extremity: Image\n\n* Edit",
- format: "org.matrix.custom.html",
- formatted_body:
- '
1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:`
+ `
Both players draw eight cards`
+ `
Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand`
- + `
Both players present their best five-or-less-card pok...
`,
+ + `
Both players present their best five-or-less-card pok...
hthrflwrs on cohost`,
"m.mentions": {}
}])
t.deepEqual(promotions, [{
@@ -315,6 +371,7 @@ test("edit2changes: generated embed", async t => {
test("edit2changes: generated embed on a reply", async t => {
let called = 0
+ data.message_update.embed_generated_on_reply.timestamp = new Date().toISOString()
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_on_reply, data.guild.general, {
getEvent(roomID, eventID) {
called++
@@ -360,3 +417,21 @@ test("edit2changes: generated embed on a reply", async t => {
t.equal(senderMxid, "@_ooye_cadence:cadence.moe")
t.equal(called, 1)
})
+
+test("edit2changes: don't generate embed if it's been too long since the message", async t => {
+ const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_social_media_image_for_matrix_user, data.guild.general)
+ t.deepEqual(eventsToRedact, [])
+ t.deepEqual(eventsToReplace, [])
+ t.deepEqual(eventsToSend, [])
+ t.deepEqual(promotions, [])
+ t.equal(senderMxid, null)
+})
+
+test("edit2changes: don't generate new data in situations where m->d(->m) subtypes don't match, like large files", async t => {
+ const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message.large_file_from_matrix)
+ t.deepEqual(eventsToRedact, [])
+ t.deepEqual(eventsToReplace, [])
+ t.deepEqual(eventsToSend, [])
+ t.deepEqual(promotions, [])
+ t.equal(senderMxid, null)
+})
diff --git a/src/d2m/converters/find-mentions.js b/src/d2m/converters/find-mentions.js
new file mode 100644
index 00000000..8107459e
--- /dev/null
+++ b/src/d2m/converters/find-mentions.js
@@ -0,0 +1,169 @@
+// @ts-check
+
+const assert = require("assert")
+
+const {reg} = require("../../matrix/read-registration")
+const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
+
+/**
+ * @typedef {{text: string, index: number, end: number}} Token
+ */
+
+/** @typedef {{mxids: {localpart: string, mxid: string, displayname?: string | null}[], names: {displaynameTokens: Token[], mxid: string}[]}} ProcessedJoined */
+
+const lengthBonusLengthCap = 50
+const lengthBonusValue = 0.5
+/**
+ * Score by how many characters in a row at the start of input are in localpart. 2x if it matches at the start. +1 tiebreaker bonus if it matches all.
+ * 0 = no match
+ * @param {string} localpart
+ * @param {string} input
+ * @param {string | null} [displayname] only for the super tiebreaker
+ * @returns {{score: number, matchedInputTokens: Token[]}}
+ */
+function scoreLocalpart(localpart, input, displayname) {
+ let score = 0
+ let atStart = false
+ let matchingLocations = []
+ do {
+ atStart = matchingLocations[0] === 0
+ let chars = input[score]
+ if (score === 0) {
+ // add all possible places
+ let i = 0
+ while ((i = localpart.indexOf(chars, i)) !== -1) {
+ matchingLocations.push(i)
+ i++
+ }
+ } else {
+ // trim down remaining places
+ matchingLocations = matchingLocations.filter(i => localpart[i+score] === input[score])
+ }
+ if (matchingLocations.length) {
+ score++
+ if (score === localpart.length) break
+ }
+ } while (matchingLocations.length)
+ /** @type {Token} */
+ const fakeToken = {text: input.slice(0, score), index: 0, end: score}
+ const displaynameLength = displayname?.length ?? 0
+ if (score === localpart.length) score = score * 2 + 1 + Math.max(((lengthBonusLengthCap-displaynameLength)/lengthBonusLengthCap)*lengthBonusValue, 0)
+ else if (atStart) score = score * 2
+ return {score, matchedInputTokens: [fakeToken]}
+}
+
+const decayDistance = 20
+const decayValue = 0.33
+/**
+ * Score by how many tokens in sequence (not necessarily back to back) at the start of input are in display name tokens. Score each token on its length. 2x if it matches at the start. +1 tiebreaker bonus if it matches all
+ * @param {Token[]} displaynameTokens
+ * @param {Token[]} inputTokens
+ * @returns {{score: number, matchedInputTokens: Token[]}}
+ */
+function scoreName(displaynameTokens, inputTokens) {
+ let matchedInputTokens = []
+ let score = 0
+ let searchFrom = 0
+ for (let nextInputTokenIndex = 0; nextInputTokenIndex < inputTokens.length; nextInputTokenIndex++) {
+ // take next
+ const nextToken = inputTokens[nextInputTokenIndex]
+ // see if it's there
+ let foundAt = displaynameTokens.findIndex((tk, idx) => idx >= searchFrom && tk.text === nextToken.text)
+ if (foundAt !== -1) {
+ // update scoring
+ matchedInputTokens.push(nextToken)
+ score += nextToken.text.length * Math.max(((decayDistance-foundAt)*(1+decayValue))/(decayDistance*(1+decayValue)), decayValue) // decay score 100%->33% the further into the displayname it's found
+ // prepare for next loop
+ searchFrom = foundAt + 1
+ } else {
+ break
+ }
+ }
+ const firstTextualInputToken = inputTokens.find(t => t.text.match(/^\w/))
+ if (matchedInputTokens[0] === inputTokens[0] || matchedInputTokens[0] === firstTextualInputToken) score *= 2
+ if (matchedInputTokens.length === displaynameTokens.length) score += 1
+ return {score, matchedInputTokens}
+}
+
+/**
+ * @param {string} name
+ * @returns {Token[]}
+ */
+function tokenise(name) {
+ name = name.replaceAll("\ufe0f", "").normalize().toLowerCase()
+ let index = 0
+ let result = []
+ for (const part of name.split(/(_|\s|\b)/g)) {
+ if (part.trim()) {
+ result.push({text: part, index, end: index + part.length})
+ }
+ index += part.length
+ }
+ return result
+}
+
+/**
+ * @param {{mxid: string, displayname?: string | null}[]} joined
+ * @returns {ProcessedJoined}
+ */
+function processJoined(joined) {
+ joined = joined.filter(j => !userRegex.some(rx => j.mxid.match(rx)))
+ return {
+ mxids: joined.map(j => {
+ const localpart = j.mxid.match(/@([^:]*)/)
+ assert(localpart)
+ return {
+ localpart: localpart[1].toLowerCase(),
+ mxid: j.mxid,
+ displayname: j.displayname
+ }
+ }),
+ names: joined.filter(j => j.displayname).map(j => {
+ return {
+ // @ts-ignore
+ displaynameTokens: tokenise(j.displayname),
+ mxid: j.mxid
+ }
+ })
+ }
+}
+
+/**
+ * @param {ProcessedJoined} pjr
+ * @param {string} maximumWrittenSection lowercase please
+ * @param {number} baseOffset
+ * @param {string} prefix
+ * @param {string} content
+ */
+function findMention(pjr, maximumWrittenSection, baseOffset, prefix, content) {
+ if (!pjr.mxids.length && !pjr.names.length) return
+ const maximumWrittenSectionTokens = tokenise(maximumWrittenSection)
+ /** @type {{mxid: string, scored: {score: number, matchedInputTokens: Token[]}}[]} */
+ 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)})))
+ const best = allItems.sort((a, b) => b.scored.score - a.scored.score)[0]
+ 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
+ // Highlight the relevant part of the message
+ const start = baseOffset + best.scored.matchedInputTokens[0].index
+ const end = baseOffset + prefix.length + best.scored.matchedInputTokens.slice(-1)[0].end
+ const newNodes = [{
+ type: "text", content: content.slice(0, start)
+ }, {
+ type: "link", target: `https://matrix.to/#/${best.mxid}`, content: [
+ {type: "text", content: content.slice(start, end)}
+ ]
+ }, {
+ type: "text", content: content.slice(end)
+ }]
+ return {
+ mxid: best.mxid,
+ newNodes
+ }
+ }
+}
+
+module.exports.scoreLocalpart = scoreLocalpart
+module.exports.scoreName = scoreName
+module.exports.tokenise = tokenise
+module.exports.processJoined = processJoined
+module.exports.findMention = findMention
diff --git a/src/d2m/converters/find-mentions.test.js b/src/d2m/converters/find-mentions.test.js
new file mode 100644
index 00000000..8f2be094
--- /dev/null
+++ b/src/d2m/converters/find-mentions.test.js
@@ -0,0 +1,129 @@
+// @ts-check
+
+const {test} = require("supertape")
+const {processJoined, scoreLocalpart, scoreName, tokenise, findMention} = require("./find-mentions")
+
+test("score localpart: score against cadence", t => {
+ const localparts = [
+ "cadence",
+ "cadence_test",
+ "roblkyogre",
+ "cat",
+ "arcade_cabinet"
+ ]
+ t.deepEqual(localparts.map(l => scoreLocalpart(l, "cadence").score), [
+ 15.5,
+ 14,
+ 0,
+ 4,
+ 4
+ ])
+})
+
+test("score mxid: tiebreak multiple perfect matches on name length", t => {
+ const users = [
+ {displayname: "Emma [it/its] ⚡️", localpart: "emma"},
+ {displayname: "Emma [it/its]", localpart: "emma"}
+ ]
+ const results = users.map(u => scoreLocalpart(u.localpart, "emma", u.displayname).score)
+ t.ok(results[0] < results[1], `comparison: ${results.join(" < ")}`)
+})
+
+test("score name: score against cadence", t => {
+ const names = [
+ "bgt lover",
+ "Ash 🦑 (xey/it)",
+ "Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆",
+ "underscore_idiot #sunshine",
+ "INX | Evil Lillith (she/her)",
+ "INX | Lillith (she/her)",
+ "🌟luna🌟",
+ "#1 Ritsuko Kinnie"
+ ]
+ t.deepEqual(names.map(n => scoreName(tokenise(n), tokenise("cadence")).score), [
+ 0,
+ 0,
+ 14,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ])
+})
+
+test("score name: nothing scored after a token doesn't match", t => {
+ const names = [
+ "bgt lover",
+ "Ash 🦑 (xey/it)",
+ "Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆",
+ "underscore_idiot #sunshine",
+ "INX | Evil Lillith (she/her)",
+ "INX | Lillith (she/her)",
+ "🌟luna🌟",
+ "#1 Ritsuko Kinnie"
+ ]
+ t.deepEqual(names.map(n => scoreName(tokenise(n), tokenise("I hope so")).score), [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ])
+})
+
+test("score name: prefers earlier match", t => {
+ const names = [
+ "INX | Lillith (she/her)",
+ "INX | Evil Lillith (she/her)"
+ ]
+ const results = names.map(n => scoreName(tokenise(n), tokenise("lillith")).score)
+ t.ok(results[0] > results[1], `comparison: ${results.join(" > ")}`)
+})
+
+test("score name: matches lots of tokens", t => {
+ t.deepEqual(
+ Math.round(scoreName(tokenise("Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆"), tokenise("cadence maid of creation eye of clarity empress of hope")).score),
+ 65
+ )
+})
+
+test("score name: prefers variation when you specify it", t => {
+ const names = [
+ "Cadence (test account)",
+ "Cadence"
+ ]
+ const results = names.map(n => scoreName(tokenise(n), tokenise("cadence test")).score)
+ t.ok(results[0] > results[1], `comparison: ${results.join(" > ")}`)
+})
+
+test("score name: prefers original when not specified", t => {
+ const names = [
+ "Cadence (test account)",
+ "Cadence"
+ ]
+ const results = names.map(n => scoreName(tokenise(n), tokenise("cadence")).score)
+ t.ok(results[0] < results[1], `comparison: ${results.join(" < ")}`)
+})
+
+test("score name: finds match location", t => {
+ const message = "evil lillith is an inspiration"
+ const result = scoreName(tokenise("INX | Evil Lillith (she/her)"), tokenise(message))
+ const startLocation = result.matchedInputTokens[0].index
+ const endLocation = result.matchedInputTokens.slice(-1)[0].end
+ t.equal(message.slice(startLocation, endLocation), "evil lillith")
+})
+
+test("find mention: test various tiebreakers", t => {
+ const found = findMention(processJoined([{
+ mxid: "@emma:conduit.rory.gay",
+ displayname: "Emma [it/its] ⚡️"
+ }, {
+ mxid: "@emma:rory.gay",
+ displayname: "Emma [it/its]"
+ }]), "emma ⚡ curious which one this prefers", 0, "@", "@emma ⚡ curious which one this prefers")
+ t.equal(found?.mxid, "@emma:conduit.rory.gay")
+})
diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js
index a8e5a6b7..c0b9622f 100644
--- a/src/d2m/converters/message-to-event.js
+++ b/src/d2m/converters/message-to-event.js
@@ -14,29 +14,33 @@ const file = sync.require("../../matrix/file")
const emojiToKey = sync.require("./emoji-to-key")
/** @type {import("../actions/lottie")} */
const lottie = sync.require("../actions/lottie")
-/** @type {import("../../m2d/converters/utils")} */
-const mxUtils = sync.require("../../m2d/converters/utils")
+/** @type {import("../../matrix/utils")} */
+const mxUtils = sync.require("../../matrix/utils")
/** @type {import("../../discord/utils")} */
const dUtils = sync.require("../../discord/utils")
+/** @type {import("./find-mentions")} */
+const findMentions = sync.require("./find-mentions")
+/** @type {import("../../discord/interactions/poll-responses")} */
+const pollResponses = sync.require("../../discord/interactions/poll-responses")
const {reg} = require("../../matrix/read-registration")
-const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
-
/**
* @param {DiscordTypes.APIMessage} message
* @param {DiscordTypes.APIGuild} guild
* @param {boolean} useHTML
+ * @param {string[]} spoilers
*/
-function getDiscordParseCallbacks(message, guild, useHTML) {
+function getDiscordParseCallbacks(message, guild, useHTML, spoilers = []) {
return {
/** @param {{id: string, type: "discordUser"}} node */
user: node => {
const mxid = select("sim", "mxid", {user_id: node.id}).pluck().get()
- const interaction = message.interaction_metadata || message.interaction
- const username = message.mentions.find(ment => ment.id === node.id)?.username
- || message.referenced_message?.mentions.find(ment => ment.id === node.id)?.username
- || (interaction?.user.id === node.id ? interaction.user.username : null)
- || node.id
+ const interactionMetadata = message.interaction_metadata
+ const username = message.mentions?.find(ment => ment.id === node.id)?.username
+ || message.referenced_message?.mentions?.find(ment => ment.id === node.id)?.username
+ || (interactionMetadata?.user.id === node.id ? interactionMetadata.user.username : null)
+ || (message.author?.id === node.id ? message.author.username : null)
+ || "unknown-user"
if (mxid && useHTML) {
return `@${username}`
} else {
@@ -89,6 +93,10 @@ function getDiscordParseCallbacks(message, guild, useHTML) {
here: () => {
if (message.mention_everyone) return "@room"
return "@here"
+ },
+ spoiler: node => {
+ spoilers.push(node.raw)
+ return useHTML
}
}
}
@@ -101,9 +109,10 @@ const embedTitleParser = markdown.markdownEngine.parserFor({
/**
* @param {{room?: boolean, user_ids?: string[]}} mentions
- * @param {DiscordTypes.APIAttachment} attachment
+ * @param {Omit} attachment
+ * @param {boolean} [alwaysLink]
*/
-async function attachmentToEvent(mentions, attachment) {
+async function attachmentToEvent(mentions, attachment, alwaysLink) {
const external_url = dUtils.getPublicUrlForCdn(attachment.url)
const emoji =
attachment.content_type?.startsWith("image/jp") ? "📸"
@@ -113,7 +122,7 @@ async function attachmentToEvent(mentions, attachment) {
: 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
- if (attachment.filename.startsWith("SPOILER_")) {
+ if (attachment.filename.startsWith("SPOILER_") || !!((attachment.flags || 0) & DiscordTypes.AttachmentFlags.IsSpoiler)) {
return {
$type: "m.room.message",
"m.mentions": mentions,
@@ -124,7 +133,7 @@ async function attachmentToEvent(mentions, attachment) {
}
}
// for large files, always link them instead of uploading so I don't use up all the space in the content repo
- else if (attachment.size > reg.ooye.max_file_size) {
+ else if (alwaysLink || attachment.size > reg.ooye.max_file_size) {
return {
$type: "m.room.message",
"m.mentions": mentions,
@@ -177,7 +186,7 @@ async function attachmentToEvent(mentions, attachment) {
info: {
mimetype: attachment.content_type,
size: attachment.size,
- duration: attachment.duration_secs ? Math.round(attachment.duration_secs * 1000) : undefined
+ duration: attachment.duration_secs && Math.round(attachment.duration_secs * 1000)
}
}
} else {
@@ -197,6 +206,87 @@ async function attachmentToEvent(mentions, attachment) {
}
}
+/** @param {DiscordTypes.APIPoll} poll */
+async function pollToEvent(poll) {
+ let fallbackText = poll.question.text
+ if (poll.allow_multiselect) {
+ var maxSelections = poll.answers.length;
+ } else {
+ var maxSelections = 1;
+ }
+ let answers = poll.answers.map(answer=>{
+ let matrixText = answer.poll_media.text
+ if (answer.poll_media.emoji) {
+ if (answer.poll_media.emoji.id) {
+ // Custom emoji. It seems like no Matrix client allows custom emoji in poll answers, so leaving this unimplemented.
+ } else {
+ matrixText = "[" + answer.poll_media.emoji.name + "] " + matrixText
+ }
+ }
+ let matrixAnswer = {
+ id: answer.answer_id.toString(),
+ "org.matrix.msc1767.text": matrixText
+ }
+ fallbackText = fallbackText + "\n" + answer.answer_id.toString() + ". " + matrixText
+ return matrixAnswer;
+ })
+ return {
+ /** @type {"org.matrix.msc3381.poll.start"} */
+ $type: "org.matrix.msc3381.poll.start",
+ "org.matrix.msc3381.poll.start": {
+ question: {
+ "org.matrix.msc1767.text": poll.question.text,
+ body: poll.question.text,
+ msgtype: "m.text"
+ },
+ kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that.
+ max_selections: maxSelections,
+ answers: answers
+ },
+ "org.matrix.msc1767.text": fallbackText
+ }
+}
+
+/**
+ * @param {DiscordTypes.APIMessageInteraction} interaction
+ * @param {boolean} isThinkingInteraction
+ */
+function getFormattedInteraction(interaction, isThinkingInteraction) {
+ const mxid = select("sim", "mxid", {user_id: interaction.user.id}).pluck().get()
+ const username = interaction.member?.nick || interaction.user.global_name || interaction.user.username
+ const thinkingText = isThinkingInteraction ? " — interaction loading..." : ""
+ return {
+ body: `❭ ${username} used \`/${interaction.name}\`${thinkingText}`,
+ html: `
❭ ${mxid ? tag`${username}` : username} used /${interaction.name}${thinkingText}
`
+ }
+}
+
+/**
+ * @param {any} newEvents merge into events
+ * @param {any} events will be modified
+ * @param {boolean} forceSameMsgtype whether m.text may only be combined with m.text, etc
+ * @param {boolean} [forceMerge] if true, must merge event, will error if it had to append
+ */
+function mergeTextEvents(newEvents, events, forceSameMsgtype, forceMerge = false) {
+ let prev = events.at(-1)
+ for (const ne of newEvents) {
+ const isAllText = prev?.body && prev?.formatted_body && ["m.text", "m.notice"].includes(ne.msgtype) && ["m.text", "m.notice"].includes(prev?.msgtype)
+ const typesPermitted = !forceSameMsgtype || ne?.msgtype === prev?.msgtype
+ if (isAllText && typesPermitted) {
+ const rep = new mxUtils.MatrixStringBuilder()
+ rep.body = prev.body
+ rep.formattedBody = prev.formatted_body
+ rep.addLine(ne.body, ne.formatted_body)
+ prev.body = rep.body
+ prev.formatted_body = rep.formattedBody
+ } else if (forceMerge) {
+ throw new Error("Unable to merge events")
+ } else {
+ events.push(ne)
+ }
+ }
+}
+
/**
* @param {DiscordTypes.APIMessage} message
* @param {DiscordTypes.APIGuild} guild
@@ -206,8 +296,10 @@ async function attachmentToEvent(mentions, attachment) {
* - alwaysReturnFormattedBody: false - formatted_body will be skipped if it is the same as body because the message is plaintext. if you want the formatted_body to be returned anyway, for example to merge it with another message, then set this to true.
* - scanTextForMentions: true - needs to be set to false when converting forwarded messages etc which may be from a different channel that can't be scanned.
* @param {{api: import("../../matrix/api"), snow?: import("snowtransfer").SnowTransfer}} di simple-as-nails dependency injection for the matrix API
+ * @returns {Promise<{$type: string, $sender?: string, [x: string]: any}[]>}
*/
async function messageToEvent(message, guild, options = {}, di) {
+ message = structuredClone(message)
const events = []
/* c8 ignore next 7 */
@@ -219,6 +311,38 @@ async function messageToEvent(message, guild, options = {}, di) {
return []
}
+ if (message.type === DiscordTypes.MessageType.PollResult) {
+ const pollMessageID = message.message_reference?.message_id
+ if (!pollMessageID) return []
+ const event_id = select("event_message", "event_id", {message_id: pollMessageID}).pluck().get()
+ const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
+ const pollQuestionText = select("poll", "question_text", {message_id: pollMessageID}).pluck().get()
+ if (!event_id || !roomID || !pollQuestionText) return [] // drop it if the corresponding poll start was not bridged
+
+ const rep = new mxUtils.MatrixStringBuilder()
+ rep.addLine(`The poll ${pollQuestionText} has closed.`, tag`The poll ${pollQuestionText} has closed.`)
+
+ const {messageString} = pollResponses.getCombinedResults(pollMessageID, true) // poll results have already been double-checked before this point, so these totals will be accurate
+ rep.addLine(markdown.toHTML(messageString, {discordOnly: true, escapeHTML: false}), markdown.toHTML(messageString, {}))
+
+ const {body, formatted_body} = rep.get()
+
+ return [{
+ $type: "org.matrix.msc3381.poll.end",
+ "m.relates_to": {
+ rel_type: "m.reference",
+ event_id
+ },
+ "org.matrix.msc3381.poll.end": {},
+ "org.matrix.msc1767.text": body,
+ "org.matrix.msc1767.html": formatted_body,
+ body: body,
+ format: "org.matrix.custom.html",
+ formatted_body: formatted_body,
+ msgtype: "m.text"
+ }]
+ }
+
if (message.type === DiscordTypes.MessageType.ThreadStarterMessage) {
// This is the message that appears at the top of a thread when the thread was based off an existing message.
// It's just a message reference, no content.
@@ -236,16 +360,20 @@ async function messageToEvent(message, guild, options = {}, di) {
}]
}
- const interaction = message.interaction_metadata || message.interaction
- if (message.type === DiscordTypes.MessageType.ChatInputCommand && interaction && "name" in interaction) {
- // Commands are sent by the responding bot. Need to attach the metadata of the person using the command at the top.
- let content = message.content
- if (content) content = `\n${content}`
- else if ((message.flags || 0) & DiscordTypes.MessageFlags.Loading) content = " — interaction loading..."
- content = `> ↪️ <@${interaction.user.id}> used \`/${interaction.name}\`${content}`
- message = {...message, content} // editToChanges reuses the object so we can't mutate it. have to clone it
+ if (message.type === DiscordTypes.MessageType.ChannelFollowAdd) {
+ return [{
+ $type: "m.room.message",
+ msgtype: "m.emote",
+ body: `set this room to receive announcements from ${message.content}`,
+ format: "org.matrix.custom.html",
+ formatted_body: tag`set this room to receive announcements from ${message.content}`,
+ "m.mentions": {}
+ }]
}
+ let isInteraction = (message.type === DiscordTypes.MessageType.ChatInputCommand || message.type === DiscordTypes.MessageType.ContextMenuCommand) && message.interaction && "name" in message.interaction
+ let isThinkingInteraction = isInteraction && !!((message.flags || 0) & DiscordTypes.MessageFlags.Loading)
+
/**
@type {{room?: boolean, user_ids?: string[]}}
We should consider the following scenarios for mentions:
@@ -263,8 +391,9 @@ async function messageToEvent(message, guild, options = {}, di) {
- So make sure we don't do anything in this case.
*/
const mentions = {}
- /** @type {{event_id: string, room_id: string, source: number}?} */
+ /** @type {{event_id: string, room_id: string, source: number, channel_id: string}?} */
let repliedToEventRow = null
+ let repliedToEventInDifferentRoom = false
let repliedToUnknownEvent = false
let repliedToEventSenderMxid = null
@@ -278,12 +407,22 @@ async function messageToEvent(message, guild, options = {}, di) {
// Mentions scenarios 1 and 2, part A. i.e. translate relevant message.mentions to m.mentions
// (Still need to do scenarios 1 and 2 part B, and scenario 3.)
if (message.type === DiscordTypes.MessageType.Reply && message.message_reference?.message_id) {
- const row = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id").select("event_id", "room_id", "source").and("WHERE message_id = ? AND part = 0").get(message.message_reference.message_id)
- if (row) {
- repliedToEventRow = row
+ const row = await getHistoricalEventRow(message.message_reference?.message_id)
+ if (row && "event_id" in row) {
+ repliedToEventRow = Object.assign(row, {channel_id: row.reference_channel_id})
} else if (message.referenced_message) {
repliedToUnknownEvent = true
}
+ } else if (message.type === DiscordTypes.MessageType.ContextMenuCommand && message.interaction && message.message_reference?.message_id) {
+ // It could be a /plu/ral emulated reply
+ if (message.interaction.name.startsWith("Reply ") && message.content.startsWith("-# [↪](")) {
+ const row = await getHistoricalEventRow(message.message_reference?.message_id)
+ if (row && "event_id" in row) {
+ repliedToEventRow = Object.assign(row, {channel_id: row.reference_channel_id})
+ message.content = message.content.replace(/^.*\n/, "")
+ isInteraction = false // declutter
+ }
+ }
} else if (dUtils.isWebhookMessage(message) && message.embeds[0]?.author?.name?.endsWith("↩️")) {
// It could be a PluralKit emulated reply, let's see if it has a message link
const isEmulatedReplyToText = message.embeds[0].description?.startsWith("**[Reply to:]")
@@ -292,8 +431,8 @@ async function messageToEvent(message, guild, options = {}, di) {
assert(message.embeds[0].description)
const match = message.embeds[0].description.match(/\/channels\/[0-9]*\/[0-9]*\/([0-9]{2,})/)
if (match) {
- const row = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id").select("event_id", "room_id", "source").and("WHERE message_id = ? AND part = 0").get(match[1])
- if (row) {
+ const row = await getHistoricalEventRow(match[1])
+ if (row && "event_id" in row) {
/*
we generate a partial referenced_message based on what PK provided. we don't need everything, since this will only be used for further message-to-event converting.
the following properties are necessary:
@@ -311,7 +450,7 @@ async function messageToEvent(message, guild, options = {}, di) {
}
}
message.embeds.shift()
- repliedToEventRow = row
+ repliedToEventRow = Object.assign(row, {channel_id: row.reference_channel_id})
}
}
}
@@ -338,6 +477,34 @@ async function messageToEvent(message, guild, options = {}, di) {
return promise
}
+ /**
+ * @param {string} messageID
+ * @param {string} [timestampChannelID]
+ */
+ async function getHistoricalEventRow(messageID, timestampChannelID) {
+ /** @type {{room_id: string} | {event_id: string, room_id: string, reference_channel_id: string, source: number} | null | undefined} */
+ let row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
+ .select("event_id", "room_id", "reference_channel_id", "source").where({message_id: messageID}).and("ORDER BY part ASC").get()
+ if (!row && timestampChannelID) {
+ const ts = dUtils.snowflakeToTimestampExact(messageID)
+ const oldestRow = from("historical_channel_room").selectUnsafe("max(upgraded_timestamp)", "room_id")
+ .where({reference_channel_id: timestampChannelID}).and("and upgraded_timestamp < ?").get(ts)
+ if (oldestRow?.room_id) {
+ row = {room_id: oldestRow.room_id}
+ try {
+ const {event_id} = await di.api.getEventForTimestamp(oldestRow.room_id, ts)
+ row = {
+ event_id,
+ room_id: oldestRow.room_id,
+ reference_channel_id: oldestRow.reference_channel_id,
+ source: 1
+ }
+ } catch (e) {}
+ }
+ }
+ return row
+ }
+
/**
* Translate Discord message links to Matrix event links.
* If OOYE has handled this message in the past, this is an instant database lookup.
@@ -349,27 +516,13 @@ async function messageToEvent(message, guild, options = {}, di) {
for (const match of [...content.matchAll(/https:\/\/(?:ptb\.|canary\.|www\.)?discord(?:app)?\.com\/channels\/[0-9]+\/([0-9]+)\/([0-9]+)/g)]) {
assert(typeof match.index === "number")
const [_, channelID, messageID] = match
- let result
-
- const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
- if (roomID) {
- const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get()
- const via = await getViaServersMemo(roomID)
- if (eventID && roomID) {
- result = `https://matrix.to/#/${roomID}/${eventID}?${via}`
- } else {
- const ts = dUtils.snowflakeToTimestampExact(messageID)
- try {
- const {event_id} = await di.api.getEventForTimestamp(roomID, ts)
- result = `https://matrix.to/#/${roomID}/${event_id}?${via}`
- } catch (e) {
- // M_NOT_FOUND: Unable to find event from in direction Direction.FORWARDS
- result = `[unknown event, timestamp resolution failed, in room: https://matrix.to/#/${roomID}?${via}]`
- }
- }
- } else {
- result = `${match[0]} [event is from another server]`
- }
+ const result = await (async () => {
+ const row = await getHistoricalEventRow(messageID, channelID)
+ if (!row) return `${match[0]} [event is from another server]`
+ const via = await getViaServersMemo(row.room_id)
+ if (!("event_id" in row)) return `[unknown event in https://matrix.to/#/${row.room_id}?${via}]`
+ return `https://matrix.to/#/${row.room_id}/${row.event_id}?${via}`
+ })()
content = content.slice(0, match.index + offset) + result + content.slice(match.index + match[0].length + offset)
offset += result.length - match[0].length
@@ -384,6 +537,7 @@ async function messageToEvent(message, guild, options = {}, di) {
return content.replace(/https:\/\/(cdn|media)\.discordapp\.(?:com|net)\/attachments\/([0-9]+)\/([0-9]+)\/([-A-Za-z0-9_.,]+)/g, url => dUtils.getPublicUrlForCdn(url))
}
+ const spoilers = []
/**
* Translate links and emojis and mentions and stuff. Give back the text and HTML so they can be combined into bigger events.
* @param {string} content Partial or complete Discord message content
@@ -395,9 +549,15 @@ async function messageToEvent(message, guild, options = {}, di) {
content = transformAttachmentLinks(content)
content = await transformContentMessageLinks(content)
+ // Remove smalltext from non-bots (I don't like it). Webhooks included due to PluralKit.
+ const isHumanOrDataMissing = !message.author?.bot
+ if (isHumanOrDataMissing || dUtils.isWebhookMessage(message)) {
+ content = content.replaceAll(/^-# +([^\n].*?)/gm, "...$1")
+ }
+
// Handling emojis that we don't know about. The emoji has to be present in the DB for it to be picked up in the emoji markdown converter.
// So we scan the message ahead of time for all its emojis and ensure they are in the DB.
- const emojiMatches = [...content.matchAll(/<(a?):([^:>]{1,64}):([0-9]+)>/g)]
+ const emojiMatches = [...content.matchAll(/<(a?):([^:>]+):([0-9]+)>/g)]
await Promise.all(emojiMatches.map(match => {
const id = match[3]
const name = match[2]
@@ -405,29 +565,60 @@ async function messageToEvent(message, guild, options = {}, di) {
return emojiToKey.emojiToKey({id, name, animated}, message.id) // Register the custom emoji if needed
}))
- async function transformParsedVia(parsed) {
- for (const node of parsed) {
- if (node.type === "discordChannel") {
+ async function transformParsedVia(parsed, scanTextForMentions) {
+ for (let n = 0; n < parsed.length; n++) {
+ const node = parsed[n]
+ if (node.type === "discordChannel" || node.type === "discordChannelLink") {
node.row = select("channel_room", ["room_id", "name", "nick"], {channel_id: node.id}).get()
if (node.row?.room_id) {
node.via = await getViaServersMemo(node.row.room_id)
}
}
- ;for (const maybeChildNodesArray of [node, node.content, node.items]) {
+ else if (node.type === "text" && typeof node.content === "string") {
+ // Merge adjacent text nodes into this one
+ while (parsed[n+1]?.type === "text" && typeof parsed[n+1].content === "string") {
+ node.content += parsed[n+1].content
+ parsed.splice(n+1, 1)
+ }
+ // Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention.
+ if (scanTextForMentions) {
+ let content = node.content
+ const matches = [...content.matchAll(/(@ ?)([a-z0-9_.#$][^@\n]+)/gi)]
+ for (let i = matches.length; i--;) {
+ const m = matches[i]
+ const prefix = m[1]
+ const maximumWrittenSection = m[2].toLowerCase()
+ if (m.index > 0 && !content[m.index-1].match(/ |\(|\n/)) continue // must have space before it
+ if (maximumWrittenSection.match(/^everyone\b/) || maximumWrittenSection.match(/^here\b/)) continue // ignore @everyone/@here
+
+ var roomID = roomID ?? select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
+ assert(roomID)
+ var pjr = pjr ?? findMentions.processJoined(Object.entries((await di.api.getJoinedMembers(roomID)).joined).map(([mxid, ev]) => ({mxid, displayname: ev.display_name})))
+
+ const found = findMentions.findMention(pjr, maximumWrittenSection, m.index, prefix, content)
+ if (found) {
+ addMention(found.mxid)
+ parsed.splice(n, 1, ...found.newNodes)
+ content = found.newNodes[0].content
+ }
+ }
+ }
+ }
+ for (const maybeChildNodesArray of [node, node.content, node.items]) {
if (Array.isArray(maybeChildNodesArray)) {
- await transformParsedVia(maybeChildNodesArray)
+ await transformParsedVia(maybeChildNodesArray, scanTextForMentions && ["blockQuote", "list", "paragraph", "em", "strong", "u", "del", "text"].includes(node.type))
}
}
}
return parsed
}
- let html = await markdown.toHtmlWithPostParser(content, transformParsedVia, {
- discordCallback: getDiscordParseCallbacks(message, guild, true),
+ let html = await markdown.toHtmlWithPostParser(content, parsed => transformParsedVia(parsed, customOptions.isTheMessageContent && options.scanTextForMentions !== false), {
+ discordCallback: getDiscordParseCallbacks(message, guild, true, spoilers),
...customOptions
}, customParser, customHtmlOutput)
- let body = await markdown.toHtmlWithPostParser(content, transformParsedVia, {
+ let body = await markdown.toHtmlWithPostParser(content, parsed => transformParsedVia(parsed, false), { // not scanning plaintext body for mentions as we don't parse whether they're in code
discordCallback: getDiscordParseCallbacks(message, guild, false),
discordOnly: true,
escapeHTML: false,
@@ -452,70 +643,90 @@ async function messageToEvent(message, guild, options = {}, di) {
const flags = message.flags || 0
if (flags & DiscordTypes.MessageFlags.IsCrosspost) {
- body = `[🔀 ${message.author.username}]\n` + body
- html = `🔀 ${message.author.username} ` + html
+ body = `[↷ ${message.author.username}]\n` + body
+ html = `↷ ${message.author.username} ` + html
}
// Fallback body/formatted_body for replies
+ // Generate a fallback if native replies are unsupported, which is in the following situations:
+ // 1. The replied-to event is in a different room to where the reply will be sent (i.e. a room upgrade occurred between)
+ // 2. The replied-to message has no corresponding Matrix event (repliedToUnknownEvent is true)
// This branch is optional - do NOT change anything apart from the reply fallback, since it may not be run
- if ((repliedToEventRow || repliedToUnknownEvent) && options.includeReplyFallback !== false) {
- let repliedToDisplayName
- let repliedToUserHtml
- if (repliedToEventRow?.source === 0 && repliedToEventSenderMxid) {
- const match = repliedToEventSenderMxid.match(/^@([^:]*)/)
- assert(match)
- repliedToDisplayName = message.referenced_message?.author.username || match[1] || "a Matrix user" // grab the localpart as the display name, whatever
- repliedToUserHtml = `${repliedToDisplayName}`
- } else {
- repliedToDisplayName = message.referenced_message?.author.global_name || message.referenced_message?.author.username || "a Discord user"
- repliedToUserHtml = repliedToDisplayName
- }
- let repliedToContent = message.referenced_message?.content
- if (repliedToContent?.match(/^(-# )?> (-# )?<:L1:/)) {
- // If the Discord user is replying to a Matrix user's reply, the fallback is going to contain the emojis and stuff from the bridged rep of the Matrix user's reply quote.
- // Need to remove that previous reply rep from this fallback body. The fallbody body should only contain the Matrix user's actual message.
- // ┌──────A─────┐ A reply rep starting with >quote or -#smalltext >quote. Match until the end of the line.
- // ┆ ┆┌─B─┐ There may be up to 2 reply rep lines in a row if it was created in the old format. Match all lines.
- repliedToContent = repliedToContent.replace(/^((-# )?> .*\n){1,2}/, "")
- }
- if (repliedToContent == "") repliedToContent = "[Media]"
- else if (!repliedToContent) repliedToContent = "[Replied-to message content wasn't provided by Discord]"
- const {body: repliedToBody, html: repliedToHtml} = await transformContent(repliedToContent)
- if (repliedToEventRow) {
- // Generate a reply pointing to the Matrix event we found
- html = `
In reply to ${repliedToUserHtml}`
- + ` ${repliedToHtml}
`
- + html
- body = (`${repliedToDisplayName}: ` // scenario 1 part B for mentions
- + repliedToBody).split("\n").map(line => "> " + line).join("\n")
- + "\n\n" + body
- } else { // repliedToUnknownEvent
- // This reply can't point to the Matrix event because it isn't bridged, we need to indicate this.
- assert(message.referenced_message)
- const dateDisplay = dUtils.howOldUnbridgedMessage(message.referenced_message.timestamp, message.timestamp)
- html = `
In reply to ${dateDisplay} from ${repliedToDisplayName}:`
- + ` ${repliedToHtml}
`
- + html
- body = (`In reply to ${dateDisplay}:\n${repliedToDisplayName}: `
- + repliedToBody).split("\n").map(line => "> " + line).join("\n")
- + "\n\n" + body
+ if ((repliedToEventRow || repliedToUnknownEvent) && options.includeReplyFallback !== false && events.length === 0) {
+ const latestRoomID = repliedToEventRow ? select("channel_room", "room_id", {channel_id: repliedToEventRow.channel_id}).pluck().get() : null
+ if (latestRoomID !== repliedToEventRow?.room_id) repliedToEventInDifferentRoom = true
+
+ // check that condition 1 or 2 is met
+ if (repliedToEventInDifferentRoom || repliedToUnknownEvent) {
+ let referenced = message.referenced_message
+ /* c8 ignore next 4 - backend couldn't be bothered to dereference the message, have to do it ourselves */
+ if (!referenced) {
+ assert(message.message_reference?.message_id)
+ referenced = await discord.snow.channel.getChannelMessage(message.message_reference.channel_id, message.message_reference.message_id)
+ }
+
+ // Username
+ let repliedToDisplayName
+ let repliedToUserHtml
+ if (repliedToEventRow?.source === 0 && repliedToEventSenderMxid) {
+ const match = repliedToEventSenderMxid.match(/^@([^:]*)/)
+ assert(match)
+ repliedToDisplayName = referenced.author.username || match[1] || "a Matrix user" // grab the localpart as the display name, whatever
+ repliedToUserHtml = tag`${repliedToDisplayName}`
+ } else {
+ repliedToDisplayName = referenced.author.global_name || referenced.author.username || "a Discord user"
+ repliedToUserHtml = repliedToDisplayName
+ }
+
+ // Content
+ let repliedToContent = referenced.content
+ if (repliedToContent?.match(/^(-# )?> (-# )?quote or -#smalltext >quote. Match until the end of the line.
+ // ┆ ┆┌─B─┐ There may be up to 2 reply rep lines in a row if it was created in the old format. Match all lines.
+ repliedToContent = repliedToContent.replace(/^((-# )?> .*\n){1,2}/, "")
+ }
+ if (repliedToContent == "") repliedToContent = "[Media]"
+ const {body: repliedToBody, html: repliedToHtml} = await transformContent(repliedToContent)
+
+ // Now branch on condition 1 or 2 for a different kind of fallback
+ if (repliedToEventRow) {
+ html = `
In reply to ${repliedToUserHtml}`
+ + ` ${repliedToHtml}
`
+ + html
+ body = `${repliedToDisplayName}: ${repliedToBody}`.split("\n").map(line => "> " + line).join("\n") // scenario 1 part B for mentions
+ + "\n\n" + body
+ } else if (referenced.type === DiscordTypes.MessageType.UserJoin) {
+ // Discord user join messages are bridged as joins, not text events. Generate substitute text for reply.
+ const joinerMxid = select("sim", "mxid", {user_id: referenced.author.id}).pluck().get()
+ const joinerHtml = joinerMxid ? tag`${repliedToDisplayName}` : tag`${repliedToDisplayName}`
+ html = `
${joinerHtml} joined the room
` + html
+ body = `> ${repliedToDisplayName} joined the room\n\n` + body
+ } else { // repliedToUnknownEvent
+ const dateDisplay = dUtils.howOldUnbridgedMessage(referenced.timestamp, message.timestamp)
+ html = `
In reply to ${dateDisplay} from ${repliedToDisplayName}:`
+ + ` ${repliedToHtml}
`
+ + html
+ body = `In reply to ${dateDisplay}:\n${repliedToDisplayName}: ${repliedToBody}`.split("\n").map(line => "> " + line).join("\n")
+ + "\n\n" + body
+ }
}
}
+ if (isInteraction && !isThinkingInteraction && message.interaction && events.length === 0) {
+ const formattedInteraction = getFormattedInteraction(message.interaction, false)
+ body = `${formattedInteraction.body}\n${body}`
+ html = `${formattedInteraction.html}${html}`
+ }
+
const newTextMessageEvent = {
$type: "m.room.message",
"m.mentions": mentions,
msgtype,
- body: body
- }
-
- const isPlaintext = body === html
-
- if (!isPlaintext || options.alwaysReturnFormattedBody) {
- Object.assign(newTextMessageEvent, {
- format: "org.matrix.custom.html",
- formatted_body: html
- })
+ body: body,
+ format: "org.matrix.custom.html",
+ formatted_body: html
}
events.push(newTextMessageEvent)
@@ -529,30 +740,51 @@ async function messageToEvent(message, guild, options = {}, di) {
message.content = "changed the channel name to **" + message.content + "**"
}
+ // Handle message type 63, new emoji announcement
+ // @ts-expect-error - should be changed to a DiscordTypes reference once it has been documented
+ if (message.type === 63) {
+ const match = message.content.match(/^<(a?):([^:>]{1,64}):([0-9]+)>$/)
+ assert(match, `message type 63, which announces a new emoji, did not include an emoji. the actual content was: "${message.content}"`)
+ const name = match[2]
+ msgtype = "m.emote"
+ message.content = `added a new emoji, ${message.content} :${name}:`
+ }
+
+ // Send Klipy GIFs in customised form
+ let isKlipyGIF = false
+ let isOnlyKlipyGIF = false
+ if (message.embeds?.length === 1 && message.embeds[0].provider?.name === "Klipy" && message.embeds[0].video?.url) {
+ isKlipyGIF = true
+ if (message.content.match(/^https?:\/\/klipy\.com[^ \n]+$/)) {
+ isOnlyKlipyGIF = true
+ }
+ }
+
// Forwarded content appears first
- if (message.message_reference?.type === DiscordTypes.MessageReferenceType.Forward && message.message_snapshots?.length) {
+ if (message.message_reference?.type === DiscordTypes.MessageReferenceType.Forward && message.message_reference.message_id && message.message_snapshots?.length) {
// Forwarded notice
- const eventID = select("event_message", "event_id", {message_id: message.message_reference.message_id}).pluck().get()
+ const row = await getHistoricalEventRow(message.message_reference.message_id, message.message_reference.channel_id)
const room = select("channel_room", ["room_id", "name", "nick"], {channel_id: message.message_reference.channel_id}).get()
const forwardedNotice = new mxUtils.MatrixStringBuilder()
if (room) {
const roomName = room && (room.nick || room.name)
- const via = await getViaServersMemo(room.room_id)
- if (eventID) {
+ if (row && "event_id" in row) {
+ const via = await getViaServersMemo(row.room_id)
forwardedNotice.addLine(
- `[🔀 Forwarded from #${roomName}]`,
- tag`🔀 Forwarded from ${roomName}`
+ `[↷ Forwarded from #${roomName}]`,
+ tag`↷ Forwarded from ${roomName} [jump to event]`
)
} else {
+ const via = await getViaServersMemo(room.room_id)
forwardedNotice.addLine(
- `[🔀 Forwarded from #${roomName}]`,
- tag`🔀 Forwarded from ${roomName}`
+ `[↷ Forwarded from #${roomName}]`,
+ tag`↷ Forwarded from ${roomName} [jump to room]`
)
}
} else {
forwardedNotice.addLine(
- `[🔀 Forwarded message]`,
- tag`🔀 Forwarded message`
+ `[↷ Forwarded message]`,
+ tag`↷ Forwarded message`
)
}
@@ -563,7 +795,6 @@ async function messageToEvent(message, guild, options = {}, di) {
// Indent
for (const event of forwardedEvents) {
if (["m.text", "m.notice"].includes(event.msgtype)) {
- event.msgtype = "m.notice"
event.body = event.body.split("\n").map(l => "» " + l).join("\n")
event.formatted_body = `
${event.formatted_body}
`
}
@@ -580,37 +811,41 @@ async function messageToEvent(message, guild, options = {}, di) {
events.push(...forwardedEvents)
}
- // Then text content
- if (message.content) {
- // Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention.
- const matches = [...message.content.matchAll(/@ ?([a-z0-9._]+)\b/gi)]
- if (options.scanTextForMentions !== false && matches.length && matches.some(m => m[1].match(/[a-z]/i) && m[1] !== "everyone" && m[1] !== "here")) {
- const writtenMentionsText = matches.map(m => m[1].toLowerCase())
- const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
- assert(roomID)
- const {joined} = await di.api.getJoinedMembers(roomID)
- for (const [mxid, member] of Object.entries(joined)) {
- if (!userRegex.some(rx => mxid.match(rx))) {
- const localpart = mxid.match(/@([^:]*)/)
- assert(localpart)
- const displayName = member.display_name || localpart[1]
- if (writtenMentionsText.includes(localpart[1].toLowerCase()) || writtenMentionsText.includes(displayName.toLowerCase())) addMention(mxid)
- }
- }
- }
+ if (isInteraction && isThinkingInteraction && message.interaction) {
+ const formattedInteraction = getFormattedInteraction(message.interaction, true)
+ await addTextEvent(formattedInteraction.body, formattedInteraction.html, "m.notice")
+ }
- const {body, html} = await transformContent(message.content)
+ // Then text content
+ if (message.content && !isOnlyKlipyGIF && !isThinkingInteraction) {
+ // Scan the content for emojihax and replace them with real emojis
+ let content = message.content.replaceAll(/\[([a-zA-Z0-9_-]{2,32})(?:~[0-9]+)?\]\(https:\/\/cdn\.discordapp\.com\/emojis\/([0-9]+)\.[^ \n)`]+\)/g, (_, name, id) => {
+ return `<:${name}:${id}>`
+ })
+
+ const {body, html} = await transformContent(content, {isTheMessageContent: true})
await addTextEvent(body, html, msgtype)
}
// Then scheduled events
if (message.content && di?.snow) {
for (const match of [...message.content.matchAll(/discord\.gg\/([A-Za-z0-9]+)\?event=([0-9]{18,})/g)]) { // snowflake has minimum 18 because the events feature is at least that old
- const invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]})
+ let invite
+ try {
+ invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]})
+ } catch (e) {
+ // Skip expired/invalid invites and events
+ if (e.message === `{"message": "Unknown Invite", "code": 10006}`) {
+ break
+ } else {
+ throw e
+ }
+ }
+
const event = invite.guild_scheduled_event
if (!event) continue // the event ID provided was not valid
- const formatter = new Intl.DateTimeFormat("en-NZ", {month: "long", day: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "shortGeneric"}) // 9 June at 3:00 pm NZT
+ const formatter = new Intl.DateTimeFormat("en-NZ", {month: "long", day: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "shortGeneric", timeZone: reg.ooye.time_zone}) // 9 June at 3:00 pm NZT
const rep = new mxUtils.MatrixStringBuilder()
// Add time
@@ -648,8 +883,134 @@ async function messageToEvent(message, guild, options = {}, di) {
// Then attachments
if (message.attachments) {
- const attachmentEvents = await Promise.all(message.attachments.map(attachmentToEvent.bind(null, mentions)))
- events.push(...attachmentEvents)
+ const attachmentEvents = await Promise.all(message.attachments.map(attachment => attachmentToEvent(mentions, attachment)))
+
+ // 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.
+ mergeTextEvents(attachmentEvents, events, false)
+ }
+
+ // Then components
+ if (message.components?.length) {
+ const stack = new mxUtils.MatrixStringBuilderStack()
+ /** @param {DiscordTypes.APIMessageComponent} component */
+ async function processComponent(component) {
+ // Standalone components
+ if (component.type === DiscordTypes.ComponentType.TextDisplay) {
+ const {body, html} = await transformContent(component.content)
+ stack.msb.addParagraph(body, html)
+ }
+ else if (component.type === DiscordTypes.ComponentType.Separator) {
+ stack.msb.addParagraph("----", "")
+ }
+ else if (component.type === DiscordTypes.ComponentType.File) {
+ /** @type {{[k in keyof DiscordTypes.APIUnfurledMediaItem]-?: NonNullable}} */ // @ts-ignore
+ const file = component.file
+ assert(component.name && component.size && file.content_type)
+ 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)
+ stack.msb.addLine(ev.body, ev.formatted_body)
+ }
+ else if (component.type === DiscordTypes.ComponentType.MediaGallery) {
+ const description = component.items.length === 1 ? component.items[0].description || "Image:" : "Image gallery:"
+ const images = component.items.map(item => {
+ const publicURL = dUtils.getPublicUrlForCdn(item.media.url)
+ return {
+ url: publicURL,
+ estimatedName: item.media.url.match(/\/([^/?]+)(\?|$)/)?.[1] || publicURL
+ }
+ })
+ stack.msb.addLine(`🖼️ ${description} ${images.map(i => i.url).join(", ")}`, tag`🖼️ ${description} $${images.map(i => tag`${i.estimatedName}`).join(", ")}`)
+ }
+ // string select, text input, user select, role select, mentionable select, channel select
+
+ // Components that can have things nested
+ else if (component.type === DiscordTypes.ComponentType.Container) {
+ // May contain action row, text display, section, media gallery, separator, file
+ stack.bump()
+ for (const innerComponent of component.components) {
+ await processComponent(innerComponent)
+ }
+ let {body, formatted_body} = stack.shift().get()
+ body = body.split("\n").map(l => "| " + l).join("\n")
+ formatted_body = `
${formatted_body}
`
+ if (stack.msb.body) stack.msb.body += "\n\n"
+ stack.msb.add(body, formatted_body)
+ }
+ else if (component.type === DiscordTypes.ComponentType.Section) {
+ // May contain text display, possibly more in the future
+ // Accessory may be button or thumbnail
+ stack.bump()
+ for (const innerComponent of component.components) {
+ await processComponent(innerComponent)
+ }
+ if (component.accessory) {
+ stack.bump()
+ await processComponent(component.accessory)
+ const {body, formatted_body} = stack.shift().get()
+ stack.msb.addLine(body, formatted_body)
+ }
+ const {body, formatted_body} = stack.shift().get()
+ stack.msb.addParagraph(body, formatted_body)
+ }
+ else if (component.type === DiscordTypes.ComponentType.ActionRow) {
+ const linkButtons = component.components.filter(c => c.type === DiscordTypes.ComponentType.Button && c.style === DiscordTypes.ButtonStyle.Link)
+ if (linkButtons.length) {
+ stack.msb.addLine("")
+ for (const linkButton of linkButtons) {
+ await processComponent(linkButton)
+ }
+ }
+ }
+ // Components that can only be inside things
+ else if (component.type === DiscordTypes.ComponentType.Thumbnail) {
+ // May only be a section accessory
+ stack.msb.add(`🖼️ ${component.media.url}`, tag`🖼️ ${component.media.url}`)
+ }
+ else if (component.type === DiscordTypes.ComponentType.Button) {
+ // May only be a section accessory or in an action row (up to 5)
+ if (component.style === DiscordTypes.ButtonStyle.Link) {
+ assert(component.label) // required for Discord to validate link buttons
+ const link = await transformContentMessageLinks(component.url)
+ stack.msb.add(`[${component.label} ${link}] `, tag`${component.label} `)
+ }
+ }
+
+ // Not handling file upload or label because they are modal-only components
+ }
+
+ for (const component of message.components) {
+ await processComponent(component)
+ }
+
+ const {body, formatted_body} = stack.msb.get()
+ if (body.trim().length) {
+ // 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)
+ }
+ }
+ }
+
+ // Then polls
+ if (message.poll) {
+ const pollEvent = await pollToEvent(message.poll)
+ events.push(pollEvent)
}
// Then embeds
@@ -663,20 +1024,42 @@ async function messageToEvent(message, guild, options = {}, di) {
continue // Matrix's own URL previews are fine for images.
}
+ if (embed.type === "video" && embed.video?.url && !embed.title && message.content.includes(embed.video.url)) {
+ continue // Doesn't add extra information and the direct video URL is already there.
+ }
+
+ if (embed.type === "poll_result") {
+ // The code here is only for the message to be bridged to Matrix. Dealing with the Discord-side updates is in d2m/actions/poll-end.js.
+ }
+
if (embed.url?.startsWith("https://discord.com/")) {
continue // If discord creates an embed preview for a discord channel link, don't copy that embed
}
+ if (embed.url && spoilers.some(sp => sp.match(/\bhttps?:\/\/[a-z]/))) {
+ // If the original message had spoilered URLs, don't generate any embeds for links.
+ // This logic is the same as the Discord desktop client. It doesn't match specific embeds to specific spoilered text, it's all or nothing.
+ // It's not easy to do much better because posting a link like youtu.be generates an embed.url with youtube.com/watch, so you can't match up the text without making at least that a special case.
+ continue
+ }
+
// Start building up a replica ("rep") of the embed in Discord-markdown format, which we will convert into both plaintext and formatted body at once
const rep = new mxUtils.MatrixStringBuilder()
+ let isAdditionalImage = false
- // Provider
- if (embed.provider?.name && embed.provider.name !== "Tenor") {
- if (embed.provider.url) {
- rep.addParagraph(`via ${embed.provider.name} ${embed.provider.url}`, tag`${embed.provider.name}`)
+ if (isKlipyGIF) {
+ assert(embed.video?.url)
+ rep.add("[GIF] ", "➿ ")
+ if (embed.title) {
+ rep.add(`${embed.title} ${embed.video.url}`, tag`${embed.title}`)
} else {
- rep.addParagraph(`via ${embed.provider.name}`, tag`${embed.provider.name}`)
+ rep.add(embed.video.url)
}
+
+ let {body, formatted_body: html} = rep.get()
+ html = `
${html}
`
+ await addTextEvent(body, html, "m.text")
+ continue
}
// Author and URL into a paragraph
@@ -720,15 +1103,35 @@ async function messageToEvent(message, guild, options = {}, di) {
let chosenImage = embed.image?.url
// the thumbnail seems to be used for "article" type but displayed big at the bottom by discord
if (embed.type === "article" && embed.thumbnail?.url && !chosenImage) chosenImage = embed.thumbnail.url
- if (chosenImage) rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`)
+
+ if (chosenImage) {
+ isAdditionalImage = !rep.body && !!events.length
+ rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`)
+ }
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`${embed.provider.name}${insertableFooterText}`)
+ } else {
+ rep.addLine(`via ${embed.provider.name}${insertableFooterText}`, tag`${embed.provider.name}${insertableFooterText}`)
+ }
+ } else if (embed.footer?.text) {
+ rep.addLine(`— ${embed.footer.text}`, tag`— ${embed.footer.text}`)
+ }
+
let {body, formatted_body: html} = rep.get()
body = body.split("\n").map(l => "| " + l).join("\n")
html = `
The following is a message composed via consensus of the Stinker Council.
For those who are not currently aware of our existence, we represent the organization known as Wonderland. Our previous mission centered around the assortment and study of puzzling objects, entities and other assorted phenomena. This mission was the focus of our organization for more than 28 years.
Due to circumstances outside of our control, this directive has now changed. Our new mission will be the extermination of the stinker race.
`,
+ "m.mentions": {}
+ }])
+})
+
+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: `https://cohost.org/jkap/post/4794219-empty`,
+ "m.mentions": {}
}, {
$type: "m.room.message",
msgtype: "m.notice",
- body: "| I condone pirating music!",
+ 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: `
Mark your calendar with a wet black stain! World of Goo 2 releases on May 23, 2024 on Nintendo Switch, Epic Games Store (Win/Mac), and http://WorldOfGoo2.com (Win/Mac/Linux).`
+ `
The shoebill (Balaeniceps rex) also known as whalehead, whale-headed stork, or shoe-billed stork, is a very large stork-like bird. It derives its name from its enormous shoe-shaped bill some people also called them the living dinosaur~~
🎞️ https://www.youtube.com/embed/kDMHHw8JqLE`
@@ -321,6 +362,21 @@ test("message2event embeds: youtube video", async t => {
}])
})
+test("message2event embeds: embed not bridged if its link was spoilered", async t => {
+ const events = await messageToEvent({
+ ...data.message_with_embeds.youtube_video,
+ content: "||https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E\n\n\nJutomi I'm gonna make these sounds in your walls tonight||"
+ }, data.guild.general)
+ t.deepEqual(events, [{
+ $type: "m.room.message",
+ msgtype: "m.text",
+ body: "[spoiler]",
+ format: "org.matrix.custom.html",
+ formatted_body: `https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E
Jutomi I'm gonna make these sounds in your walls tonight`,
+ "m.mentions": {}
+ }])
+})
+
test("message2event embeds: tenor gif should show a video link without a provider", async t => {
const events = await messageToEvent(data.message_with_embeds.tenor_gif, data.guild.general, {}, {})
t.deepEqual(events, [{
@@ -340,19 +396,22 @@ test("message2event embeds: tenor gif should show a video link without a provide
}])
})
+test("message2event embeds: klipy gif should send in customised format", async t => {
+ const events = await messageToEvent(data.message_with_embeds.klipy_gif, data.guild.general, {}, {})
+ t.deepEqual(events, [{
+ $type: "m.room.message",
+ msgtype: "m.text",
+ body: "[GIF] Cute Corgi Waddle https://static.klipy.com/ii/d7aec6f6f171607374b2065c836f92f4/5b/5b/7ndEhcilPNKJ8O.mp4",
+ format: "org.matrix.custom.html",
+ formatted_body: "
Well, they don't seem to...",
+ body: "Well, they don't seem to..."
}])
})
@@ -716,9 +679,7 @@ test("message2event: infinidoge's reply to ami's matrix smalltext reply to infin
user_ids: ["@ami:the-apothecary.club"]
},
msgtype: "m.text",
- body: `> Ami (she/her): let me guess they got a lot of bug reports like "empty chest with no loot?"\n\nMost likely`,
- format: "org.matrix.custom.html",
- formatted_body: `
In reply toAmi (she/her) let me guess they got a lot of bug reports like "empty chest with no loot?"
Most likely`,
+ body: `Most likely`
}])
})
@@ -755,9 +716,7 @@ test("message2event: infinidoge's reply to ami's matrix smalltext singleline rep
user_ids: ["@ami:the-apothecary.club"]
},
msgtype: "m.text",
- body: `> Ami (she/her): let me guess they got a lot of bug reports like "empty chest with no loot?"\n\nMost likely`,
- format: "org.matrix.custom.html",
- formatted_body: `
In reply toAmi (she/her) let me guess they got a lot of bug reports like "empty chest with no loot?"
Most likely`,
+ body: `Most likely`
}])
})
@@ -775,6 +734,31 @@ test("message2event: reply to a Discord message that wasn't bridged", async t =>
}])
})
+test("message2event: reply to a Discord member join (who didn't join on Matrix)", async t => {
+ const events = await messageToEvent(data.message.reply_to_member_join, data.guild.general)
+ t.deepEqual(events, [{
+ $type: "m.room.message",
+ msgtype: "m.text",
+ body: "> PEASANT!! joined the room\n\nwhen the broke friend who we pay to bring food shows up at the medieval lord party",
+ format: "org.matrix.custom.html",
+ formatted_body: "
PEASANT!! joined the room
when the broke friend who we pay to bring food shows up at the medieval lord party",
+ "m.mentions": {}
+ }])
+})
+
+test("message2event: reply to a Discord member join (who did join on Matrix)", async t => {
+ db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES ('1461677775554478161', 'peasant321_76775', 'peasant321_76775', '@_ooye_peasant321_76775:cadence.moe')").run()
+ const events = await messageToEvent(data.message.reply_to_member_join, data.guild.general)
+ t.deepEqual(events, [{
+ $type: "m.room.message",
+ msgtype: "m.text",
+ body: "> PEASANT!! joined the room\n\nwhen the broke friend who we pay to bring food shows up at the medieval lord party",
+ format: "org.matrix.custom.html",
+ formatted_body: `
`,
"m.mentions": {},
- msgtype: "m.notice",
+ msgtype: "m.text",
},
{
$type: "m.room.message",
@@ -1192,13 +1467,13 @@ test("message2event: don't scan forwarded messages for mentions", async t => {
t.deepEqual(events, [
{
$type: "m.room.message",
- body: "[🔀 Forwarded message]"
+ body: "[↷ Forwarded message]"
+ "\n» If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile https://social.luca.run/@luca/113950834185678114",
format: "org.matrix.custom.html",
- formatted_body: `🔀 Forwarded message`
+ formatted_body: `↷ Forwarded message`
+ `
If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile https://social.luca.run/@luca/113950834185678114
Everyone in the comments is like "you're so ahead of the curve" and "crazy that this came out before the scandal" but I thought Mr Beast sucking was a common sentiment`,
+ formatted_body: `
Everyone in the comments is like "you're so ahead of the curve" and "can't believe this came out before the scandal" but I thought Mr Beast sucking was a common sentiment`,
"m.relates_to": {
"m.in_reply_to": {
event_id: "$qmyjr-ISJtnOM5WTWLI0fT7uSlqRLgpyin2d2NCglCU"
@@ -1457,7 +2086,7 @@ test("event2message: should suppress embeds for links in reply preview", async t
},
event_id: "$0Bs3rbsXaeZmSztGMx1NIsqvOrkXOpIWebN-dqr09i4",
room_id: "!fGgIymcYWOqjbSRUdV:cadence.moe"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$qmyjr-ISJtnOM5WTWLI0fT7uSlqRLgpyin2d2NCglCU", {
"type": "m.room.message",
@@ -1478,8 +2107,8 @@ test("event2message: should suppress embeds for links in reply preview", async t
username: "RNL",
content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1273204543739396116 **Ⓜcadence [they]**:"
+ " "
- + `\nEveryone in the comments is like "you're so ahead of the curve" and "crazy that this came out before the scandal" but I thought Mr Beast sucking was a common sentiment`,
- avatar_url: undefined,
+ + `\nEveryone in the comments is like "you're so ahead of the curve" and "can't believe this came out before the scandal" but I thought Mr Beast sucking was a common sentiment`,
+ avatar_url: "https://bridge.example.org/download/letter-avatar?letter=R&hue=240",
allowed_mentions: {
parse: ["users", "roles"]
}
@@ -1488,6 +2117,57 @@ test("event2message: should suppress embeds for links in reply preview", async t
)
})
+test("event2message: truncated links in reply preview should be unlinked to prevent broken links", async t => {
+ t.deepEqual(
+ await eventToMessage({
+ type: "m.room.message",
+ sender: "@cadence:cadence.moe",
+ content: {
+ msgtype: "m.text",
+ body: `> <@_ooye_.wing.:cadence.moe> https://huggingface.co/blog/security-incident-july-2026\n\nthis is either written by claude or somebody who's too claude-brained and I just can't get through it`,
+ format: "org.matrix.custom.html",
+ formatted_body: `