diff --git a/.gitignore b/.gitignore index c38dd88..e533dce 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,6 @@ 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 f0e850c..e13c829 100755 --- a/addbot.js +++ b/addbot.js @@ -1,27 +1,17 @@ #!/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=${permissions} ` + return `Open this link to add the bot to a Discord server:\nhttps://discord.com/oauth2/authorize?client_id=${id}&scope=bot&permissions=1610883072 ` } -/* c8 ignore next 3 */ if (process.argv.find(a => a.endsWith("addbot") || a.endsWith("addbot.js"))) { console.log(addbot()) } module.exports.id = id module.exports.addbot = addbot -module.exports.permissions = permissions diff --git a/docs/api.md b/docs/api.md deleted file mode 100644 index 4689db1..0000000 --- a/docs/api.md +++ /dev/null @@ -1,52 +0,0 @@ -# API - -There is a web API for getting information about things that are bridged with Out Of Your Element. - -The base URL is the URL of the particular OOYE instance, for example, https://bridge.cadence.moe. - -No authentication is required. - -I'm happy to add more endpoints, just ask for them. - -## Endpoint: GET /api/message - -|Query parameter|Type|Description| -|---------------|----|-----------| -|`message_id`|regexp `/^[0-9]+$/`|Discord message ID to look up information for| - -Response: - -```typescript -{ - source: "matrix" | "discord" // Which platform the message originated on - matrix_author?: { // Only for Matrix messages; should be up-to-date rather than historical data - displayname: string, // Matrix user's current display name - avatar_url: string | null, // Absolute HTTP(S) URL to download the Matrix user's current avatar - mxid: string // Matrix user ID, can never change - }, - events: [ // Data about each individual event - { - metadata: { // Data from OOYE's database about how bridging was performed - sender: string, // Same as matrix user ID - event_id: string, // Unique ID of the event on Matrix, can never change - event_type: "m.room.message" | string, // Event type - event_subtype: "m.text" | string | null, // For m.room.message events, this is the msgtype property - part: 0 | 1, // For multi-event messages, 0 if this is the first part - reaction_part: 0 | 1, // For multi-event messages, 0 if this is the last part - room_id: string, // Room ID that the event was sent in, linked to the Discord channel - source: number - }, - raw: { // Raw historical event data from the Matrix API. Contains at least these properties: - content: any, // The only non-metadata property, entirely client-generated - type: string, - room_id: string, - sender: string, - origin_server_ts: number, - unsigned?: any, - event_id: string, - user_id: string - } - } - ] -} -``` diff --git a/docs/developer-orientation.md b/docs/developer-orientation.md deleted file mode 100644 index dbb19f3..0000000 --- a/docs/developer-orientation.md +++ /dev/null @@ -1,129 +0,0 @@ -# Development setup - -* Install development dependencies with `npm install --save-dev` so you can run the tests. -* Most files you change, such as actions, converters, and web, will automatically be reloaded. -* If developing on a different computer to the one running the homeserver, use SSH port forwarding so that Synapse can connect on its `localhost:6693` to reach the running bridge on your computer. Example: `ssh -T -v -R 6693:localhost:6693 me@matrix.cadence.moe` -* I recommend developing in Visual Studio Code so that the JSDoc x TypeScript annotation comments just work automatically. I don't know which other editors or language servers support annotations and type inference. - -# Efficiency details - -Using WeatherStack as a thin layer between the bridge application and the Discord API lets us control exactly what data is cached in memory. Only necessary information is cached. For example, member data, user data, message content, and past edits are never stored in memory. This keeps the memory usage low and also prevents it ballooning in size over the bridge's runtime. - -The bridge uses a small SQLite database to store relationships like which Discord messages correspond to which Matrix messages. This is so the bridge knows what to edit when some message is edited on Discord. Using `without rowid` on the database tables stores the index and the data in the same B-tree. Since Matrix and Discord's internal IDs are quite long, this vastly reduces storage space because those IDs do not have to be stored twice separately. Some event IDs and URLs are actually stored as xxhash integers to reduce storage requirements even more. On my personal instance of OOYE, every 300,000 messages (representing a year of conversations) requires 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. - -File uploads (like avatars from bridged members) are checked locally and deduplicated. Only brand new files are uploaded to the homeserver. This saves loads of space in the homeserver's media repo, especially for Synapse. - -Switching to [WAL mode](https://www.sqlite.org/wal.html) could improve your database access speed even more. Run `node scripts/wal.js` if you want to switch to WAL mode. (This will also enable `synchronous = NORMAL`.) - - -# Repository structure - - . - * Runtime configuration, like tokens and user info: - ├── registration.yaml - * You are here! :) - ├── readme.md - * The bridge's SQLite database is stored here: - ├── ooye.db* - * Source code - └── src - * Database schema: - ├── db - │   ├── orm.js, orm-defs.d.ts - │   * Migrations change the database schema when you update to a newer version of OOYE: - │   ├── migrate.js - │   └── migrations - │       └── *.sql, *.js - * Discord-to-Matrix bridging: - ├── d2m - │   * Execute actions through the whole flow, like sending a Discord message to Matrix: - │   ├── actions - │   │   └── *.js - │   * Convert data from one form to another without depending on bridge state. Called by actions: - │   ├── converters - │   │   └── *.js - │   * Making Discord work: - │   ├── discord-*.js - │   * Listening to events from Discord and dispatching them to the correct `action`: - │   └── event-dispatcher.js - * Discord bot commands and menus: - ├── discord - │   ├── interactions - │   │   └── *.js - │   └── discord-command-handler.js - * Matrix-to-Discord bridging: - ├── m2d - │   * Execute actions through the whole flow, like sending a Matrix message to Discord: - │   ├── actions - │   │   └── *.js - │   * Convert data from one form to another without depending on bridge state. Called by actions: - │   ├── converters - │   │   └── *.js - │   * Listening to events from Matrix and dispatching them to the correct `action`: - │   └── event-dispatcher.js - * We aren't using the matrix-js-sdk, so here are all the functions for the Matrix C-S and Appservice APIs: - ├── matrix - │   └── *.js - * Various files you can run once if you need them. - └── scripts - * First time running a new bridge? Run this file to set up prerequisites on the Matrix server: - ├── setup.js - * Hopefully you won't need the rest of these. Code quality varies wildly. - └── *.js - -# Read next - -If you haven't set up Out Of Your Element yet, you might find [Simplified homeserver setup](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/simplified-homeserver-setup.md) helpful. - -If you don't know what the Matrix event JSON generally looks like, turn on developer tools in your client (Element has pretty good ones). Right click a couple of messages and see what they look like on the inside. - -I recommend first reading [How to add a new event type](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/how-to-add-a-new-event-type.md) as this will fill you in on key information in how the codebase is organised, which data structures are important, and what level of abstraction we're working on. - -If you haven't seen the [Discord API documentation](https://discord.com/developers/docs/) before, have a quick look at one of the pages on there. Same with the [Matrix Client-Server APIs](https://spec.matrix.org/latest/client-server-api/). You don't need to know these inside out, they're primarily references, not stories. But it is useful to have an idea of what a couple of the API endpoints look like, the kind of data they tend to accept, and the kind of data they tend to return. - -Then you might like to peruse the other files in the docs folder. Most of these were written stream-of-thought style as I try to work through a problem and find the best way to implement it. You might enjoy getting inside my head and seeing me invent and evaluate ways to solve the problem. - -Whether you read those or not, I'm more than happy to help you 1-on-1 with coding your dream feature. Join the chatroom [#out-of-your-element:cadence.moe](https://matrix.to/#/#out-of-your-element:cadence.moe) or PM me [@cadence:cadence.moe](https://matrix.to/#/@cadence:cadence.moe) and ask away. - -# Dependency justification - -Total transitive production dependencies: 134 - -### 🦕 - -* (31) better-sqlite3: SQLite is the best database, and this is the best library for it. -* (27) @cloudrac3r/pug: Language for dynamic web pages. This is my fork. (I released code that hadn't made it to npm, and removed the heavy pug-filters feature.) -* (16) stream-mime-type@1: This seems like the best option. Version 1 is used because version 2 is ESM-only. -* (9) h3: Web server. OOYE needs this for the appservice listener, authmedia proxy, self-service, and more. -* (11) sharp: Image resizing and compositing. OOYE needs this for the emoji sprite sheets. - -### 🪱 - -* (0) @chriscdn/promise-semaphore: It does what I want. -* (1) @cloudrac3r/discord-markdown: This is my fork. -* (0) @cloudrac3r/giframe: This is my fork. -* (1) @cloudrac3r/html-template-tag: This is my fork. -* (0) @cloudrac3r/in-your-element: This is my Matrix Appservice API library. It depends on h3 and zod, which are already pulled in by OOYE. -* (0) @cloudrac3r/mixin-deep: This is my fork. (It fixes a bug in regular mixin-deep.) -* (0) @cloudrac3r/pngjs: Lottie stickers are converted to bitmaps with the vendored Rlottie WASM build, then the bitmaps are converted to PNG with pngjs. -* (0) @cloudrac3r/turndown: This HTML-to-Markdown converter looked the most suitable. I forked it to change the escaping logic to match the way Discord works. -* (3) @stackoverflow/stacks: Stack Overflow design language and icons. -* (0) ansi-colors: Helps with interactive prompting for the initial setup, and it's already pulled in by enquirer. -* (1) chunk-text: It does what I want. -* (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust. -* (0) discord-api-types: Bitfields needed at runtime and types needed for development. -* (0) domino: DOM implementation that's already pulled in by turndown. -* (1) enquirer: Interactive prompting for the initial setup rather than forcing users to edit YAML non-interactively. -* (0) entities: Looks fine. No dependencies. -* (0) get-relative-path: Looks fine. No dependencies. -* (1) heatsync: Module hot-reloader that I trust. -* (0) lru-cache: For holding unused nonce in memory and letting them be overwritten later if never used. -* (0) mime-type: File extension to mime type mapping that's already pulled in by stream-mime-type. -* (0) prettier-bytes: It does what I want and has no dependencies. -* (0) snowtransfer: Discord API library with bring-your-own-caching that I trust. -* (0) try-to-catch: Not strictly necessary, but it's already pulled in by supertape, so I may as well. -* (0) uqr: QR code SVG generator. Used on the website to scan in an invite link. -* (0) xxhash-wasm: Used where cryptographically secure hashing is not required. -* (0) zod: Input validation for the web server. It's popular and easy to use. diff --git a/docs/docker.md b/docs/docker.md deleted file mode 100644 index 7e3eb7d..0000000 --- a/docs/docker.md +++ /dev/null @@ -1,76 +0,0 @@ -# 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/docker-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/foreign-keys.md b/docs/foreign-keys.md deleted file mode 100644 index 1e5e21c..0000000 --- a/docs/foreign-keys.md +++ /dev/null @@ -1,98 +0,0 @@ -# Foreign keys in the Out Of Your Element database - -Historically, Out Of Your Element did not use foreign keys in the database, but since I found a need for them, I have decided to add them. Referential integrity is probably valuable as well. - -The need is that unlinking a channel and room using the web interface should clear up all related entries from `message_channel`, `event_message`, `reaction`, etc. Without foreign keys, this requires multiple DELETEs with tricky queries. With foreign keys and ON DELETE CASCADE, this just works. - -## Quirks - -* **REPLACE INTO** internally causes a DELETE followed by an INSERT, and the DELETE part **will trigger any ON DELETE CASCADE** foreign key conditions on the table, even when the primary key being replaced is the same. - * ```sql - CREATE TABLE discord_channel (channel_id TEXT NOT NULL, name TEXT NOT NULL, PRIMARY KEY (channel_id)); - CREATE TABLE discord_message (message_id TEXT NOT NULL, channel_id TEXT NOT NULL, PRIMARY KEY (message_id), - FOREIGN KEY (channel_id) REFERENCES discord_channel (channel_id) ON DELETE CASCADE); - INSERT INTO discord_channel (channel_id, name) VALUES ("c_1", "place"); - INSERT INTO discord_message (message_id, channel_id) VALUES ("m_2", "c_1"); -- i love my message - REPLACE INTO discord_channel (channel_id, name) VALUES ("c_1", "new place"); -- replace into time - -- i love my message - SELECT * FROM discord_message; -- where is my message - ``` -* In SQLite, `pragma foreign_keys = on` must be set **for each connection** after it's established. I've added this at the start of `migrate.js`, which is called by all database connections. - * Pragma? Pragma keys -* Whenever a child row is inserted, SQLite will look up a row from the parent table to ensure referential integrity. This means **the parent table should be sufficiently keyed or indexed on columns referenced by foreign keys**, or SQLite won't let you do it, with a cryptic error message later on during DML. Due to normal forms, foreign keys naturally tend to reference the parent table's primary key, which is indexed, so that's okay. But still keep this in mind, since many of OOYE's tables effectively have two primary keys, for the Discord and Matrix IDs. A composite primary key doesn't count, even when it's the first column. A unique index counts. - -## Where keys - -Here are some tables that could potentially have foreign keys added between them, and my thought process of whether foreign keys would be a good idea: - -* `guild_active` <--(PK guild_id FK)-- `channel_room` ✅ - * Could be good for referential integrity. - * Linking to guild_space would be pretty scary in case the guild was being relinked to a different space - since rooms aren't tied to a space, this wouldn't actually disturb anything. So I pick guild_active instead. -* `channel_room` <--(PK channel_id FK)-- `message_channel` ✅ - * Seems useful as we want message records to be deleted when a channel is unlinked. -* `message_channel` <--(PK message_id PK)-- `event_message` ✅ - * Seems useful as we want event information to be deleted when a channel is unlinked. -* `guild_active` <--(PK guild_id PK)-- `guild_space` ✅ - * All bridged guilds should have a corresponding guild_active entry, so referential integrity would be useful here to make sure we haven't got any weird states. -* `channel_room` <--(**C** room_id PK)-- `member_cache` ✅ - * Seems useful as we want to clear the member cache when a channel is unlinked. - * There is no index on `channel_room.room_id` right now. It would be good to create this index. Will just make it UNIQUE in the table definition. -* `message_channel` <--(PK message_id FK)-- `reaction` ✅ - * Seems useful as we want to clear the reactions cache when a channel is unlinked. -* `sim` <--(**C** mxid FK)-- `sim_member` - * OOYE inner joins on this. - * Sims are never deleted so if this was added it would only be used for enforcing referential integrity. - * The storage cost of the additional index on `sim` would not be worth the benefits. -* `channel_room` <--(**C** room_id PK)-- `sim_member` - * If a room is being permanently unlinked, it may be useful to see a populated member list. If it's about to be relinked to another channel, we want to keep the sims in the room for more speed and to avoid spamming state events into the timeline. - * Either way, the sims could remain in the room even after it's been unlinked. So no referential integrity is desirable here. -* `sim` <--(PK user_id PK)-- `sim_proxy` - * OOYE left joins on this. In normal operation, this relationship might not exist. -* `channel_room` <--(PK channel_id PK)-- `webhook` ✅ - * Seems useful. Webhooks should be deleted from Discord just before the channel is unlinked. That should be mirrored in the database too. - -## Occurrences of REPLACE INTO/DELETE FROM - -* `edit-message.js` — `REPLACE INTO message_channel` - * Scary! Changed to INSERT OR IGNORE -* `send-message.js` — `REPLACE INTO message_channel` - * Changed to INSERT OR IGNORE -* `add-reaction.js` — `REPLACE INTO reaction` -* `channel-webhook.js` — `REPLACE INTO webhook` -* `send-event.js` — `REPLACE INTO message_channel` - * Seems incorrect? Maybe?? Originally added in fcbb045. Changed to INSERT -* `event-to-message.js` — `REPLACE INTO member_cache` -* `oauth.js` — `REPLACE INTO guild_active` - * Very scary!! Changed to INSERT .. ON CONFLICT DO UPDATE -* `create-room.js` — `DELETE FROM channel_room` - * Please cascade -* `delete-message.js` - * Removed redundant DELETEs -* `edit-message.js` — `DELETE FROM event_message` -* `register-pk-user.js` — `DELETE FROM sim` - * It's a failsafe during creation -* `register-user.js` — `DELETE FROM sim` - * It's a failsafe during creation -* `remove-reaction.js` — `DELETE FROM reaction` -* `event-dispatcher.js` — `DELETE FROM member_cache` -* `redact.js` — `DELETE FROM event_message` - * Removed this redundant DELETE -* `send-event.js` — `DELETE FROM event_message` - * Removed this redundant DELETE - -## How keys - -SQLite does not have a complete ALTER TABLE command, so I have to DROP and CREATE. According to [the docs](https://www.sqlite.org/lang_altertable.html), the correct strategy is: - -1. (Not applicable) *If foreign key constraints are enabled, disable them using PRAGMA foreign_keys=OFF.* -2. Start a transaction. -3. (Not applicable) *Remember the format of all indexes, triggers, and views associated with table X. This information will be needed in step 8 below. One way to do this is to run a query like the following: SELECT type, sql FROM sqlite_schema WHERE tbl_name='X'.* -4. Use CREATE TABLE to construct a new table "new_X" that is in the desired revised format of table X. Make sure that the name "new_X" does not collide with any existing table name, of course. -5. Transfer content from X into new_X using a statement like: INSERT INTO new_X SELECT ... FROM X. -6. Drop the old table X: DROP TABLE X. -7. Change the name of new_X to X using: ALTER TABLE new_X RENAME TO X. -8. (Not applicable) *Use CREATE INDEX, CREATE TRIGGER, and CREATE VIEW to reconstruct indexes, triggers, and views associated with table X. Perhaps use the old format of the triggers, indexes, and views saved from step 3 above as a guide, making changes as appropriate for the alteration.* -9. (Not applicable) *If any views refer to table X in a way that is affected by the schema change, then drop those views using DROP VIEW and recreate them with whatever changes are necessary to accommodate the schema change using CREATE VIEW.* -10. If foreign key constraints were originally enabled then run PRAGMA foreign_key_check to verify that the schema change did not break any foreign key constraints. -11. Commit the transaction started in step 2. -12. (Not applicable) *If foreign keys constraints were originally enabled, reenable them now.* diff --git a/docs/get-started.md b/docs/get-started.md deleted file mode 100644 index 5b14b2a..0000000 --- a/docs/get-started.md +++ /dev/null @@ -1,116 +0,0 @@ -# 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: - -* Administrative access to a homeserver -* Discord bot -* Domain name for the bridge's website - [more info](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/why-does-the-bridge-have-a-website.md) -* Reverse proxy for that domain - an interactive process will help you set this up in step 5! - -Follow these steps: - -1. [Get Node.js version 22 or later](https://nodejs.org/en/download/prebuilt-installer). If you're on Linux, you may prefer to install through system's package manager, though Debian and Ubuntu have hopelessly out of date packages. - -1. Switch to a normal user account. (i.e. do not run any of the following commands as root or sudo.) - -1. Clone this repo and checkout a specific tag. (Development happens on main. Stable versions are tagged.) - * The latest release tag is ![](https://img.shields.io/gitea/v/release/cadence/out-of-your-element?gitea_url=https%3A%2F%2Fgitdab.com&style=flat-square&label=%20&color=black). - -1. Install dependencies: `npm install` - -1. Run `npm run setup` to check your setup and set the bot's initial state. You only need to run this once ever. This command will guide you precisely through the following steps: - - * First, you'll be asked for information like your homeserver URL. - - * Then you'll be prompted to set up a reverse proxy pointing from your domain to the bridge's web server. Sample configurations can be found at the end of this guide. It will check that the reverse proxy works before you continue. - - * Then you'll need to provide information about your Discord bot, and you'll be asked to change some of its settings. - - * Finally, a registration.yaml file will be generated, which you need to give to your homeserver. You'll be told how to do this. It will check that it's done properly. - -1. Start the bridge: `npm run start` - -## Update - -New versions are announced in [#updates](https://matrix.to/#/#ooye-updates:cadence.moe) and listed on [releases](https://gitdab.com/cadence/out-of-your-element/releases). Here's how to update: - -1. Fetch the repo and checkout the latest release tag. - -1. Install dependencies: `npm install` - -1. Restart the bridge: Stop the currently running process, and then start the new one with `npm run start` - -# Get Started - -Visit the website on the domain name you set up, and click the button to add the bot to your Discord server. - -* If you click the Easy Mode button, it will automatically create a Matrix room corresponding to each Discord channel. This happens next time a message is sent on Discord (so that your Matrix-side isn't immediately cluttered with lots of inactive rooms). - -* If you click the Self Service button, it won't create anything for you. You'll have to provide your own Matrix space and rooms. After you click, you'll be prompted through the process. Use this if you're migrating from another bridge! - -After that, to get into the rooms on your Matrix account, use the invite form on the website, or the `/invite [your mxid here]` command on Discord. - -I hope you enjoy Out Of Your Element! - ----- -




- -# Appendix - -## Example reverse proxy for nginx, dedicated domain name - -Replace `bridge.cadence.moe` with the hostname you're using. - -```nix -server { - listen 80; - listen [::]:80; - server_name bridge.cadence.moe; - - return 301 https://bridge.cadence.moe$request_uri; -} - -server { - listen 443 ssl http2; - listen [::]:443 ssl http2; - server_name bridge.cadence.moe; - - # ssl parameters here... - client_max_body_size 5M; - - location / { - add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; - proxy_pass http://127.0.0.1:6693; - } -} -``` - -## Example reverse proxy for nginx, sharing a domain name - -Same as above, but change the following: - -- `location / {` -> `location /ooye/ {` (any sub-path you want; you MUST use a trailing slash or it won't work) -- `proxy_pass http://127.0.0.1:6693;` -> `proxy_pass http://127.0.0.1:6693/;` (you MUST use a trailing slash on this too or it won't work) - -## Example reverse proxy for Caddy, dedicated domain name - -```nix -bridge.cadence.moe { - log { - output file /var/log/caddy/access.log - format console - } - encode gzip - reverse_proxy 127.0.0.1:6693 -} -``` - -## 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 deleted file mode 100644 index a435555..0000000 Binary files a/docs/img/poll-star-avatar.png and /dev/null differ diff --git a/docs/img/poll_win.png b/docs/img/poll_win.png deleted file mode 100644 index 61c8590..0000000 Binary files a/docs/img/poll_win.png and /dev/null differ diff --git a/docs/room-upgrades.md b/docs/room-upgrades.md deleted file mode 100644 index 49308fd..0000000 --- a/docs/room-upgrades.md +++ /dev/null @@ -1,80 +0,0 @@ -# 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/self-service-room-creation-rules.md b/docs/self-service-room-creation-rules.md index 4156f26..6292fe7 100644 --- a/docs/self-service-room-creation-rules.md +++ b/docs/self-service-room-creation-rules.md @@ -63,15 +63,8 @@ Pressing buttons on web or using the /invite command on a guild will insert a ro So here's all the technical changes needed to support self-service in v3: -- New guild_active table showing whether, and how, a guild is bridged. +- New guild_active table showing whether, and how, a guild is bridged - When /invite command is used, INSERT OR IGNORE INTO state 1 and ensureRoom + ensureSpace. - When bot is added through "easy mode" web button, REPLACE INTO state 1 and ensureSpace. -- When bot is added through "self-service" web button, REPLACE INTO state 0. -- Event dispatcher will only ensureRoom if the guild_active state is 1. -- createRoom will only create other dependencies if the guild is autocreate. - -## Enough with your theory. How do rooms actually get bridged now? - -After clicking the easy mode button on web and adding the bot to a server, it will create new Matrix rooms on-demand when any invite features are used (web or command) OR just when any message is sent on Discord. - -Alternatively, pressing the self-service mode button and adding the bot to a server will prompt the web user to link it with a space. After doing so, they'll be on the standard guild management page where they can invite to the space and manually link rooms. Nothing will be autocreated. +- When bot is added through "self-service" web button, REPLACE INTO state 2. +- Event dispatcher will only ensureRoom if the guild_active state is 1 diff --git a/docs/third-party/reverse-proxy-traefik.md b/docs/third-party/reverse-proxy-traefik.md deleted file mode 100644 index 8b06a9b..0000000 --- a/docs/third-party/reverse-proxy-traefik.md +++ /dev/null @@ -1,113 +0,0 @@ -> 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/why-does-the-bridge-have-a-website.md b/docs/why-does-the-bridge-have-a-website.md deleted file mode 100644 index 387f59b..0000000 --- a/docs/why-does-the-bridge-have-a-website.md +++ /dev/null @@ -1,55 +0,0 @@ -# Why does the bridge have a website? - -## It's essential for making images work - -Matrix has a feature called [Authenticated Media](https://matrix.org/blog/2024/06/26/sunsetting-unauthenticated-media/), where uploaded media (like user avatars and uploaded files) is restricted to Matrix users only. This means Discord users wouldn't be able to see important parts of the conversation. - -To keep things working for Discord users, OOYE's web server can act as a proxy files that were uploaded on Matrix-side. This will automatically take effect when needed, so Discord users shouldn't notice any issues. - -## Why now? - -I knew a web interface had a lot of potential, but I was reluctant to add one because it would make the initial setup more complicated. However, when authenticated media forced my hand, I saw an opportunity to introduce new useful features. Hopefully you'll agree that it's worth it! - -# What else does it do? - -## Makes it easy to invite the bot - -The home page of the website has buttons to add the bridge bot to a Discord server. If you are primarly a Matrix user and you want somebody else (who may be less technical) to add the bot to their own Discord server, this should make it a lot more intuitive for them. - -## Makes it easy to invite yourself or others - -After your hypothetical less-technical friend adds the bot to their Discord server, you need to generate an invite for your Matrix account on Matrix-side. Without the website, you might need to guide them through running a /invite command with your user ID. With the website, they don't have to do anything extra. You can use your phone to scan the QR code on their screen, which lets you invite your user ID in your own time. - -You can also set the person's permissions when you invite them, so you can easily bootstrap the Matrix side with your trusted ones as moderators. - -## To link channels and rooms - -Without a website, to link Discord channels to existing Matrix rooms, you'd need to run a /link command with the internal IDs of each room. This is tedious and error-prone, especially if you want to set up a lot of channels. With the web interface, you can see a list of all the available rooms and click them to link them together. - -## To change settings - -Important settings, like whether the Matrix rooms should be private or publicly accessible, can be configured by clicking buttons rather than memorising commands. Changes take effect immediately. - -# Permissions - -## Bot invites - -Anybody who can access the home page can use the buttons to add your bot - but even without the website, they can already do this by manually constructing a URL. If you want to make it so _only you_ can add your bot to servers, you need to edit [your Discord application](https://discord.com/developers/applications), go to the Bot section, and turn off the switch that says Public Bot. - -## Server settings - -If you have either the Administrator or Manage Server permissions in a Discord server, you can use the website to manage that server, including linking channels and changing settings. - -# Initial setup - -The website is built in to OOYE and is always running as part of the bridge. For authenticated media proxy to work, you'll need to make the web server accessible on the public internet over HTTPS, presumably using a reverse proxy. - -When you use `npm run setup` as part of OOYE's initial setup, it will guide you through this process, and it will do a thorough self-test to make sure it's configured correctly. If you get stuck or want a configuration template, check the notes below. - -## Reverse proxy - -When OOYE is running, the web server runs on port 6693. (To use a different port or a UNIX socket, edit registration.yaml's `socket` setting and restart.) - -It doesn't have to have its own dedicated domain name, you can also use a sub-path on an existing domain, like the domain of your Matrix homeserver. You are likely already using a reverse proxy to run your homeserver, so this should just be a configuration change. - -[See here for sample configurations!](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/get-started.md#appendix) diff --git a/jsconfig.json b/jsconfig.json deleted file mode 100644 index 65a9b50..0000000 --- a/jsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "compilerOptions": { - "target": "es2024", - "module": "nodenext", - "lib": ["ESNext"], - "strict": true, - "noImplicitAny": false, - "useUnknownInCatchVariables": false - } -} diff --git a/package-lock.json b/package-lock.json index 9847400..178690a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,71 +1,80 @@ { "name": "out-of-your-element", - "version": "3.4.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "out-of-your-element", - "version": "3.4.0", + "version": "1.1.1", "license": "AGPL-3.0-or-later", "dependencies": { - "@chriscdn/promise-semaphore": "^3.0.1", - "@cloudrac3r/discord-markdown": "^2.6.10", + "@chriscdn/promise-semaphore": "^2.0.1", + "@cloudrac3r/discord-markdown": "^2.6.3", "@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/in-your-element": "^1.0.0", + "@cloudrac3r/mixin-deep": "^3.0.0", "@cloudrac3r/pngjs": "^7.0.3", "@cloudrac3r/pug": "^4.0.4", "@cloudrac3r/turndown": "^7.1.4", - "@stackoverflow/stacks": "^2.5.4", + "@stackoverflow/stacks": "^2.5.7", "@stackoverflow/stacks-icons": "^6.0.2", "ansi-colors": "^4.1.3", - "better-sqlite3": "^12.2.0", + "better-sqlite3": "^11.1.2", "chunk-text": "^2.0.1", - "cloudstorm": "^0.15.2", - "discord-api-types": "^0.38.38", + "cloudstorm": "^0.10.10", "domino": "^2.1.6", "enquirer": "^2.4.1", "entities": "^5.0.0", - "get-relative-path": "^1.0.2", - "h3": "^1.15.1", - "heatsync": "^2.7.2", - "htmx.org": "^2.0.4", - "lru-cache": "^11.0.2", - "mime-types": "^2.1.35", + "get-stream": "^6.0.1", + "h3": "^1.12.0", + "heatsync": "^2.5.3", + "lru-cache": "^10.4.3", + "minimist": "^1.2.8", + "node-fetch": "^2.6.7", "prettier-bytes": "^1.0.4", - "sharp": "^0.34.5", - "snowtransfer": "^0.17.1", + "sharp": "^0.33.4", + "snowtransfer": "^0.10.5", "stream-mime-type": "^1.0.2", "try-to-catch": "^3.0.1", - "uqr": "^0.1.2", "xxhash-wasm": "^1.0.2", - "zod": "^4.0.17" + "zod": "^3.23.8" }, "devDependencies": { "@cloudrac3r/tap-dot": "^2.0.3", - "@types/node": "^22.17.1", + "@types/node": "^18.16.0", + "@types/node-fetch": "^2.6.3", "c8": "^10.1.2", "cross-env": "^7.0.3", - "supertape": "^12.0.12" + "discord-api-types": "^0.37.60", + "supertape": "^10.4.0" }, "engines": { - "node": ">=22" + "node": ">=20" } }, - "../extended-errors/enhance-errors": { - "version": "1.0.0", + "../in-your-element": { + "name": "@cloudrac3r/in-your-element", + "version": "0.0.0", "extraneous": true, - "license": "UNLICENSED", + "license": "AGPL-3.0-or-later", "dependencies": { - "ts-expose-internals": "^5.6.3", - "ts-patch": "^3.3.0", - "typescript": "^5.9.3" + "h3": "^1.12.0", + "zod": "^3.23.8" }, "devDependencies": { - "@types/node": "^22.19.1", - "ts-node": "^10.9.2" + "@cloudrac3r/tap-dot": "^2.0.2", + "@types/node": "^18.19.42", + "c8": "^10.1.2", + "cross-env": "^7.0.3", + "mock-req": "^0.2.0", + "readable-mock-req": "^0.2.2", + "supertape": "^10.7.2", + "try-to-catch": "^3.0.1" + }, + "engines": { + "node": ">=18" } }, "../tap-dot": { @@ -82,30 +91,27 @@ } }, "node_modules/@babel/helper-string-parser": { - "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", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", + "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "license": "MIT", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", + "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", - "license": "MIT", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.6.tgz", + "integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.25.6" }, "bin": { "parser": "bin/babel-parser.js" @@ -115,51 +121,133 @@ } }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "license": "MIT", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.6.tgz", + "integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.24.8", + "@babel/helper-validator-identifier": "^7.24.7", + "to-fast-properties": "^2.0.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, - "engines": { - "node": ">=18" - } + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true }, "node_modules/@chriscdn/promise-semaphore": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@chriscdn/promise-semaphore/-/promise-semaphore-3.1.2.tgz", - "integrity": "sha512-rELbH6FSr9wr5J249Ax8dpzQdTaqEgcW+lilDKZxB13Hz0Bz3Iyx4q/7qZxPMnra9FUW4ZOkVf+bx5tbi6Goog==", - "license": "MIT" + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@chriscdn/promise-semaphore/-/promise-semaphore-2.0.9.tgz", + "integrity": "sha512-kKXJcm5gM8FN8O8U20H19/85b8R33K0Q2u5cnm9mfblK/7QcNChlOhCTWgnrr8wYiuF1ZbYIZcioxW79QfjnmQ==" }, "node_modules/@cloudcmd/stub": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@cloudcmd/stub/-/stub-5.0.0.tgz", - "integrity": "sha512-jLC05CmcvEKDFXWf95UZGgqyJePhP3kh6/5ZXm7BAB42hv72RIx9LsYMhqGXlPtXjShV5KioOHri6QGnWMzHwQ==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@cloudcmd/stub/-/stub-4.0.1.tgz", + "integrity": "sha512-7x7tVxJZOdQowHv/VKwHLo9aoNNoVRc6PdKYqyKcDHX+xrF78jSXnqEWrOplnD/gF+tCnyFafu1Is+lFfWCILw==", "dev": true, - "license": "MIT", "dependencies": { - "jest-diff": "^30.2.0" + "chalk": "^4.0.0", + "jest-diff": "^27.0.6", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=22" + "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_modules/@cloudrac3r/discord-markdown": { - "version": "2.6.10", - "resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.6.10.tgz", - "integrity": "sha512-E+F9UYDUHP2kHDCciX63SBzgsUnHpu2Pp/h98x9Zo+vKuzXjCQ5PcFNdUlH6M18bvHDZPoIsKVmjnON8UYaAPQ==", - "license": "MIT", + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.6.3.tgz", + "integrity": "sha512-9pELy0wk0SiAfdj8QQDUpxZkzFum1c3/ybCG7DKs9EQQMPgP0AF7tNGEIfX76eJVisdGKftzuNd0xIfeGFKJxg==", "dependencies": { "simple-markdown": "^0.7.3" } @@ -178,23 +266,22 @@ } }, "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.0.0", + "resolved": "https://registry.npmjs.org/@cloudrac3r/in-your-element/-/in-your-element-1.0.0.tgz", + "integrity": "sha512-g6vdxNJtc9+Y0djClrc0xNwL6DFiEQ9ikWHBBDJ3iKAWygdjANnFJ/Q1DVMmNqUYRsQIN3yH1aIQICKA2CDXhQ==", "license": "AGPL-3.0-or-later", "dependencies": { "h3": "^1.12.0", - "zod": "^4.0.17" + "zod": "^3.23.8" }, "engines": { "node": ">=18" } }, "node_modules/@cloudrac3r/mixin-deep": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@cloudrac3r/mixin-deep/-/mixin-deep-3.0.1.tgz", - "integrity": "sha512-awxfIraHjJ/URNlZ0ROc78Tdjtfk/fM/Gnj1embfrSN08h/HpRtLmPc3xlG3T2vFAy1AkONaebd52u7o6kDaYw==", - "license": "MIT", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@cloudrac3r/mixin-deep/-/mixin-deep-3.0.0.tgz", + "integrity": "sha512-yQz1wHSZbHfbKaGSjrV3wIG0e9MnElKlmekMKJPRdTn2jhF2Mt8wfMPX8U7v6rTyzR/7BTrX8CCUcrJMLgoQqw==", "engines": { "node": ">=6" } @@ -286,9 +373,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", - "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.2.0.tgz", + "integrity": "sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==", "license": "MIT", "optional": true, "dependencies": { @@ -300,19 +387,10 @@ "resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.2.tgz", "integrity": "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A==" }, - "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", "cpu": [ "arm64" ], @@ -328,13 +406,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.0.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", "cpu": [ "x64" ], @@ -350,13 +428,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.0.4" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", "cpu": [ "arm64" ], @@ -370,9 +448,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", "cpu": [ "x64" ], @@ -386,9 +464,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", "cpu": [ "arm" ], @@ -402,9 +480,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", "cpu": [ "arm64" ], @@ -417,42 +495,10 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", - "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.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", - "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.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", "cpu": [ "s390x" ], @@ -466,9 +512,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", "cpu": [ "x64" ], @@ -482,9 +528,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", "cpu": [ "arm64" ], @@ -498,9 +544,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", "cpu": [ "x64" ], @@ -514,9 +560,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", "cpu": [ "arm" ], @@ -532,13 +578,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.0.5" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", "cpu": [ "arm64" ], @@ -554,57 +600,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", - "cpu": [ - "ppc64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", - "cpu": [ - "riscv64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.0.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", "cpu": [ "s390x" ], @@ -620,13 +622,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.0.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", "cpu": [ "x64" ], @@ -642,13 +644,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.0.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", "cpu": [ "arm64" ], @@ -664,13 +666,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", "cpu": [ "x64" ], @@ -686,20 +688,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", "cpu": [ "wasm32" ], "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@emnapi/runtime": "^1.2.0" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -708,29 +710,10 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", "cpu": [ "ia32" ], @@ -747,9 +730,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", "cpu": [ "x64" ], @@ -766,13 +749,77 @@ } }, "node_modules/@isaacs/cliui": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", - "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "dev": true, - "license": "BlueOak-1.0.0", + "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": ">=18" + "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": { @@ -784,37 +831,16 @@ "node": ">=8" } }, - "node_modules/@jest/diff-sequences": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", - "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/get-type": { - "version": "30.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": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/@jest/schemas": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "dev": true, - "license": "MIT", "dependencies": { - "@sinclair/typebox": "^0.34.0" + "@sinclair/typebox": "^0.27.8" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -827,23 +853,32 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "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" + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "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==", "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", @@ -854,17 +889,16 @@ } }, "node_modules/@putout/cli-keypress": { - "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==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@putout/cli-keypress/-/cli-keypress-2.0.0.tgz", + "integrity": "sha512-EXJv2HaXM+5scjoxE6Tf+o4+pxwL1tYJZJBDMygrF7cocjirGcU05GgNr9WHOaUPaVOpVjVU98ugYD7XJLmMkw==", "dev": true, - "license": "MIT", "dependencies": { "ci-info": "^4.0.0", - "fullstore": "^4.0.0" + "fullstore": "^3.0.0" }, "engines": { - "node": ">=22" + "node": ">=16" } }, "node_modules/@putout/cli-validate-args": { @@ -881,90 +915,83 @@ } }, "node_modules/@sinclair/typebox": { - "version": "0.34.47", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.47.tgz", - "integrity": "sha512-ZGIBQ+XDvO5JQku9wmwtabcVTHJsgSWAHYtVuM9pBNNR5E88v6Jcj/llpmsjivig5X8A8HHOb4/mbEKPS5EvAw==", - "dev": true, - "license": "MIT" + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true }, "node_modules/@stackoverflow/stacks": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/@stackoverflow/stacks/-/stacks-2.8.6.tgz", - "integrity": "sha512-pR0vMDBA5rNV5Cb/McG+2F1nG68fZk3UiNwuOvQrP82WoQy41k9PsBjRjD7wHDVVZyDrWHLb/WnosXX7uK3IdA==", + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@stackoverflow/stacks/-/stacks-2.5.7.tgz", + "integrity": "sha512-1ipTt7jqUszyd78Gn9TADT22PL0yXe14iEfgZyvJlDvrNrmyJLoGsFMRMwcduPol6/C/zkFt2dmfph/5vFDcYA==", "dependencies": { "@hotwired/stimulus": "^3.2.2", "@popperjs/core": "^2.11.8" } }, "node_modules/@stackoverflow/stacks-icons": { - "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" + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@stackoverflow/stacks-icons/-/stacks-icons-6.0.2.tgz", + "integrity": "sha512-NDXV/0w6on9fJBfaLrBtPSXTbGyitD+mBTmIpLmDWbVgZo3EJgZBdPdElO0nO7K0WR2Yee1nKhC8euRhd9nicg==" }, "node_modules/@supertape/engine-loader": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@supertape/engine-loader/-/engine-loader-4.0.0.tgz", - "integrity": "sha512-2HFza8zaCGIC3Inaf3TEkWn3wvCkg+JPRWuSGrX+LM+j5OUptq6XtHnPeH037iEITTIDMie7OKlvfhZcFONGcw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@supertape/engine-loader/-/engine-loader-2.0.0.tgz", + "integrity": "sha512-1G2MmfZnSxx546omLPAVNgvG/iqOQZGiXHnjJ2JXKvuf2lpPdDRnNm5eLl81lvEG473zE9neX979TzeFcr3Dxw==", "dev": true, - "license": "MIT", "dependencies": { - "try-catch": "^4.0.2" + "try-catch": "^3.0.0" }, "engines": { - "node": ">=22" + "node": ">=16" } }, "node_modules/@supertape/formatter-fail": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@supertape/formatter-fail/-/formatter-fail-5.0.0.tgz", - "integrity": "sha512-nwE9c07hSFwoIf2Mex9PgWSe0f7PXbCbaPqL2oK3VWewNNRdGYBJv2VkPj0oAldvV86gTgQgS1b1k84XfthUCA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@supertape/formatter-fail/-/formatter-fail-3.0.2.tgz", + "integrity": "sha512-mSBnNprfLFmGvZkP+ODGroPLFCIN5BWE/06XaD5ghiTVWqek7eH8IDqvKyEduvuQu1O5tvQiaTwQsyxvikF+2w==", "dev": true, - "license": "MIT", "dependencies": { - "@supertape/formatter-tap": "^4.0.0", - "fullstore": "^4.0.0" + "@supertape/formatter-tap": "^3.0.3", + "fullstore": "^3.0.0" }, "engines": { - "node": ">=22" + "node": ">=16" } }, "node_modules/@supertape/formatter-json-lines": { - "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==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@supertape/formatter-json-lines/-/formatter-json-lines-2.0.1.tgz", + "integrity": "sha512-9LWOCu4yOF9orf4QJseS8lP3hXkYn24qn57VqYt/3r2aRJv4vWTPfaL1ot5JRHCZs0qXrV1sqPmN6E05rRLDYA==", "dev": true, - "license": "MIT", "dependencies": { - "fullstore": "^4.0.0" + "fullstore": "^3.0.0" }, "engines": { - "node": ">=22" + "node": ">=16" } }, "node_modules/@supertape/formatter-progress-bar": { - "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==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@supertape/formatter-progress-bar/-/formatter-progress-bar-6.1.0.tgz", + "integrity": "sha512-BVnLW08BMbF/Xf9DNxTtc5V5Ong4VCj0w46Ts2cc1EboX+RQGuxGO0/wrzTBTt4t30iUzFhG/t2g280MfLHutQ==", "dev": true, - "license": "MIT", "dependencies": { "chalk": "^5.3.0", "ci-info": "^4.0.0", "cli-progress": "^3.8.2", - "fullstore": "^4.0.0", + "fullstore": "^3.0.0", "once": "^1.4.0" }, "engines": { - "node": ">=22" + "node": ">=18" } }, "node_modules/@supertape/formatter-progress-bar/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", "dev": true, - "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" }, @@ -973,49 +1000,45 @@ } }, "node_modules/@supertape/formatter-short": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@supertape/formatter-short/-/formatter-short-3.0.0.tgz", - "integrity": "sha512-lKiIMekxQgkF4YBj/IiFoRUQrF/Ow7D8zt9ZEBdHTkRys30vhRFn9557okECKGdpnAcSsoTHWwgikS/NPc3g/g==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@supertape/formatter-short/-/formatter-short-2.0.1.tgz", + "integrity": "sha512-zxFrZfCccFV+bf6A7MCEqT/Xsf0Elc3qa0P3jShfdEfrpblEcpSo0T/Wd9jFwc7uHA3ABgxgcHy7LNIpyrFTCg==", "dev": true, - "license": "MIT", "engines": { - "node": ">=20" + "node": ">=16" } }, "node_modules/@supertape/formatter-tap": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@supertape/formatter-tap/-/formatter-tap-4.0.0.tgz", - "integrity": "sha512-cupeiik+FeTQ24d0fihNdS901Ct720UhUqgtPl2DiLWadEIT/B8+TIB4MG60sTmaE8xclbCieanbS/I94CQTPw==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@supertape/formatter-tap/-/formatter-tap-3.0.3.tgz", + "integrity": "sha512-U5OuMotfYhGo9cZ8IgdAXRTH5Yy8yfLDZzYo1upTPTwlJJquKwtvuz7ptiB7BN3OFr5YakkDYlFxOYPcLo7urg==", "dev": true, - "license": "MIT", "engines": { - "node": ">=20" + "node": ">=16" } }, "node_modules/@supertape/formatter-time": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@supertape/formatter-time/-/formatter-time-3.0.0.tgz", - "integrity": "sha512-+A0uSQPdVSYCEwHSgdnWmn9tq4C+Dg9rj0ky05uHvrHhfPktA/XKlBocU8qpFMN+HllquWuKhZXeuwd18raQBw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@supertape/formatter-time/-/formatter-time-1.0.2.tgz", + "integrity": "sha512-QihQWA/3LSNuODHrL8MGNHkdRunaEqNQkuMUDGNgEQO8MYBB0d83WGlNxDFGjn4kRlq47hovw3Skq7Btb2i2JA==", "dev": true, - "license": "MIT", "dependencies": { "chalk": "^5.3.0", "ci-info": "^4.0.0", "cli-progress": "^3.8.2", - "fullstore": "^4.0.0", + "fullstore": "^3.0.0", "once": "^1.4.0", "timer-node": "^5.0.7" }, "engines": { - "node": ">=22" + "node": ">=18" } }, "node_modules/@supertape/formatter-time/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", "dev": true, - "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" }, @@ -1024,16 +1047,15 @@ } }, "node_modules/@supertape/operator-stub": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@supertape/operator-stub/-/operator-stub-4.0.0.tgz", - "integrity": "sha512-t+LAKOA92m1pidzaXYzRHMAffYqqk19QOkMEbarP57/Sav90x9Q3ndvH6kRwa3HQhU2N7SuZrc21zh7vSwIOKA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@supertape/operator-stub/-/operator-stub-3.1.0.tgz", + "integrity": "sha512-jzC56u1k+3DLRo854+J6v/DP/4SjRV2mAqfR6qzsyaAocC9OFe7NHYQQMmlJ4cUJwgFjUh7AVnjFfC0Z0XuH+g==", "dev": true, - "license": "MIT", "dependencies": { - "@cloudcmd/stub": "^5.0.0" + "@cloudcmd/stub": "^4.0.0" }, "engines": { - "node": ">=22" + "node": ">=16" } }, "node_modules/@tokenizer/token": { @@ -1048,24 +1070,45 @@ "dev": true }, "node_modules/@types/node": { - "version": "22.19.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", - "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", + "version": "18.19.46", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.46.tgz", + "integrity": "sha512-vnRgMS7W6cKa1/0G3/DTtQYpVrZ8c0Xm6UkLaVFrb9jtcVC3okokW09Ki1Qdrj9ISokszD69nY4WDLRlvHlhAA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~5.26.4" } }, - "node_modules/@types/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", - "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", - "license": "MIT", + "node_modules/@types/node-fetch": { + "version": "2.6.11", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.11.tgz", + "integrity": "sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==", + "dev": true, "dependencies": { - "csstype": "^3.2.2" + "@types/node": "*", + "form-data": "^4.0.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==", + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.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", @@ -1085,6 +1128,18 @@ "node": ">=6" } }, + "node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1114,6 +1169,12 @@ "resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.3.0.tgz", "integrity": "sha512-9Z3vxQ+berkL/JJo0dK+EY3Lp0s3NtSnP3VCLsh5HDcZPrh0M+KQRK5sWhUeyPPH+/RCxZqOxLMR+YC6vlviEQ==" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true + }, "node_modules/babel-walk": { "version": "3.0.0-canary-5", "resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz", @@ -1126,10 +1187,9 @@ } }, "node_modules/backtracker": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/backtracker/-/backtracker-5.0.0.tgz", - "integrity": "sha512-2rY1s1iMlF1FVb4jpIMxTeGE+KRppuVvPyU61q7gvap1MWVahToUI8WUqy+v3L37iip5a4mJOTRBZxNRbTv4bg==", - "license": "MIT" + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/backtracker/-/backtracker-4.0.0.tgz", + "integrity": "sha512-XG2ldN+WDRq9niJMnoZDjLLUnhDOQGhFZc6qZQotN59xj8oOa4KXSCu6YyZQawPqi6gG3HilGFt91zT6Hbdh1w==" }, "node_modules/balanced-match": { "version": "1.0.2", @@ -1157,17 +1217,14 @@ ] }, "node_modules/better-sqlite3": { - "version": "12.6.2", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", - "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.2.1.tgz", + "integrity": "sha512-Xbt1d68wQnUuFIEVsbt6V+RG30zwgbtCGQ4QOcXVrOH0FE4eHk64FWZ9NUfRHS4/x1PXqwz/+KOrnXD7f0WieA==", "hasInstallScript": true, "license": "MIT", "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" - }, - "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x" } }, "node_modules/bindings": { @@ -1225,22 +1282,21 @@ } }, "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==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "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": "10.1.2", + "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.2.tgz", + "integrity": "sha512-Qr6rj76eSshu5CgRYvktW0uM0CFY0yi4Fd5D0duDXO6sYinyopmftUiJVuzBQxQcwQLor7JWDVRP+dUfCmzgJw==", "dev": true, "dependencies": { - "@bcoe/v8-coverage": "^1.0.1", + "@bcoe/v8-coverage": "^0.2.3", "@istanbuljs/schema": "^0.1.3", "find-up": "^5.0.0", "foreground-child": "^3.1.1", @@ -1306,9 +1362,9 @@ } }, "node_modules/ci-info": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", - "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.0.0.tgz", + "integrity": "sha512-TdHqgGf9odd8SXNuxtUBVx8Nv+qZOejE6qyqiy5NtbYYQOeFa6zmHkxlPzmaLxWWHsU6nJmB7AETdVPi+2NBUg==", "dev": true, "funding": [ { @@ -1316,7 +1372,6 @@ "url": "https://github.com/sponsors/sibiraj-s" } ], - "license": "MIT", "engines": { "node": ">=8" } @@ -1326,7 +1381,6 @@ "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", "dev": true, - "license": "MIT", "dependencies": { "string-width": "^4.2.3" }, @@ -1370,23 +1424,34 @@ } }, "node_modules/cloudstorm": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.15.2.tgz", - "integrity": "sha512-5y7E0uI39R3d7c+AWksqAQAlZlpx+qNjxjQfNIem2hh68s6QRmOFHTKu34I7pBE6JonpZf8AmoMYArY/4lLVmg==", + "version": "0.10.11", + "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.10.11.tgz", + "integrity": "sha512-A3lN0o404la7ryWIxN73gW2ehC0RO4h0yCA2grtOtPh8rNTd6+R2U4llyJlb61HlyOFrEVJ7AbOoFblVSmkrtw==", "license": "MIT", "dependencies": { - "discord-api-types": "^0.38.37", - "snowtransfer": "^0.17.0" + "discord-api-types": "^0.37.98", + "snowtransfer": "^0.10.7" }, "engines": { - "node": ">=22.0.0" + "node": ">=14.8.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, "dependencies": { "color-name": "~1.1.4" }, @@ -1397,8 +1462,37 @@ "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==", - "dev": true + "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" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/consola": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.2.3.tgz", + "integrity": "sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } }, "node_modules/constantinople": { "version": "4.0.1", @@ -1440,9 +1534,9 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", "dev": true, "dependencies": { "path-key": "^3.1.0", @@ -1454,19 +1548,23 @@ } }, "node_modules/crossws": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", - "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.2.4.tgz", + "integrity": "sha512-DAxroI2uSOgUKLz00NX6A8U/8EE3SZHmIND+10jkVSaypvyt57J5JEOxAQOL6lQxyzi/wZbTIwssU1uy69h5Vg==", "license": "MIT", - "dependencies": { - "uncrypto": "^0.1.3" + "peerDependencies": { + "uWebSockets.js": "*" + }, + "peerDependenciesMeta": { + "uWebSockets.js": { + "optional": true + } } }, "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/data-uri-to-buffer": { "version": "2.0.2", @@ -1499,31 +1597,46 @@ "node_modules/defu": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==" + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } }, "node_modules/destr": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", - "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.3.tgz", + "integrity": "sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==", "license": "MIT" }, "node_modules/detect-libc": { - "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", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", "engines": { "node": ">=8" } }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/discord-api-types": { - "version": "0.38.38", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.38.tgz", - "integrity": "sha512-7qcM5IeZrfb+LXW07HvoI5L+j4PQeMZXEkSm1htHAHh4Y9JSMXBWjy/r7zmUCOj4F7zNjMcm7IMWr131MT2h0Q==", - "license": "MIT", - "workspaces": [ - "scripts/actions/documentation" - ] + "version": "0.37.98", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.98.tgz", + "integrity": "sha512-xsH4UwmnCQl4KjAf01/p9ck9s+/vDqzHbUxPOBzo8fcVUa/hQG6qInD7Cr44KAuCM+xCxGJFSAUx450pBrX0+g==", + "license": "MIT" }, "node_modules/doctypes": { "version": "1.1.0", @@ -1535,6 +1648,13 @@ "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" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -1661,13 +1781,12 @@ "dev": true }, "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz", + "integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==", "dev": true, - "license": "ISC", "dependencies": { - "cross-spawn": "^7.0.6", + "cross-spawn": "^7.0.0", "signal-exit": "^4.0.1" }, "engines": { @@ -1677,19 +1796,32 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "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==" }, "node_modules/fullstore": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/fullstore/-/fullstore-4.0.0.tgz", - "integrity": "sha512-Y9hN79Q1CFU8akjGnTZoBnTzlA/o8wmtBijJOI8dKCmdC7GLX7OekpLxmbaeRetTOi4OdFGjfsg4c5dxP3jgPw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fullstore/-/fullstore-3.0.0.tgz", + "integrity": "sha512-EEIdG+HWpyygWRwSLIZy+x4u0xtghjHNfhQb0mI5825Mmjq6oFESFUY0hoZigEgd3KH8GX+ZOCK9wgmOiS7VBQ==", "dev": true, - "license": "MIT", "engines": { - "node": ">=22" + "node": ">=4" } }, "node_modules/function-bind": { @@ -1710,11 +1842,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-relative-path": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/get-relative-path/-/get-relative-path-1.0.2.tgz", - "integrity": "sha512-dGkopYfmB4sXMTcZslq5SojEYakpdCSj/SVSHLhv7D6RBHzvDtd/3Q8lTEOAhVKxPPeAHu/YYkENbbz3PaH+8w==" - }, "node_modules/get-source": { "version": "2.0.12", "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", @@ -1725,92 +1852,59 @@ "source-map": "^0.6.1" } }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" }, "node_modules/glob": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-12.0.0.tgz", - "integrity": "sha512-5Qcll1z7IKgHr5g485ePDdHcNQY0k2dtv/bjYy0iuyGxQw2qSOiiXUXJ+AYQpg3HNoUMHqAruX478Jeev7UULw==", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "ISC", "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.1.1", + "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": "^2.0.0" + "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob/node_modules/balanced-match": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz", - "integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==", - "dev": true, - "license": "MIT", - "dependencies": { - "jackspeak": "^4.2.3" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", - "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz", - "integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "20 || >=22" - }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/h3": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.5.tgz", - "integrity": "sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.12.0.tgz", + "integrity": "sha512-Zi/CcNeWBXDrFNlV0hUBJQR9F7a96RjMeAZweW/ZWkR9fuXrMcvKnSA63f/zZ9l0GgQOZDVHGvXivNN9PWOwhA==", "license": "MIT", "dependencies": { - "cookie-es": "^1.2.2", - "crossws": "^0.3.5", + "cookie-es": "^1.1.0", + "crossws": "^0.2.4", "defu": "^6.1.4", - "destr": "^2.0.5", - "iron-webcrypto": "^1.2.1", - "node-mock-http": "^1.0.4", + "destr": "^2.0.3", + "iron-webcrypto": "^1.1.1", + "ohash": "^1.1.3", "radix3": "^1.1.2", - "ufo": "^1.6.3", - "uncrypto": "^0.1.3" + "ufo": "^1.5.3", + "uncrypto": "^0.1.3", + "unenv": "^1.9.0" } }, "node_modules/has-flag": { @@ -1835,15 +1929,11 @@ } }, "node_modules/heatsync": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/heatsync/-/heatsync-2.8.3.tgz", - "integrity": "sha512-9pVRC3BZD1NZ0EYnU5akjoO10+s/aJc04QqUxgtBqAYUeberV8st0ctWH7selEnyU8OEAUKZhBCFxmH7MvCQQQ==", - "license": "MIT", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/heatsync/-/heatsync-2.5.4.tgz", + "integrity": "sha512-KzsM+wR0MIykD80kCHNZCpNvFY4uC1Yze8R37eehJyGIvEepJd+7ubczh6FVoBFtK0nVEszt5Hl8AbzUvb+vMQ==", "dependencies": { - "backtracker": "^5.0.0" - }, - "engines": { - "node": ">=14.6.0" + "backtracker": "^4.0.0" } }, "node_modules/html-es6cape": { @@ -1857,12 +1947,6 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, - "node_modules/htmx.org": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.8.tgz", - "integrity": "sha512-fm297iru0iWsNJlBrjvtN7V9zjaxd+69Oqjh4F/Vq9Wwi2kFisLcrLCiv5oBX0KLfOX/zG8AUo9ROMU5XUB44Q==", - "license": "0BSD" - }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -1901,6 +1985,11 @@ "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", @@ -1974,35 +2063,43 @@ } }, "node_modules/jackspeak": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", - "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "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": "^9.0.0" - }, - "engines": { - "node": "20 || >=22" + "@isaacs/cliui": "^8.0.2" }, "funding": { "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" } }, "node_modules/jest-diff": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", - "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", "dev": true, - "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.0.1", - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "pretty-format": "30.2.0" + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/js-stringify": { @@ -2010,13 +2107,6 @@ "resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz", "integrity": "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==" }, - "node_modules/json-with-bigint": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.4.4.tgz", - "integrity": "sha512-AhpYAAaZsPjU7smaBomDt1SOQshi9rEm6BlTbfVwsG1vNmeHKtEedJi62sHZzJTyKNtwzmNnrsd55kjwJ7054A==", - "dev": true, - "license": "MIT" - }, "node_modules/just-kebab-case": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/just-kebab-case/-/just-kebab-case-4.2.0.tgz", @@ -2039,13 +2129,10 @@ } }, "node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" }, "node_modules/make-dir": { "version": "4.0.0", @@ -2062,6 +2149,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -2074,7 +2173,6 @@ "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" }, @@ -2147,10 +2245,29 @@ "node": ">=10" } }, - "node_modules/node-mock-http": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.4.tgz", - "integrity": "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==", + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.4.tgz", + "integrity": "sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==", "license": "MIT" }, "node_modules/object-assign": { @@ -2161,6 +2278,12 @@ "node": ">=0.10.0" } }, + "node_modules/ohash": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-1.1.3.tgz", + "integrity": "sha512-zuHHiGTYTA1sYJ/wZN+t5HKZaH23i4yI1HMwbuXm24Nid7Dv0KcuRlKoNKS9UNfAVSBlnGLcuQrnOKWOZoEGaw==", + "license": "MIT" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -2230,22 +2353,28 @@ "dev": true }, "node_modules/path-scurry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": "20 || >=22" + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "license": "MIT" + }, "node_modules/peek-readable": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", @@ -2289,18 +2418,17 @@ "integrity": "sha512-dLbWOa4xBn+qeWeIF60qRoB6Pk2jX5P3DIVgOQyMyvBpu931Q+8dXz8X0snJiFkQdohDDLnZQECjzsAj75hgZQ==" }, "node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", "dev": true, - "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", - "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/pretty-format/node_modules/ansi-styles": { @@ -2308,7 +2436,6 @@ "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" }, @@ -2419,11 +2546,10 @@ "license": "MIT" }, "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true }, "node_modules/readable-web-to-node-stream": { "version": "3.0.2", @@ -2507,10 +2633,9 @@ ] }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "license": "ISC", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "bin": { "semver": "bin/semver.js" }, @@ -2519,15 +2644,15 @@ } }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -2536,30 +2661,25 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" } }, "node_modules/shebang-command": { @@ -2642,21 +2762,29 @@ "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.17.1", - "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.17.1.tgz", - "integrity": "sha512-WSXj055EJhzzfD7B3oHVyRTxkqFCaxcVhwKY6B3NkBSHRyM6wHxZLq6VbFYhopUg+lMtd7S1ZO8JM+Ut+js2iA==", + "version": "0.10.7", + "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.10.7.tgz", + "integrity": "sha512-lXUYp6jOou0DI8uFl3Dh78KD1gVa3dNbUt2TK6RW39mHenAR6XpoPoydUNXCWvdxi6uGU6zQ1yNICZpKjF6wMA==", "license": "MIT", "dependencies": { - "discord-api-types": "^0.38.37" + "discord-api-types": "^0.37.98", + "undici": "^6.19.8" }, "engines": { - "node": ">=22.0.0" + "node": ">=14.18.0" } }, "node_modules/source-map": { @@ -2734,6 +2862,45 @@ "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==", + "dev": true, + "license": "MIT", + "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/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", @@ -2755,6 +2922,45 @@ "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" + }, + "engines": { + "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", @@ -2780,62 +2986,42 @@ } }, "node_modules/supertape": { - "version": "12.0.12", - "resolved": "https://registry.npmjs.org/supertape/-/supertape-12.0.12.tgz", - "integrity": "sha512-ugmCQsB7s22fCTJKiMb6+Fd8kP7Hsvlo6/aly0qLGgOepu1PVBydhrBPMWaoY3wf+VqLtMkkvwGxUTCFde5z/g==", + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/supertape/-/supertape-10.7.3.tgz", + "integrity": "sha512-t/zv0sev+5261g9KampNVL7io8UQ7zmouRWt9/UU+Yr7Ap0MqBKlyDFFvkzcfADT+O6bXZMW5x3nzYzSU+LAYg==", "dev": true, "license": "MIT", "dependencies": { - "@cloudcmd/stub": "^5.0.0", - "@putout/cli-keypress": "^4.0.0", + "@cloudcmd/stub": "^4.0.0", + "@putout/cli-keypress": "^2.0.0", "@putout/cli-validate-args": "^2.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": "^3.0.0", - "@supertape/operator-stub": "^4.0.0", + "@supertape/engine-loader": "^2.0.0", + "@supertape/formatter-fail": "^3.0.0", + "@supertape/formatter-json-lines": "^2.0.0", + "@supertape/formatter-progress-bar": "^6.0.0", + "@supertape/formatter-short": "^2.0.0", + "@supertape/formatter-tap": "^3.0.0", + "@supertape/formatter-time": "^1.0.0", + "@supertape/operator-stub": "^3.0.0", "cli-progress": "^3.8.2", "flatted": "^3.3.1", - "fullstore": "^4.0.0", - "glob": "^11.0.1", - "jest-diff": "^30.0.3", - "json-with-bigint": "^3.4.4", + "fullstore": "^3.0.0", + "glob": "^10.0.0", + "jest-diff": "^29.0.1", "once": "^1.4.0", "resolve": "^1.17.0", "stacktracey": "^2.1.7", - "try-to-catch": "^4.0.0", + "strip-ansi": "^7.0.0", + "try-to-catch": "^3.0.0", "wraptile": "^3.0.0", - "yargs-parser": "^22.0.0" + "yargs-parser": "^21.0.0" }, "bin": { - "supertape": "bin/tracer.js", - "tape": "bin/tracer.js" + "supertape": "bin/tracer.mjs", + "tape": "bin/tracer.mjs" }, "engines": { - "node": ">=22" - } - }, - "node_modules/supertape/node_modules/try-to-catch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/try-to-catch/-/try-to-catch-4.0.3.tgz", - "integrity": "sha512-mUz1zpe6nkRQW0XZ/Ojfe/Eg7e5h3s+r+h7ONfP3Oo27/Jm8mkNDAnLzZ/A3sEMApROolzuJGBiQhGmmVDAFLw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=22" - } - }, - "node_modules/supertape/node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" + "node": ">=18" } }, "node_modules/supports-color": { @@ -2863,10 +3049,9 @@ } }, "node_modules/tar-fs": { - "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", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", + "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -2938,11 +3123,18 @@ } }, "node_modules/timer-node": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/timer-node/-/timer-node-5.0.9.tgz", - "integrity": "sha512-zXxCE/5/YDi0hY9pygqgRqjRbrFRzigYxOudG0I3syaqAAmX9/w9sxex1bNFCN6c1S66RwPtEIJv65dN+1psew==", - "dev": true, - "license": "MIT" + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/timer-node/-/timer-node-5.0.7.tgz", + "integrity": "sha512-M1aP6ASmuVD0PSxl5fqjCAGY9WyND3DHZ8RwT5I8o7469XE53Lb5zbPai20Dhj7TProyaapfVj3TaT0P+LoSEA==", + "dev": true + }, + "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", @@ -2965,14 +3157,18 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "node_modules/try-catch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/try-catch/-/try-catch-4.0.7.tgz", - "integrity": "sha512-gkBWUxbiN4T4PsO8KhoQYWzUPN6e0/h12H9H3YhcfPbwaN8b84fy8cFqL4rWTiPh7qHPFaEfklr6OkVxYRW0Gg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/try-catch/-/try-catch-3.0.1.tgz", + "integrity": "sha512-91yfXw1rr/P6oLpHSyHDOHm0vloVvUoo9FVdw8YwY05QjJQG9OT0LUxe2VRAzmHG+0CUOmI3nhxDUMLxDN/NEQ==", "dev": true, - "license": "MIT", "engines": { - "node": ">=22" + "node": ">=6" } }, "node_modules/try-to-catch": { @@ -2984,9 +3180,9 @@ } }, "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", "license": "0BSD", "optional": true }, @@ -3002,9 +3198,9 @@ } }, "node_modules/ufo": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", - "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz", + "integrity": "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==", "license": "MIT" }, "node_modules/uncrypto": { @@ -3013,18 +3209,33 @@ "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", "license": "MIT" }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" + "node_modules/undici": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.19.8.tgz", + "integrity": "sha512-U8uCCl2x9TK3WANvmBavymRzxbfFYG+tAu+fgx3zxQy3qdagQqBLwJVrdyO1TBfUXvfKveMKJZhpvUYoOjM+4g==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } }, - "node_modules/uqr": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.2.tgz", - "integrity": "sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==", - "license": "MIT" + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "node_modules/unenv": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-1.10.0.tgz", + "integrity": "sha512-wY5bskBQFL9n3Eca5XnhH6KbUo/tfvkwm9OpcdCvLaeA7piBNbavbOKJySEwQ1V0RH6HvNlSAFRTpvTqgKRQXQ==", + "license": "MIT", + "dependencies": { + "consola": "^3.2.3", + "defu": "^6.1.4", + "mime": "^3.0.0", + "node-fetch-native": "^1.6.4", + "pathe": "^1.1.2" + } }, "node_modules/util-deprecate": { "version": "1.0.2", @@ -3053,6 +3264,20 @@ "node": ">=0.10.0" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3099,6 +3324,48 @@ "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==", + "dev": true, + "license": "MIT", + "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/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.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", @@ -3132,9 +3399,9 @@ "dev": true }, "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==" + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.0.2.tgz", + "integrity": "sha512-ibF0Or+FivM9lNrg+HGJfVX8WJqgo+kCLDc4vx6xMeTce7Aj+DLttKbxxRR/gNLSAelRc1omAPlJ77N/Jem07A==" }, "node_modules/y18n": { "version": "5.0.8", @@ -3185,9 +3452,9 @@ } }, "node_modules/zod": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", - "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index afbb90a..ba02a83 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "out-of-your-element", - "version": "3.4.0", + "version": "1.1.1", "description": "A bridge between Matrix and Discord", "main": "index.js", "repository": { @@ -12,61 +12,58 @@ "discord", "bridge" ], - "author": "Cadence", + "author": "Cadence, PapiOphidian", "license": "AGPL-3.0-or-later", "engines": { - "node": ">=22" + "node": ">=20" }, "dependencies": { - "@chriscdn/promise-semaphore": "^3.0.1", - "@cloudrac3r/discord-markdown": "^2.6.10", + "@chriscdn/promise-semaphore": "^2.0.1", + "@cloudrac3r/discord-markdown": "^2.6.3", "@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/in-your-element": "^1.0.0", + "@cloudrac3r/mixin-deep": "^3.0.0", "@cloudrac3r/pngjs": "^7.0.3", "@cloudrac3r/pug": "^4.0.4", "@cloudrac3r/turndown": "^7.1.4", - "@stackoverflow/stacks": "^2.5.4", + "@stackoverflow/stacks": "^2.5.7", "@stackoverflow/stacks-icons": "^6.0.2", "ansi-colors": "^4.1.3", - "better-sqlite3": "^12.2.0", + "better-sqlite3": "^11.1.2", "chunk-text": "^2.0.1", - "cloudstorm": "^0.15.2", - "discord-api-types": "^0.38.38", + "cloudstorm": "^0.10.10", "domino": "^2.1.6", "enquirer": "^2.4.1", "entities": "^5.0.0", - "get-relative-path": "^1.0.2", - "h3": "^1.15.1", - "heatsync": "^2.7.2", - "htmx.org": "^2.0.4", - "lru-cache": "^11.0.2", - "mime-types": "^2.1.35", + "get-stream": "^6.0.1", + "h3": "^1.12.0", + "heatsync": "^2.5.3", + "lru-cache": "^10.4.3", + "minimist": "^1.2.8", + "node-fetch": "^2.6.7", "prettier-bytes": "^1.0.4", - "sharp": "^0.34.5", - "snowtransfer": "^0.17.1", + "sharp": "^0.33.4", + "snowtransfer": "^0.10.5", "stream-mime-type": "^1.0.2", "try-to-catch": "^3.0.1", - "uqr": "^0.1.2", "xxhash-wasm": "^1.0.2", - "zod": "^4.0.17" - }, - "overrides": { - "glob@<11.1": "^12" + "zod": "^3.23.8" }, "devDependencies": { "@cloudrac3r/tap-dot": "^2.0.3", - "@types/node": "^22.17.1", + "@types/node": "^18.16.0", + "@types/node-fetch": "^2.6.3", "c8": "^10.1.2", "cross-env": "^7.0.3", - "supertape": "^12.0.12" + "discord-api-types": "^0.37.60", + "supertape": "^10.4.0" }, "scripts": { - "start": "node --enable-source-maps start.js", - "setup": "node --enable-source-maps scripts/setup.js", + "start": "node start.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", - "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" + "test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap 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 matrix/file.js -x matrix/api.js -x matrix/mreq.js -x d2m/converters/rlottie-wasm.js -r html -r text supertape --no-check-assertions-count --format fail --no-worker test/test.js -- --slow" } } diff --git a/readme.md b/readme.md index e8a8e7e..992d0de 100644 --- a/readme.md +++ b/readme.md @@ -6,13 +6,15 @@ Modern Matrix-to-Discord appservice bridge, created by [@cadence:cadence.moe](ht [![Releases](https://img.shields.io/gitea/v/release/cadence/out-of-your-element?gitea_url=https%3A%2F%2Fgitdab.com&style=plastic&color=green)](https://gitdab.com/cadence/out-of-your-element/releases) [![Discuss on Matrix](https://img.shields.io/badge/discuss-%23out--of--your--element-white?style=plastic)](https://matrix.to/#/#out-of-your-element:cadence.moe) -![](https://cadence.moe/i/f42a3f) +## Docs + +This readme has the most important info. The rest is [in the docs folder.](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs) ## Why a new bridge? * Modern: Supports new Discord features like replies, threads and stickers, and new Matrix features like edits, spaces and space membership. -* Efficient: Special attention has been given to memory usage, database indexes, disk footprint, runtime algorithms, and queries to the homeserver. [Efficiency details.](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/developer-orientation.md) -* Reliable: Any errors on either side are notified on Matrix and can be retried. Messages sent during bridge downtime will still be bridged after it comes back up. +* Efficient: Special attention has been given to memory usage, database indexes, disk footprint, runtime algorithms, and queries to the homeserver. +* Reliable: Any errors on either side are notified on Matrix and can be retried. * Tested: A test suite and code coverage make sure all the logic and special cases work. * Simple development: No build step (it's JavaScript, not TypeScript), minimal/lightweight dependencies, and abstraction only where necessary so that less background knowledge is required. No need to learn about Intents or library functions. * No locking algorithm: Other bridges use a locking algorithm which is a source of frequent bugs. This bridge avoids the need for one. @@ -20,17 +22,27 @@ Modern Matrix-to-Discord appservice bridge, created by [@cadence:cadence.moe](ht ## What works? -Most features you'd expect in both directions: messages, edits, deletions, formatting (including spoilers), reactions, custom emojis, custom emoji reactions, mentions, channel mentions, replies, threads, stickers (all formats: PNG, APNG, GIF, Lottie), attachments, spoiler attachments (compatible with most clients), embeds, URL previews, presence, discord.com hyperlinks, and more. +Most features you'd expect in both directions, plus a little extra spice: -Metadata is also synced: people's names, avatars, usernames; channel names, icons, topics; spaces containing rooms; custom emoji lists. Syncing Matrix rooms, room icons, and topics is optional: you can keep them different from the Discord ones if you prefer. - -I've also added some interesting features that I haven't seen in any other bridge: - -* Members using the PluralKit bot each get their own persistent accounts -* Replies from PluralKit members are restyled into native Matrix replies +* Messages +* Edits +* Deletions +* Text formatting, including spoilers +* Reactions +* Mentions +* Replies +* Threads +* Stickers (all formats: PNG, APNG, GIF, and Lottie) +* Attachments +* Spoiler attachments +* Embeds +* Guild-Space details syncing +* Channel-Room details syncing +* Custom emoji list syncing +* Custom emojis in messages +* Custom room names/avatars can be applied on Matrix-side +* Larger files from Discord are linked instead of reuploaded to Matrix (links don't expire) * Simulated user accounts are named @the_persons_username rather than @112233445566778899 -* Matrix custom emojis from private rooms are still visible on Discord as a sprite sheet -* To save space, larger files from Discord are linked instead of reuploaded to Matrix (links don't expire) For more information about features, [see the user guide.](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/user-guide.md) @@ -38,8 +50,150 @@ 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! +## Efficiency details -[Read the installation instructions →](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/get-started.md) +Using WeatherStack as a thin layer between the bridge application and the Discord API lets us control exactly what data is cached in memory. Only necessary information is cached. For example, member data, user data, message content, and past edits are never stored in memory. This keeps the memory usage low and also prevents it ballooning in size over the bridge's runtime. + +The bridge uses a small SQLite database to store relationships like which Discord messages correspond to which Matrix messages. This is so the bridge knows what to edit when some message is edited on Discord. Using `without rowid` on the database tables stores the index and the data in the same B-tree. Since Matrix and Discord's internal IDs are quite long, this vastly reduces storage space because those IDs do not have to be stored twice separately. Some event IDs and URLs are actually stored as xxhash integers to reduce storage requirements even more. On my personal instance of OOYE, every 300,000 messages (representing a year of conversations) requires 47.3 MB of storage space in the SQLite database. + +Only necessary data and columns are queried from the database. We only contact the homeserver API if the database doesn't contain what we need. + +File uploads (like avatars from bridged members) are checked locally and deduplicated. Only brand new files are uploaded to the homeserver. This saves loads of space in the homeserver's media repo, especially for Synapse. + +Switching to [WAL mode](https://www.sqlite.org/wal.html) could improve your database access speed even more. Run `node scripts/wal.js` if you want to switch to WAL mode. This will also enable `synchronous = NORMAL`. + +# Setup + +If you get stuck, you're welcome to message [#out-of-your-element:cadence.moe](https://matrix.to/#/#out-of-your-element:cadence.moe) or [@cadence:cadence.moe](https://matrix.to/#/@cadence:cadence.moe) to ask for help setting up OOYE! + +You'll need: + +* Administrative access to a homeserver +* Discord bot + +Follow these steps: + +1. [Get Node.js version 20 or later](https://nodejs.org/en/download/prebuilt-installer) + +1. Clone this repo and checkout a specific tag. (Development happens on main. Stable versions are tagged.) + * The latest release tag is ![](https://img.shields.io/gitea/v/release/cadence/out-of-your-element?gitea_url=https%3A%2F%2Fgitdab.com&style=flat-square&label=%20&color=black). + +1. Install dependencies: `npm install` + +1. Run `node scripts/seed.js` to check your setup and set the bot's initial state. It will prompt you for information. You only need to run this once ever. + +1. Start the bridge: `npm start` + +1. Add the bot to a server - use any *one* of the following commands for an invite link: + * (in the REPL) `addbot` + * $ `node addbot.js` + * $ `npm run addbot` + * $ `./addbot.sh` + +Now any message on Discord will create the corresponding rooms on Matrix-side. After the rooms have been created, Matrix and Discord users can chat back and forth. + +To get into the rooms on your Matrix account, use the `/invite [your mxid here]` command on Discord. + +# Development setup + +* Install development dependencies with `npm install --save-dev` so you can run the tests. +* Most files you change, such as actions, converters, and web, will automatically be reloaded. +* If developing on a different computer to the one running the homeserver, use SSH port forwarding so that Synapse can connect on its `localhost:6693` to reach the running bridge on your computer. Example: `ssh -T -v -R 6693:localhost:6693 me@matrix.cadence.moe` +* I recommend developing in Visual Studio Code so that the JSDoc x TypeScript annotation comments work. I don't know which other editors or language servers support annotations and type inference. + +## Repository structure + + . + * Runtime configuration, like tokens and user info: + ├── registration.yaml + * You are here! :) + ├── readme.md + * The bridge's SQLite database is stored here: + ├── ooye.db* + * Source code + └── src + * Database schema: + ├── db + │   ├── orm.js, orm-defs.d.ts + │   * Migrations change the database schema when you update to a newer version of OOYE: + │   ├── migrate.js + │   └── migrations + │       └── *.sql, *.js + * Discord-to-Matrix bridging: + ├── d2m + │   * Execute actions through the whole flow, like sending a Discord message to Matrix: + │   ├── actions + │   │   └── *.js + │   * Convert data from one form to another without depending on bridge state. Called by actions: + │   ├── converters + │   │   └── *.js + │   * Making Discord work: + │   ├── discord-*.js + │   * Listening to events from Discord and dispatching them to the correct `action`: + │   └── event-dispatcher.js + * Discord bot commands and menus: + ├── discord + │   ├── interactions + │   │   └── *.js + │   └── discord-command-handler.js + * Matrix-to-Discord bridging: + ├── m2d + │   * Execute actions through the whole flow, like sending a Matrix message to Discord: + │   ├── actions + │   │   └── *.js + │   * Convert data from one form to another without depending on bridge state. Called by actions: + │   ├── converters + │   │   └── *.js + │   * Listening to events from Matrix and dispatching them to the correct `action`: + │   └── event-dispatcher.js + * We aren't using the matrix-js-sdk, so here are all the functions for the Matrix C-S and Appservice APIs: + ├── matrix + │   └── *.js + * Various files you can run once if you need them. + └── scripts + * First time running a new bridge? Run this file to plant a seed, which will flourish into state for the bridge: + ├── seed.js + * Hopefully you won't need the rest of these. Code quality varies wildly. + └── *.js + +## Dependency justification + +Total transitive production dependencies: 147 + +### 🦕 + +* (31) better-sqlite3: SQLite3 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. +* (14) h3: Web server. OOYE needs this for the appservice listener, authmedia proxy, and more. 14 transitive dependencies is on the low end for a web server. +* (11) sharp: Image resizing and compositing. OOYE needs this for the emoji sprite sheets. + +### 🪱 + +* (0) @chriscdn/promise-semaphore: It does what I want. +* (1) @cloudrac3r/discord-markdown: This is my fork. +* (0) @cloudrac3r/giframe: This is my fork. +* (1) @cloudrac3r/html-template-tag: This is my fork. +* (0) @cloudrac3r/in-your-element: This is my Matrix Appservice API library. It depends on h3 and zod, which are already pulled in by OOYE. +* (0) @cloudrac3r/mixin-deep: This is my fork. (It fixes a bug in regular mixin-deep.) +* (0) @cloudrac3r/pngjs: Lottie stickers are converted to bitmaps with the vendored Rlottie WASM build, then the bitmaps are converted to PNG with pngjs. +* (0) @cloudrac3r/turndown: This HTML-to-Markdown converter looked the most suitable. I forked it to change the escaping logic to match the way Discord works. +* (3) @stackoverflow/stacks: Stack Overflow design language and icons. +* (0) ansi-colors: Helps with interactive prompting for the initial setup, and it's already pulled in by enquirer. +* (1) chunk-text: It does what I want. +* (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust. +* (0) domino: DOM implementation that's already pulled in by turndown. +* (1) enquirer: Interactive prompting for the initial setup rather than forcing users to edit YAML non-interactively. +* (0) entities: Looks fine. No dependencies. +* (0) get-stream: Only needed if content_length_workaround is true. +* (1) heatsync: Module hot-reloader that I trust. +* (1) js-yaml: Will be removed in the future after registration.yaml is converted to JSON. +* (0) lru-cache: For holding unused nonce in memory and letting them be overwritten later if never used. +* (0) minimist: It's already pulled in by better-sqlite3->prebuild-install. +* (3) node-fetch@2: I like it and it does what I want. Version 2 is used because version 3 is ESM-only. +* (0) prettier-bytes: It does what I want and has no dependencies. +* (2) snowtransfer: Discord API library with bring-your-own-caching that I trust. +* (0) try-to-catch: Not strictly necessary, but it's already pulled in by supertape, so I may as well. +* (0) xxhash-wasm: Used where cryptographically secure hashing is not required. +* (0) zod: Input validation for the web server. It's popular and easy to use. diff --git a/registration.example.yaml b/registration.example.yaml new file mode 100644 index 0000000..d38f5ae --- /dev/null +++ b/registration.example.yaml @@ -0,0 +1,25 @@ +id: de8c56117637cb5d9f4ac216f612dc2adb1de4c09ae8d13553f28c33a28147c7 +hs_token: [a unique 64 character hex string] +as_token: [a unique 64 character hex string] +url: http://localhost:6693 +sender_localpart: _ooye_bot +protocols: + - discord +namespaces: + users: + - exclusive: true + regex: '@_ooye_.*' + aliases: + - exclusive: true + regex: '#_ooye_.*' +rate_limited: false +ooye: + namespace_prefix: _ooye_ + max_file_size: 5000000 + server_name: [the part after the colon in your matrix id, like cadence.moe] + server_origin: [the full protocol and domain of your actual matrix server's location, with no trailing slash, like https://matrix.cadence.moe] + content_length_workaround: false + include_user_id_in_mxid: false + invite: + # uncomment this to auto-invite the named user to newly created spaces and mark them as admin (PL 100) everywhere + # - '@cadence:cadence.moe' diff --git a/scripts/backfill.js b/scripts/backfill.js deleted file mode 100644 index 27600f0..0000000 --- a/scripts/backfill.js +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env node -// @ts-check - -console.log("-=- This script is experimental. It WILL mess up the room history on Matrix. -=-") -console.log() - -const {channel: channelID} = require("minimist")(process.argv.slice(2), {string: ["channel"]}) -if (!channelID) { - console.error("Usage: ./scripts/backfill.js --channel=") - process.exit(1) -} - -const assert = require("assert/strict") -const sqlite = require("better-sqlite3") -const backfill = new sqlite("scripts/backfill.db") -backfill.prepare("CREATE TABLE IF NOT EXISTS backfill (channel_id TEXT NOT NULL, message_id INTEGER NOT NULL, PRIMARY KEY (channel_id, message_id))").run() - -const HeatSync = require("heatsync") - -const {reg} = require("../src/matrix/read-registration") -const passthrough = require("../src/passthrough") - -const sync = new HeatSync({watchFS: false}) -const db = new sqlite("ooye.db") -Object.assign(passthrough, {sync, db}) - -const DiscordClient = require("../src/d2m/discord-client") - -const discord = new DiscordClient(reg.ooye.discord_token, "half") -passthrough.discord = discord - -const {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) -} - -;(async () => { - await discord.cloud.connect() - console.log("Connected, waiting for data about requested channel...") - - discord.cloud.on("event", event) -})() - -const preparedInsert = backfill.prepare("INSERT INTO backfill (channel_id, message_id) VALUES (?, ?)") - -async function event(event) { - if (event.t !== "GUILD_CREATE") return - const channel = event.d.channels.find(c => c.id === channelID) - if (!channel) return - const guild_id = event.d.id - - let last = backfill.prepare("SELECT cast(max(message_id) as TEXT) FROM backfill WHERE channel_id = ?").pluck().get(channelID) || "0" - console.log(`OK, processing messages for #${channel.name}, continuing from ${last}`) - - while (last) { - const messages = await discord.snow.channel.getChannelMessages(channelID, {limit: 50, after: String(last)}) - messages.reverse() // More recent messages come first -> More recent messages come last - for (const message of messages) { - const simulatedGatewayDispatchData = { - guild_id, - backfill: true, - ...message - } - await eventDispatcher.MESSAGE_CREATE(discord, simulatedGatewayDispatchData) - preparedInsert.run(channelID, message.id) - } - last = messages.at(-1)?.id - } - - process.exit() -} diff --git a/scripts/emoji-surrogates-statistics.js b/scripts/emoji-surrogates-statistics.js deleted file mode 100644 index 29abce3..0000000 --- a/scripts/emoji-surrogates-statistics.js +++ /dev/null @@ -1,77 +0,0 @@ -// @ts-check - -const fs = require("fs") -const {join} = require("path") -const s = fs.readFileSync(join(__dirname, "..", "src", "m2d", "converters", "emojis.txt"), "utf8").split("\n").map(x => encodeURIComponent(x)) -const searchPattern = "%EF%B8%8F" - -/** - * adapted from es.map.group-by.js in core-js - * @template K,V - * @param {V[]} items - * @param {(item: V) => K} fn - * @returns {Map} - */ -function groupBy(items, fn) { - var map = new Map(); - for (const value of items) { - var key = fn(value); - if (!map.has(key)) map.set(key, [value]); - else map.get(key).push(value); - } - return map; -} - -/** - * @param {number[]} items - * @param {number} width - */ -function xhistogram(items, width) { - const chars = " ▏▎▍▌▋▊▉" - const max = items.reduce((a, c) => c > a ? c : a, 0) - return items.map(v => { - const p = v / max * (width-1) - return ( - Array(Math.floor(p)).fill("█").join("") /* whole part */ - + chars[Math.ceil((p % 1) * (chars.length-1))] /* decimal part */ - ).padEnd(width) - }) -} - -/** - * @param {number[]} items - * @param {[number, number]} xrange - */ -function yhistogram(items, xrange, printHeader = false) { - const chars = "░▁_▂▃▄▅▆▇█" - const ones = "₀₁₂₃₄₅₆₇₈₉" - const tens = "0123456789" - const xy = [] - let max = 0 - /** value (x) -> frequency (y) */ - const grouped = groupBy(items, x => x) - for (let i = xrange[0]; i <= xrange[1]; i++) { - if (printHeader) { - if (i === -1) process.stdout.write("-") - else if (i.toString().at(-1) === "0") process.stdout.write(tens[i/10]) - else process.stdout.write(ones[i%10]) - } - const y = grouped.get(i)?.length ?? 0 - if (y > max) max = y - xy.push(y) - } - if (printHeader) console.log() - return xy.map(y => chars[Math.ceil(y / max * (chars.length-1))]).join("") -} - -const grouped = groupBy(s, x => x.length) -const sortedGroups = [...grouped.entries()].sort((a, b) => b[0] - a[0]) -let length = 0 -const lengthHistogram = xhistogram(sortedGroups.map(v => v[1].length), 10) -for (let i = 0; i < sortedGroups.length; i++) { - const [k, v] = sortedGroups[i] - const l = lengthHistogram[i] - const h = yhistogram(v.map(x => x.indexOf(searchPattern)), [-1, k - searchPattern.length], i === 0) - if (i === 0) length = h.length + 1 - console.log(`${h.padEnd(length, i % 2 === 0 ? "⸱" : " ")}length ${k.toString().padEnd(3)} ${l} ${v.length}`) -} diff --git a/scripts/estimate-size.js b/scripts/estimate-size.js deleted file mode 100644 index 341abc0..0000000 --- a/scripts/estimate-size.js +++ /dev/null @@ -1,65 +0,0 @@ -// @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 1842c16..c5e50a1 100755 --- a/scripts/migrate-from-old-bridge.js +++ b/scripts/migrate-from-old-bridge.js @@ -2,7 +2,8 @@ // @ts-check const assert = require("assert").strict -const {Semaphore} = require("@chriscdn/promise-semaphore") +/** @type {any} */ // @ts-ignore bad types from semaphore +const Semaphore = require("@chriscdn/promise-semaphore") const sqlite = require("better-sqlite3") const HeatSync = require("heatsync") @@ -37,9 +38,7 @@ 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("../src/matrix/api") -/** @type {import("../src/matrix/utils")} */ -const utils = sync.require("../src/matrix/utils") +const api = sync.require("../matrix/api") const sema = new Semaphore() @@ -82,8 +81,16 @@ 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 () => { - await api.inviteToRoom(roomID, newBridgeMxid) - await utils.setUserPower(roomID, newBridgeMxid, 100, api) + 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.joinRoom(roomID) @@ -108,7 +115,7 @@ async function migrateGuild(guild) { // (By the way, thread_parent is always null here because thread rooms would never be migrated because they are not in the old bridge.) db.transaction(() => { db.prepare("DELETE FROM channel_room WHERE channel_id = ?").run(channel.id) - db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, custom_avatar, guild_id) VALUES (?, ?, ?, ?, ?, ?)").run(channel.id, row.matrix_id, channel.name, preMigrationRow.nick, preMigrationRow.custom_avatar, guild.id) + db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, custom_avatar) VALUES (?, ?, ?, ?, ?)").run(channel.id, row.matrix_id, channel.name, preMigrationRow.nick, preMigrationRow.custom_avatar) console.log(`-- -- Added to database (transferred properties from previous OOYE room)`) })() } else { diff --git a/scripts/remove-old-bridged-users.js b/scripts/remove-old-bridged-users.js index 756a492..d8910bd 100644 --- a/scripts/remove-old-bridged-users.js +++ b/scripts/remove-old-bridged-users.js @@ -10,7 +10,6 @@ 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() @@ -26,7 +25,7 @@ const rooms = db.prepare("select room_id from channel_room").pluck().all() await api.leaveRoom(roomID, mxid) } } - await utils.setUserPower(roomID, "@_discord_bot:cadence.moe", 0, api) + await api.setUserPower(roomID, "@_discord_bot:cadence.moe", 0) await api.leaveRoom(roomID) } catch (e) { if (e.message.includes("Appservice not in room")) { diff --git a/scripts/reset-web-password.js b/scripts/reset-web-password.js deleted file mode 100644 index 9131efb..0000000 --- a/scripts/reset-web-password.js +++ /dev/null @@ -1,17 +0,0 @@ -// @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. Restart Out Of Your Element to apply this change.") -})() diff --git a/scripts/seed.js b/scripts/seed.js new file mode 100755 index 0000000..3603595 --- /dev/null +++ b/scripts/seed.js @@ -0,0 +1,332 @@ +#!/usr/bin/env node +// @ts-check + +const assert = require("assert").strict +const fs = require("fs") +const sqlite = require("better-sqlite3") +const {scheduler} = require("timers/promises") +const {isDeepStrictEqual} = require("util") +const {createServer} = require("http") + +const {prompt} = require("enquirer") +const Input = require("enquirer/lib/prompts/input") +const fetch = require("node-fetch").default +const {magenta, bold, cyan} = require("ansi-colors") +const HeatSync = require("heatsync") +const {SnowTransfer} = require("snowtransfer") +const {createApp, defineEventHandler, toNodeListener} = require("h3") + +const args = require("minimist")(process.argv.slice(2), {string: ["emoji-guild"]}) + +// 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") + +/** @type {import("heatsync").default} */ // @ts-ignore +const sync = new HeatSync({watchFS: false}) + +Object.assign(passthrough, {sync, db}) + +const orm = sync.require("../src/db/orm") +passthrough.from = orm.from +passthrough.select = orm.select + +let registration = require("../src/matrix/read-registration") +let {reg, getTemplateRegistration, writeRegistration, readRegistration, checkRegistration, registrationFilePath} = registration + +function die(message) { + console.error(message) + process.exit(1) +} + +async function uploadAutoEmoji(snow, guild, name, filename) { + let emoji = guild.emojis.find(e => e.name === name) + if (!emoji) { + console.log(` Uploading ${name}...`) + const data = fs.readFileSync(filename, null) + emoji = await snow.guildAssets.createEmoji(guild.id, {name, image: "data:image/png;base64," + data.toString("base64")}) + } else { + console.log(` Reusing ${name}...`) + } + db.prepare("REPLACE INTO auto_emoji (name, emoji_id, guild_id) VALUES (?, ?, ?)").run(emoji.name, emoji.id, guild.id) + return emoji +} + +async function validateHomeserverOrigin(serverUrlPrompt, url) { + if (!url.match(/^https?:\/\//)) return "Must be a URL" + if (url.match(/\/$/)) return "Must not end with a slash" + process.stdout.write(magenta(" checking, please wait...")) + try { + var json = await fetch(`${url}/.well-known/matrix/client`).then(res => res.json()) + let baseURL = json["m.homeserver"].base_url.replace(/\/$/, "") + if (baseURL && baseURL !== url) { + serverUrlPrompt.initial = baseURL + return `Did you mean: ${bold(baseURL)}? (Enter to accept)` + } + } catch (e) {} + try { + var res = await fetch(`${url}/_matrix/client/versions`) + } catch (e) { + return e.message + } + if (res.status !== 200) return `There is no Matrix server at that URL (${url}/_matrix/client/versions returned ${res.status})` + try { + var json = await res.json() + if (!Array.isArray(json?.versions) || !json.versions.includes("v1.11")) { + return `OOYE needs Matrix version v1.11, but ${url} doesn't support this` + } + } catch (e) { + return `There is no Matrix server at that URL (${url}/_matrix/client/versions is not JSON)` + } + return true +} + +;(async () => { + // create registration file with prompts... + if (!reg) { + console.log("What is the name of your homeserver? This is the part after : in your username.") + /** @type {{server_name: string}} */ + const serverNameResponse = await prompt({ + type: "input", + name: "server_name", + message: "Homeserver name", + validate: serverName => !!serverName.match(/[a-z][a-z.]+[a-z]/) + }) + + console.log("What is the URL of your homeserver?") + const serverOriginPrompt = new Input({ + type: "input", + name: "server_origin", + message: "Homeserver URL", + initial: () => `https://${serverNameResponse.server_name}`, + validate: url => validateHomeserverOrigin(serverOriginPrompt, url) + }) + /** @type {string} */ // @ts-ignore + const serverOrigin = await serverOriginPrompt.run() + + const app = createApp() + app.use(defineEventHandler(() => "Out Of Your Element is listening.\n")) + const server = createServer(toNodeListener(app)) + await server.listen(6693) + + console.log("OOYE has its own web server. It needs to be accessible on the public internet.") + console.log("You need to enter a public URL where you will be able to host this web server.") + console.log("OOYE listens on localhost:6693, so you will probably have to set up a reverse proxy.") + console.log("Now listening on port 6693. Feel free to send some test requests.") + /** @type {{bridge_origin: string}} */ + const bridgeOriginResponse = await prompt({ + type: "input", + name: "bridge_origin", + message: "URL to reach OOYE", + initial: () => `https://bridge.${serverNameResponse.server_name}`, + validate: async url => { + process.stdout.write(magenta(" checking, please wait...")) + try { + const res = await fetch(url) + if (res.status !== 200) return `Server returned status code ${res.status}` + const text = await res.text() + if (text !== "Out Of Your Element is listening.\n") return `Server does not point to OOYE` + return true + } catch (e) { + return e.message + } + } + }) + + await server.close() + + console.log("What is your Discord bot token?") + /** @type {{discord_token: string}} */ + const discordTokenResponse = await prompt({ + type: "input", + name: "discord_token", + message: "Bot token", + validate: async token => { + process.stdout.write(magenta(" checking, please wait...")) + try { + const snow = new SnowTransfer(token) + await snow.user.getSelf() + return true + } catch (e) { + return e.message + } + } + }) + + console.log("What is your Discord client secret?") + /** @type {{discord_client_secret: string}} */ + const clientSecretResponse = await prompt({ + type: "input", + name: "discord_client_secret", + message: "Client secret" + }) + + const template = getTemplateRegistration(serverNameResponse.server_name) + reg = { + ...template, + url: bridgeOriginResponse.bridge_origin, + ooye: { + ...template.ooye, + ...serverNameResponse, + ...bridgeOriginResponse, + server_origin: serverOrigin, + ...discordTokenResponse, + ...clientSecretResponse + } + } + registration.reg = reg + checkRegistration(reg) + writeRegistration(reg) + console.log(`✅ Registration file saved as ${registrationFilePath}`) + } else { + console.log(`✅ Valid registration file found at ${registrationFilePath}`) + } + console.log(` In ${cyan("Synapse")}, you need to add it to homeserver.yaml and ${cyan("restart Synapse")}.`) + console.log(" https://element-hq.github.io/synapse/latest/application_services.html") + console.log(` In ${cyan("Conduit")}, you need to send the file contents to the #admins room.`) + console.log(" https://docs.conduit.rs/appservices.html") + console.log() + + // Done with user prompts, reg is now guaranteed to be valid + const api = require("../src/matrix/api") + const file = require("../src/matrix/file") + const utils = require("../src/m2d/converters/utils") + 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") + console.log("⏳ Waiting until homeserver registration works... (Ctrl+C to cancel)") + + let itWorks = false + let lastError = null + do { + const result = await api.ping().catch(e => ({ok: false, status: "net", root: e.message})) + // If it didn't work, log details and retry after some time + itWorks = result.ok + if (!itWorks) { + // Log the full error data if the error is different to last time + if (!isDeepStrictEqual(lastError, result.root)) { + if (typeof result.root === "string") { + console.log(`\nCannot reach homeserver: ${result.root}`) + } else if (result.root.error) { + console.log(`\nHomeserver said: [${result.status}] ${result.root.error}`) + } else { + console.log(`\nHomeserver said: [${result.status}] ${JSON.stringify(result.root)}`) + } + lastError = result.root + } else { + process.stderr.write(".") + } + await scheduler.wait(5000) + } + } while (!itWorks) + console.log("") + + as.close().catch(() => {}) + + const mxid = `@${reg.sender_localpart}:${reg.ooye.server_name}` + + // ensure registration is correctly set... + assert(reg.sender_localpart.startsWith(reg.ooye.namespace_prefix), "appservice's localpart must be in the namespace it controls") + assert(utils.eventSenderIsFromDiscord(mxid), "appservice's mxid must be in the namespace it controls") + assert(reg.ooye.server_origin.match(/^https?:\/\//), "server origin must start with http or https") + assert.notEqual(reg.ooye.server_origin.slice(-1), "/", "server origin must not end in slash") + const botID = Buffer.from(reg.ooye.discord_token.split(".")[0], "base64").toString() + assert(botID.match(/^[0-9]{10,}$/), "discord token must follow the correct format") + assert.match(reg.url, /^https?:/, "url must start with http:// or https://") + + console.log("✅ Configuration looks good...") + + // database ddl... + await migrate.migrate(db) + + // add initial rows to database, like adding the bot to sim... + db.prepare("INSERT OR IGNORE INTO sim (user_id, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(botID, reg.sender_localpart.slice(reg.ooye.namespace_prefix.length), reg.sender_localpart, mxid) + + console.log("✅ Database is ready...") + + // ensure appservice bot user is registered... + try { + await api.register(reg.sender_localpart) + } catch (e) { + if (e.errcode === "M_USER_IN_USE" || e.data?.error === "Internal server error") { + // "Internal server error" is the only OK error because older versions of Synapse say this if you try to register the same username twice. + } else { + throw e + } + } + + // upload initial images... + const avatarUrl = await file.uploadDiscordFileToMxc("https://cadence.moe/friends/out_of_your_element.png") + + console.log("✅ Matrix appservice login works...") + + // upload the L1 L2 emojis to some guild + const emojis = db.prepare("SELECT name FROM auto_emoji WHERE name = 'L1' OR name = 'L2'").pluck().all() + if (emojis.length !== 2) { + // If an argument was supplied, always use that one + let guild = null + if (args["emoji-guild"]) { + if (typeof args["emoji-guild"] === "string") { + guild = await discord.snow.guild.getGuild(args["emoji-guild"]) + } + if (!guild) return die(`Error: You asked emojis to be uploaded to guild ID ${args["emoji-guild"]}, but the bot isn't in that guild.`) + } + // Otherwise, check if we have already registered an auto emoji guild + if (!guild) { + const guildID = passthrough.select("auto_emoji", "guild_id", {name: "_"}).pluck().get() + if (guildID) { + guild = await discord.snow.guild.getGuild(guildID, false) + } + } + // Otherwise, check if we should create a new guild + if (!guild) { + const guilds = await discord.snow.user.getGuilds({limit: 11, with_counts: false}) + if (guilds.length < 10) { + console.log(" Creating a guild for emojis...") + guild = await discord.snow.guild.createGuild({name: "OOYE Emojis"}) + } + } + // Otherwise, it's the user's problem + if (!guild) { + return die(`Error: The bot needs to upload some emojis. Please say where to upload them to. Run seed.js again with --emoji-guild=GUILD_ID`) + } + // Upload those emojis to the chosen location + db.prepare("REPLACE INTO auto_emoji (name, emoji_id, guild_id) VALUES ('_', '_', ?)").run(guild.id) + await uploadAutoEmoji(discord.snow, guild, "L1", "docs/img/L1.png") + await uploadAutoEmoji(discord.snow, guild, "L2", "docs/img/L2.png") + } + console.log("✅ Emojis are ready...") + + // set profile data on discord... + const avatarImageBuffer = await fetch("https://cadence.moe/friends/out_of_your_element.png").then(res => res.arrayBuffer()) + await discord.snow.user.updateSelf({avatar: "data:image/png;base64," + Buffer.from(avatarImageBuffer).toString("base64")}) + await discord.snow.requestHandler.request(`/applications/@me`, {}, "patch", "json", {description: "Powered by **Out Of Your Element**\nhttps://gitdab.com/cadence/out-of-your-element"}) + console.log("✅ Discord profile updated...") + + // set profile data on homeserver... + console.log("⏩ Updating Matrix profile... (If you've joined lots of rooms, this is slow. Please allow at least 30 seconds.)") + await api.profileSetDisplayname(mxid, "Out Of Your Element") + await api.profileSetAvatarUrl(mxid, avatarUrl) + console.log("✅ Matrix profile updated...") + + console.log("Good to go. I hope you enjoy Out Of Your Element.") + process.exit() +})() diff --git a/scripts/setup.js b/scripts/setup.js deleted file mode 100644 index 69b62a2..0000000 --- a/scripts/setup.js +++ /dev/null @@ -1,373 +0,0 @@ -#!/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") -const {scheduler} = require("timers/promises") -const {isDeepStrictEqual} = require("util") -const {createServer} = require("http") -const {join} = require("path") - -const {prompt} = require("enquirer") -const Input = require("enquirer/lib/prompts/input") -const {magenta, bold, cyan} = require("ansi-colors") -const HeatSync = require("heatsync") -const {SnowTransfer} = require("snowtransfer") -const DiscordTypes = require("discord-api-types/v10") -const {createApp, defineEventHandler, toNodeListener} = require("h3") - -const passthrough = require("../src/passthrough") -const db = new sqlite("ooye.db") -const migrate = require("../src/db/migrate") - -const sync = new HeatSync({watchFS: false}) - -Object.assign(passthrough, {sync, db}) - -const orm = sync.require("../src/db/orm") -passthrough.from = orm.from -passthrough.select = orm.select - -let registration = require("../src/matrix/read-registration") -let {reg, getTemplateRegistration, writeRegistration, readRegistration, checkRegistration, registrationFilePath} = registration - -const {setupEmojis} = require("../src/m2d/actions/setup-emojis") - -async function suggestWellKnown(serverUrlPrompt, url, otherwise) { - try { - var json = await fetch(`${url}/.well-known/matrix/client`).then(res => res.json()) - let baseURL = json["m.homeserver"].base_url.replace(/\/$/, "") - if (baseURL && baseURL !== url) { - serverUrlPrompt.initial = baseURL - return `Did you mean: ${bold(baseURL)}? (Enter to accept)` - } - } catch (e) {} - return otherwise -} - -async function validateHomeserverOrigin(serverUrlPrompt, url) { - if (!url.match(/^https?:\/\//)) return "Must be a URL" - if (url.match(/\/$/)) return "Must not end with a slash" - process.stdout.write(magenta(" checking, please wait...")) - try { - var res = await fetch(`${url}/_matrix/client/versions`) - if (res.status !== 200) { - return suggestWellKnown(serverUrlPrompt, url, `There is no Matrix server at that URL (${url}/_matrix/client/versions returned ${res.status})`) - } - } catch (e) { - return e.message - } - try { - /** @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` - } - } catch (e) { - return suggestWellKnown(serverUrlPrompt, url, `There is no Matrix server at that URL (${url}/_matrix/client/versions is not JSON)`) - } - return true -} - -function defineEchoHandler() { - return defineEventHandler(event => { - return "Out Of Your Element is listening.\n" + - `Received a ${event.method} request on path ${event.path}\n` - }) -} - -;(async () => { - // create registration file with prompts... - if (!reg) { - console.log("What is the name of your homeserver? This is the part after : in your username.") - /** @type {{server_name: string}} */ - const serverNameResponse = await prompt({ - type: "input", - name: "server_name", - message: "Homeserver name", - validate: serverName => !!serverName.match(/[a-z0-9][.a-z0-9-]+[a-z]/) - }) - - console.log("What is the URL of your homeserver?") - const serverOriginPrompt = new Input({ - type: "input", - name: "server_origin", - message: "Homeserver URL", - initial: () => `https://${serverNameResponse.server_name}`, - validate: url => validateHomeserverOrigin(serverOriginPrompt, url) - }) - /** @type {string} */ // @ts-ignore - const serverOrigin = await serverOriginPrompt.run() - - 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(portResponse.socket) - - 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") - 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", - name: "bridge_origin", - message: "URL to reach OOYE", - initial: () => `https://bridge.${serverNameResponse.server_name}`, - validate: async url => { - process.stdout.write(magenta(" checking, please wait...")) - try { - const res = await fetch(url) - if (res.status !== 200) return `Server returned status code ${res.status}` - const text = await res.text() - if (!text.startsWith("Out Of Your Element is listening.")) return `Server does not point to OOYE` - return true - } catch (e) { - return e.message - } - } - }) - bridgeOriginResponse.bridge_origin = bridgeOriginResponse.bridge_origin.replace(/\/+$/, "") // remove trailing slash - - await server.close() - - console.log("What is your Discord bot token?") - console.log("Go to https://discord.com/developers, create or pick an app, go to the Bot section, and reset the token.") - /** @type {SnowTransfer} */ // @ts-ignore - let snow = null - /** @type {{id: string, flags: number, redirect_uris: string[], description: string}} */ // @ts-ignore - let client = null - /** @type {{discord_token: string}} */ - const discordTokenResponse = await prompt({ - type: "input", - name: "discord_token", - message: "Bot token", - validate: async token => { - process.stdout.write(magenta(" checking, please wait...")) - try { - snow = new SnowTransfer(token) - client = await snow.requestHandler.request(`/applications/@me`, {}, "get", "json") - return true - } catch (e) { - return e.message - } - } - }) - - const 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 () => { - process.stdout.write(magenta("checking, please wait...")) - client = await snow.requestHandler.request(`/applications/@me`, {}, "get", "json") - if (intentFlagPossibilities.includes(client.flags & intentFlagMask)) { - return true - } else { - return "Switches have not been enabled yet" - } - } - }) - } - - console.log("Would you like to require a password to add your bot to servers? This will discourage others from using your bridge.") - console.log("Important: To make it truly private, you MUST ALSO disable Public Bot in the Discord bot configuration page.") - /** @type {{web_password: string}} */ - const passwordResponse = await prompt({ - type: "text", - name: "web_password", - message: "Choose a simple password (optional)" - }) - - console.log("To fulfill license obligations, I recommend mentioning Out Of Your Element in your Discord bot's profile.") - console.log("On the Discord bot configuration page, go to General and add something like this to the description:") - console.log(cyan("Powered by **Out Of Your Element**")) - console.log(cyan("https://gitdab.com/cadence/out-of-your-element")) - await prompt({ - type: "invisible", - name: "description", - message: "Press Enter to acknowledge", - validate: async () => { - process.stdout.write(magenta("checking, please wait...")) - client = await snow.requestHandler.request(`/applications/@me`, {}, "get", "json") - if (client.description?.match(/out.of.your.element/i)) { - return true - } else { - return "Description must name or link Out Of Your Element" - } - } - }) - - console.log("What is your Discord client secret?") - console.log(`You can find it in the application's OAuth2 section: https://discord.com/developers/applications/${client.id}/oauth2`) - /** @type {{discord_client_secret: string}} */ - const clientSecretResponse = await prompt({ - type: "input", - name: "discord_client_secret", - message: "Client secret", - validate: secret => !!secret - }) - - const expectedUri = `${bridgeOriginResponse.bridge_origin}/oauth` - if (!client.redirect_uris.includes(expectedUri)) { - console.log(`On that same page, scroll down to Redirects and add this URI: ${cyan(expectedUri)}`) - await prompt({ - type: "invisible", - name: "redirect_uri", - message: "Press Enter when you've added it", - validate: async () => { - process.stdout.write(magenta("checking, please wait...")) - client = await snow.requestHandler.request(`/applications/@me`, {}, "get", "json") - if (client.redirect_uris.includes(expectedUri)) { - return true - } else { - return "Redirect URI has not been added yet" - } - } - }) - } - - const template = getTemplateRegistration(serverNameResponse.server_name) - reg = { - ...template, - url: bridgeOriginResponse.bridge_origin, - ...portResponse, - ooye: { - ...template.ooye, - ...bridgeOriginResponse, - server_origin: serverOrigin, - ...discordTokenResponse, - ...clientSecretResponse, - ...passwordResponse - } - } - registration.reg = reg - checkRegistration(reg) - writeRegistration(reg) - console.log(`✅ Your responses have been saved as ${registrationFilePath}`) - } else { - try { - checkRegistration(reg) - console.log(`✅ Skipped questions - reusing data from ${registrationFilePath}`) - } catch (e) { - console.log(`❌ Failed to reuse data from ${registrationFilePath}`) - console.log("Consider deleting this file. You can re-run setup to safely make a new one.") - console.log("") - console.log(e.toString().replace(/^ *\n/gm, "")) - process.exit(1) - } - } - console.log(` In ${cyan("Synapse")}, you need to reference that file in your homeserver.yaml and ${cyan("restart Synapse")}.`) - console.log(" https://element-hq.github.io/synapse/latest/application_services.html") - console.log(` In ${cyan("Conduit")}, you need to send the file contents to the #admins room.`) - console.log(" https://docs.conduit.rs/appservices.html") - console.log() - - // Done with user prompts, reg is now guaranteed to be valid - const mreq = require("../src/matrix/mreq") - const api = require("../src/matrix/api") - 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", () => { - console.log("(Ctrl+C) Quit early. Please re-run setup later and allow it to complete.") - process.exit(1) - }) - - let itWorks = false - let lastError = null - do { - const result = await api.ping().catch(e => ({ok: false, status: "net", root: e.message})) - // If it didn't work, log details and retry after some time - itWorks = result.ok - if (!itWorks) { - // Log the full error data if the error is different to last time - if (!isDeepStrictEqual(lastError, result.root)) { - if (typeof result.root === "string") { - console.log(`\nCannot reach homeserver: ${result.root}`) - } else if (result.root.error) { - console.log(`\nHomeserver said: [${result.status}] ${result.root.error}`) - } else { - console.log(`\nHomeserver said: [${result.status}] ${JSON.stringify(result.root)}`) - } - lastError = result.root - } else { - process.stderr.write(".") - } - await scheduler.wait(5000) - } - } while (!itWorks) - console.log("") - - as.close().catch(() => {}) - - const mxid = `@${reg.sender_localpart}:${reg.ooye.server_name}` - - // database ddl... - await migrate.migrate(db) - - // add initial rows to database, like adding the bot to sim... - const client = await discord.snow.user.getSelf() - db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING").run(client.id, client.username, reg.sender_localpart.slice(reg.ooye.namespace_prefix.length), mxid) - - console.log("✅ Database is ready...") - - // ensure appservice bot user is registered... - await api.register(reg.sender_localpart) - - // upload initial images... - const 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...") - - // upload the L1 L2 emojis to user emojis - await setupEmojis() - console.log("✅ Emojis are ready...") - - // set profile data on discord... - await discord.snow.user.updateSelf({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.profileSetAvatarUrl(mxid, avatarUrl) - console.log("✅ Matrix profile updated...") - - console.log("Good to go. I hope you enjoy Out Of Your Element.") - process.exit() -})() diff --git a/scripts/start-server.js b/scripts/start-server.js index 44edbcb..430b3ba 100755 --- a/scripts/start-server.js +++ b/scripts/start-server.js @@ -12,6 +12,7 @@ const {reg} = require("../src/matrix/read-registration") const passthrough = require("../src/passthrough") const db = new sqlite("ooye.db") +/** @type {import("heatsync").default} */ // @ts-ignore const sync = new HeatSync() Object.assign(passthrough, {sync, db}) @@ -21,7 +22,12 @@ 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") +const app = createApp() +const router = createRouter() +app.use(router) +const server = createServer(toNodeListener(app)) +server.listen(reg.socket || new URL(reg.url).port) +const as = Object.assign(new EventEmitter(), {app, router, server}) // @ts-ignore passthrough.as = as const orm = sync.require("../src/db/orm") @@ -33,10 +39,4 @@ passthrough.select = orm.select await discord.cloud.connect() 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 deleted file mode 100644 index cc93405..0000000 --- a/scripts/text-probability.js +++ /dev/null @@ -1,65 +0,0 @@ -// @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 476f8dd..b131f13 100644 --- a/src/d2m/actions/add-reaction.js +++ b/src/d2m/actions/add-reaction.js @@ -21,11 +21,11 @@ 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}, "ORDER BY reaction_part").pluck().get() + const parentID = select("event_message", "event_id", {message_id: data.message_id, reaction_part: 0}).pluck().get() if (!parentID) return // Nothing can be done if the parent message was never bridged. assert.equal(typeof parentID, "string") - const key = await emojiToKey.emojiToKey(data.emoji, data.message_id) + const key = await emojiToKey.emojiToKey(data.emoji) const shortcode = key.startsWith("mxc://") ? `:${data.emoji.name}:` : undefined const roomID = await createRoom.ensureRoom(data.channel_id) diff --git a/src/d2m/actions/announce-thread.js b/src/d2m/actions/announce-thread.js index c8cbf9d..324c7a5 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").strict +const assert = require("assert") 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 651eaf4..509d0ff 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -6,21 +6,17 @@ const Ty = require("../../types") const {reg} = require("../../matrix/read-registration") const passthrough = require("../../passthrough") -const {discord, sync, db, select, from} = passthrough +const {discord, sync, db, select} = passthrough /** @type {import("../../matrix/file")} */ const file = sync.require("../../matrix/file") /** @type {import("../../matrix/api")} */ const api = sync.require("../../matrix/api") -/** @type {import("../../matrix/mreq")} */ -const mreq = sync.require("../../matrix/mreq") /** @type {import("../../matrix/kstate")} */ const ks = sync.require("../../matrix/kstate") /** @type {import("../../discord/utils")} */ -const dUtils = sync.require("../../discord/utils") -/** @type {import("../../matrix/utils")} */ -const mUtils = sync.require("../../matrix/utils") -/** @type {import("./create-space")} */ -const createSpace = sync.require("./create-space") +const utils = sync.require("../../discord/utils") +/** @type {import("./create-space")}) */ +const createSpace = sync.require("./create-space") // watch out for the require loop /** * There are 3 levels of room privacy: @@ -30,7 +26,7 @@ const createSpace = sync.require("./create-space") */ const PRIVACY_ENUMS = { PRESET: ["private_chat", "public_chat", "public_chat"], - VISIBILITY: ["private", "private", "private"], + VISIBILITY: ["private", "private", "public"], SPACE_HISTORY_VISIBILITY: ["invited", "world_readable", "world_readable"], // copying from element client ROOM_HISTORY_VISIBILITY: ["shared", "shared", "world_readable"], // any events sent after are visible, but for world_readable anybody can read without even joining GUEST_ACCESS: ["can_join", "forbidden", "forbidden"], // whether guests can join space if other conditions are met @@ -40,11 +36,29 @@ const PRIVACY_ENUMS = { const DEFAULT_PRIVACY_LEVEL = 0 -const READ_ONLY_ROOM_EVENTS_DEFAULT_POWER = 50 - /** @type {Map>} channel ID -> Promise */ const inflightRoomCreate = new Map() +/** + * Async because it gets all room state from the homeserver. + * @param {string} roomID + */ +async function roomToKState(roomID) { + const root = await api.getAllState(roomID) + return ks.stateToKState(root) +} + +/** + * @param {string} roomID + * @param {any} kstate + */ +async function applyKStateDiffToRoom(roomID, kstate) { + const events = await ks.kstateToState(kstate) + return Promise.all(events.map(({type, state_key, content}) => + api.sendState(roomID, type, state_key, content) + )) +} + /** * @param {{id: string, name: string, topic?: string?, type: number, parent_id?: string?}} channel * @param {{id: string}} guild @@ -56,7 +70,6 @@ function convertNameAndTopic(channel, guild, customName) { let channelPrefix = ( parentChannel?.type === DiscordTypes.ChannelType.GuildForum ? "" : channel.type === DiscordTypes.ChannelType.PublicThread ? "[⛓️] " - : channel.type === DiscordTypes.ChannelType.AnnouncementThread ? "[⛓️] " : channel.type === DiscordTypes.ChannelType.PrivateThread ? "[🔒⛓️] " : channel.type === DiscordTypes.ChannelType.GuildVoice ? "[🔊] " : "") @@ -77,25 +90,32 @@ 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, getStateEventOuter: typeof api.getStateEventOuter}}} di simple-as-nails dependency injection for the matrix API + * @param {{api: {getStateEvent: typeof api.getStateEvent}}} di simple-as-nails dependency injection for the matrix API */ async function channelToKState(channel, guild, di) { // @ts-ignore const parentChannel = discord.channels.get(channel.parent_id) - /** Used for membership/permission checks. */ - const guildSpaceID = await createSpace.ensureSpace(guild) + let guildSpaceID /** Used as the literal parent on Matrix, for categorisation. Will be the same as `guildSpaceID` unless it's a forum channel's thread, in which case a different space is used to group those threads. */ - let parentSpaceID = guildSpaceID - if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum) { + let parentSpaceID + let privacyLevel + if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum) { // it's a forum channel's thread, so use a different space to group those threads + guildSpaceID = await createSpace.ensureSpace(guild) parentSpaceID = await ensureRoom(channel.parent_id) - assert(typeof parentSpaceID === "string") + privacyLevel = select("guild_space", "privacy_level", {space_id: guildSpaceID}).pluck().get() + } else { // otherwise use the guild's space like usual + parentSpaceID = await createSpace.ensureSpace(guild) + guildSpaceID = parentSpaceID + privacyLevel = select("guild_space", "privacy_level", {space_id: parentSpaceID}).pluck().get() } + assert(typeof parentSpaceID === "string") + assert(typeof guildSpaceID === "string") + assert(typeof privacyLevel === "number") - const channelRow = select("channel_room", ["nick", "custom_avatar", "custom_topic"], {channel_id: channel.id}).get() - const customName = channelRow?.nick - const customAvatar = channelRow?.custom_avatar - const hasCustomTopic = channelRow?.custom_topic + const row = select("channel_room", ["nick", "custom_avatar"], {channel_id: channel.id}).get() + const customName = row?.nick + const customAvatar = row?.custom_avatar const [convertedName, convertedTopic] = convertNameAndTopic(channel, guild, customName) const avatarEventContent = {} @@ -105,8 +125,6 @@ async function channelToKState(channel, guild, di) { avatarEventContent.url = {$url: file.guildIcon(guild)} } - const privacyLevel = select("guild_space", "privacy_level", {guild_id: guild.id}).pluck().get() - assert(privacyLevel != null) // already ensured the space exists let history_visibility = PRIVACY_ENUMS.ROOM_HISTORY_VISIBILITY[privacyLevel] if (channel["thread_metadata"]) history_visibility = "world_readable" @@ -122,31 +140,17 @@ async function channelToKState(channel, guild, di) { join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]} } - const everyonePermissions = dUtils.getPermissions(guild.id, [], guild.roles, undefined, channel.permission_overwrites) - const everyoneCanSend = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendMessages) - 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 everyonePermissions = utils.getPermissions([], guild.roles, undefined, channel.permission_overwrites) + const everyoneCanMentionEveryone = utils.hasAllPermissions(everyonePermissions, ["MentionEveryone"]) const globalAdmins = select("member_power", ["mxid", "power_level"], {room_id: "*"}).all() const globalAdminPower = globalAdmins.reduce((a, c) => (a[c.mxid] = c.power_level, a), {}) - const additionalCreators = select("member_power", "mxid", {room_id: "*"}, "AND power_level > 100").pluck().all().concat(spaceCreatorsAndFounders) - const creationContent = {} - creationContent.additional_creators = additionalCreators + /** @type {Ty.Event.M_Power_Levels} */ + const spacePowerEvent = await di.api.getStateEvent(guildSpaceID, "m.room.power_levels", "") + const spacePower = spacePowerEvent.users - 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, @@ -158,21 +162,17 @@ async function channelToKState(channel, guild, di) { }, /** @type {{join_rule: string, [x: string]: any}} */ "m.room.join_rules/": join_rules, - /** @type {Ty.Event.M_Power_Levels} */ "m.room.power_levels/": { - events_default: everyoneCanSend ? 0 : READ_ONLY_ROOM_EVENTS_DEFAULT_POWER, - events: { - "m.reaction": 0, - "m.room.redaction": 0, // only affects redactions of own events, required to be able to un-react - ...pollStartPowerLevel - }, notifications: { room: everyoneCanMentionEveryone ? 0 : 20 }, - users: {...spacePowerDetails.powerLevels.users, ...globalAdminPower} + users: {...spacePower, ...globalAdminPower} + }, + "chat.schildi.hide_ui/read_receipts": { + hidden: true }, [`uk.half-shot.bridge/moe.cadence.ooye://discord/${guild.id}/${channel.id}`]: { - bridgebot: mUtils.bot, + bridgebot: `@${reg.sender_localpart}:${reg.ooye.server_name}`, protocol: { id: "discord", displayname: "Discord" @@ -180,7 +180,7 @@ async function channelToKState(channel, guild, di) { network: { id: guild.id, displayname: guild.name, - avatar_url: {$url: file.guildIcon(guild)} + avatar_url: await file.uploadDiscordFileToMxc(file.guildIcon(guild)) }, channel: { id: channel.id, @@ -190,23 +190,13 @@ async function channelToKState(channel, guild, di) { } } - // Don't overwrite room topic if the topic has been customised - if (hasCustomTopic) delete channelKState["m.room.topic/"] - - // Don't add a space parent if it's self service - // (The person setting up self-service has already put it in their preferred space to be able to get this far.) - const autocreate = select("guild_active", "autocreate", {guild_id: guild.id}).pluck().get() - if (autocreate === 0 && ![DiscordTypes.ChannelType.PrivateThread, DiscordTypes.ChannelType.PublicThread, DiscordTypes.ChannelType.AnnouncementThread].includes(channel.type)) { - delete channelKState[`m.space.parent/${parentSpaceID}`] - } - return {spaceID: parentSpaceID, privacyLevel, channelKState} } /** * Create a bridge room, store the relationship in the database, and add it to the guild's space. * @param {DiscordTypes.APIGuildTextChannel} channel - * @param {DiscordTypes.APIGuild} guild + * @param guild * @param {string} spaceID * @param {any} kstate * @param {number} privacyLevel @@ -216,6 +206,9 @@ 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 @@ -225,7 +218,7 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) { delete kstate["m.room.topic/"] assert(topic) - const roomCreate = await postApplyPowerLevels(kstate, async kstate => { + const roomID = await postApplyPowerLevels(kstate, async kstate => { const roomID = await api.createRoom({ name, topic, @@ -233,23 +226,16 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) { visibility: PRIVACY_ENUMS.VISIBILITY[privacyLevel], invite: [], initial_state: await ks.kstateToState(kstate), - creation_content: ks.kstateToCreationContent(kstate) + ...spaceCreationContent }) - /** @type {Ty.Event.StateOuter} */ - const roomCreate = await api.getStateEventOuter(roomID, "m.room.create", "") + db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent) VALUES (?, ?, ?, NULL, ?)").run(channel.id, roomID, channel.name, threadParent) - 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 + return roomID }) - const roomID = roomCreate.room_id - // Put the newly created child into the space - await _syncSpaceMember(channel, spaceID, roomID, guild.id) + // Put the newly created child into the space, no need to await this + _syncSpaceMember(channel, spaceID, roomID) return roomID } @@ -261,30 +247,25 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) { * 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 and room version - * @returns {Promise>} room ID + * @param {(_: any) => Promise} callback must return room ID + * @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/"] - const roomCreate = await callback(kstateWithoutPowerLevels) - const roomID = roomCreate.room_id + /** @type {string} */ + const roomID = await callback(kstateWithoutPowerLevels) // Now *really* apply the power level overrides on top of what Synapse *really* set if (powerLevelContent) { - 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) + const newRoomKState = await roomToKState(roomID) + const newRoomPowerLevelsDiff = ks.diffKState(newRoomKState, {"m.room.power_levels/": powerLevelContent}) + await applyKStateDiffToRoom(roomID, newRoomPowerLevelsDiff) } - return roomCreate + return roomID } /** @@ -298,61 +279,6 @@ function channelToGuild(channel) { return guild } -/** - * This function handles whether it's allowed to bridge messages in this channel, and if so, where to. - * This has to account for whether self-service is enabled for the guild or not. - * This also has to account for different channel types, like forum channels (which need the - * parent forum to already exist, and ignore the self-service setting), or thread channels (which - * need the parent channel to already exist, and ignore the self-service setting). - * @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel text channel or thread - * @param {string} guildID - * @returns obj if bridged; 1 if autocreatable; null/undefined if guild is not bridged; 0 if self-service and not autocreatable thread - */ -function existsOrAutocreatable(channel, guildID) { - // 1. If the channel is already linked somewhere, it's always okay to bridge to that destination, no matter what. Yippee! - const existing = select("channel_room", ["room_id", "thread_parent"], {channel_id: channel.id}).get() - if (existing) return existing - - // 2. If the guild is an autocreate guild, it's always okay to bridge to that destination, and - // we'll need to create any dependent resources recursively. - const autocreate = select("guild_active", "autocreate", {guild_id: guildID}).pluck().get() - if (autocreate === 1) return autocreate - - // 3. If the guild is not approved for bridging yet, we can't bridge there. - // They need to decide one way or another whether it's self-service before we can continue. - if (autocreate == null) return autocreate - - // 4. If we got here, the guild is in self-service mode. - // New channels won't be able to create new rooms. But forum threads or channel threads could be fine. - if ([DiscordTypes.ChannelType.PublicThread, DiscordTypes.ChannelType.PrivateThread, DiscordTypes.ChannelType.AnnouncementThread].includes(channel.type)) { - // In self-service mode, threads rely on the parent resource already existing. - /** @type {DiscordTypes.APIGuildTextChannel} */ // @ts-ignore - const parent = discord.channels.get(channel.parent_id) - assert(parent) - const parentExisting = existsOrAutocreatable(parent, guildID) - if (parentExisting) return 1 // Autocreatable - } - - // 5. If we got here, the guild is in self-service mode and the channel is truly not bridged. - return autocreate -} - -/** - * @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel text channel or thread - * @param {string} guildID - * @returns obj if bridged; 1 if autocreatable. (throws if not autocreatable) - */ -function assertExistsOrAutocreatable(channel, guildID) { - const existing = existsOrAutocreatable(channel, guildID) - if (existing === 0) { - throw new Error(`Guild ${guildID} is self-service, so won't create a Matrix room for channel ${channel.id}`) - } - if (!existing) { - throw new Error(`Guild ${guildID} is not bridged, so won't create a Matrix room for channel ${channel.id}`) - } - return existing -} - /* Ensure flow: 1. Get IDs @@ -371,13 +297,12 @@ function assertExistsOrAutocreatable(channel, guildID) { */ /** - * Create room and/or sync room data. Please check that a channel_room entry exists or autocreate = 1 before calling this. * @param {string} channelID * @param {boolean} shouldActuallySync false if just need to ensure room exists (which is a quick database check), true if also want to sync room data when it does exist (slow) * @returns {Promise} room ID */ async function _syncRoom(channelID, shouldActuallySync) { - /** @ts-ignore @type {DiscordTypes.APIGuildTextChannel} */ + /** @ts-ignore @type {DiscordTypes.APIGuildChannel} */ const channel = discord.channels.get(channelID) assert.ok(channel) const guild = channelToGuild(channel) @@ -386,9 +311,9 @@ async function _syncRoom(channelID, shouldActuallySync) { await inflightRoomCreate.get(channelID) // just waiting, and then doing a new db query afterwards, is the simplest way of doing it } - const existing = assertExistsOrAutocreatable(channel, guild.id) + const existing = select("channel_room", ["room_id", "thread_parent"], {channel_id: channelID}).get() - if (existing === 1) { + if (!existing) { const creation = (async () => { const {spaceID, privacyLevel, channelKState} = await channelToKState(channel, guild, {api}) const roomID = await createRoom(channel, guild, spaceID, channelKState, privacyLevel) @@ -408,117 +333,76 @@ 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 (!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 + const roomKState = await roomToKState(roomID) + if (+roomKState["m.room.create/"].room_version <= 8) { + // join_rule `restricted` is not available in room version < 8 and not working properly in version == 8 // read more: https://spec.matrix.org/v1.8/rooms/v9/ // 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 channel_id = ?").run(channel.name, channel.id) + const roomApply = applyKStateDiffToRoom(roomID, roomDiff) + db.prepare("UPDATE channel_room SET name = ? WHERE room_id = ?").run(channel.name, roomID) // sync room as space member - const spaceApply = _syncSpaceMember(channel, spaceID, roomID, guild.id) + const spaceApply = _syncSpaceMember(channel, spaceID, roomID) await Promise.all([roomApply, spaceApply]) return roomID } -/** Ensures the room exists. If it doesn't, creates the room with an accurate initial state. Please check that a channel_room entry exists or guild autocreate = 1 before calling this. */ +/** Ensures the room exists. If it doesn't, creates the room with an accurate initial state. */ function ensureRoom(channelID) { return _syncRoom(channelID, false) } -/** Actually syncs. Gets all room state from the homeserver in order to diff, and uploads the icon to mxc if it has changed. Please check that a channel_room entry exists or guild autocreate = 1 before calling this. */ +/** Actually syncs. Gets all room state from the homeserver in order to diff, and uploads the icon to mxc if it has changed. */ function syncRoom(channelID) { return _syncRoom(channelID, true) } +async function _unbridgeRoom(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 {{id: string, topic?: string?}} channel * @param {string} guildID */ -async function unbridgeChannel(channel, guildID) { +async function unbridgeDeletedChannel(channel, guildID) { 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").where({guild_id: guildID}).get() - assert.ok(row) + const spaceID = select("guild_space", "space_id", {guild_id: guildID}).pluck().get() + assert.ok(spaceID) - let botInRoom = true + // remove room from being a space member + await api.sendState(roomID, "m.space.parent", spaceID, {}) + await api.sendState(spaceID, "m.space.child", roomID, {}) // remove declaration that the room is bridged - try { - await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channel.id}`, {}) - } catch (e) { - if (String(e).includes("not in room")) { - botInRoom = false - } else { - throw e - } - } - - if (botInRoom && "topic" in channel) { + await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channel.id}`, {}) + if ("topic" in channel) { // previously the Matrix topic would say the channel ID. we should remove that await api.sendState(roomID, "m.room.topic", "", {topic: channel.topic || ""}) } - // delete webhook on discord - const webhook = select("webhook", ["webhook_id", "webhook_token"], {channel_id: channel.id}).get() - if (webhook) { - await discord.snow.webhook.deleteWebhook(webhook.webhook_id, webhook.webhook_token).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 and historical rooms - - if (!botInRoom) return - - // 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." }) - // if it is an easy mode room, clean up the room from the managed space and make it clear it's not being bridged - // (don't do this for self-service rooms, because they might continue to be used on Matrix or linked somewhere else later) - if (row.autocreate === 1) { - // remove room from being a space member - await api.sendState(roomID, "m.space.parent", row.space_id, {}) - await api.sendState(row.space_id, "m.space.child", roomID, {}) - } - - // if it is a self-service room, remove sim members - // (the room can be used with less clutter and the member list makes sense if it's bridged somewhere else) - if (row.autocreate === 0) { - // remove sim members - const members = db.prepare("SELECT mxid FROM sim_member WHERE room_id = ? AND mxid <> ?").pluck().all(roomID, 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) - preparedDelete.run(roomID, mxid) - } - } - // leave room - await mUtils.setUserPower(roomID, mUtils.bot, 0, api) await api.leaveRoom(roomID) + + // delete room from database + db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channel.id) } /** @@ -526,25 +410,14 @@ async function unbridgeChannel(channel, guildID) { * @param {DiscordTypes.APIGuildTextChannel} channel * @param {string} spaceID * @param {string} roomID - * @param {string} guild_id * @returns {Promise} */ -async function _syncSpaceMember(channel, spaceID, roomID, guild_id) { - // If space is self-service then only permit changes to space parenting for threads - // (The person setting up self-service has already put it in their preferred space to be able to get this far.) - const autocreate = select("guild_active", "autocreate", {guild_id}).pluck().get() - if (autocreate === 0 && ![DiscordTypes.ChannelType.PrivateThread, DiscordTypes.ChannelType.PublicThread, DiscordTypes.ChannelType.AnnouncementThread].includes(channel.type)) { - return [] - } - - const spaceKState = await ks.roomToKState(spaceID) +async function _syncSpaceMember(channel, spaceID, roomID) { + const spaceKState = await roomToKState(spaceID) let spaceEventContent = {} if ( channel.type !== DiscordTypes.ChannelType.PrivateThread // private threads do not belong in the space (don't offer people something they can't join) - && ( - !channel["thread_metadata"]?.archived // archived threads do not belong in the space (don't offer people conversations that are no longer relevant) - || discord.channels.get(channel.parent_id || "")?.type === DiscordTypes.ChannelType.GuildForum - ) + && !channel["thread_metadata"]?.archived // archived threads do not belong in the space (don't offer people conversations that are no longer relevant) ) { spaceEventContent = { via: [reg.ooye.server_name] @@ -553,7 +426,7 @@ async function _syncSpaceMember(channel, spaceID, roomID, guild_id) { const spaceDiff = ks.diffKState(spaceKState, { [`m.space.child/${roomID}`]: spaceEventContent }) - return ks.applyKStateDiffToRoom(spaceID, spaceDiff) + return applyKStateDiffToRoom(spaceID, spaceDiff) } async function createAllForGuild(guildID) { @@ -570,16 +443,15 @@ async function createAllForGuild(guildID) { } module.exports.DEFAULT_PRIVACY_LEVEL = DEFAULT_PRIVACY_LEVEL -module.exports.READ_ONLY_ROOM_EVENTS_DEFAULT_POWER = READ_ONLY_ROOM_EVENTS_DEFAULT_POWER module.exports.PRIVACY_ENUMS = PRIVACY_ENUMS module.exports.createRoom = createRoom module.exports.ensureRoom = ensureRoom module.exports.syncRoom = syncRoom module.exports.createAllForGuild = createAllForGuild module.exports.channelToKState = channelToKState +module.exports.roomToKState = roomToKState +module.exports.applyKStateDiffToRoom = applyKStateDiffToRoom module.exports.postApplyPowerLevels = postApplyPowerLevels module.exports._convertNameAndTopic = convertNameAndTopic -module.exports._syncSpaceMember = _syncSpaceMember -module.exports.unbridgeChannel = unbridgeChannel -module.exports.existsOrAutocreatable = existsOrAutocreatable -module.exports.assertExistsOrAutocreatable = assertExistsOrAutocreatable +module.exports._unbridgeRoom = _unbridgeRoom +module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel diff --git a/src/d2m/actions/create-room.test.js b/src/d2m/actions/create-room.test.js index 36fccba..a1766dd 100644 --- a/src/d2m/actions/create-room.test.js +++ b/src/d2m/actions/create-room.test.js @@ -9,44 +9,19 @@ 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 => { - const api = mockAPI(t) + let called = 0 + async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room + called++ + t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return {users: {"@example:matrix.org": 50}} + } db.prepare("UPDATE guild_space SET privacy_level = 2").run() t.deepEqual( - kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api}).then(x => x.channelKState)), + kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)), Object.assign({}, testData.room.general, { "m.room.guest_access/": {guest_access: "forbidden"}, "m.room.join_rules/": {join_rule: "public"}, @@ -54,37 +29,58 @@ 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(api.getCalled(), 2) + t.equal(called, 1) }) test("channel2room: linkable privacy room", async t => { - const api = mockAPI(t) + let called = 0 + async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room + called++ + t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return {users: {"@example:matrix.org": 50}} + } db.prepare("UPDATE guild_space SET privacy_level = 1").run() t.deepEqual( - kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api}).then(x => x.channelKState)), + kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)), Object.assign({}, testData.room.general, { "m.room.guest_access/": {guest_access: "forbidden"}, "m.room.join_rules/": {join_rule: "public"}, "m.room.power_levels/": mixin({users: {"@example:matrix.org": 50}}, testData.room.general["m.room.power_levels/"]) }) ) - t.equal(api.getCalled(), 2) + t.equal(called, 1) }) test("channel2room: invite-only privacy room", async t => { - const api = mockAPI(t) + let called = 0 + async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room + called++ + t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return {users: {"@example:matrix.org": 50}} + } db.prepare("UPDATE guild_space SET privacy_level = 0").run() t.deepEqual( - kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api}).then(x => x.channelKState)), + kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)), Object.assign({}, testData.room.general, { "m.room.power_levels/": mixin({users: {"@example:matrix.org": 50}}, testData.room.general["m.room.power_levels/"]) }) ) - t.equal(api.getCalled(), 2) + t.equal(called, 1) }) test("channel2room: room where limited people can mention everyone", async t => { - const api = mockAPI(t) + let called = 0 + async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room + called++ + t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return {users: {"@example:matrix.org": 50}} + } const limitedGuild = mixin({}, testData.guild.general) limitedGuild.roles[0].permissions = (BigInt(limitedGuild.roles[0].permissions) - 131072n).toString() const limitedRoom = mixin({}, testData.room.general, {"m.room.power_levels/": { @@ -92,102 +88,10 @@ 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}).then(x => x.channelKState)), + kstateStripConditionals(await channelToKState(testData.channel.general, limitedGuild, {api: {getStateEvent}}).then(x => x.channelKState)), limitedRoom ) - t.equal(api.getCalled(), 2) -}) - -test("channel2room: matrix room that already has a custom topic set", async t => { - 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}, users: {"@example:matrix.org": 50}}}) - // @ts-ignore - delete expected["m.room.topic/"] - t.deepEqual( - kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api}).then(x => x.channelKState)), - expected - ) - t.equal(api.getCalled(), 2) -}) - -test("channel2room: read-only discord channel", async t => { - const api = mockAPI(t) - const expected = { - "m.room.create/": { - additional_creators: ["@test_auto_invite:example.org"], - }, - "m.room.avatar/": { - url: { - $url: "/icons/112760669178241024/a_f83622e09ead74f0c5c527fe241f8f8c.png?size=1024", - }, - }, - "m.room.guest_access/": { - guest_access: "can_join", - }, - "m.room.history_visibility/": { - history_visibility: "shared", - }, - "m.room.join_rules/": { - allow: [ - { - room_id: "!jjmvBegULiLucuWEHU:cadence.moe", - type: "m.room_membership", - }, - ], - join_rule: "restricted", - }, - "m.room.name/": { - name: "updates", - }, - "m.room.topic/": { - topic: "Updates and release announcements for Out Of Your Element.\n\nChannel ID: 1161864271370666075\nGuild ID: 112760669178241024" - }, - "m.room.power_levels/": { - events_default: 50, // <-- it should be read-only! - events: { - "m.reaction": 0, - "m.room.redaction": 0 - }, - notifications: { - room: 20, - }, - users: { - "@test_auto_invite:example.org": 150, - "@example:matrix.org": 50 - }, - }, - "m.space.parent/!jjmvBegULiLucuWEHU:cadence.moe": { - canonical: true, - via: [ - "cadence.moe", - ], - }, - "uk.half-shot.bridge/moe.cadence.ooye://discord/112760669178241024/1161864271370666075": { - bridgebot: "@_ooye_bot:cadence.moe", - channel: { - displayname: "updates", - external_url: "https://discord.com/channels/112760669178241024/1161864271370666075", - id: "1161864271370666075", - }, - network: { - avatar_url: { - "$url": "/icons/112760669178241024/a_f83622e09ead74f0c5c527fe241f8f8c.png?size=1024", - }, - displayname: "Psychonauts 3", - id: "112760669178241024", - }, - protocol: { - displayname: "Discord", - id: "discord", - } - } - } - t.deepEqual( - kstateStripConditionals(await channelToKState(testData.channel.updates, testData.guild.general, {api}).then(x => x.channelKState)), - expected - ) - t.equal(api.getCalled(), 2) + t.equal(called, 1) }) test("convertNameAndTopic: custom name and topic", t => { diff --git a/src/d2m/actions/create-space.js b/src/d2m/actions/create-space.js index 7a751e2..ec1677e 100644 --- a/src/d2m/actions/create-space.js +++ b/src/d2m/actions/create-space.js @@ -31,12 +31,10 @@ async function createSpace(guild, kstate) { const topic = kstate["m.room.topic/"]?.topic || undefined assert(name) - const memberCount = guild["member_count"] ?? guild.approximate_member_count ?? 0 - const enablePresenceByDefault = +(memberCount < 50) // scary! all active users in a presence-enabled guild will be pinging the server every <30 seconds to stay online const globalAdmins = select("member_power", "mxid", {room_id: "*"}).pluck().all() - const roomCreate = await createRoom.postApplyPowerLevels(kstate, async kstate => { - const roomID = await api.createRoom({ + const roomID = await createRoom.postApplyPowerLevels(kstate, async kstate => { + return 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,15 +44,13 @@ async function createSpace(guild, kstate) { }, invite: globalAdmins, topic, - initial_state: await ks.kstateToState(kstate), - creation_content: ks.kstateToCreationContent(kstate) + creation_content: { + type: "m.space" + }, + initial_state: await ks.kstateToState(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) + db.prepare("INSERT INTO guild_space (guild_id, space_id) VALUES (?, ?)").run(guild.id, roomID) return roomID } @@ -65,13 +61,7 @@ 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, @@ -102,9 +92,6 @@ async function _syncSpace(guild, shouldActuallySync) { const row = select("guild_space", ["space_id", "privacy_level"], {guild_id: guild.id}).get() if (!row) { - const autocreate = select("guild_active", "autocreate", {guild_id: guild.id}).pluck().get() - assert.equal(autocreate, 1, `refusing to implicitly create a space for guild ${guild.id}. set the guild_active data first before calling ensureSpace/syncSpace.`) - const creation = (async () => { const guildKState = await guildToKState(guild, createRoom.DEFAULT_PRIVACY_LEVEL) // New spaces will have to use the default privacy level; we obviously can't look up the existing entry const spaceID = await createSpace(guild, guildKState) @@ -124,13 +111,11 @@ 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) + const spaceKState = await createRoom.roomToKState(spaceID) const spaceDiff = ks.diffKState(spaceKState, guildKState) - await ks.applyKStateDiffToRoom(spaceID, spaceDiff) + await createRoom.applyKStateDiffToRoom(spaceID, spaceDiff) // guild icon was changed, so room avatars need to be updated as well as the space ones // doing it this way rather than calling syncRoom for great efficiency gains @@ -139,10 +124,16 @@ async function _syncSpace(guild, shouldActuallySync) { // don't try to update rooms with custom avatars though const roomsWithCustomAvatars = select("channel_room", "room_id", {}, "WHERE custom_avatar IS NOT NULL").pluck().all() - for await (const room of api.generateFullHierarchy(spaceID)) { - if (room.avatar_url === newAvatarState.url) continue - if (roomsWithCustomAvatars.includes(room.room_id)) continue - await api.sendState(room.room_id, "m.room.avatar", "", newAvatarState) + const state = await ks.kstateToState(spaceKState) + const childRooms = state.filter(({type, state_key, content}) => { + return type === "m.space.child" && "via" in content && !roomsWithCustomAvatars.includes(state_key) + }).map(({state_key}) => state_key) + + for (const roomID of childRooms) { + const avatarEventContent = await api.getStateEvent(roomID, "m.room.avatar", "") + if (avatarEventContent.url !== newAvatarState.url) { + await api.sendState(roomID, "m.room.avatar", "", newAvatarState) + } } } @@ -187,13 +178,11 @@ 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) + const spaceKState = await createRoom.roomToKState(spaceID) const spaceDiff = ks.diffKState(spaceKState, guildKState) - await ks.applyKStateDiffToRoom(spaceID, spaceDiff) + await createRoom.applyKStateDiffToRoom(spaceID, spaceDiff) const childRooms = await api.getFullHierarchy(spaceID) @@ -203,7 +192,7 @@ async function syncSpaceFully(guildID) { if (discord.channels.has(channelID)) { await createRoom.syncRoom(channelID) } else { - await createRoom.unbridgeChannel({id: channelID}, guildID) + await createRoom.unbridgeDeletedChannel({id: channelID}, guildID) } } @@ -229,24 +218,22 @@ async function syncSpaceExpressions(data, checkBeforeSync) { */ async function update(spaceID, key, eventKey, fn) { if (!(key in data) || !data[key].length) return - const guild = discord.guilds.get(data.guild_id) - assert(guild) - const content = await fn(data[key], guild) + const content = await fn(data[key]) 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([], guild) + existing = fn([]) } if (isDeepStrictEqual(existing, content)) return } - await api.sendState(spaceID, "im.ponies.room_emotes", eventKey, content) + api.sendState(spaceID, "im.ponies.room_emotes", eventKey, content) } - await update(spaceID, "emojis", "moe.cadence.ooye.pack.emojis", expression.emojisToState) - await update(spaceID, "stickers", "moe.cadence.ooye.pack.stickers", expression.stickersToState) + update(spaceID, "emojis", "moe.cadence.ooye.pack.emojis", expression.emojisToState) + update(spaceID, "stickers", "moe.cadence.ooye.pack.stickers", expression.stickersToState) } module.exports.createSpace = createSpace diff --git a/src/d2m/actions/create-space.test.js b/src/d2m/actions/create-space.test.js index fc6eba4..cb4d90a 100644 --- a/src/d2m/actions/create-space.test.js +++ b/src/d2m/actions/create-space.test.js @@ -13,10 +13,6 @@ 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" }, @@ -34,7 +30,7 @@ test("guild2space: can generate kstate for a guild, passing privacy level 0", as }, "m.room.power_levels/": { users: { - "@test_auto_invite:example.org": 150 + "@test_auto_invite:example.org": 100 }, }, } diff --git a/src/d2m/actions/delete-message.js b/src/d2m/actions/delete-message.js index 39b9fc8..bc8adfb 100644 --- a/src/d2m/actions/delete-message.js +++ b/src/d2m/actions/delete-message.js @@ -14,13 +14,12 @@ async function deleteMessage(data) { const row = select("channel_room", ["room_id", "speedbump_checked", "thread_parent"], {channel_id: data.channel_id}).get() if (!row) return - // 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) { + const eventsToRedact = select("event_message", "event_id", {message_id: data.id}).pluck().all() + db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(data.id) + db.prepare("DELETE FROM event_message WHERE message_id = ?").run(data.id) + for (const eventID of eventsToRedact) { // Unfortunately, we can't specify a sender to do the redaction as, unless we find out that info via the audit logs - await api.redactEvent(room_id, event_id) + await api.redactEvent(row.room_id, eventID) } await speedbump.updateCache(row.thread_parent || data.channel_id, row.speedbump_checked) @@ -30,17 +29,16 @@ async function deleteMessage(data) { * @param {import("discord-api-types/v10").GatewayMessageDeleteBulkDispatchData} data */ async function deleteMessageBulk(data) { - const row = select("channel_room", "room_id", {channel_id: data.channel_id}).get() - if (!row) return + const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get() + if (!roomID) return const sids = JSON.stringify(data.ids) - // 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) { + const eventsToRedact = from("event_message").pluck("event_id").and("WHERE message_id IN (SELECT value FROM json_each(?))").all(sids) + db.prepare("DELETE FROM message_channel WHERE message_id IN (SELECT value FROM json_each(?))").run(sids) + db.prepare("DELETE FROM event_message WHERE message_id IN (SELECT value FROM json_each(?))").run(sids) + for (const eventID of eventsToRedact) { // Awaiting will make it go slower, but since this could be a long-running operation either way, we want to leave rate limit capacity for other operations - await api.redactEvent(room_id, event_id) + await api.redactEvent(roomID, eventID) } } diff --git a/src/d2m/actions/edit-message.js b/src/d2m/actions/edit-message.js index f86a9c8..d85f925 100644 --- a/src/d2m/actions/edit-message.js +++ b/src/d2m/actions/edit-message.js @@ -3,15 +3,13 @@ const assert = require("assert").strict const passthrough = require("../../passthrough") -const {sync, db, select, from} = passthrough +const {sync, db, select} = passthrough /** @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("../../matrix/api")} */ const api = sync.require("../../matrix/api") -/** @type {import("../../matrix/mreq")} */ -const mreq = sync.require("../../matrix/mreq") /** * @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message @@ -19,18 +17,14 @@ const mreq = sync.require("../../matrix/mreq") * @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) { - 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, true) + const root = await registerPkUser.fetchMessage(message.id) + assert(root.member) + senderMxid = await registerPkUser.ensureSimJoined(root, roomID) } } @@ -67,7 +61,7 @@ async function editMessage(message, guild, row) { // 4. Send all the things. if (eventsToSend.length) { - db.prepare("INSERT OR IGNORE INTO message_room (message_id, historical_room_index) VALUES (?, ?)").run(message.id, currentRoom.historical_room_index) + db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id) } for (const content of eventsToSend) { const eventType = content.$type @@ -78,17 +72,8 @@ 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 - - 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 - } - } + 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 } } diff --git a/src/d2m/actions/expression.js b/src/d2m/actions/expression.js index c7ab27a..fd75aa5 100644 --- a/src/d2m/actions/expression.js +++ b/src/d2m/actions/expression.js @@ -9,12 +9,11 @@ const file = sync.require("../../matrix/file") /** * @param {DiscordTypes.APIEmoji[]} emojis - * @param {DiscordTypes.APIGuild} guild */ -async function emojisToState(emojis, guild) { +async function emojisToState(emojis) { const result = { pack: { - display_name: `${guild.name} (Discord Emojis)`, + display_name: "Discord Emojis", usage: ["emoticon"] // we'll see... }, images: { @@ -25,7 +24,7 @@ async function emojisToState(emojis, guild) { file.uploadDiscordFileToMxc(file.emoji(emoji.id, emoji.animated)).then(url => { result.images[emoji.name] = { info: { - mimetype: "image/webp" + mimetype: emoji.animated ? "image/gif" : "image/png" }, url } @@ -43,12 +42,11 @@ async function emojisToState(emojis, guild) { /** * @param {DiscordTypes.APISticker[]} stickers - * @param {DiscordTypes.APIGuild} guild */ -async function stickersToState(stickers, guild) { +async function stickersToState(stickers) { const result = { pack: { - display_name: `${guild.name} (Discord Stickers)`, + display_name: "Discord Stickers", usage: ["sticker"] // we'll see... }, images: { diff --git a/src/d2m/actions/lottie.js b/src/d2m/actions/lottie.js index 0185980..4635fed 100644 --- a/src/d2m/actions/lottie.js +++ b/src/d2m/actions/lottie.js @@ -33,7 +33,7 @@ async function convert(stickerItem) { if (res.status !== 200) throw new Error("Sticker data file not found.") const text = await res.text() - // Convert to PNG (stream.Readable) + // Convert to PNG (readable stream) const readablePng = await convertLottie.convert(text) // Upload to MXC diff --git a/src/d2m/actions/poll-end.js b/src/d2m/actions/poll-end.js deleted file mode 100644 index 8ede9e2..0000000 --- a/src/d2m/actions/poll-end.js +++ /dev/null @@ -1,122 +0,0 @@ -// @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 deleted file mode 100644 index 66918fe..0000000 --- a/src/d2m/actions/poll-vote.js +++ /dev/null @@ -1,100 +0,0 @@ -// @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 6ecd077..411fcf8 100644 --- a/src/d2m/actions/register-pk-user.js +++ b/src/d2m/actions/register-pk-user.js @@ -3,9 +3,10 @@ const assert = require("assert") const {reg} = require("../../matrix/read-registration") const Ty = require("../../types") +const fetch = require("node-fetch").default const passthrough = require("../../passthrough") -const {sync, db, select, from} = passthrough +const {discord, sync, db, select} = passthrough /** @type {import("../../matrix/api")} */ const api = sync.require("../../matrix/api") /** @type {import("../../matrix/file")} */ @@ -13,20 +14,12 @@ const file = sync.require("../../matrix/file") /** @type {import("./register-user")} */ const registerUser = sync.require("./register-user") -/** @returns {Promise} */ -async function fetchMessage(messageID) { - try { - var res = await fetch(`https://api.pluralkit.me/v2/messages/${messageID}`) - } catch (networkError) { - // Network issue, raise a more readable message - throw new Error(`Failed to connect to PK API: ${networkError.toString()}`) - } - if (!res.ok) throw new Error(`PK API returned an error: ${await res.text()}`) - /** @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 -} +/** + * @typedef WebhookAuthor Discord API message->author. A webhook as an author. + * @prop {string} username + * @prop {string?} avatar + * @prop {string} id + */ /** * A sim is an account that is being simulated by the bridge to copy events from the other side. @@ -40,7 +33,7 @@ async function createSim(pkMessage) { const mxid = `@${localpart}:${reg.ooye.server_name}` // Save chosen name in the database forever - db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?)").run(pkMessage.member.uuid, simName, simName, mxid) + db.prepare("INSERT INTO sim (user_id, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(pkMessage.member.uuid, simName, localpart, mxid) // Register matrix user with that name try { @@ -87,17 +80,24 @@ async function ensureSimJoined(pkMessage, roomID) { // 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) + 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 + } + } 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.PkMessage} pkMessage - * @param {Ty.WebhookAuthor} author + * @param {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, @@ -116,40 +116,49 @@ async function memberToStateContent(pkMessage, author) { /** * 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} 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 + * 1. Join the sim to the room if needed + * 2. Make an object of what the new room member state content would be, including uploading the profile picture if it hasn't been done before + * 3. Compare against the previously known state content, which is helpfully stored in the database + * 4. If the state content has changed, send it to Matrix and update it in the database for next time + * @param {WebhookAuthor} author + * @param {Ty.PkMessage} pkMessage + * @param {string} roomID * @returns {Promise} mxid of the updated sim */ -async function syncUser(messageID, author, roomID, shouldActuallySync) { - try { - // API lookup - var pkMessage = await fetchMessage(messageID) - db.prepare("REPLACE INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES (?, ?, ?)").run(pkMessage.member.uuid, pkMessage.sender, author.username) - } catch (e) { - // Fall back to offline cache - const senderMxid = from("sim_proxy").join("sim", "user_id").join("sim_member", "mxid").where({displayname: author.username, room_id: roomID}).pluck("mxid").get() - if (!senderMxid) throw e - return senderMxid - } - - // Create and join the sim to the room if needed +async function syncUser(author, pkMessage, roomID) { const mxid = await ensureSimJoined(pkMessage, roomID) - - if (shouldActuallySync) { - // Build current profile data and sync if the hash has changed - const content = await memberToStateContent(pkMessage, author) - await registerUser._sendSyncUser(roomID, mxid, content, null) + // Update the sim_proxy table, so mentions can look up the original sender later + db.prepare("INSERT OR IGNORE INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES (?, ?, ?)").run(pkMessage.member.uuid, pkMessage.sender, author.username) + // Sync the member state + const content = await memberToStateContent(pkMessage, author) + const currentHash = registerUser._hashProfileContent(content, 0) + const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get() + // only do the actual sync if the hash has changed since we last looked + if (existingHash !== currentHash) { + await api.sendState(roomID, "m.room.member", mxid, content, mxid) + db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid) } - return mxid } +/** @returns {Promise} */ +async function fetchMessage(messageID) { + // Their backend is weird. Sometimes it says "message not found" (code 20006) on the first try, so we make multiple attempts. + let attempts = 0 + do { + var res = await fetch(`https://api.pluralkit.me/v2/messages/${messageID}`) + if (res.ok) return res.json() + + // I think the backend needs some time to update. + await new Promise(resolve => setTimeout(resolve, 2000)) + } while (++attempts < 3) + + const errorMessage = await res.json() + throw new Error(`PK API returned an error after ${attempts} tries: ${JSON.stringify(errorMessage)}`) +} + +module.exports._memberToStateContent = memberToStateContent +module.exports.ensureSim = ensureSim +module.exports.ensureSimJoined = ensureSimJoined module.exports.syncUser = syncUser +module.exports.fetchMessage = fetchMessage diff --git a/src/d2m/actions/register-user.js b/src/d2m/actions/register-user.js index 1bdd6e3..94daa34 100644 --- a/src/d2m/actions/register-user.js +++ b/src/d2m/actions/register-user.js @@ -3,29 +3,23 @@ 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, from, select} = passthrough +const {discord, sync, db, 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 dUtils = sync.require("../../discord/utils") -/** @type {import("../../matrix/utils")} */ -const mxUtils = sync.require("../../matrix/utils") +const utils = sync.require("../../discord/utils") /** @type {import("../converters/user-to-mxid")} */ const userToMxid = sync.require("../converters/user-to-mxid") -/** @type {import("./create-room")} */ -const createRoom = sync.require("./create-room") /** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore let hasher = null // @ts-ignore 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 @@ -39,7 +33,7 @@ async function createSim(user) { // Save chosen name in the database forever // Making this database change right away so that in a concurrent registration, the 2nd registration will already have generated a different localpart because it can see this row when it generates - db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?)").run(user.id, user.username, simName, mxid) + db.prepare("INSERT INTO sim (user_id, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(user.id, simName, localpart, mxid) // Register matrix user with that name try { @@ -86,8 +80,16 @@ async function ensureSimJoined(user, roomID) { // 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) + 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 + } + } db.prepare("INSERT OR IGNORE INTO sim_member (room_id, mxid) VALUES (?, ?)").run(roomID, mxid) } return mxid @@ -95,29 +97,12 @@ async function ensureSimJoined(user, roomID) { /** * @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 + * @param {Omit} member */ async function memberToStateContent(user, member, guildID) { let displayname = user.username if (user.global_name) displayname = user.global_name - if (member?.nick) displayname = member.nick + if (member.nick) displayname = member.nick const content = { displayname, @@ -132,7 +117,7 @@ async function memberToStateContent(user, member, guildID) { } } - if (member?.avatar || user.avatar) { + if (member.avatar || user.avatar) { // const avatarPath = file.userAvatar(user) // the user avatar only const avatarPath = file.memberAvatar(guildID, user, member) // the member avatar or the user avatar content["moe.cadence.ooye.member"].avatar = avatarPath @@ -145,16 +130,13 @@ async function memberToStateContent(user, member, guildID) { /** * https://gitdab.com/cadence/out-of-your-element/issues/9 * @param {DiscordTypes.APIUser} user - * @param {Omit | undefined} member + * @param {Omit} member * @param {DiscordTypes.APIGuild} guild * @param {DiscordTypes.APIGuildChannel} channel * @returns {number} 0 to 100 */ function memberToPowerLevel(user, member, guild, channel) { - if (!member) return 0 - - const permissions = dUtils.getPermissions(guild.id, member.roles, guild.roles, user.id, channel.permission_overwrites) - const everyonePermissions = dUtils.getPermissions(guild.id, [], guild.roles, undefined, channel.permission_overwrites) + const permissions = utils.getPermissions(member.roles, guild.roles, user.id, channel.permission_overwrites) /* * PL 100 = Administrator = People who can brick the room. RATIONALE: * - Administrator. @@ -163,7 +145,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 || dUtils.hasSomePermissions(permissions, ["Administrator", "ManageWebhooks", "ManageGuild", "ManageChannels"])) return 100 + if (guild.owner_id === user.id || utils.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. @@ -173,19 +155,9 @@ 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 (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 = 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 + if (utils.hasSomePermissions(permissions, ["ManageMessages", "ManageNicknames", "ManageThreads", "KickMembers", "BanMembers", "MuteMembers", "DeafenMembers", "ModerateMembers"])) return 50 /* PL 20 = Mention Everyone for technical reasons. */ - 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 + if (utils.hasSomePermissions(permissions, ["MentionEveryone"])) return 20 return 0 } @@ -207,7 +179,7 @@ function _hashProfileContent(content, powerLevel) { * 4. Compare against the previously known state content, which is helpfully stored in the database * 5. If the state content or power level have changed, send them to Matrix and update them in the database for next time * @param {DiscordTypes.APIUser} user - * @param {Omit | undefined} member + * @param {Omit} member * @param {DiscordTypes.APIGuildChannel} channel * @param {DiscordTypes.APIGuild} guild * @param {string} roomID @@ -217,53 +189,58 @@ async function syncUser(user, member, channel, guild, roomID) { const mxid = await ensureSimJoined(user, roomID) const content = await memberToStateContent(user, member, guild.id) const powerLevel = memberToPowerLevel(user, member, guild, channel) - 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 currentHash = _hashProfileContent(content, powerLevel) + const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get() + // only do the actual sync if the hash has changed since we last looked + if (existingHash !== currentHash) { + // Update room member state + await api.sendState(roomID, "m.room.member", mxid, content, mxid) + // Update power levels + const powerLevelsStateContent = await api.getStateEvent(roomID, "m.room.power_levels", "") + const oldPowerLevel = powerLevelsStateContent.users?.[mxid] || 0 + mixin(powerLevelsStateContent, {users: {[mxid]: powerLevel}}) + if (powerLevel === 0) delete powerLevelsStateContent.users[mxid] // keep the event compact + const sendPowerLevelAs = powerLevel < oldPowerLevel ? mxid : undefined // bridge bot won't not have permission to demote equal power users, so do this action as themselves + await api.sendState(roomID, "m.room.power_levels", "", powerLevelsStateContent, sendPowerLevelAs) + // Update cached hash + db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid) + } return mxid } /** + * Sync profile data for a webhook user. The _ooye_webhook name will always be reused. + * 1. Join the sim to the room if needed + * 2. Make an object of what the new room member state content would be, including uploading the profile picture if it hasn't been done before + * 4. Compare against the previously known state content, which is helpfully stored in the database + * 5. If the state content has changed, send it to Matrix and update it in the database for next time + * @param {DiscordTypes.APIUser} user + * @param {DiscordTypes.APIGuildChannel} channel + * @param {DiscordTypes.APIGuild} guild * @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] + * @returns {Promise} mxid of the updated sim */ -async function _sendSyncUser(roomID, mxid, content, powerLevel, options) { - const currentHash = _hashProfileContent(content, powerLevel ?? 0) +async function syncWebhook(user, channel, guild, roomID) { + const mxid = await ensureSimJoined(user, roomID) + // @ts-ignore + const content = await memberToStateContent(user, {}, guild.id) + const currentHash = _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 - const hashHasChanged = existingHash !== currentHash - // 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 = [] + if (existingHash !== currentHash) { // Update room member state - actions.push(api.sendState(roomID, "m.room.member", mxid, content, mxid)) - // Update power levels - 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) + await api.sendState(roomID, "m.room.member", mxid, content, mxid) // 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 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 mxids = select("sim_member", "mxid", {room_id: roomID}).pluck().all() const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get() assert.ok(typeof channelID === "string") @@ -275,7 +252,10 @@ async function syncAllUsersInRoom(roomID) { /** @ts-ignore @type {DiscordTypes.APIGuild} */ const guild = discord.guilds.get(guildID) - for (const userID of users) { + for (const mxid of mxids) { + const userID = select("sim", "user_id", {mxid}).pluck().get() + assert.ok(typeof userID === "string") + /** @ts-ignore @type {Required} */ const member = await discord.snow.guild.getGuildMember(guildID, userID) /** @ts-ignore @type {Required} user */ @@ -292,7 +272,5 @@ module.exports._hashProfileContent = _hashProfileContent module.exports.ensureSim = ensureSim module.exports.ensureSimJoined = ensureSimJoined module.exports.syncUser = syncUser -module.exports._sendSyncUser = _sendSyncUser +module.exports.syncWebhook = syncWebhook module.exports.syncAllUsersInRoom = syncAllUsersInRoom -module.exports._memberToPowerLevel = memberToPowerLevel -module.exports.supportsMsc4069 = supportsMsc4069 diff --git a/src/d2m/actions/register-user.test.js b/src/d2m/actions/register-user.test.js index 13971b3..353c89f 100644 --- a/src/d2m/actions/register-user.test.js +++ b/src/d2m/actions/register-user.test.js @@ -1,12 +1,10 @@ -const {_memberToStateContent, _memberToPowerLevel} = require("./register-user") +const {_memberToStateContent} = require("./register-user") const {test} = require("supertape") -const data = require("../../../test/data") -const mixin = require("@cloudrac3r/mixin-deep") -const DiscordTypes = require("discord-api-types/v10") +const testData = require("../../../test/data") test("member2state: without member nick or avatar", async t => { t.deepEqual( - await _memberToStateContent(data.member.kumaccino.user, data.member.kumaccino, data.guild.general.id), + await _memberToStateContent(testData.member.kumaccino.user, testData.member.kumaccino, testData.guild.general.id), { avatar_url: "mxc://cadence.moe/UpAeIqeclhKfeiZNdIWNcXXL", displayname: "kumaccino", @@ -26,7 +24,7 @@ test("member2state: without member nick or avatar", async t => { test("member2state: with global name, without member nick or avatar", async t => { t.deepEqual( - await _memberToStateContent(data.member.papiophidian.user, data.member.papiophidian, data.guild.general.id), + await _memberToStateContent(testData.member.papiophidian.user, testData.member.papiophidian, testData.guild.general.id), { avatar_url: "mxc://cadence.moe/JPzSmALLirnIprlSMKohSSoX", displayname: "PapiOphidian", @@ -46,7 +44,7 @@ test("member2state: with global name, without member nick or avatar", async t => test("member2state: with member nick and avatar", async t => { t.deepEqual( - await _memberToStateContent(data.member.sheep.user, data.member.sheep, data.guild.general.id), + await _memberToStateContent(testData.member.sheep.user, testData.member.sheep, testData.guild.general.id), { avatar_url: "mxc://cadence.moe/rfemHmAtcprjLEiPiEuzPhpl", displayname: "The Expert's Submarine", @@ -63,64 +61,3 @@ test("member2state: with member nick and avatar", async t => { } ) }) - -test("member2power: default to zero if member roles unknown", async t => { - const power = _memberToPowerLevel(data.user.clyde_ai, null, data.guild.data_horde, data.channel.saving_the_world) - t.equal(power, 0) -}) - -test("member2power: unremarkable = 0", async t => { - const power = _memberToPowerLevel(data.user.clyde_ai, { - roles: [] - }, data.guild.data_horde, data.channel.general) - t.equal(power, 0) -}) - -test("member2power: can mention everyone = 20", async t => { - const power = _memberToPowerLevel(data.user.clyde_ai, { - roles: ["684524730274807911"] - }, data.guild.data_horde, data.channel.general) - t.equal(power, 20) -}) - -test("member2power: can send messages in protected channel due to role = 50", async t => { - const power = _memberToPowerLevel(data.user.clyde_ai, { - roles: ["684524730274807911"] - }, data.guild.data_horde, data.channel.saving_the_world) - t.equal(power, 50) -}) - -test("member2power: can send messages in protected channel due to user override = 50", async t => { - const power = _memberToPowerLevel(data.user.clyde_ai, { - roles: [] - }, data.guild.data_horde, mixin({}, data.channel.saving_the_world, { - permission_overwrites: data.channel.saving_the_world.permission_overwrites.concat({ - type: DiscordTypes.OverwriteType.member, - id: data.user.clyde_ai.id, - allow: String(DiscordTypes.PermissionFlagsBits.SendMessages), - deny: "0" - }) - })) - t.equal(power, 50) -}) - -test("member2power: can kick users = 50", async t => { - const power = _memberToPowerLevel(data.user.clyde_ai, { - roles: ["682789592390281245"] - }, data.guild.data_horde, data.channel.general) - t.equal(power, 50) -}) - -test("member2power: can manage channels = 100", async t => { - const power = _memberToPowerLevel(data.user.clyde_ai, { - roles: ["665290147377578005"] - }, data.guild.data_horde, data.channel.saving_the_world) - t.equal(power, 100) -}) - -test("member2power: pathfinder use case", async t => { - const power = _memberToPowerLevel(data.user.jerassicore, { - roles: ["1235396773510647810", "1359752622130593802", "1249165855632265267", "1380768596929806356", "1380756348190462015"] - }, data.guild.pathfinder, data.channel.character_art) - t.equal(power, 50) -}) diff --git a/src/d2m/actions/register-webhook-user.js b/src/d2m/actions/register-webhook-user.js deleted file mode 100644 index 145eeb8..0000000 --- a/src/d2m/actions/register-webhook-user.js +++ /dev/null @@ -1,131 +0,0 @@ -// @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-reaction.js b/src/d2m/actions/remove-reaction.js index af7fd6a..d991f08 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, from, select} = passthrough +const {discord, sync, db, select} = passthrough /** @type {import("../../matrix/api")} */ const api = sync.require("../../matrix/api") /** @type {import("../converters/emoji-to-key")} */ @@ -18,15 +18,12 @@ const converter = sync.require("../converters/remove-reaction") * @param {DiscordTypes.GatewayMessageReactionRemoveDispatchData | DiscordTypes.GatewayMessageReactionRemoveEmojiDispatchData | DiscordTypes.GatewayMessageReactionRemoveAllDispatchData} data */ async function removeSomeReactions(data) { - const row = select("channel_room", "room_id", {channel_id: data.channel_id}).get() - if (!row) return + 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 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") + const reactions = await api.getFullRelations(roomID, eventIDForMessage, "m.annotation") // Run the proper strategy and any strategy-specific database changes const removals = await @@ -36,7 +33,7 @@ async function removeSomeReactions(data) { // Redact the events and delete individual stored events in the database for (const removal of removals) { - await api.redactEvent(eventReactedTo.room_id, removal.eventID, removal.mxid) + await api.redactEvent(roomID, removal.eventID, removal.mxid) if (removal.hash) db.prepare("DELETE FROM reaction WHERE hashed_event_id = ?").run(removal.hash) } } @@ -46,7 +43,7 @@ async function removeSomeReactions(data) { * @param {Ty.Event.Outer[]} reactions */ async function removeReaction(data, reactions) { - const key = await emojiToKey.emojiToKey(data.emoji, data.message_id) + const key = await emojiToKey.emojiToKey(data.emoji) return converter.removeReaction(data, reactions, key) } @@ -55,8 +52,8 @@ async function removeReaction(data, reactions) { * @param {Ty.Event.Outer[]} reactions */ async function removeEmojiReaction(data, reactions) { - const key = await emojiToKey.emojiToKey(data.emoji, data.message_id) - const discordPreferredEncoding = await emoji.encodeEmoji(key, undefined) + const key = await emojiToKey.emojiToKey(data.emoji) + const discordPreferredEncoding = emoji.encodeEmoji(key, undefined) db.prepare("DELETE FROM reaction WHERE message_id = ? AND encoded_emoji = ?").run(data.message_id, discordPreferredEncoding) return converter.removeEmojiReaction(data, reactions, key) diff --git a/src/d2m/actions/retrigger.js b/src/d2m/actions/retrigger.js index 66ef19e..030ffbf 100644 --- a/src/d2m/actions/retrigger.js +++ b/src/d2m/actions/retrigger.js @@ -12,81 +12,50 @@ function debugRetrigger(message) { } } -const paused = new Set() const emitter = new EventEmitter() /** * Due to Eventual Consistency(TM) an update/delete may arrive before the original message arrives * (or before the it has finished being bridged to an event). * In this case, wait until the original message has finished bridging, then retrigger the passed function. - * @template {(...args: any[]) => any} T - * @param {string} inputID + * @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(inputID, fn, ...rest) { - if (!paused.has(inputID)) { - if (inputID.match(/^[0-9]+$/)) { - const eventID = select("event_message", "event_id", {message_id: inputID}).pluck().get() - if (eventID) { - debugRetrigger(`[retrigger] OK mid <-> eid = ${inputID} <-> ${eventID}`) - return false // event was found so don't retrigger - } - } else if (inputID.match(/^\$/)) { - const messageID = select("event_message", "message_id", {event_id: inputID}).pluck().get() - if (messageID) { - debugRetrigger(`[retrigger] OK eid <-> mid = ${inputID} <-> ${messageID}`) - return false // message was found so don't retrigger - } - } +function eventNotFoundThenRetrigger(messageID, fn, ...rest) { + const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get() + if (eventID) { + debugRetrigger(`[retrigger] OK mid <-> eid = ${messageID} <-> ${eventID}`) + return false // event was found so don't retrigger } - debugRetrigger(`[retrigger] WAIT id = ${inputID}`) - emitter.once(inputID, () => { - debugRetrigger(`[retrigger] TRIGGER id = ${inputID}`) + debugRetrigger(`[retrigger] WAIT mid <-> eid = ${messageID} <-> ${eventID}`) + 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(inputID).length) { - debugRetrigger(`[retrigger] EXPIRE id = ${inputID}`) + if (emitter.listeners(messageID).length) { + debugRetrigger(`[retrigger] EXPIRE mid = ${messageID}`) } - emitter.removeAllListeners(inputID) + emitter.removeAllListeners(messageID) }, 60 * 1000) // 1 minute return true // event was not found, then retrigger } -/** - * Anything calling retrigger during the callback will be paused and retriggered after the callback resolves. - * @template T - * @param {string} messageID - * @param {Promise} promise - * @returns {Promise} - */ -async function pauseChanges(messageID, promise) { - try { - debugRetrigger(`[retrigger] PAUSE id = ${messageID}`) - paused.add(messageID) - return await promise - } finally { - debugRetrigger(`[retrigger] RESUME id = ${messageID}`) - paused.delete(messageID) - messageFinishedBridging(messageID) - } -} - /** * Triggers any pending operations that were waiting on the corresponding event ID. * @param {string} messageID */ function messageFinishedBridging(messageID) { if (emitter.listeners(messageID).length) { - debugRetrigger(`[retrigger] EMIT id = ${messageID}`) + debugRetrigger(`[retrigger] EMIT mid = ${messageID}`) } emitter.emit(messageID) } module.exports.eventNotFoundThenRetrigger = eventNotFoundThenRetrigger module.exports.messageFinishedBridging = messageFinishedBridging -module.exports.pauseChanges = pauseChanges diff --git a/src/d2m/actions/send-message.js b/src/d2m/actions/send-message.js index eb919bb..ac3378c 100644 --- a/src/d2m/actions/send-message.js +++ b/src/d2m/actions/send-message.js @@ -4,7 +4,7 @@ const assert = require("assert").strict const DiscordTypes = require("discord-api-types/v10") const passthrough = require("../../passthrough") -const { discord, sync, db, select, from} = passthrough +const { discord, sync, db } = passthrough /** @type {import("../converters/message-to-event")} */ const messageToEvent = sync.require("../converters/message-to-event") /** @type {import("../../matrix/api")} */ @@ -13,16 +13,10 @@ const api = sync.require("../../matrix/api") const registerUser = sync.require("./register-user") /** @type {import("./register-pk-user")} */ const registerPkUser = sync.require("./register-pk-user") -/** @type {import("./register-webhook-user")} */ -const registerWebhookUser = sync.require("./register-webhook-user") /** @type {import("../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 @@ -32,46 +26,29 @@ const channelWebhook = sync.require("../../m2d/actions/channel-webhook") */ async function sendMessage(message, channel, guild, row) { const roomID = await createRoom.ensureRoom(message.channel_id) - const historicalRoomIndex = select("historical_channel_room", "historical_room_index", {room_id: roomID}).pluck().get() - assert(historicalRoomIndex) let senderMxid = null - if (dUtils.isWebhookMessage(message)) { - const useWebhookProfile = select("guild_space", "webhook_profile", {guild_id: guild.id}).pluck().get() ?? 0 - if (row && row.speedbump_webhook_id === message.webhook_id) { - // Handle the PluralKit public instance - if (row.speedbump_id === "466378653216014359") { - senderMxid = await registerPkUser.syncUser(message.id, message.author, roomID, true) - } - } else if (useWebhookProfile) { - senderMxid = await registerWebhookUser.syncUser(message.author, roomID, true) - } - } 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) + if (row && row.speedbump_webhook_id === message.webhook_id) { + // Handle the PluralKit public instance + if (row.speedbump_id === "466378653216014359") { + const pkMessage = await registerPkUser.fetchMessage(message.id) + senderMxid = await registerPkUser.syncUser(message.author, pkMessage, roomID) } + } else if (message.author.id === discord.application.id) { + // no need to sync the bot's own user + } else if (dUtils.isWebhookMessage(message)) { + senderMxid = await registerUser.syncWebhook(message.author, channel, guild, roomID) + } else if (message.member) { // available on a gateway message create event + senderMxid = await registerUser.syncUser(message.author, message.member, channel, guild, roomID) + } else { // well, good enough... + senderMxid = await registerUser.ensureSimJoined(message.author, roomID) } - 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 = threadParent ? threadParent : message.channel_id - const threadID = threadParent ? message.channel_id : undefined - sentResultsMessage = await channelWebhook.sendMessageWithWebhook(channelID, detailedResultsMessage, threadID) - } - } - - const events = await messageToEvent.messageToEvent(message, guild, {}, {api, snow: discord.snow}) + const events = await messageToEvent.messageToEvent(message, guild, {}, {api}) const eventIDs = [] if (events.length) { - 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(() => {}) + db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id) + if (senderMxid) api.sendTyping(roomID, false, senderMxid) } for (const event of events) { const part = event === events[0] ? 0 : 1 @@ -86,19 +63,7 @@ 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) - 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 [] - } - } + 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 // 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. @@ -106,35 +71,7 @@ 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. - - 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) - })() - } + eventIDs.push(eventID) } return eventIDs diff --git a/src/d2m/actions/set-presence.js b/src/d2m/actions/set-presence.js deleted file mode 100644 index f26668f..0000000 --- a/src/d2m/actions/set-presence.js +++ /dev/null @@ -1,114 +0,0 @@ -// @ts-check - -const passthrough = require("../../passthrough") -const {sync, select} = passthrough -/** @type {import("../../matrix/api")} */ -const api = sync.require("../../matrix/api") - -/* - We do this in two phases for optimisation reasons. - Discord sends us an event when the presence *changes.* - We need to keep the event data in memory because we need to *repeatedly* send it to Matrix using a long-lived loop. - - There are two phases to get it from Discord to Matrix. - The first phase stores Discord presence data in memory. - The second phase loops over the memory and sends it on to Matrix. - - Optimisations: - * Presence can be deactivated per-guild in OOYE settings. If the user doesn't share any presence-enabled-guilds with us, we don't need to do anything. - * Presence can be sent for users without sims. In this case, they will be discarded from memory when the next loop begins. - * Matrix ID is cached in memory on the Presence class. The alternative to this is querying it every time we receive a presence change event in a valid guild. - * Presence can be sent multiple times in a row for the same user for each guild we share. The loop timer prevents these "changes" from individually reaching the homeserver. -*/ - -// Synapse expires each user's presence after 30 seconds and makes them offline, so we have to loop every 28 seconds and update each user again. -const presenceLoopInterval = 28e3 - -// Cache the list of enabled guilds rather than accessing it like multiple times per second when any user changes presence -const guildPresenceSetting = new class { - /** @private @type {Set} */ guilds - constructor() { - this.update() - } - update() { - this.guilds = new Set(select("guild_space", "guild_id", {presence: 1}).pluck().all()) - } - isEnabled(guildID) { - return this.guilds.has(guildID) - } -} - -class Presence extends sync.reloadClassMethods(() => Presence) { - /** @type {string} */ userID - /** @type {{presence: "online" | "offline" | "unavailable", status_msg?: string}} */ data - /** @private @type {?string | undefined} */ mxid - /** @private @type {number} */ delay = Math.random() - - constructor(userID) { - super() - this.userID = userID - } - - /** - * @param {string} status status field from Discord's PRESENCE_UPDATE event - */ - setData(status) { - const presence = - ( status === "online" ? "online" - : status === "offline" ? "offline" - : "unavailable") - this.data = {presence} - } - - sync(presences) { - const mxid = this.mxid ??= select("sim", "mxid", {user_id: this.userID}).pluck().get() - if (!mxid) return presences.delete(this.userID) - // I haven't tried, but I assume Synapse explodes if you try to update too many presences at the same time. - // This random delay will space them out over the whole 28 second cycle. - setTimeout(() => { - api.setPresence(this.data, mxid).catch(() => {}) - }, this.delay * presenceLoopInterval).unref() - } -} - -const presenceTracker = new class { - /** @private @type {Map} userID -> Presence */ presences = sync.remember(() => new Map()) - - constructor() { - sync.addTemporaryInterval(() => this.syncPresences(), presenceLoopInterval) - } - - /** - * This function is called for each Discord presence packet. - * @param {string} userID Discord user ID - * @param {string} guildID Discord guild ID that this presence applies to (really, the same presence applies to every single guild, but is delivered separately by Discord for some reason) - * @param {string} status status field from Discord's PRESENCE_UPDATE event - */ - incomingPresence(userID, guildID, status) { - // stop tracking offline presence objects - they will naturally expire and fall offline on the homeserver - if (status === "offline") return this.presences.delete(userID) - // check if we care about this guild - if (!guildPresenceSetting.isEnabled(guildID)) return - // start tracking presence for user (we'll check if they have a sim in the next sync loop) - this.getOrCreatePresence(userID).setData(status) - } - - /** @private */ - getOrCreatePresence(userID) { - return this.presences.get(userID) || (() => { - const presence = new Presence(userID) - this.presences.set(userID, presence) - return presence - })() - } - - /** @private */ - syncPresences() { - for (const presence of this.presences.values()) { - presence.sync(this.presences) - } - } -} - -module.exports.presenceTracker = presenceTracker -module.exports.guildPresenceSetting = guildPresenceSetting diff --git a/src/d2m/actions/speedbump.js b/src/d2m/actions/speedbump.js index 218f046..7c3109b 100644 --- a/src/d2m/actions/speedbump.js +++ b/src/d2m/actions/speedbump.js @@ -4,14 +4,6 @@ const DiscordTypes = require("discord-api-types/v10") const passthrough = require("../../passthrough") const {discord, select, db} = passthrough -const DEBUG_SPEEDBUMP = false - -function debugSpeedbump(message) { - if (DEBUG_SPEEDBUMP) { - console.log(message) - } -} - const SPEEDBUMP_SPEED = 4000 // 4 seconds delay const SPEEDBUMP_UPDATE_FREQUENCY = 2 * 60 * 60 // 2 hours @@ -35,8 +27,8 @@ async function updateCache(channelID, lastChecked) { db.prepare("UPDATE channel_room SET speedbump_id = ?, speedbump_webhook_id = ?, speedbump_checked = ? WHERE channel_id = ?").run(foundApplication, foundWebhook, now, channelID) } -/** @type {Map} messageID -> number of gateway events currently bumping */ -const bumping = new Map() +/** @type {Set} set of messageID */ +const bumping = new Set() /** * Slow down a message. After it passes the speedbump, return whether it's okay or if it's been deleted. @@ -44,26 +36,9 @@ const bumping = new Map() * @returns whether it was deleted */ async function doSpeedbump(messageID) { - let value = (bumping.get(messageID) ?? 0) + 1 - bumping.set(messageID, value) - debugSpeedbump(`[speedbump] WAIT ${messageID}++ = ${value}`) - + bumping.add(messageID) await new Promise(resolve => setTimeout(resolve, SPEEDBUMP_SPEED)) - - if (!bumping.has(messageID)) { - debugSpeedbump(`[speedbump] DELETED ${messageID}`) - return true - } - value = (bumping.get(messageID) ?? 0) - 1 - if (value <= 0) { - debugSpeedbump(`[speedbump] OK ${messageID}-- = ${value}`) - bumping.delete(messageID) - return false - } else { - debugSpeedbump(`[speedbump] MULTI ${messageID}-- = ${value}`) - bumping.set(messageID, value) - return true - } + return !bumping.delete(messageID) } /** diff --git a/src/d2m/actions/update-pins.js b/src/d2m/actions/update-pins.js index 56c9642..5d98501 100644 --- a/src/d2m/actions/update-pins.js +++ b/src/d2m/actions/update-pins.js @@ -6,8 +6,6 @@ const {discord, sync, db} = passthrough const pinsToList = sync.require("../converters/pins-to-list") /** @type {import("../../matrix/api")} */ const api = sync.require("../../matrix/api") -/** @type {import("../../matrix/kstate")} */ -const ks = sync.require("../../matrix/kstate") /** * @template {string | null | undefined} T @@ -25,21 +23,13 @@ function convertTimestamp(timestamp) { * @param {number?} convertedTimestamp */ async function updatePins(channelID, roomID, convertedTimestamp) { - try { - var discordPins = await discord.snow.channel.getChannelPinnedMessages(channelID) - } catch (e) { - if (e.message === `{"message": "Missing Access", "code": 50001}`) { - return // Discord sends channel pins update events even for channels that the bot can't view/get pins in, just ignore it - } - throw e + const pins = await discord.snow.channel.getChannelPinnedMessages(channelID) + const eventIDs = pinsToList.pinsToList(pins) + if (pins.length === eventIDs.length || eventIDs.length) { + await api.sendState(roomID, "m.room.pinned_events", "", { + pinned: eventIDs + }) } - - 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}}) - await ks.applyKStateDiffToRoom(roomID, diff) - db.prepare("UPDATE channel_room SET last_bridged_pin_timestamp = ? WHERE channel_id = ?").run(convertedTimestamp || 0, channelID) } diff --git a/src/d2m/converters/edit-to-changes.js b/src/d2m/converters/edit-to-changes.js index 4f743eb..f93c510 100644 --- a/src/d2m/converters/edit-to-changes.js +++ b/src/d2m/converters/edit-to-changes.js @@ -6,10 +6,8 @@ const passthrough = require("../../passthrough") const {sync, select, from} = passthrough /** @type {import("./message-to-event")} */ const messageToEvent = sync.require("../converters/message-to-event") -/** @type {import("../../discord/utils")} */ -const dUtils = sync.require("../../discord/utils") -/** @type {import("../../matrix/utils")} */ -const mxUtils = sync.require("../../matrix/utils") +/** @type {import("../../m2d/converters/utils")} */ +const utils = sync.require("../../m2d/converters/utils") function eventCanBeEdited(ev) { // Discord does not allow files, images, attachments, or videos to be edited. @@ -20,39 +18,27 @@ 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 } -function eventIsText(ev) { - return ev.old.event_type === "m.room.message" && (ev.old.event_subtype === "m.text" || ev.old.event_subtype === "m.notice") -} - /** * @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message * @param {import("discord-api-types/v10").APIGuild} guild * @param {import("../../matrix/api")} api simple-as-nails dependency injection for the matrix API */ async function editToChanges(message, guild, api) { + // If it is a user edit, allow deleting old messages (e.g. they might have removed text from an image). + // If it is the system adding a generated embed to a message, don't delete old messages since the system only sends partial data. + // Since an update in August 2024, the system always provides the full data of message updates. I'll leave in the old code since it won't cause problems. + + const isGeneratedEmbed = !("content" in message) + // 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", "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 + const oldEventRows = select("event_message", ["event_id", "event_type", "event_subtype", "part", "reaction_part"], {message_id: message.id}).all() /** @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 @@ -62,7 +48,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 (mxUtils.eventSenderIsFromDiscord(event.sender)) { + if (utils.eventSenderIsFromDiscord(event.sender)) { senderMxid = event.sender } } @@ -81,25 +67,13 @@ 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 - * @type {{old: typeof oldEventRows[0], oldMentions?: any, newFallbackContent: typeof newFallbackContent[0], newInnerContent: typeof newInnerContent[0]}[]} - */ + /** 1. Events that are matched, and should be edited by sending another m.replace event */ let eventsToReplace = [] - /** - * 2. Events that are present in the old version only, and should be blanked or redacted - * @type {{old: typeof oldEventRows[0]}[]} - */ + /** 2. Events that are present in the old version only, and should be blanked or redacted */ let eventsToRedact = [] - /** - * 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} - */ + /** 3. Events that are present in the new version only, and should be sent as new, with references back to the context */ let eventsToSend = [] - /** - * 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])[]} - */ + /** 4. Events that are matched and have definitely not changed, so they don't need to be edited or replaced at all. */ let unchangedEvents = [] function shift() { @@ -136,119 +110,68 @@ 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. - // 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 - if (messageReallyOld) { - eventsToSend = [] // Only allow edits to change and delete, but not send new. - } else if ((messageQuiteOld || !embedsEnabled) && !message.author?.bot) { - eventsToSend = eventsToSend.filter(e => e.msgtype !== "m.notice") // Only send events that aren't embeds. + 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") } // 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 = 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.) - // Only way to detect this is by text content. So we'll remove text events from eventsToReplace that have the same new text as text currently in the event. - for (let i = eventsToReplace.length; i--;) { // move backwards through array - const event = eventsToReplace[i] - if (!eventIsText(event)) continue // not text, can't analyse - const oldEvent = await api.getEvent(roomID, eventsToReplace[i].old.event_id) - 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. - unchangedEvents.push(...eventsToReplace.filter(ev => ev.old.event_id === event.old.event_id)) - eventsToReplace = eventsToReplace.filter(ev => ev.old.event_id !== event.old.event_id) - } + unchangedEvents.push(...eventsToReplace.filter(ev => !eventCanBeEdited(ev))) // Move them from eventsToRedact to unchangedEvents. + eventsToReplace = eventsToReplace.filter(eventCanBeEdited) // We want to maintain exactly one part = 0 and one reaction_part = 0 database row at all times. - // This would be disrupted if existing events that are (reaction_)part = 0 will be redacted. - // If that is the case, pick a different existing or newly sent event to be (reaction_)part = 0. /** @type {({column: string, eventID: string, value?: number} | {column: string, nextEvent: true})[]} */ const promotions = [] for (const column of ["part", "reaction_part"]) { const candidatesForParts = unchangedEvents.concat(eventsToReplace) // If no events with part = 0 exist (or will exist), we need to do some management. if (!candidatesForParts.some(e => e.old[column] === 0)) { - // Try to find an existing event to promote. Bigger order is better. if (candidatesForParts.length) { + // We can choose an existing event to promote. Bigger order is better. const order = e => 2*+(e.event_type === "m.room.message") + 1*+(e.old.event_subtype === "m.text") candidatesForParts.sort((a, b) => order(b) - order(a)) if (column === "part") { promotions.push({column, eventID: candidatesForParts[0].old.event_id}) // part should be the first one - } else if (eventsToSend.length) { - promotions.push({column, nextEvent: true}) // reaction_part should be the last one } else { promotions.push({column, eventID: candidatesForParts[candidatesForParts.length - 1].old.event_id}) // reaction_part should be the last one } - } - // Or, if there are no existing events to promote and new events will be sent, whatever gets sent will be the next part = 0. - else { + } else { + // No existing events to promote, but new events are being sent. Whatever gets sent will be the next part = 0. promotions.push({column, nextEvent: true}) } } - } - - // If adding events, try to keep reactions attached to the bottom of the group (unless reactions have already been added) - if (eventsToSend.length && !promotions.length) { - const existingReaction = select("reaction", "message_id", {message_id: message.id}).pluck().get() - if (!existingReaction) { - const existingPartZero = unchangedEvents.concat(eventsToReplace).find(p => p.old.reaction_part === 0) - assert(existingPartZero) // will exist because a reaction_part=0 always exists and no events are being removed - promotions.push({column: "reaction_part", eventID: existingPartZero.old.event_id, value: 1}) // update the current reaction_part to 1 - promotions.push({column: "reaction_part", nextEvent: true}) // the newly created event will have reaction_part = 0 + // If adding events, try to keep reactions attached to the bottom of the group (unless reactions have already been added) + if (eventsToSend.length && !promotions.length) { + const existingReaction = select("reaction", "message_id", {message_id: message.id}).pluck().get() + if (!existingReaction) { + const existingPartZero = candidatesForParts.find(p => p.old.reaction_part === 0) + assert(existingPartZero) // will exist because a reaction_part=0 always exists and no events are being removed + promotions.push({column: "reaction_part", eventID: existingPartZero.old.event_id, value: 1}) // update the current reaction_part to 1 + promotions.push({column: "reaction_part", nextEvent: true}) // the newly created event will have reaction_part = 0 + } } } // Removing unnecessary properties before returning - 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 - } + 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} } /** * @template T * @param {string} oldID - * @param {any} oldMentions * @param {T} newFallbackContent * @param {T} newInnerContent * @returns {import("../../types").Event.ReplacementContent} content */ -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 - } +function makeReplacementEventContent(oldID, newFallbackContent, newInnerContent) { const content = { ...newFallbackContent, - "m.mentions": mentions, + "m.mentions": {}, "m.new_content": { ...newInnerContent }, @@ -264,3 +187,4 @@ function makeReplacementEventContent(oldID, oldMentions, newFallbackContent, new } module.exports.editToChanges = editToChanges +module.exports.makeReplacementEventContent = makeReplacementEventContent diff --git a/src/d2m/converters/edit-to-changes.test.js b/src/d2m/converters/edit-to-changes.test.js index cb1fb5a..9721a85 100644 --- a/src/d2m/converters/edit-to-changes.test.js +++ b/src/d2m/converters/edit-to-changes.test.js @@ -4,14 +4,7 @@ const data = require("../../../test/data") const Ty = require("../../types") test("edit2changes: edit by webhook", async t => { - let called = 0 - const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edit_by_webhook, data.guild.general, { - getEvent(roomID, eventID) { - called++ - t.equal(eventID, "$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10") - return {content: {body: "dummy"}} - } - }) + const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edit_by_webhook, data.guild.general, {}) t.deepEqual(eventsToRedact, []) t.deepEqual(eventsToSend, []) t.deepEqual(eventsToReplace, [{ @@ -35,22 +28,10 @@ test("edit2changes: edit by webhook", async t => { }]) t.equal(senderMxid, null) t.deepEqual(promotions, []) - t.equal(called, 1) }) test("edit2changes: bot response", async t => { const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.bot_response, data.guild.general, { - getEvent(roomID, eventID) { - t.equal(eventID, "$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY") - return { - content: { - "m.mentions": { - user_ids: ["@cadence:cadence.moe"], - }, - body: "dummy" - } - } - }, async getJoinedMembers(roomID) { t.equal(roomID, "!hYnGGlPHlbujVVfktC:cadence.moe") return new Promise(resolve => { @@ -78,18 +59,18 @@ test("edit2changes: bot response", async t => { newContent: { $type: "m.room.message", msgtype: "m.text", - body: "* :ae_botrac4r: [@cadence](https://matrix.to/#/@cadence:cadence.moe) asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.", + body: "* :ae_botrac4r: @cadence asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.", format: "org.matrix.custom.html", - formatted_body: '* :ae_botrac4r: @cadence asked ­, I respond: Stop drinking paint. (No)

Hit :bn_re: to reroll.', + formatted_body: '* :ae_botrac4r: @cadence asked ­, I respond: Stop drinking paint. (No)

Hit :bn_re: to reroll.', "m.mentions": { // Client-Server API spec 11.37.7: Copy Discord's behaviour by not re-notifying anyone that an *edit occurred* }, // *** Replaced With: *** "m.new_content": { msgtype: "m.text", - body: ":ae_botrac4r: [@cadence](https://matrix.to/#/@cadence:cadence.moe) asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.", + body: ":ae_botrac4r: @cadence asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.", format: "org.matrix.custom.html", - formatted_body: ':ae_botrac4r: @cadence asked ­, I respond: Stop drinking paint. (No)

Hit :bn_re: to reroll.', + formatted_body: ':ae_botrac4r: @cadence asked ­, I respond: Stop drinking paint. (No)

Hit :bn_re: 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"] @@ -142,14 +123,7 @@ test("edit2changes: add caption back to that image (due to it having a reaction, }) test("edit2changes: stickers and attachments are not changed, only the content can be edited", async t => { - let called = 0 - const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments, data.guild.general, { - getEvent(roomID, eventID) { - called++ - t.equal(eventID, "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4") - return {content: {body: "dummy"}} - } - }) + const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments, data.guild.general, {}) t.deepEqual(eventsToRedact, []) t.deepEqual(eventsToSend, []) t.deepEqual(eventsToReplace, [{ @@ -171,16 +145,10 @@ test("edit2changes: stickers and attachments are not changed, only the content c } } }]) - t.equal(called, 1) }) test("edit2changes: edit of reply to skull webp attachment with content", async t => { - const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edit_of_reply_to_skull_webp_attachment_with_content, data.guild.general, { - getEvent(roomID, eventID) { - t.equal(eventID, "$vgTKOR5ZTYNMKaS7XvgEIDaOWZtVCEyzLLi5Pc5Gz4M") - return {content: {body: "dummy"}} - } - }) + const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edit_of_reply_to_skull_webp_attachment_with_content, data.guild.general, {}) t.deepEqual(eventsToRedact, []) t.deepEqual(eventsToSend, []) t.deepEqual(eventsToReplace, [{ @@ -188,7 +156,12 @@ test("edit2changes: edit of reply to skull webp attachment with content", async newContent: { $type: "m.room.message", msgtype: "m.text", - body: "* Edit", + body: "> Extremity: Image\n\n* Edit", + format: "org.matrix.custom.html", + formatted_body: + '
In reply to Extremity' + + '
Image
' + + '* Edit', "m.mentions": {}, "m.new_content": { msgtype: "m.text", @@ -204,12 +177,7 @@ test("edit2changes: edit of reply to skull webp attachment with content", async }) test("edit2changes: edits the text event when multiple rows have part = 0 (should never happen in real life, but make sure the safety net works)", async t => { - const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments_but_all_parts_equal_0, data.guild.general, { - getEvent(roomID, eventID) { - t.equal(eventID, "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd999") - return {content: {body: "dummy"}} - } - }) + const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments_but_all_parts_equal_0, data.guild.general, {}) t.deepEqual(eventsToRedact, []) t.deepEqual(eventsToSend, []) t.deepEqual(eventsToReplace, [{ @@ -234,12 +202,7 @@ test("edit2changes: edits the text event when multiple rows have part = 0 (shoul }) test("edit2changes: promotes the text event when multiple rows have part = 1 (should never happen in real life, but make sure the safety net works)", async t => { - const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments_but_all_parts_equal_1, data.guild.general, { - getEvent(roomID, eventID) { - t.equal(eventID, "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd111") - return {content: {body: "dummy"}} - } - }) + const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments_but_all_parts_equal_1, data.guild.general, {}) t.deepEqual(eventsToRedact, []) t.deepEqual(eventsToSend, []) t.deepEqual(eventsToReplace, [{ @@ -273,61 +236,6 @@ test("edit2changes: promotes the text event when multiple rows have part = 1 (sh ]) }) -test("edit2changes: promotes newly sent event", async t => { - const {eventsToReplace, eventsToRedact, eventsToSend, promotions} = await editToChanges({ - channel_id: "1160894080998461480", - id: "1404133238414376971", - content: "hi", - attachments: [{ - id: "1157854643037163610", - filename: "Screenshot_20231001_034036.jpg", - size: 51981, - url: "https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg?ex=651a1faa&is=6518ce2a&hm=eb5ca80a3fa7add8765bf404aea2028a28a2341e4a62435986bcdcf058da82f3&", - proxy_url: "https://media.discordapp.net/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg?ex=651a1faa&is=6518ce2a&hm=eb5ca80a3fa7add8765bf404aea2028a28a2341e4a62435986bcdcf058da82f3&", - width: 1080, - height: 1170, - content_type: "image/jpeg" - }], - author: { - username: "cadence.worm", - global_name: "Cadence" - } - }, data.guild.general, { - async getEvent(roomID, eventID) { - t.equal(eventID, "$uUKLcTQvik5tgtTGDKuzn0Ci4zcCvSoUcYn2X7mXm9I") - return { - type: "m.room.message", - sender: "@_ooye_cadence.worm:cadence.moe", - content: { - msgtype: "m.text", - body: "hi" - } - } - } - }) - t.deepEqual(eventsToRedact, ["$LhmoWWvYyn5_AHkfb6FaXmLI6ZOC1kloql5P40YDmIk"]) - t.deepEqual(eventsToReplace, []) - t.deepEqual(eventsToSend, [{ - $type: "m.room.message", - body: "Screenshot_20231001_034036.jpg", - external_url: "https://bridge.example.org/download/discordcdn/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg", - filename: "Screenshot_20231001_034036.jpg", - info: { - mimetype: "image/jpeg", - size: 51981, - w: 1080, - h: 1170 - }, - url: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR", - "m.mentions": {}, - msgtype: "m.image" - }]) - t.deepEqual(promotions, [ - {column: "reaction_part", nextEvent: true} - ]) - // assert that the event parts will be consistent in database after this -}) - test("edit2changes: generated embed", async t => { let called = 0 const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_social_media_image, data.guild.general, { @@ -371,32 +279,32 @@ 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++ - t.equal(eventID, "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF") - return { - type: "m.room.message", - content: { - // Unfortunately the edited message doesn't include the message_reference field. Fine. Whatever. It looks normal if you're using a good client. - body: "> a Discord user: [Replied-to message content wasn't provided by Discord]" - + "\n\nhttps://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM", - format: "org.matrix.custom.html", - formatted_body: "
In reply to a Discord user
[Replied-to message content wasn't provided by Discord]
https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM", - "m.mentions": {}, - "m.relates_to": { - event_id: "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF", - rel_type: "m.replace", - }, - msgtype: "m.text", - } - } - } - }) + const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_on_reply, data.guild.general, {}) t.deepEqual(eventsToRedact, []) - t.deepEqual(eventsToReplace, []) + t.deepEqual(eventsToReplace, [{ + oldID: "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF", + newContent: { + $type: "m.room.message", + // Unfortunately the edited message doesn't include the message_reference field. Fine. Whatever. It looks normal if you're using a good client. + body: "> a Discord user: [Replied-to message content wasn't provided by Discord]" + + "\n\n* https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM", + format: "org.matrix.custom.html", + formatted_body: "
In reply to a Discord user
[Replied-to message content wasn't provided by Discord]
* https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM", + "m.mentions": {}, + "m.new_content": { + body: "https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM", + format: "org.matrix.custom.html", + formatted_body: "https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM", + "m.mentions": {}, + msgtype: "m.text", + }, + "m.relates_to": { + event_id: "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF", + rel_type: "m.replace", + }, + msgtype: "m.text", + }, + }]) t.deepEqual(eventsToSend, [{ $type: "m.room.message", msgtype: "m.notice", @@ -416,23 +324,4 @@ test("edit2changes: generated embed on a reply", async t => { "nextEvent": true, }]) 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/emoji-to-key.js b/src/d2m/converters/emoji-to-key.js index 54bda18..267664c 100644 --- a/src/d2m/converters/emoji-to-key.js +++ b/src/d2m/converters/emoji-to-key.js @@ -8,10 +8,9 @@ const file = sync.require("../../matrix/file") /** * @param {import("discord-api-types/v10").APIEmoji} emoji - * @param {string} message_id * @returns {Promise} */ -async function emojiToKey(emoji, message_id) { +async function emojiToKey(emoji) { let key if (emoji.id) { // Custom emoji @@ -31,10 +30,7 @@ async function emojiToKey(emoji, message_id) { // Default emoji const name = emoji.name assert(name) - // If the reaction was used on Matrix already, it might be using a different arrangement of Variation Selector 16 characters. - // We'll use the same arrangement that was originally used, otherwise a duplicate of the emoji will appear as a separate reaction. - const originalEncoding = select("reaction", "original_encoding", {message_id, encoded_emoji: encodeURIComponent(name)}).pluck().get() - key = originalEncoding || name + key = name } return key } diff --git a/src/d2m/converters/find-mentions.js b/src/d2m/converters/find-mentions.js deleted file mode 100644 index 8726830..0000000 --- a/src/d2m/converters/find-mentions.js +++ /dev/null @@ -1,161 +0,0 @@ -// @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 newContent = content.slice(0, start) + "[" + content.slice(start, end) + "](https://matrix.to/#/" + best.mxid + ")" + content.slice(end) - return { - mxid: best.mxid, - newContent - } - } -} - -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 deleted file mode 100644 index 8f2be09..0000000 --- a/src/d2m/converters/find-mentions.test.js +++ /dev/null @@ -1,129 +0,0 @@ -// @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/lottie.js b/src/d2m/converters/lottie.js index 969d345..12a311a 100644 --- a/src/d2m/converters/lottie.js +++ b/src/d2m/converters/lottie.js @@ -21,7 +21,7 @@ const Rlottie = (async () => { /** * @param {string} text - * @returns {Promise} + * @returns {Promise} */ async function convert(text) { const r = await Rlottie @@ -41,7 +41,6 @@ async function convert(text) { png.data = Buffer.from(rendered) // png.pack() is a bad stream and will throw away any data it sends if it's not connected to a destination straight away. // We use Duplex.from to convert it into a good stream. - // @ts-ignore return stream.Duplex.from(png.pack()) } diff --git a/src/d2m/converters/message-to-event.test.embeds.js b/src/d2m/converters/message-to-event.embeds.test.js similarity index 73% rename from src/d2m/converters/message-to-event.test.embeds.js rename to src/d2m/converters/message-to-event.embeds.test.js index 259aa66..ef7e9b8 100644 --- a/src/d2m/converters/message-to-event.test.embeds.js +++ b/src/d2m/converters/message-to-event.embeds.test.js @@ -1,34 +1,26 @@ const {test} = require("supertape") const {messageToEvent} = require("./message-to-event") const data = require("../../../test/data") -const {mockGetEffectivePower} = require("../../matrix/utils.test") -const {db} = require("../../passthrough") - -test("message2event embeds: interaction loading", async t => { - const events = await messageToEvent(data.interaction_message.thinking_interaction, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - body: "↪️ Brad used `/stats` — interaction loading...", - format: "org.matrix.custom.html", - formatted_body: "
↪️ Brad used /stats — interaction loading...
", - "m.mentions": {}, - msgtype: "m.notice", - }]) -}) +const Ty = require("../../types") test("message2event embeds: nothing but a field", async t => { const events = await messageToEvent(data.message_with_embeds.nothing_but_a_field, data.guild.general, {}) t.deepEqual(events, [{ + $type: "m.room.message", + body: "> ↪️ @papiophidian: used `/stats`", + format: "org.matrix.custom.html", + formatted_body: "
↪️ @papiophidian used /stats
", + "m.mentions": {}, + msgtype: "m.text", + }, { $type: "m.room.message", "m.mentions": {}, msgtype: "m.notice", - body: "↪️ PapiOphidian used `/stats`" - + "\n| ### Amanda 🎵#2192 :online:" + body: "| ### Amanda 🎵#2192 :online:" + "\n| willow tree, branch 0" + "\n| **❯ Uptime:**\n| 3m 55s\n| **❯ Memory:**\n| 64.45MB", format: "org.matrix.custom.html", - formatted_body: '
↪️ PapiOphidian used /stats
' - + '

Amanda 🎵#2192 \":online:\"' + formatted_body: '

Amanda 🎵#2192 \":online:\"' + '
willow tree, branch 0
' + '
❯ Uptime:
3m 55s' + '
❯ Memory:
64.45MB

' @@ -41,9 +33,7 @@ test("message2event embeds: reply with just an embed", async t => { $type: "m.room.message", msgtype: "m.notice", "m.mentions": {}, - body: "> In reply to an unbridged message:" - + "\n> PokemonGod: https://twitter.com/dynastic/status/1707484191963648161" - + "\n\n| ## ⏺️ dynastic (@dynastic) https://twitter.com/i/user/719631291747078145" + body: "| ## ⏺️ dynastic (@dynastic) https://twitter.com/i/user/719631291747078145" + "\n| \n| does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?" + "\n| \n| ### Retweets" + "\n| 119" @@ -51,8 +41,7 @@ test("message2event embeds: reply with just an embed", async t => { + "\n| 5581" + "\n| — Twitter", format: "org.matrix.custom.html", - formatted_body: '
In reply to an unbridged message from PokemonGod:
https://twitter.com/dynastic/status/1707484191963648161
' - + '

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

⏺️ dynastic (@dynastic)' + '

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

Retweets
119

Likes
5581

— Twitter
' }]) @@ -78,7 +67,7 @@ test("message2event embeds: image embed and attachment", async t => { msgtype: "m.image", url: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR", body: "Screenshot_20231001_034036.jpg", - external_url: "https://bridge.example.org/download/discordcdn/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg", + external_url: "https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg?ex=651a1faa&is=6518ce2a&hm=eb5ca80a3fa7add8765bf404aea2028a28a2341e4a62435986bcdcf058da82f3&", filename: "Screenshot_20231001_034036.jpg", info: { h: 1170, @@ -94,7 +83,17 @@ test("message2event embeds: blockquote in embed", async t => { let called = 0 const events = await messageToEvent(data.message_with_embeds.blockquote_in_embed, data.guild.general, {}, { api: { - getEffectivePower: mockGetEffectivePower(), + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return { + users: { + "@_ooye_bot:cadence.moe": 100 + } + } + }, async getJoinedMembers(roomID) { called++ t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") @@ -122,7 +121,7 @@ test("message2event embeds: blockquote in embed", async t => { formatted_body: "

⏺️ minimus

reply draft

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.

There will be no further communication.

Go to Message

", "m.mentions": {} }]) - t.equal(called, 1, "should call getJoinedMembers once") + t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each") }) test("message2event embeds: crazy html is all escaped", async t => { @@ -152,12 +151,17 @@ test("message2event embeds: title without url", async t => { const events = await messageToEvent(data.message_with_embeds.title_without_url, data.guild.general) t.deepEqual(events, [{ $type: "m.room.message", - msgtype: "m.notice", - body: "↪️ PapiOphidian used `/stats`" - + "\n| ## Hi, I'm Amanda!\n| \n| I condone pirating music!", + body: "> ↪️ @papiophidian: used `/stats`", format: "org.matrix.custom.html", - formatted_body: '
↪️ PapiOphidian used /stats
' - + `

Hi, I'm Amanda!

I condone pirating music!

`, + formatted_body: "
↪️ @papiophidian used /stats
", + "m.mentions": {}, + msgtype: "m.text", + }, { + $type: "m.room.message", + msgtype: "m.notice", + body: "| ## Hi, I'm Amanda!\n| \n| I condone pirating music!", + format: "org.matrix.custom.html", + formatted_body: `

Hi, I'm Amanda!

I condone pirating music!

`, "m.mentions": {} }]) }) @@ -166,12 +170,17 @@ test("message2event embeds: url without title", async t => { const events = await messageToEvent(data.message_with_embeds.url_without_title, data.guild.general) t.deepEqual(events, [{ $type: "m.room.message", - msgtype: "m.notice", - body: "↪️ PapiOphidian used `/stats`" - + "\n| I condone pirating music!", + body: "> ↪️ @papiophidian: used `/stats`", format: "org.matrix.custom.html", - formatted_body: '
↪️ PapiOphidian used /stats
' - + `

I condone pirating music!

`, + formatted_body: "
↪️ @papiophidian used /stats
", + "m.mentions": {}, + msgtype: "m.text", + }, { + $type: "m.room.message", + msgtype: "m.notice", + body: "| I condone pirating music!", + format: "org.matrix.custom.html", + formatted_body: `

I condone pirating music!

`, "m.mentions": {} }]) }) @@ -180,12 +189,17 @@ test("message2event embeds: author without url", async t => { const events = await messageToEvent(data.message_with_embeds.author_without_url, data.guild.general) t.deepEqual(events, [{ $type: "m.room.message", - msgtype: "m.notice", - body: "↪️ PapiOphidian used `/stats`" - + "\n| ## Amanda\n| \n| I condone pirating music!", + body: "> ↪️ @papiophidian: used `/stats`", format: "org.matrix.custom.html", - formatted_body: '
↪️ PapiOphidian used /stats
' - + `

Amanda

I condone pirating music!

`, + formatted_body: "
↪️ @papiophidian used /stats
", + "m.mentions": {}, + msgtype: "m.text", + }, { + $type: "m.room.message", + msgtype: "m.notice", + body: "| ## Amanda\n| \n| I condone pirating music!", + format: "org.matrix.custom.html", + formatted_body: `

Amanda

I condone pirating music!

`, "m.mentions": {} }]) }) @@ -194,12 +208,17 @@ test("message2event embeds: author url without name", async t => { const events = await messageToEvent(data.message_with_embeds.author_url_without_name, data.guild.general) t.deepEqual(events, [{ $type: "m.room.message", - msgtype: "m.notice", - body: "↪️ PapiOphidian used `/stats`" - + "\n| I condone pirating music!", + body: "> ↪️ @papiophidian: used `/stats`", format: "org.matrix.custom.html", - formatted_body: '
↪️ PapiOphidian used /stats
' - + `

I condone pirating music!

`, + formatted_body: "
↪️ @papiophidian used /stats
", + "m.mentions": {}, + msgtype: "m.text", + }, { + $type: "m.room.message", + msgtype: "m.notice", + body: "| I condone pirating music!", + format: "org.matrix.custom.html", + formatted_body: `

I condone pirating music!

`, "m.mentions": {} }]) }) @@ -299,56 +318,19 @@ 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, [{ - $type: "m.room.message", - msgtype: "m.text", - body: "@Realdditors: get real https://tenor.com/view/get-real-gif-26176788", - format: "org.matrix.custom.html", - formatted_body: "@Realdditors get real https://tenor.com/view/get-real-gif-26176788", - "m.mentions": {} - }, { - $type: "m.room.message", - msgtype: "m.notice", - body: "| 🎞️ https://media.tenor.com/Bz5pfRIu81oAAAPo/get-real.mp4", - format: "org.matrix.custom.html", - formatted_body: "

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

", - "m.mentions": {} - }]) -}) - -test("message2event embeds: 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: "
Cute Corgi Waddle
", - "m.mentions": {} - }]) -}) - test("message2event embeds: if discord creates an embed preview for a discord channel link, don't copy that embed", async t => { const events = await messageToEvent(data.message_with_embeds.discord_server_included_punctuation_bad_discord, data.guild.general, {}, { api: { - getEffectivePower: mockGetEffectivePower(), + async getStateEvent(roomID, type, key) { + t.equal(roomID, "!TqlyQmifxGUggEmdBN:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return { + users: { + "@_ooye_bot:cadence.moe": 100 + } + } + }, async getJoinedMembers(roomID) { t.equal(roomID, "!TqlyQmifxGUggEmdBN:cadence.moe") return { @@ -369,16 +351,3 @@ test("message2event embeds: if discord creates an embed preview for a discord ch "m.mentions": {} }]) }) - -test("message2event embeds: nothing generated if embeds are disabled in settings", async t => { - db.prepare("UPDATE guild_space SET url_preview = 0 WHERE guild_id = ?").run(data.guild.general.id) - const events = await messageToEvent(data.message_with_embeds.youtube_video, data.guild.general) - t.deepEqual(events, [{ - $type: "m.room.message", - msgtype: "m.text", - body: "https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E\n\n\nJutomi I'm gonna make these sounds in your walls tonight", - format: "org.matrix.custom.html", - formatted_body: `https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E


Jutomi I'm gonna make these sounds in your walls tonight`, - "m.mentions": {} - }]) -}) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 7f77b81..582b26c 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -14,33 +14,28 @@ 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("../../matrix/utils")} */ -const mxUtils = sync.require("../../matrix/utils") +/** @type {import("../../m2d/converters/utils")} */ +const mxUtils = sync.require("../../m2d/converters/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, spoilers = []) { +function getDiscordParseCallbacks(message, guild, useHTML) { 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 + const username = message.mentions.find(ment => ment.id === node.id)?.username || (interaction?.user.id === node.id ? interaction.user.username : null) - || (message.author?.id === node.id ? message.author.username : null) - || "unknown-user" + || node.id if (mxid && useHTML) { return `@${username}` } else { @@ -93,10 +88,6 @@ function getDiscordParseCallbacks(message, guild, useHTML, spoilers = []) { here: () => { if (message.mention_everyone) return "@room" return "@here" - }, - spoiler: node => { - spoilers.push(node.raw) - return useHTML } } } @@ -109,11 +100,10 @@ const embedTitleParser = markdown.markdownEngine.parserFor({ /** * @param {{room?: boolean, user_ids?: string[]}} mentions - * @param {Omit} attachment - * @param {boolean} [alwaysLink] + * @param {DiscordTypes.APIAttachment} attachment */ -async function attachmentToEvent(mentions, attachment, alwaysLink) { - const external_url = dUtils.getPublicUrlForCdn(attachment.url) +async function attachmentToEvent(mentions, attachment) { + const publicURL = dUtils.getPublicUrlForCdn(attachment.url) const emoji = attachment.content_type?.startsWith("image/jp") ? "📸" : attachment.content_type?.startsWith("image/") ? "🖼️" @@ -127,20 +117,20 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) { $type: "m.room.message", "m.mentions": mentions, msgtype: "m.text", - body: `${emoji} Uploaded SPOILER file: ${external_url} (${pb(attachment.size)})`, + body: `${emoji} Uploaded SPOILER file: ${publicURL} (${pb(attachment.size)})`, format: "org.matrix.custom.html", - formatted_body: `
${emoji} Uploaded SPOILER file: ${external_url} (${pb(attachment.size)})
` + formatted_body: `
${emoji} Uploaded SPOILER file: ${publicURL} (${pb(attachment.size)})
` } } // for large files, always link them instead of uploading so I don't use up all the space in the content repo - else if (alwaysLink || attachment.size > reg.ooye.max_file_size) { + else if (attachment.size > reg.ooye.max_file_size) { return { $type: "m.room.message", "m.mentions": mentions, msgtype: "m.text", - body: `${emoji} Uploaded file: ${external_url} (${pb(attachment.size)})`, + body: `${emoji} Uploaded file: ${publicURL} (${pb(attachment.size)})`, format: "org.matrix.custom.html", - formatted_body: `${emoji} Uploaded file: ${attachment.filename} (${pb(attachment.size)})` + formatted_body: `${emoji} Uploaded file: ${attachment.filename} (${pb(attachment.size)})` } } else if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) { return { @@ -148,7 +138,7 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) { "m.mentions": mentions, msgtype: "m.image", url: await file.uploadDiscordFileToMxc(attachment.url), - external_url, + external_url: attachment.url, body: attachment.description || attachment.filename, filename: attachment.filename, info: { @@ -164,7 +154,7 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) { "m.mentions": mentions, msgtype: "m.video", url: await file.uploadDiscordFileToMxc(attachment.url), - external_url, + external_url: attachment.url, body: attachment.description || attachment.filename, filename: attachment.filename, info: { @@ -180,13 +170,13 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) { "m.mentions": mentions, msgtype: "m.audio", url: await file.uploadDiscordFileToMxc(attachment.url), - external_url, + external_url: attachment.url, body: attachment.description || attachment.filename, filename: attachment.filename, info: { mimetype: attachment.content_type, size: attachment.size, - duration: attachment.duration_secs && Math.round(attachment.duration_secs * 1000) + duration: attachment.duration_secs ? attachment.duration_secs * 1000 : undefined } } } else { @@ -195,7 +185,7 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) { "m.mentions": mentions, msgtype: "m.file", url: await file.uploadDiscordFileToMxc(attachment.url), - external_url, + external_url: attachment.url, body: attachment.description || attachment.filename, filename: attachment.filename, info: { @@ -206,74 +196,15 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) { } } -/** @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 {DiscordTypes.APIMessage} message - * @param {DiscordTypes.APIGuild} guild - * @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean, alwaysReturnFormattedBody?: boolean, scanTextForMentions?: boolean}} options default values: + * @param {import("discord-api-types/v10").APIMessage} message + * @param {import("discord-api-types/v10").APIGuild} guild + * @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean}} options default values: * - includeReplyFallback: true * - includeEditFallbackStar: false - * - alwaysReturnFormattedBody: false - formatted_body will be skipped if it is the same as body because the message is plaintext. if you want the formatted_body to be returned anyway, for example to merge it with another message, then set this to true. - * - 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}[]>} + * @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API */ async function messageToEvent(message, guild, options = {}, di) { - message = structuredClone(message) const events = [] /* c8 ignore next 7 */ @@ -285,38 +216,6 @@ 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. @@ -335,8 +234,11 @@ async function messageToEvent(message, guild, options = {}, di) { } const interaction = message.interaction_metadata || message.interaction - const isInteraction = message.type === DiscordTypes.MessageType.ChatInputCommand && !!interaction && "name" in interaction - const isThinkingInteraction = isInteraction && !!((message.flags || 0) & DiscordTypes.MessageFlags.Loading) + 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. + if (message.content) message.content = `\n${message.content}` + message.content = `> ↪️ <@${interaction.user.id}> used \`/${interaction.name}\`${message.content}` + } /** @type {{room?: boolean, user_ids?: string[]}} @@ -355,10 +257,7 @@ 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, channel_id: string}?} */ let repliedToEventRow = null - let repliedToEventInDifferentRoom = false - let repliedToUnknownEvent = false let repliedToEventSenderMxid = null if (message.mention_everyone) mentions.room = true @@ -371,11 +270,9 @@ 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 = 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 + const row = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id").select("event_id", "room_id", "source").and("WHERE message_id = ? AND part = 0").get(message.message_reference.message_id) + if (row) { + repliedToEventRow = row } } else if (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 @@ -385,8 +282,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 = await getHistoricalEventRow(match[1]) - if (row && "event_id" in row) { + 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) { /* 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: @@ -404,7 +301,7 @@ async function messageToEvent(message, guild, options = {}, di) { } } message.embeds.shift() - repliedToEventRow = Object.assign(row, {channel_id: row.reference_channel_id}) + repliedToEventRow = row } } } @@ -431,34 +328,6 @@ 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. @@ -470,13 +339,27 @@ 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 - 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}` - })() + 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]` + } content = content.slice(0, match.index + offset) + result + content.slice(match.index + match[0].length + offset) offset += result.length - match[0].length @@ -491,7 +374,6 @@ 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 @@ -503,12 +385,6 @@ 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)] @@ -516,28 +392,26 @@ async function messageToEvent(message, guild, options = {}, di) { const id = match[3] const name = match[2] const animated = !!match[1] - return emojiToKey.emojiToKey({id, name, animated}, message.id) // Register the custom emoji if needed + return emojiToKey.emojiToKey({id, name, animated}) // Register the custom emoji if needed })) async function transformParsedVia(parsed) { for (const node of parsed) { - if (node.type === "discordChannel" || node.type === "discordChannelLink") { + if (node.type === "discordChannel") { 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]) { - if (Array.isArray(maybeChildNodesArray)) { - await transformParsedVia(maybeChildNodesArray) - } + if (Array.isArray(node.content)) { + await transformParsedVia(node.content) } } return parsed } let html = await markdown.toHtmlWithPostParser(content, transformParsedVia, { - discordCallback: getDiscordParseCallbacks(message, guild, true, spoilers), + discordCallback: getDiscordParseCallbacks(message, guild, true), ...customOptions }, customParser, customHtmlOutput) @@ -551,13 +425,8 @@ async function messageToEvent(message, guild, options = {}, di) { return {body, html} } - /** - * After converting Discord content to Matrix plaintext and HTML content, post-process the bodies and push the resulting text event - * @param {string} body matrix event plaintext body - * @param {string} html matrix event HTML body - * @param {string} msgtype matrix event msgtype (maybe m.text or m.notice) - */ - async function addTextEvent(body, html, msgtype) { + // FIXME: What was the scanMentions parameter supposed to activate? It's unused. + async function addTextEvent(body, html, msgtype, {scanMentions}) { // Star * prefix for fallback edits if (options.includeEditFallbackStar) { body = "* " + body @@ -565,84 +434,65 @@ async function messageToEvent(message, guild, options = {}, di) { } const flags = message.flags || 0 - if (flags & DiscordTypes.MessageFlags.IsCrosspost) { + if (flags & 2) { 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 && 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 - if (!referenced) { // backend couldn't be bothered to dereference the message, have to do it ourselves - 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 = `${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 { // 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 (repliedToEventRow && 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 } - } - - if (isInteraction && !isThinkingInteraction && events.length === 0) { - const formattedInteraction = getFormattedInteraction(interaction, false) - body = `${formattedInteraction.body}\n${body}` - html = `${formattedInteraction.html}${html}` + 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 repliedToHtml = markdown.toHTML(repliedToContent, { + discordCallback: getDiscordParseCallbacks(message, guild, true) + }) + const repliedToBody = markdown.toHTML(repliedToContent, { + discordCallback: getDiscordParseCallbacks(message, guild, false), + discordOnly: true, + escapeHTML: false, + }) + html = `
In reply to ${repliedToUserHtml}` + + `
${repliedToHtml}
` + + html + body = (`${repliedToDisplayName}: ` // scenario 1 part B for mentions + + repliedToBody).split("\n").map(line => "> " + line).join("\n") + + "\n\n" + body } const newTextMessageEvent = { $type: "m.room.message", "m.mentions": mentions, msgtype, - body: body, - format: "org.matrix.custom.html", - formatted_body: html + body: body + } + + const isPlaintext = body === html + + if (!isPlaintext) { + Object.assign(newTextMessageEvent, { + format: "org.matrix.custom.html", + formatted_body: html + }) } events.push(newTextMessageEvent) @@ -656,332 +506,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_reference.message_id && message.message_snapshots?.length) { - // Forwarded notice - 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) - if (row && "event_id" in row) { - const via = await getViaServersMemo(row.room_id) - forwardedNotice.addLine( - `[🔀 Forwarded from #${roomName}]`, - tag`🔀 Forwarded from ${roomName} [jump to event]` - ) - } else { - const via = await getViaServersMemo(room.room_id) - forwardedNotice.addLine( - `[🔀 Forwarded from #${roomName}]`, - tag`🔀 Forwarded from ${roomName} [jump to room]` - ) - } - } else { - forwardedNotice.addLine( - `[🔀 Forwarded message]`, - tag`🔀 Forwarded message` - ) - } - - // Forwarded content - // @ts-ignore - const forwardedEvents = await messageToEvent(message.message_snapshots[0].message, guild, {includeReplyFallback: false, includeEditFallbackStar: false, alwaysReturnFormattedBody: true, scanTextForMentions: false}, di) - - // Indent - for (const event of forwardedEvents) { - if (["m.text", "m.notice"].includes(event.msgtype)) { - event.body = event.body.split("\n").map(l => "» " + l).join("\n") - event.formatted_body = `
${event.formatted_body}
` - } - } - - // Try to merge the forwarded content with the forwarded notice - let {body, formatted_body} = forwardedNotice.get() - if (forwardedEvents.length >= 1 && ["m.text", "m.notice"].includes(forwardedEvents[0].msgtype)) { // Try to merge the forwarded content and the forwarded notice - forwardedEvents[0].body = body + "\n" + forwardedEvents[0].body - forwardedEvents[0].formatted_body = formatted_body + "
" + forwardedEvents[0].formatted_body - } else { - await addTextEvent(body, formatted_body, "m.notice") - } - events.push(...forwardedEvents) - } - - if (isThinkingInteraction) { - const formattedInteraction = getFormattedInteraction(interaction, true) - await addTextEvent(formattedInteraction.body, formattedInteraction.html, "m.notice") - } - - // Then text content - if (message.content && !isOnlyKlipyGIF && !isThinkingInteraction) { + 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. - let content = message.content - if (options.scanTextForMentions !== false) { - 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) - content = found.newContent + const matches = [...message.content.matchAll(/@ ?([a-z0-9._]+)\b/gi)] + if (matches.length && matches.some(m => m[1].match(/[a-z]/i) && m[1] !== "everyone" && m[1] !== "here")) { + 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) } } } - // Scan the content for emojihax and replace them with real emojis - content = 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) - await addTextEvent(body, html, msgtype) - } - - // Then scheduled events - if (message.content && di?.snow) { - for (const match of [...message.content.matchAll(/discord\.gg\/([A-Za-z0-9]+)\?event=([0-9]{18,})/g)]) { // snowflake has minimum 18 because the events feature is at least that old - const invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]}) - const event = invite.guild_scheduled_event - if (!event) continue // the event ID provided was not valid - - const formatter = new Intl.DateTimeFormat("en-NZ", {month: "long", day: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "shortGeneric", timeZone: reg.ooye.time_zone}) // 9 June at 3:00 pm NZT - const rep = new mxUtils.MatrixStringBuilder() - - // Add time - if (event.scheduled_end_time) { - // @ts-ignore - no definition available for formatRange - rep.addParagraph(`Scheduled Event - ${formatter.formatRange(new Date(event.scheduled_start_time), new Date(event.scheduled_end_time))}`) - } else { - rep.addParagraph(`Scheduled Event - ${formatter.format(new Date(event.scheduled_start_time))}`) - } - - // Add details - rep.addLine(`## ${event.name}`, tag`${event.name}`) - if (event.description) rep.addLine(event.description) - - // Add location - if (event.entity_metadata?.location) { - rep.addParagraph(`📍 ${event.entity_metadata.location}`) - } else if (invite.channel?.name) { - const roomID = select("channel_room", "room_id", {channel_id: invite.channel.id}).pluck().get() - if (roomID) { - const via = await getViaServersMemo(roomID) - rep.addParagraph(`🔊 ${invite.channel.name} - https://matrix.to/#/${roomID}?${via}`, tag`🔊 ${invite.channel.name} - ${invite.channel.name}`) - } else { - rep.addParagraph(`🔊 ${invite.channel.name}`) - } - } - - // Send like an embed - let {body, formatted_body: html} = rep.get() - body = body.split("\n").map(l => "| " + l).join("\n") - html = `
${html}
` - await addTextEvent(body, html, "m.notice") - } + // Text content appears first + const {body, html} = await transformContent(message.content) + await addTextEvent(body, html, msgtype, {scanMentions: true}) } // Then attachments if (message.attachments) { - 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. - let prev = events.at(-1) - for (const atch of attachmentEvents) { - if (atch.msgtype === "m.text" && prev?.body && prev?.formatted_body && ["m.text", "m.notice"].includes(prev?.msgtype)) { - prev.body = prev.body + "\n" + atch.body - prev.formatted_body = prev.formatted_body + "
" + atch.formatted_body - } else { - events.push(atch) - } - } - } - - // 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({}, {...file, filename: component.name, size: component.size}, 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) { - if (component.label) { - stack.msb.add(`[${component.label} ${component.url}] `, tag`${component.label} `) - } else { - stack.msb.add(component.url) - } - } - } - - // 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) { - await addTextEvent(body, formatted_body, "m.text") - } - } - - // Then polls - if (message.poll) { - const pollEvent = await pollToEvent(message.poll) - events.push(pollEvent) + const attachmentEvents = await Promise.all(message.attachments.map(attachmentToEvent.bind(null, mentions))) + events.push(...attachmentEvents) } // Then embeds - const urlPreviewEnabled = select("guild_space", "url_preview", {guild_id: guild?.id}).pluck().get() ?? 1 for (const embed of message.embeds || []) { - if (!urlPreviewEnabled && !message.author?.bot) { - continue // show embeds for everyone if enabled, or bot users only if disabled (bots often send content in embeds) - } - if (embed.type === "image") { continue // Matrix's own URL previews are fine for images. } - 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() - if (isKlipyGIF) { - assert(embed.video?.url) - rep.add("[GIF] ", "➿ ") - if (embed.title) { - rep.add(`${embed.title} ${embed.video.url}`, tag`${embed.title}`) - } else { - rep.add(embed.video.url) - } - - let {body, formatted_body: html} = rep.get() - html = `
${html}
` - await addTextEvent(body, html, "m.text") - continue - } - // Provider - if (embed.provider?.name && embed.provider.name !== "Tenor") { + if (embed.provider?.name) { if (embed.provider.url) { rep.addParagraph(`via ${embed.provider.name} ${embed.provider.url}`, tag`${embed.provider.name}`) } else { @@ -1030,9 +599,9 @@ async function messageToEvent(message, guild, options = {}, di) { let chosenImage = embed.image?.url // the thumbnail seems to be used for "article" type but displayed big at the bottom by discord if (embed.type === "article" && embed.thumbnail?.url && !chosenImage) chosenImage = embed.thumbnail.url - if (chosenImage) rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`) + if (chosenImage) rep.addParagraph(`📸 ${chosenImage}`) - if (embed.video?.url) rep.addParagraph(`🎞️ ${dUtils.getPublicUrlForCdn(embed.video.url)}`) + if (embed.video?.url) rep.addParagraph(`🎞️ ${embed.video.url}`) if (embed.footer?.text) rep.addLine(`— ${embed.footer.text}`, tag`— ${embed.footer.text}`) let {body, formatted_body: html} = rep.get() @@ -1040,7 +609,7 @@ async function messageToEvent(message, guild, options = {}, di) { html = `
${html}
` // Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person - await addTextEvent(body, html, "m.notice") + await addTextEvent(body, html, "m.notice", {scanMentions: false}) } // Then stickers @@ -1076,7 +645,7 @@ async function messageToEvent(message, guild, options = {}, di) { } // Rich replies - if (repliedToEventRow && !repliedToEventInDifferentRoom) { + if (repliedToEventRow) { Object.assign(events[0], { "m.relates_to": { "m.in_reply_to": { @@ -1086,16 +655,6 @@ async function messageToEvent(message, guild, options = {}, di) { }) } - // Strip formatted_body where equivalent to body - if (!options.alwaysReturnFormattedBody) { - for (const event of events) { - if (event.$type === "m.room.message" && "msgtype" in event && ["m.text", "m.notice"].includes(event.msgtype) && event.body === event.formatted_body) { - delete event.format - delete event.formatted_body - } - } - } - return events } diff --git a/src/d2m/converters/message-to-event.test.pk.js b/src/d2m/converters/message-to-event.pk.test.js similarity index 72% rename from src/d2m/converters/message-to-event.test.pk.js rename to src/d2m/converters/message-to-event.pk.test.js index 1323280..ce83d54 100644 --- a/src/d2m/converters/message-to-event.test.pk.js +++ b/src/d2m/converters/message-to-event.pk.test.js @@ -50,7 +50,11 @@ test("message2event: pk reply to matrix is converted to native matrix reply", as ] }, msgtype: "m.text", - body: "this is a reply", + body: "> cadence [they]: now for my next experiment:\n\nthis is a reply", + format: "org.matrix.custom.html", + formatted_body: '
In reply to cadence [they]
' + + "now for my next experiment:
" + + "this is a reply", "m.relates_to": { "m.in_reply_to": { event_id: "$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU" @@ -76,7 +80,11 @@ test("message2event: pk reply to discord is converted to native matrix reply", a $type: "m.room.message", msgtype: "m.text", "m.mentions": {}, - body: "this is a reply", + body: "> wing: some text\n\nthis is a reply", + format: "org.matrix.custom.html", + formatted_body: '
In reply to wing
' + + "some text
" + + "this is a reply", "m.relates_to": { "m.in_reply_to": { event_id: "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA" @@ -112,7 +120,11 @@ test("message2event: pk reply to matrix attachment is converted to native matrix "m.mentions": { user_ids: ["@ampflower:matrix.org"] }, - body: "Cat nod", + body: "> Ampflower 🌺: [Media]\n\nCat nod", + format: "org.matrix.custom.html", + formatted_body: '
In reply to Ampflower 🌺
' + + "[Media]
" + + "Cat nod", "m.relates_to": { "m.in_reply_to": { event_id: "$OEEK-Wam2FTh6J-6kVnnJ6KnLA_lLRnLTHatKKL62-Y" diff --git a/src/d2m/converters/message-to-event.test.components.js b/src/d2m/converters/message-to-event.test.components.js deleted file mode 100644 index 7d875a6..0000000 --- a/src/d2m/converters/message-to-event.test.components.js +++ /dev/null @@ -1,79 +0,0 @@ -const {test} = require("supertape") -const {messageToEvent} = require("./message-to-event") -const data = require("../../../test/data") - -test("message2event components: pk question mark output", async t => { - const events = await messageToEvent(data.message_with_components.pk_question_mark_response, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - body: - "| ### Lillith (INX)" - + "\n| " - + "\n| **Display name:** Lillith (she/her)" - + "\n| **Pronouns:** She/Her" - + "\n| **Message count:** 3091" - + "\n| 🖼️ https://files.inx.moe/p/cdn/lillith.webp" - + "\n| " - + "\n| ----" - + "\n| " - + "\n| **Proxy tags:**" - + "\n| ``l;text``" - + "\n| ``l:text``" - + "\n| ``l.text``" - + "\n| ``textl.``" - + "\n| ``textl;``" - + "\n| ``textl:``" - + "\n" - + "\n-# System ID: `xffgnx` ∙ Member ID: `pphhoh`" - + "\n-# Created: 2025-12-31 03:16:45 UTC" - + "\n[View on dashboard https://dash.pluralkit.me/profile/m/pphhoh] " - + "\n" - + "\n----" - + "\n" - + "\n| **System:** INX (`xffgnx`)" - + "\n| **Member:** Lillith (`pphhoh`)" - + "\n| **Sent by:** infinidoge1337 (@unknown-user:)" - + "\n| " - + "\n| **Account Roles (7)**" - + "\n| §b, !, ‼, Ears Port Ping, Ears Update Ping, Yttr Ping, unsup Ping" - + "\n| 🖼️ https://files.inx.moe/p/cdn/lillith.webp" - + "\n| " - + "\n| ----" - + "\n| " - + "\n| Same hat" - + "\n| 🖼️ Image: https://bridge.example.org/download/discordcdn/934955898965729280/1466556006527012987/image.png" - + "\n" - + "\n-# Original Message ID: 1466556003645657118 · ", - format: "org.matrix.custom.html", - formatted_body: "
" - + "

Lillith (INX)

" - + "

Display name: Lillith (she/her)" - + "
Pronouns: She/Her" - + "
Message count: 3091

" - + `🖼️ https://files.inx.moe/p/cdn/lillith.webp` - + "
" - + "

Proxy tags:" - + "
l;text" - + "
l:text" - + "
l.text" - + "
textl." - + "
textl;" - + "
textl:

" - + "

System ID: xffgnx ∙ Member ID: pphhoh
" - + "Created: 2025-12-31 03:16:45 UTC

" - + `View on dashboard ` - + "
" - + "

System: INX (xffgnx)" - + "
Member: Lillith (pphhoh)" - + "
Sent by: infinidoge1337 (@unknown-user:)" - + "

Account Roles (7)" - + "
§b, !, ‼, Ears Port Ping, Ears Update Ping, Yttr Ping, unsup Ping

" - + `🖼️ https://files.inx.moe/p/cdn/lillith.webp` - + "
" - + "

Same hat

" - + `🖼️ Image: image.png
` - + "

Original Message ID: 1466556003645657118 · <t:1769724599:f>

", - "m.mentions": {}, - msgtype: "m.text", - }]) -}) diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index 1a73aea..415f48d 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -2,7 +2,6 @@ const {test} = require("supertape") const {messageToEvent} = require("./message-to-event") const {MatrixServerError} = require("../../matrix/mreq") const data = require("../../../test/data") -const {mockGetEffectivePower} = require("../../matrix/utils.test") const Ty = require("../../types") /** @@ -67,7 +66,17 @@ test("message2event: simple room mention", async t => { let called = 0 const events = await messageToEvent(data.message.simple_room_mention, data.guild.general, {}, { api: { - getEffectivePower: mockGetEffectivePower(), + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return { + users: { + "@_ooye_bot:cadence.moe": 100 + } + } + }, async getJoinedMembers(roomID) { called++ t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe") @@ -88,42 +97,24 @@ test("message2event: simple room mention", async t => { format: "org.matrix.custom.html", formatted_body: '#worm-farm' }]) - t.equal(called, 1, "should call getJoinedMembers") -}) - -test("message2event: simple room link", async t => { - let called = 0 - const events = await messageToEvent(data.message.simple_room_link, data.guild.general, {}, { - api: { - getEffectivePower: mockGetEffectivePower(), - async getJoinedMembers(roomID) { - called++ - t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe") - return { - joined: { - "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, - "@user:matrix.org": {display_name: null, avatar_url: null} - } - } - } - } - }) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.text", - body: "#worm-farm", - format: "org.matrix.custom.html", - formatted_body: '#worm-farm' - }]) - t.equal(called, 1, "should call getJoinedMembers once") + t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each") }) test("message2event: nicked room mention", async t => { let called = 0 const events = await messageToEvent(data.message.nicked_room_mention, data.guild.general, {}, { api: { - getEffectivePower: mockGetEffectivePower(), + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return { + users: { + "@_ooye_bot:cadence.moe": 100 + } + } + }, async getJoinedMembers(roomID) { called++ t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") @@ -144,7 +135,7 @@ test("message2event: nicked room mention", async t => { format: "org.matrix.custom.html", formatted_body: '#main' }]) - t.equal(called, 1, "should call getJoinedMembers once") + t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each") }) test("message2event: unknown room mention", async t => { @@ -195,7 +186,17 @@ test("message2event: simple message link", async t => { let called = 0 const events = await messageToEvent(data.message.simple_message_link, data.guild.general, {}, { api: { - getEffectivePower: mockGetEffectivePower(), + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return { + users: { + "@_ooye_bot:cadence.moe": 100 + } + } + }, async getJoinedMembers(roomID) { called++ t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") @@ -216,14 +217,13 @@ test("message2event: simple message link", async t => { format: "org.matrix.custom.html", formatted_body: 'https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg?via=cadence.moe&via=super.invalid' }]) - t.equal(called, 1, "should call getJoinedMembers once") + t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each") }) test("message2event: message link that OOYE doesn't know about", async t => { let called = 0 const events = await messageToEvent(data.message.message_link_to_before_ooye, data.guild.general, {}, { api: { - getEffectivePower: mockGetEffectivePower(), async getEventForTimestamp(roomID, ts) { called++ t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") @@ -232,6 +232,17 @@ test("message2event: message link that OOYE doesn't know about", async t => { origin_server_ts: 1613287812754 } }, + async getStateEvent(roomID, type, key) { // for ?via calculation + called++ + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return { + users: { + "@_ooye_bot:cadence.moe": 100 + } + } + }, async getJoinedMembers(roomID) { // for ?via calculation called++ t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") @@ -254,7 +265,7 @@ test("message2event: message link that OOYE doesn't know about", async t => { formatted_body: "Me: I'll scroll up to find a certain message I'll send
scrolls up and clicks message links for god knows how long
completely forgets what they were looking for and simply begins scrolling up to find some fun moments
stumbles upon: " + 'https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$E8IQDGFqYzOU7BwY5Z74Bg-cwaU9OthXSroaWtgYc7U?via=cadence.moe&via=matrix.org' }]) - t.equal(called, 2, "getEventForTimestamp and getJoinedMembers should be called once each") + t.equal(called, 3, "getEventForTimestamp, getStateEvent, and getJoinedMembers should be called once each") }) test("message2event: message timestamp failed to fetch", async t => { @@ -269,7 +280,17 @@ test("message2event: message timestamp failed to fetch", async t => { error: "Unable to find event from 1726762095974 in direction Direction.FORWARDS" }, {}) }, - getEffectivePower: mockGetEffectivePower(), + async getStateEvent(roomID, type, key) { // for ?via calculation + called++ + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return { + users: { + "@_ooye_bot:cadence.moe": 100 + } + } + }, async getJoinedMembers(roomID) { // for ?via calculation called++ t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") @@ -287,12 +308,12 @@ test("message2event: message timestamp failed to fetch", async t => { "m.mentions": {}, msgtype: "m.text", body: "Me: I'll scroll up to find a certain message I'll send\n_scrolls up and clicks message links for god knows how long_\n_completely forgets what they were looking for and simply begins scrolling up to find some fun moments_\n_stumbles upon:_ " - + "[unknown event in https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&via=matrix.org]", + + "[unknown event, timestamp resolution failed, in room: https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&via=matrix.org]", format: "org.matrix.custom.html", formatted_body: "Me: I'll scroll up to find a certain message I'll send
scrolls up and clicks message links for god knows how long
completely forgets what they were looking for and simply begins scrolling up to find some fun moments
stumbles upon: " - + '[unknown event in https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&via=matrix.org]' + + '[unknown event, timestamp resolution failed, in room: https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&via=matrix.org]' }]) - t.equal(called, 2, "getEventForTimestamp and getJoinedMembers should be called once each") + t.equal(called, 3, "getEventForTimestamp, getStateEvent, and getJoinedMembers should be called once each") }) test("message2event: message link from another server", async t => { @@ -316,7 +337,7 @@ test("message2event: attachment with no content", async t => { msgtype: "m.image", url: "mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM", body: "image.png", - external_url: "https://bridge.example.org/download/discordcdn/497161332244742154/1124628646431297546/image.png", + external_url: "https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png", filename: "image.png", info: { mimetype: "image/png", @@ -352,7 +373,7 @@ test("message2event: stickers", async t => { msgtype: "m.image", url: "mxc://cadence.moe/ZDCNYnkPszxGKgObUIFmvjus", body: "image.png", - external_url: "https://bridge.example.org/download/discordcdn/122155380120748034/1106366167486038016/image.png", + external_url: "https://cdn.discordapp.com/attachments/122155380120748034/1106366167486038016/image.png", filename: "image.png", info: { mimetype: "image/png", @@ -406,7 +427,7 @@ test("message2event: skull webp attachment with content", async t => { mimetype: "image/webp", size: 74290 }, - external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1128084747910918195/skull.webp", + external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1128084747910918195/skull.webp", filename: "skull.webp", url: "mxc://cadence.moe/sDxWmDErBhYBxtDcJQgBETes" }]) @@ -423,7 +444,12 @@ test("message2event: reply to skull webp attachment with content", async t => { }, "m.mentions": {}, msgtype: "m.text", - body: "Reply" + body: "> Extremity: Image\n\nReply", + format: "org.matrix.custom.html", + formatted_body: + '
In reply to Extremity' + + '
Image
' + + 'Reply' }, { $type: "m.room.message", "m.mentions": {}, @@ -435,7 +461,7 @@ test("message2event: reply to skull webp attachment with content", async t => { mimetype: "image/jpeg", size: 85906 }, - external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1128084851023675515/RDT_20230704_0936184915846675925224905.jpg", + external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1128084851023675515/RDT_20230704_0936184915846675925224905.jpg", filename: "RDT_20230704_0936184915846675925224905.jpg", url: "mxc://cadence.moe/WlAbFSiNRIHPDEwKdyPeGywa" }]) @@ -467,7 +493,12 @@ test("message2event: simple reply to matrix user", async t => { ] }, msgtype: "m.text", - body: "Reply" + body: "> cadence: so can you reply to my webhook uwu\n\nReply", + format: "org.matrix.custom.html", + formatted_body: + '
In reply to cadence' + + '
so can you reply to my webhook uwu
' + + 'Reply' }]) }) @@ -501,38 +532,6 @@ test("message2event: simple reply to matrix user, reply fallbacks disabled", asy }]) }) -test("message2event: reply to matrix user with mention", async t => { - const events = await messageToEvent(data.message.reply_to_matrix_user_mention, data.guild.general, {}, { - api: { - getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$7P2O_VTQNHvavX5zNJ35DV-dbJB1Ag80tGQP_JzGdhk", { - type: "m.room.message", - content: { - msgtype: "m.text", - body: "@_ooye_extremity:cadence.moe you owe me $30", - format: "org.matrix.custom.html", - formatted_body: "@_ooye_extremity:cadence.moe you owe me $30" - }, - sender: "@cadence:cadence.moe" - }) - } - }) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.relates_to": { - "m.in_reply_to": { - event_id: "$7P2O_VTQNHvavX5zNJ35DV-dbJB1Ag80tGQP_JzGdhk" - } - }, - "m.mentions": { - user_ids: [ - "@cadence:cadence.moe" - ] - }, - msgtype: "m.text", - body: "kys" - }]) -}) - test("message2event: reply with a video", async t => { const events = await messageToEvent(data.message.reply_with_video, data.guild.general, { api: { @@ -552,7 +551,7 @@ test("message2event: reply with a video", async t => { body: "Ins_1960637570.mp4", filename: "Ins_1960637570.mp4", url: "mxc://cadence.moe/kMqLycqMURhVpwleWkmASpnU", - external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1197621094786531358/Ins_1960637570.mp4", + external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1197621094786531358/Ins_1960637570.mp4?ex=65bbee8f&is=65a9798f&hm=ae14f7824c3d526c5e11c162e012e1ee405fd5776e1e9302ed80ccd86503cfda&", info: { h: 854, mimetype: "video/mp4", @@ -573,10 +572,10 @@ test("message2event: voice message", async t => { t.deepEqual(events, [{ $type: "m.room.message", body: "voice-message.ogg", - external_url: "https://bridge.example.org/download/discordcdn/1099031887500034088/1112476845502365786/voice-message.ogg", + external_url: "https://cdn.discordapp.com/attachments/1099031887500034088/1112476845502365786/voice-message.ogg?ex=65c92d4c&is=65b6b84c&hm=0654bab5027474cbe23875954fa117cf44d8914c144cd151879590fa1baf8b1c&", filename: "voice-message.ogg", info: { - duration: 3960, + duration: 3960.0000381469727, mimetype: "audio/ogg", size: 10584, }, @@ -596,7 +595,7 @@ test("message2event: misc file", async t => { }, { $type: "m.room.message", body: "the.yml", - external_url: "https://bridge.example.org/download/discordcdn/122155380120748034/1174514575220158545/the.yml", + external_url: "https://cdn.discordapp.com/attachments/122155380120748034/1174514575220158545/the.yml?ex=65cd6270&is=65baed70&hm=8c5f1b571784e3c7f99628492298815884e351ae0dc7c2ae40dd22d97caf27d9&", filename: "the.yml", info: { mimetype: "text/plain; charset=utf-8", @@ -641,7 +640,9 @@ test("message2event: simple reply in thread to a matrix user's reply", async t = user_ids: ["@cadence:cadence.moe"] }, msgtype: "m.text", - body: "Well, they don't seem to..." + body: "> cadence [they]: What about them?\n\nWell, they don't seem to...", + format: "org.matrix.custom.html", + formatted_body: "
In reply to cadence [they]
What about them?
Well, they don't seem to...", }]) }) @@ -678,7 +679,9 @@ test("message2event: infinidoge's reply to ami's matrix smalltext reply to infin user_ids: ["@ami:the-apothecary.club"] }, msgtype: "m.text", - body: `Most likely` + body: `> Ami (she/her): let me guess they got a lot of bug reports like "empty chest with no loot?"\n\nMost likely`, + format: "org.matrix.custom.html", + formatted_body: `
In reply to Ami (she/her)
let me guess they got a lot of bug reports like "empty chest with no loot?"
Most likely`, }]) }) @@ -715,21 +718,9 @@ test("message2event: infinidoge's reply to ami's matrix smalltext singleline rep user_ids: ["@ami:the-apothecary.club"] }, msgtype: "m.text", - body: `Most likely` - }]) -}) - -test("message2event: reply to a Discord message that wasn't bridged", async t => { - const events = await messageToEvent(data.message.reply_to_unknown_message, data.guild.general) - t.deepEqual(events, [{ - $type: "m.room.message", - msgtype: "m.text", - body: `> In reply to a 1-day-old unbridged message:` - + `\n> Occimyy: BILLY BOB THE GREAT` - + `\n\nenigmatic`, + body: `> Ami (she/her): let me guess they got a lot of bug reports like "empty chest with no loot?"\n\nMost likely`, format: "org.matrix.custom.html", - formatted_body: `
In reply to a 1-day-old unbridged message from Occimyy:
BILLY BOB THE GREAT
enigmatic`, - "m.mentions": {} + formatted_body: `
In reply to Ami (she/her)
let me guess they got a lot of bug reports like "empty chest with no loot?"
Most likely`, }]) }) @@ -789,13 +780,11 @@ test("message2event: simple written @mention for matrix user", async t => { ] }, msgtype: "m.text", - body: "[@ash](https://matrix.to/#/@she_who_brings_destruction:cadence.moe) do you need anything from the store btw as I'm heading there after gym", - format: "org.matrix.custom.html", - formatted_body: `@ash do you need anything from the store btw as I'm heading there after gym` + body: "@ash do you need anything from the store btw as I'm heading there after gym" }]) }) -test("message2event: many written @mentions for matrix users", async t => { +test("message2event: advanced written @mentions for matrix users", async t => { let called = 0 const events = await messageToEvent(data.message.advanced_written_at_mention_for_matrix, data.guild.general, {}, { api: { @@ -833,200 +822,16 @@ test("message2event: many written @mentions for matrix users", async t => { $type: "m.room.message", "m.mentions": { user_ids: [ - "@huckleton:cadence.moe", - "@cadence:cadence.moe" + "@cadence:cadence.moe", + "@huckleton:cadence.moe" ] }, msgtype: "m.text", - body: "[@Cadence](https://matrix.to/#/@cadence:cadence.moe), tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and [@huck](https://matrix.to/#/@huckleton:cadence.moe)", - format: "org.matrix.custom.html", - formatted_body: `@Cadence, tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and @huck` + body: "@Cadence, tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and @huck" }]) t.equal(called, 1, "should only look up the member list once") }) -test("message2event: written @mentions may match part of the name", async t => { - let called = 0 - const events = await messageToEvent({ - ...data.message.advanced_written_at_mention_for_matrix, - content: "I wonder if @cadence saw this?" - }, data.guild.general, {}, { - api: { - async getJoinedMembers(roomID) { - called++ - t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") - return new Promise(resolve => { - setTimeout(() => { - resolve({ - joined: { - "@secret:cadence.moe": { - display_name: "cadence [they]", - avatar_url: "whatever" - }, - "@huckleton:cadence.moe": { - display_name: "huck", - avatar_url: "whatever" - }, - "@_ooye_botrac4r:cadence.moe": { - display_name: "botrac4r", - avatar_url: "whatever" - }, - "@_ooye_bot:cadence.moe": { - display_name: "Out Of Your Element", - avatar_url: "whatever" - } - } - }) - }) - }) - } - } - }) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": { - user_ids: [ - "@secret:cadence.moe", - ] - }, - msgtype: "m.text", - body: "I wonder if [@cadence](https://matrix.to/#/@secret:cadence.moe) saw this?", - format: "org.matrix.custom.html", - formatted_body: `I wonder if @cadence saw this?` - }]) -}) - -test("message2event: written @mentions may match part of the mxid", async t => { - let called = 0 - const events = await messageToEvent({ - ...data.message.advanced_written_at_mention_for_matrix, - content: "I wonder if @huck saw this?" - }, data.guild.general, {}, { - api: { - async getJoinedMembers(roomID) { - called++ - t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") - return new Promise(resolve => { - setTimeout(() => { - resolve({ - joined: { - "@cadence:cadence.moe": { - display_name: "cadence [they]", - avatar_url: "whatever" - }, - "@huckleton:cadence.moe": { - display_name: "wa", - avatar_url: "whatever" - }, - "@_ooye_botrac4r:cadence.moe": { - display_name: "botrac4r", - avatar_url: "whatever" - }, - "@_ooye_bot:cadence.moe": { - display_name: "Out Of Your Element", - avatar_url: "whatever" - } - } - }) - }) - }) - } - } - }) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": { - user_ids: [ - "@huckleton:cadence.moe", - ] - }, - msgtype: "m.text", - body: "I wonder if [@huck](https://matrix.to/#/@huckleton:cadence.moe) saw this?", - format: "org.matrix.custom.html", - formatted_body: `I wonder if @huck saw this?` - }]) -}) - -test("message2event: written @mentions do not match in URLs", async t => { - const events = await messageToEvent({ - ...data.message.advanced_written_at_mention_for_matrix, - content: "the fucking around with pixel composer continues https://pub.mastodon.sleeping.town/@exa/116037641900024965" - }, data.guild.general, {}, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.text", - body: "the fucking around with pixel composer continues https://pub.mastodon.sleeping.town/@exa/116037641900024965", - format: "org.matrix.custom.html", - formatted_body: `the fucking around with pixel composer continues https://pub.mastodon.sleeping.town/@exa/116037641900024965` - }]) -}) - -test("message2event: entire message may match elaborate display name", async t => { - let called = 0 - const events = await messageToEvent({ - ...data.message.advanced_written_at_mention_for_matrix, - content: "@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆" - }, data.guild.general, {}, { - api: { - async getJoinedMembers(roomID) { - called++ - t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") - return new Promise(resolve => { - setTimeout(() => { - resolve({ - joined: { - "@wa:cadence.moe": { - display_name: "Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆", - avatar_url: "whatever" - }, - "@huckleton:cadence.moe": { - display_name: "huck", - avatar_url: "whatever" - }, - "@_ooye_botrac4r:cadence.moe": { - display_name: "botrac4r", - avatar_url: "whatever" - }, - "@_ooye_bot:cadence.moe": { - display_name: "Out Of Your Element", - avatar_url: "whatever" - } - } - }) - }) - }) - } - } - }) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": { - user_ids: [ - "@wa:cadence.moe", - ] - }, - msgtype: "m.text", - body: "[@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆](https://matrix.to/#/@wa:cadence.moe)", - format: "org.matrix.custom.html", - formatted_body: `@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆` - }]) -}) - -test("message2event: spoilers are removed from plaintext body", async t => { - const events = await messageToEvent({ - content: "||**beatrice**||" - }) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.text", - body: "[spoiler]", - format: "org.matrix.custom.html", - formatted_body: `beatrice` - }]) -}) - test("message2event: very large attachment is linked instead of being uploaded", async t => { const events = await messageToEvent({ content: "hey", @@ -1037,66 +842,18 @@ test("message2event: very large attachment is linked instead of being uploaded", size: 100e6 }] }) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.text", - body: "hey\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)", - format: "org.matrix.custom.html", - formatted_body: 'hey
📄 Uploaded file: hey.jpg (100 MB)' - }]) -}) - -test("message2event: multiple attachments are combined into the same event where possible", async t => { - const events = await messageToEvent({ - content: "hey", - attachments: [{ - filename: "hey.jpg", - url: "https://cdn.discordapp.com/attachments/123/456/789.mega", - content_type: "application/i-made-it-up", - size: 100e6 - }, { - filename: "SPOILER_secret.jpg", - url: "https://cdn.discordapp.com/attachments/123/456/SPOILER_secret.jpg", - content_type: "image/jpeg", - size: 38291 - }, { - filename: "my enemies.txt", - url: "https://cdn.discordapp.com/attachments/123/456/my_enemies.txt", - content_type: "text/plain", - size: 8911 - }, { - filename: "hey.jpg", - url: "https://cdn.discordapp.com/attachments/123/456/789.mega", - content_type: "application/i-made-it-up", - size: 100e6 - }] - }) t.deepEqual(events, [{ $type: "m.room.message", "m.mentions": {}, msgtype: "m.text", body: "hey" - + "\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)" - + "\n📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg (38 KB)" - + "\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)", - format: "org.matrix.custom.html", - formatted_body: "hey" - + `
📄 Uploaded file: hey.jpg (100 MB)` - + `
📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg (38 KB)
` - + `
📄 Uploaded file: hey.jpg (100 MB)` }, { $type: "m.room.message", "m.mentions": {}, - msgtype: "m.file", - body: "my enemies.txt", - filename: "my enemies.txt", - external_url: "https://bridge.example.org/download/discordcdn/123/456/my_enemies.txt", - url: "mxc://cadence.moe/y89EOTRp2lbeOkgdsEleGOge", - info: { - mimetype: "text/plain", - size: 8911 - } + msgtype: "m.text", + body: "📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)", + format: "org.matrix.custom.html", + formatted_body: '📄 Uploaded file: hey.jpg (100 MB)' }]) }) @@ -1171,18 +928,6 @@ test("message2event: emoji that hasn't been registered yet", async t => { }]) }) -test("message2event: emojihax", async t => { - const events = await messageToEvent(data.message.emojihax, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.text", - body: "I only violate the don't modify our console part of terms of service :troll:", - format: "org.matrix.custom.html", - formatted_body: `I only violate the don't modify our console part of terms of service :troll:` - }]) -}) - test("message2event: emoji triple long name", async t => { const events = await messageToEvent(data.message.emoji_triple_long_name, data.guild.general, {}) t.deepEqual(events, [{ @@ -1269,538 +1014,3 @@ test("message2event: @everyone within a link", async t => { "m.mentions": {} }]) }) - -test("message2event: forwarded image", async t => { - const events = await messageToEvent(data.message.forwarded_image) - t.deepEqual(events, [ - { - $type: "m.room.message", - body: "[🔀 Forwarded message]", - format: "org.matrix.custom.html", - formatted_body: "🔀 Forwarded message", - "m.mentions": {}, - msgtype: "m.notice", - }, - { - $type: "m.room.message", - body: "100km.gif", - external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1296237494987133070/100km.gif", - filename: "100km.gif", - info: { - h: 300, - mimetype: "image/gif", - size: 2965649, - w: 300, - }, - "m.mentions": {}, - msgtype: "m.image", - url: "mxc://cadence.moe/qDAotmebTfEIfsAIVCEZptLh", - }, - ]) -}) - -test("message2event: constructed forwarded message", async t => { - const events = await messageToEvent(data.message.constructed_forwarded_message, {}, {}, { - api: { - getEffectivePower: mockGetEffectivePower(), - async getJoinedMembers() { - return { - joined: { - "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, - "@user:matrix.org": {display_name: null, avatar_url: null} - } - } - } - } - }) - t.deepEqual(events, [ - { - $type: "m.room.message", - body: "[🔀 Forwarded from #wonderland]" - + "\n» What's cooking, good looking? :hipposcope:", - format: "org.matrix.custom.html", - formatted_body: `🔀 Forwarded from wonderland [jump to event]` - + `
What's cooking, good looking? :hipposcope:
`, - "m.mentions": {}, - msgtype: "m.text", - }, - { - $type: "m.room.message", - body: "100km.gif", - external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1296237494987133070/100km.gif", - filename: "100km.gif", - info: { - h: 300, - mimetype: "image/gif", - size: 2965649, - w: 300, - }, - "m.mentions": {}, - msgtype: "m.image", - url: "mxc://cadence.moe/qDAotmebTfEIfsAIVCEZptLh", - }, - { - $type: "m.room.message", - body: "» | ## This man" - + "\n» | " - + "\n» | ## This man is 100 km away from your house" - + "\n» | " - + "\n» | ### Distance away" - + "\n» | 99 km" - + "\n» | " - + "\n» | ### Distance away" - + "\n» | 98 km", - format: "org.matrix.custom.html", - formatted_body: "

This man

This man is 100 km away from your house

Distance away
99 km

Distance away
98 km

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

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

` - + `forest exploration` - + `

📍 the dark forest

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

Scheduled Event - 9 June at 3:00 pm NZT

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

🔊 Cooking

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

Scheduled Event - 9 June at 3:00 pm NZT

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

🔊 Hey. - Hey.

`, - "m.mentions": {} - } - ]) -}) - -test("message2event: channel links are converted even inside lists (parser post-processer descends into list items)", async t => { - let called = 0 - const events = await messageToEvent({ - content: "1. Don't be a dick" - + "\n2. Follow rule number 1" - + "\n3. Follow Discord TOS" - + "\n4. Do **not** post NSFW content, shock content, suggestive content" - + "\n5. Please keep <#176333891320283136> professional and helpful, no random off-topic joking" - + "\nThis list will probably change in the future" - }, data.guild.general, {}, { - api: { - getEffectivePower: mockGetEffectivePower(), - getJoinedMembers(roomID) { - called++ - t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") - return { - joined: { - "@quadradical:federated.nexus": { - membership: "join", - display_name: "quadradical" - } - } - } - } - } - }) - t.deepEqual(events, [ - { - $type: "m.room.message", - body: "1. Don't be a dick" - + "\n2. Follow rule number 1" - + "\n3. Follow Discord TOS" - + "\n4. Do **not** post NSFW content, shock content, suggestive content" - + "\n5. Please keep #wonderland professional and helpful, no random off-topic joking" - + "\nThis list will probably change in the future", - format: "org.matrix.custom.html", - formatted_body: "
  1. Don't be a dick
  2. Follow rule number 1
  3. Follow Discord TOS
  4. Do not post NSFW content, shock content, suggestive content
  5. Please keep #wonderland professional and helpful, no random off-topic joking
This list will probably change in the future", - "m.mentions": {}, - msgtype: "m.text" - } - ]) - t.equal(called, 1) -}) - -test("message2event: emoji added special message", async t => { - const events = await messageToEvent(data.special_message.emoji_added) - t.deepEqual(events, [ - { - $type: "m.room.message", - msgtype: "m.emote", - body: "added a new emoji, :cx_marvelous: :cx_marvelous:", - format: "org.matrix.custom.html", - formatted_body: `added a new emoji, :cx_marvelous: :cx_marvelous:`, - "m.mentions": {} - } - ]) -}) - -test("message2event: cross-room reply", async t => { - let called = 0 - const events = await messageToEvent({ - type: 19, - message_reference: { - channel_id: "1161864271370666075", - guild_id: "1160893336324931584", - message_id: "1458091145136443547" - }, - referenced_message: { - channel_id: "1161864271370666075", - id: "1458091145136443547", - content: "", - attachments: [{ - filename: "image.png", - id: "1456813607693193478", - size: 104006, - content_type: "image/png", - url: "https://cdn.discordapp.com/attachments/1160893337029586956/1458790740338409605/image.png?ex=696194ff&is=6960437f&hm=923d0ef7d1b249470be49edbc37628cc4ff8a438f0ab12f54c045578135f7050" - }], - author: { - username: "Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆" - } - }, - content: "cross-room reply" - }, {}, {}, {api: { - async getEvent(roomID, eventID) { - called++ - t.equal(roomID, "!mHmhQQPwXNananaOLD:cadence.moe") - t.equal(eventID, "$pgzCQjq_y5sy8RvWOUuoF3obNHjs8iNvt9c-odrOCPY") - return { - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - "body": "image.png", - "info": { - "h": 738, - "mimetype": "image/png", - "org.matrix.msc4230.is_animated": false, - "size": 111189, - "w": 772, - "xyz.amorgan.blurhash": "L255Oa~qRPD$-pxuoJoLIUM{xuxu" - }, - "m.mentions": {}, - "msgtype": "m.image", - "url": "mxc://matrix.org/QbSujQjRLekzPknKlPsXbGDS" - } - } - } - }}) - t.deepEqual(events, [ - { - $type: "m.room.message", - msgtype: "m.text", - body: "> Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆: [Media]\n\ncross-room reply", - format: "org.matrix.custom.html", - formatted_body: `
In reply to Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆
[Media]
cross-room reply`, - "m.mentions": { - user_ids: [ - "@cadence:cadence.moe" - ] - } - } - ]) -}) - -test("message2event: forwarded message with unreferenced mention", async t => { - const events = await messageToEvent({ - type: 0, - content: "", - attachments: [], - embeds: [], - timestamp: "2026-01-20T14:14:21.281Z", - edited_timestamp: null, - flags: 16384, - components: [], - id: "1463174818823405651", - channel_id: "893634327722721290", - author: { - id: "100031256988766208", - username: "leo60228", - discriminator: "0", - avatar: "8a164f29946f23eb4f45cde71a75e5a6", - avatar_decoration_data: null, - public_flags: 768, - global_name: "leo vriska", - primary_guild: null, - collectibles: null, - display_name_styles: null - }, - bot: false, - pinned: false, - mentions: [], - mention_roles: [], - mention_everyone: false, - tts: false, - message_reference: { - type: 1, - channel_id: "937181373943382036", - message_id: "1032034158261846038", - guild_id: "936370934292549712" - }, - message_snapshots: [ - { - message: { - type: 0, - content: "<@77084495118868480>", - attachments: [ - { - id: "1463174815119704114", - filename: "2022-10-18_16-49-46.mp4", - size: 51238885, - url: "https://cdn.discordapp.com/attachments/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4?ex=6970df3c&is=696f8dbc&hm=515d3cbcc8464bdada7f4c3d9ccc8174f671cb75391ce21a46a804fcb1e4befe&", - proxy_url: "https://media.discordapp.net/attachments/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4?ex=6970df3c&is=696f8dbc&hm=515d3cbcc8464bdada7f4c3d9ccc8174f671cb75391ce21a46a804fcb1e4befe&", - width: 1920, - height: 1080, - content_type: "video/mp4", - content_scan_version: 3, - spoiler: false - } - ], - embeds: [], - timestamp: "2022-10-18T20:55:17.597Z", - edited_timestamp: null, - flags: 0, - components: [] - } - } - ] - }) - t.deepEqual(events, [{ - $type: "m.room.message", - msgtype: "m.text", - body: "[🔀 Forwarded message]\n» @unknown-user:\n» 🎞️ Uploaded file: https://bridge.example.org/download/discordcdn/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4 (51 MB)", - format: "org.matrix.custom.html", - formatted_body: "🔀 Forwarded message
@unknown-user:
🎞️ Uploaded file: 2022-10-18_16-49-46.mp4 (51 MB)
", - "m.mentions": {} - }]) -}) - -test("message2event: single-choice poll", async t => { - const events = await messageToEvent(data.message.poll_single_choice, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "org.matrix.msc3381.poll.start", - "org.matrix.msc3381.poll.start": { - question: { - "org.matrix.msc1767.text": "only one answer allowed!", - body: "only one answer allowed!", - msgtype: "m.text" - }, - kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that. - max_selections: 1, - answers: [{ - id: "1", - "org.matrix.msc1767.text": "[\ud83d\udc4d] answer one" - }, { - id: "2", - "org.matrix.msc1767.text": "[\ud83d\udc4e] answer two" - }, { - id: "3", - "org.matrix.msc1767.text": "answer three" - }] - }, - "org.matrix.msc1767.text": "only one answer allowed!\n1. [\ud83d\udc4d] answer one\n2. [\ud83d\udc4e] answer two\n3. answer three" - }]) -}) - -test("message2event: multiple-choice poll", async t => { - const events = await messageToEvent(data.message.poll_multiple_choice, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "org.matrix.msc3381.poll.start", - "org.matrix.msc3381.poll.start": { - question: { - "org.matrix.msc1767.text": "more than one answer allowed", - body: "more than one answer allowed", - msgtype: "m.text" - }, - kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that. - max_selections: 3, - answers: [{ - id: "1", - "org.matrix.msc1767.text": "[😭] no" - }, { - id: "2", - "org.matrix.msc1767.text": "oh no" - }, { - id: "3", - "org.matrix.msc1767.text": "oh noooooo" - }] - }, - "org.matrix.msc1767.text": "more than one answer allowed\n1. [😭] no\n2. oh no\n3. oh noooooo" - }]) -}) - -test("message2event: smalltext from regular user", async t => { - const events = await messageToEvent({ - content: "-# hmm", - author: { - bot: false - } - }) - t.deepEqual(events, [{ - $type: "m.room.message", - msgtype: "m.text", - "m.mentions": {}, - body: "...hmm" - }]) -}) diff --git a/src/d2m/converters/pins-to-list.js b/src/d2m/converters/pins-to-list.js index 5a33c7c..047bb9f 100644 --- a/src/d2m/converters/pins-to-list.js +++ b/src/d2m/converters/pins-to-list.js @@ -3,30 +3,17 @@ const {select} = require("../../passthrough") /** - * @param {import("discord-api-types/v10").RESTGetAPIChannelMessagesPinsResult} pins - * @param {{"m.room.pinned_events/"?: {pinned?: string[]}}} kstate + * @param {import("discord-api-types/v10").RESTGetAPIChannelPinsResult} pins */ -function pinsToList(pins, kstate) { - /** Most recent last. */ - let alreadyPinned = kstate["m.room.pinned_events/"]?.pinned || [] - - // If any of the already pinned messages are bridged messages then remove them from the already pinned list. - // * If a bridged message is still pinned then it'll be added back in the next step. - // * If a bridged message was unpinned from Discord-side then it'll be unpinned from our side due to this step. - // * Matrix-only unbridged messages that are pinned will remain pinned. - alreadyPinned = alreadyPinned.filter(event_id => { - const messageID = select("event_message", "message_id", {event_id}).pluck().get() - return !messageID || pins.items.find(m => m.message.id === messageID) // if it is bridged then remove it from the filter - }) - +function pinsToList(pins) { /** @type {string[]} */ const result = [] - for (const pin of pins.items) { - const eventID = select("event_message", "event_id", {message_id: pin.message.id, part: 0}).pluck().get() - if (eventID && !alreadyPinned.includes(eventID)) result.push(eventID) + for (const message of pins) { + const eventID = select("event_message", "event_id", {message_id: message.id, part: 0}).pluck().get() + if (eventID) result.push(eventID) } result.reverse() - return alreadyPinned.concat(result) + return result } module.exports.pinsToList = pinsToList diff --git a/src/d2m/converters/pins-to-list.test.js b/src/d2m/converters/pins-to-list.test.js index 571735e..7ee89b6 100644 --- a/src/d2m/converters/pins-to-list.test.js +++ b/src/d2m/converters/pins-to-list.test.js @@ -1,64 +1,12 @@ const {test} = require("supertape") const data = require("../../../test/data") const {pinsToList} = require("./pins-to-list") -const mixin = require("@cloudrac3r/mixin-deep") test("pins2list: converts known IDs, ignores unknown IDs", t => { - const result = pinsToList(data.pins.faked, {}) + const result = pinsToList(data.pins.faked) t.deepEqual(result, [ "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4", "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" ]) }) - -test("pins2list: already pinned duplicate items are not moved", t => { - const result = pinsToList(data.pins.faked, { - "m.room.pinned_events/": { - pinned: [ - "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA" - ] - } - }) - t.deepEqual(result, [ - "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", - "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4", - "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" - ]) -}) - -test("pins2list: already pinned unknown items are not moved", t => { - const result = pinsToList(data.pins.faked, { - "m.room.pinned_events/": { - pinned: [ - "$unknown1", - "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", - "$unknown2" - ] - } - }) - t.deepEqual(result, [ - "$unknown1", - "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", - "$unknown2", - "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4", - "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" - ]) -}) - -test("pins2list: bridged messages can be unpinned", t => { - const shortPins = mixin({}, data.pins.faked) - shortPins.items = shortPins.items.slice(0, -2) - const result = pinsToList(shortPins, { - "m.room.pinned_events/": { - pinned: [ - "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", - "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4" - ] - } - }) - t.deepEqual(result, [ - "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", - "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", - ]) -}) diff --git a/src/d2m/converters/remove-reaction.js b/src/d2m/converters/remove-reaction.js index 4ca22b6..caa96d1 100644 --- a/src/d2m/converters/remove-reaction.js +++ b/src/d2m/converters/remove-reaction.js @@ -5,8 +5,8 @@ const DiscordTypes = require("discord-api-types/v10") const passthrough = require("../../passthrough") const {discord, sync, select} = passthrough -/** @type {import("../../matrix/utils")} */ -const utils = sync.require("../../matrix/utils") +/** @type {import("../../m2d/converters/utils")} */ +const utils = sync.require("../../m2d/converters/utils") /** * @typedef ReactionRemoveRequest diff --git a/src/d2m/converters/thread-to-announcement.js b/src/d2m/converters/thread-to-announcement.js index 575b3c5..11a067f 100644 --- a/src/d2m/converters/thread-to-announcement.js +++ b/src/d2m/converters/thread-to-announcement.js @@ -4,8 +4,8 @@ const assert = require("assert").strict const passthrough = require("../../passthrough") const {discord, sync, db, select} = passthrough -/** @type {import("../../matrix/utils")} */ -const mxUtils = sync.require("../../matrix/utils") +/** @type {import("../../m2d/converters/utils")} */ +const mxUtils = sync.require("../../m2d/converters/utils") const {reg} = require("../../matrix/read-registration.js") const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) @@ -32,10 +32,13 @@ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thr const template = creatorMxid ? "started a thread:" : "Thread started:" const via = await mxUtils.getViaServersQuery(threadRoomID, di.api) let body = `${template} ${thread.name} https://matrix.to/#/${threadRoomID}?${via.toString()}` + let html = `${template} ${thread.name}` return { msgtype, body, + format: "org.matrix.custom.html", + formatted_body: html, "m.mentions": {}, ...context } diff --git a/src/d2m/converters/thread-to-announcement.test.js b/src/d2m/converters/thread-to-announcement.test.js index 3286f62..471cd94 100644 --- a/src/d2m/converters/thread-to-announcement.test.js +++ b/src/d2m/converters/thread-to-announcement.test.js @@ -2,7 +2,6 @@ const {test} = require("supertape") const {threadToAnnouncement} = require("./thread-to-announcement") const data = require("../../../test/data") const Ty = require("../../types") -const {mockGetEffectivePower} = require("../../matrix/utils.test") /** * @param {string} roomID @@ -31,7 +30,13 @@ function mockGetEvent(t, roomID_in, eventID_in, outer) { } const viaApi = { - getEffectivePower: mockGetEffectivePower(), + async getStateEvent(roomID, type, key) { + return { + users: { + "@_ooye_bot:cadence.moe": 100 + } + } + }, async getJoinedMembers(roomID) { return { joined: { @@ -50,6 +55,8 @@ test("thread2announcement: no known creator, no branched from event", async t => t.deepEqual(content, { msgtype: "m.text", body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", + format: "org.matrix.custom.html", + formatted_body: `Thread started: test thread`, "m.mentions": {} }) }) @@ -62,6 +69,8 @@ test("thread2announcement: known creator, no branched from event", async t => { t.deepEqual(content, { msgtype: "m.emote", body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", + format: "org.matrix.custom.html", + formatted_body: `started a thread: test thread`, "m.mentions": {} }) }) @@ -86,6 +95,8 @@ test("thread2announcement: no known creator, branched from discord event", async t.deepEqual(content, { msgtype: "m.text", body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", + format: "org.matrix.custom.html", + formatted_body: `Thread started: test thread`, "m.mentions": {}, "m.relates_to": { "m.in_reply_to": { @@ -115,6 +126,8 @@ test("thread2announcement: known creator, branched from discord event", async t t.deepEqual(content, { msgtype: "m.emote", body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", + format: "org.matrix.custom.html", + formatted_body: `started a thread: test thread`, "m.mentions": {}, "m.relates_to": { "m.in_reply_to": { @@ -144,6 +157,8 @@ test("thread2announcement: no known creator, branched from matrix event", async t.deepEqual(content, { msgtype: "m.text", body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", + format: "org.matrix.custom.html", + formatted_body: `Thread started: test thread`, "m.mentions": { user_ids: ["@cadence:cadence.moe"] }, diff --git a/src/d2m/converters/user-to-mxid.js b/src/d2m/converters/user-to-mxid.js index 7705aff..3d7d834 100644 --- a/src/d2m/converters/user-to-mxid.js +++ b/src/d2m/converters/user-to-mxid.js @@ -2,7 +2,6 @@ const assert = require("assert") const {reg} = require("../../matrix/read-registration") -const Ty = require("../../types") const passthrough = require("../../passthrough") const {select} = passthrough @@ -14,16 +13,16 @@ const SPECIAL_USER_MAPPINGS = new Map([ /** * Downcased and stripped username. Can only include a basic set of characters. * https://spec.matrix.org/v1.6/appendices/#user-identifiers - * @param {import("discord-api-types/v10").APIUser | Ty.WebhookAuthor} user + * @param {import("discord-api-types/v10").APIUser} user * @returns {string} localpart */ function downcaseUsername(user) { // First, try to convert the username to the set of allowed characters let downcased = user.username.toLowerCase() - // spaces and slashes to underscores... - .replace(/[ /]/g, "_") + // spaces to underscores... + .replace(/ /g, "_") // remove disallowed characters... - .replace(/[^a-z0-9._=-]*/g, "") + .replace(/[^a-z0-9._=/-]*/g, "") // remove leading and trailing dashes and underscores... .replace(/(?:^[_-]*|[_-]*$)/g, "") // If requested, also make the Discord user ID part of the username @@ -61,11 +60,11 @@ function* generateLocalpartAlternatives(preferences) { */ function userToSimName(user) { if (!SPECIAL_USER_MAPPINGS.has(user.id)) { // skip this check for known special users - assert.notEqual(user.discriminator, "0000", `cannot create user for a webhook: ${JSON.stringify(user)}`) + if (user.discriminator === "0000") return "webhook" } // 1. Is sim user already registered? - const existing = select("sim", "user_id", {user_id: user.id}).pluck().get() + const existing = select("sim", "sim_name", {user_id: user.id}).pluck().get() assert.equal(existing, null, "Shouldn't try to create a new name for an existing sim") // 2. Register based on username (could be new or old format) @@ -86,49 +85,4 @@ function userToSimName(user) { throw new Error(`Ran out of suggestions when generating sim name. downcased: "${downcased}"`) } -/** - * Webhooks have an ID specific to that webhook, but a single webhook can send messages with any user name. - * The point of this feature (gated by guild_space webhook_profile) is to create persistent Matrix accounts for individual webhook "users". - * This is convenient when using a bridge to a platform that does not assign persistent user IDs (e.g. IRC, Minecraft). - * In this case, webhook "users" are disambiguated by their username (downcased). - * @param {Ty.WebhookAuthor} author - * @returns {string} - */ -function webhookAuthorToFakeUserID(author) { - const downcased = downcaseUsername(author) - return `webhook_${downcased}` -} - -function isWebhookUserID(userID) { - return userID.match(/^webhook_[a-z90-9._=/-]+$/) -} - -/** - * @param {Ty.WebhookAuthor} author - * @returns {string} - */ -function webhookAuthorToSimName(author) { - assert(!SPECIAL_USER_MAPPINGS.has(author.id), "Special users should have followed the other code path.") - - // 1. Is sim user already registered? - const fakeUserID = webhookAuthorToFakeUserID(author) - const existing = select("sim", "user_id", {user_id: fakeUserID}).pluck().get() - assert.equal(existing, null, "Shouldn't try to create a new name for an existing sim") - - // 2. Register based on username (could be new or old format) - const downcased = "webhook_" + downcaseUsername(author) - - // Check for conflicts with already registered sims - const matches = select("sim", "sim_name", {}, "WHERE sim_name LIKE ? ESCAPE '@'").pluck().all(downcased + "%") - // Keep generating until we get a suggestion that doesn't conflict - for (const suggestion of generateLocalpartAlternatives([downcased])) { - if (!matches.includes(suggestion)) return suggestion - } - /* c8 ignore next */ - throw new Error(`Ran out of suggestions when generating sim name. downcased: "${downcased}"`) -} - module.exports.userToSimName = userToSimName -module.exports.webhookAuthorToFakeUserID = webhookAuthorToFakeUserID -module.exports.webhookAuthorToSimName = webhookAuthorToSimName -module.exports.isWebhookUserID = isWebhookUserID diff --git a/src/d2m/converters/user-to-mxid.test.js b/src/d2m/converters/user-to-mxid.test.js index f8cf16a..86f151b 100644 --- a/src/d2m/converters/user-to-mxid.test.js +++ b/src/d2m/converters/user-to-mxid.test.js @@ -2,7 +2,7 @@ const {test} = require("supertape") const tryToCatch = require("try-to-catch") const assert = require("assert") const data = require("../../../test/data") -const {userToSimName, webhookAuthorToSimName} = require("./user-to-mxid") +const {userToSimName} = require("./user-to-mxid") test("user2name: cannot create user for a webhook", async t => { const [error] = await tryToCatch(() => userToSimName({discriminator: "0000"})) @@ -21,12 +21,8 @@ test("user2name: works on single emoji at the end", t => { t.equal(userToSimName({username: "Melody 🎵", discriminator: "2192"}), "melody") }) -test("user2name: works on really weird name", t => { - t.equal(userToSimName({username: "*** D3 &W (89) _7//-", discriminator: "0001"}), "d3_w_89__7") -}) - -test("user2name: treats slashes", t => { - t.equal(userToSimName({username: "Evil Lillith (she/her)", discriminator: "5892"}), "evil_lillith_she_her") +test("user2name: works on crazy name", t => { + t.equal(userToSimName({username: "*** D3 &W (89) _7//-", discriminator: "0001"}), "d3_w_89__7//") }) test("user2name: adds discriminator if name is unavailable (old tag format)", t => { @@ -49,17 +45,10 @@ test("user2name: works on special user", t => { t.equal(userToSimName(data.user.clyde_ai), "clyde_ai") }) -test("webhook author: can generate sim names", t => { - t.equal(webhookAuthorToSimName({ - username: "Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆", - avatar: null, - id: "123" - }), "webhook_cadence_maid_of_creation_eye_of_clarity_empress_of_hope") -}) - test("user2name: includes ID if requested in config", t => { const {reg} = require("../../matrix/read-registration") reg.ooye.include_user_id_in_mxid = true t.equal(userToSimName({username: "Harry Styles!", discriminator: "0001", id: "123456"}), "123456_harry_styles") - t.equal(userToSimName({username: "f***", discriminator: "0001", id: "123456"}), "123456_f") + t.equal(userToSimName({username: "f***", discriminator: "0001", id: "123456"}), "123456_f") + reg.ooye.include_user_id_in_mxid = false }) diff --git a/src/d2m/discord-client.js b/src/d2m/discord-client.js index 7b0fcf8..bffb904 100644 --- a/src/d2m/discord-client.js +++ b/src/d2m/discord-client.js @@ -1,15 +1,10 @@ // @ts-check -const DiscordTypes = require("discord-api-types/v10") -const {Endpoints, SnowTransfer} = require("snowtransfer") -const {reg} = require("../matrix/read-registration") -const {Client: CloudStorm} = require("cloudstorm") - -// @ts-ignore -Endpoints.BASE_HOST = reg.ooye.discord_origin || "https://discord.com"; Endpoints.CDN_URL = reg.ooye.discord_cdn_origin || "https://cdn.discordapp.com" +const { SnowTransfer } = require("snowtransfer") +const { Client: CloudStorm } = require("cloudstorm") const passthrough = require("../passthrough") -const {sync} = passthrough +const { sync } = passthrough /** @type {import("./discord-packets")} */ const discordPackets = sync.require("./discord-packets") @@ -20,34 +15,32 @@ class DiscordClient { * @param {string} listen "full", "half", "no" - whether to set up the event listeners for OOYE to operate */ constructor(discordToken, listen = "full") { - /** @type {import("cloudstorm").IClientOptions["intents"]} */ - const intents = [ - "DIRECT_MESSAGES", "DIRECT_MESSAGE_REACTIONS", "DIRECT_MESSAGE_TYPING", - "GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS", "GUILD_MESSAGE_POLLS", - "MESSAGE_CONTENT" - ] - if (reg.ooye.receive_presences !== false) intents.push("GUILD_PRESENCES") this.discordToken = discordToken this.snow = new SnowTransfer(discordToken) this.cloud = new CloudStorm(discordToken, { shards: [0], + reconnect: true, snowtransferInstance: this.snow, - intents, + intents: [ + "DIRECT_MESSAGES", "DIRECT_MESSAGE_REACTIONS", "DIRECT_MESSAGE_TYPING", + "GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS", + "MESSAGE_CONTENT" + ], ws: { compress: false, encoding: "json" } }) this.ready = false - /** @type {DiscordTypes.APIUser} */ + /** @type {import("discord-api-types/v10").APIUser} */ // @ts-ignore avoid setting as or null because we know we need to wait for ready anyways this.user = null - /** @type {Pick} */ + /** @type {Pick} */ // @ts-ignore this.application = null - /** @type {Map} */ + /** @type {Map} */ this.channels = new Map() - /** @type {Map} */ // we get members from the GUILD_CREATE and we do maintain it + /** @type {Map} */ this.guilds = new Map() /** @type {Map>} */ this.guildChannelMap = new Map() @@ -64,6 +57,9 @@ class DiscordClient { addEventLogger("error", "Error") addEventLogger("disconnected", "Disconnected") addEventLogger("ready", "Ready") + this.snow.requestHandler.on("requestError", (requestID, error) => { + console.error("request error:", error) + }) } } diff --git a/src/d2m/discord-packets.js b/src/d2m/discord-packets.js index 8cf2fde..f619f2b 100644 --- a/src/d2m/discord-packets.js +++ b/src/d2m/discord-packets.js @@ -4,7 +4,7 @@ const DiscordTypes = require("discord-api-types/v10") const passthrough = require("../passthrough") -const {sync, db} = passthrough +const { sync } = passthrough const utils = { /** @@ -28,7 +28,6 @@ const utils = { console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`) } else if (message.t === "GUILD_CREATE") { - message.d.members = message.d.members.filter(m => m.user.id === client.user.id) // only keep the bot's own member - it's needed to determine private channels on web client.guilds.set(message.d.id, message.d) const arr = [] client.guildChannelMap.set(message.d.id, arr) @@ -44,17 +43,10 @@ const utils = { arr.push(thread.id) client.channels.set(thread.id, thread) } - if (listen === "full") { - try { - interactions.registerInteractions() - await eventDispatcher.checkMissedExpressions(message.d) - await eventDispatcher.checkMissedPins(client, message.d) - await eventDispatcher.checkMissedMessages(client, message.d) - } catch (e) { - console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:") - console.error(e) - } + eventDispatcher.checkMissedExpressions(message.d) + eventDispatcher.checkMissedPins(client, message.d) + eventDispatcher.checkMissedMessages(client, message.d) } } else if (message.t === "GUILD_UPDATE") { @@ -97,19 +89,9 @@ const utils = { } } - } else if (message.t === "GUILD_MEMBER_UPDATE") { - const guild = client.guilds.get(message.d.guild_id) - const member = guild?.members.find(m => m.user.id === message.d.user.id) - if (member) { // only update existing members (i.e. the bot's own member) - don't want to inflate the cache with new irrelevant ones - Object.assign(member, message.d) - } - } else if (message.t === "THREAD_CREATE") { client.channels.set(message.d.id, message.d) - if (message.d["guild_id"]) { - const channels = client.guildChannelMap.get(message.d["guild_id"]) - if (channels && !channels.includes(message.d.id)) channels.push(message.d.id) - } + } else if (message.t === "CHANNEL_UPDATE" || message.t === "THREAD_UPDATE") { client.channels.set(message.d.id, message.d) @@ -131,36 +113,75 @@ const utils = { client.guildChannelMap.delete(message.d.id) - } else if (message.t === "CHANNEL_CREATE") { - client.channels.set(message.d.id, message.d) - if (message.d["guild_id"]) { // obj[prop] notation can be used to access a property without typescript complaining that it doesn't exist on all values something can have - const channels = client.guildChannelMap.get(message.d["guild_id"]) - if (channels && !channels.includes(message.d.id)) channels.push(message.d.id) - } - - } else if (message.t === "CHANNEL_DELETE") { - client.channels.delete(message.d.id) - if (message.d["guild_id"]) { - const channels = client.guildChannelMap.get(message.d["guild_id"]) - if (channels) { - const previous = channels.indexOf(message.d.id) - if (previous !== -1) channels.splice(previous, 1) + } else if (message.t === "CHANNEL_CREATE" || message.t === "CHANNEL_DELETE") { + if (message.t === "CHANNEL_CREATE") { + client.channels.set(message.d.id, message.d) + if (message.d["guild_id"]) { // obj[prop] notation can be used to access a property without typescript complaining that it doesn't exist on all values something can have + const channels = client.guildChannelMap.get(message.d["guild_id"]) + if (channels && !channels.includes(message.d.id)) channels.push(message.d.id) + } + } else { + client.channels.delete(message.d.id) + if (message.d["guild_id"]) { + const channels = client.guildChannelMap.get(message.d["guild_id"]) + if (channels) { + const previous = channels.indexOf(message.d.id) + if (previous !== -1) channels.splice(previous, 1) + } } } } // Event dispatcher for OOYE bridge operations - if (listen === "full" && message.t) { + if (listen === "full") { try { - if (message.t === "MESSAGE_REACTION_REMOVE" || message.t === "MESSAGE_REACTION_REMOVE_EMOJI" || message.t === "MESSAGE_REACTION_REMOVE_ALL") { + if (message.t === "GUILD_UPDATE") { + await eventDispatcher.onGuildUpdate(client, message.d) + + } else if (message.t === "GUILD_EMOJIS_UPDATE" || message.t === "GUILD_STICKERS_UPDATE") { + await eventDispatcher.onExpressionsUpdate(client, message.d) + + } else if (message.t === "CHANNEL_UPDATE") { + await eventDispatcher.onChannelOrThreadUpdate(client, message.d, false) + + } else if (message.t === "CHANNEL_PINS_UPDATE") { + await eventDispatcher.onChannelPinsUpdate(client, message.d) + + } else if (message.t === "CHANNEL_DELETE") { + await eventDispatcher.onChannelDelete(client, message.d) + + } else if (message.t === "THREAD_CREATE") { + // @ts-ignore + await eventDispatcher.onThreadCreate(client, message.d) + + } else if (message.t === "THREAD_UPDATE") { + await eventDispatcher.onChannelOrThreadUpdate(client, message.d, true) + + } else if (message.t === "MESSAGE_CREATE") { + await eventDispatcher.onMessageCreate(client, message.d) + + } else if (message.t === "MESSAGE_UPDATE") { + await eventDispatcher.onMessageUpdate(client, message.d) + + } else if (message.t === "MESSAGE_DELETE") { + await eventDispatcher.onMessageDelete(client, message.d) + + } else if (message.t === "MESSAGE_DELETE_BULK") { + await eventDispatcher.onMessageDeleteBulk(client, message.d) + + } else if (message.t === "TYPING_START") { + await eventDispatcher.onTypingStart(client, message.d) + + } else if (message.t === "MESSAGE_REACTION_ADD") { + await eventDispatcher.onReactionAdd(client, message.d) + + } else if (message.t === "MESSAGE_REACTION_REMOVE" || message.t === "MESSAGE_REACTION_REMOVE_EMOJI" || message.t === "MESSAGE_REACTION_REMOVE_ALL") { await eventDispatcher.onSomeReactionsRemoved(client, message.d) } else if (message.t === "INTERACTION_CREATE") { await interactions.dispatchInteraction(message.d) - - } else if (message.t in eventDispatcher) { - await eventDispatcher[message.t](client, message.d) } + } catch (e) { // Let OOYE try to handle errors too await eventDispatcher.onError(client, e, message) diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index 01bbc67..1806ee6 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -2,6 +2,7 @@ const assert = require("assert").strict const DiscordTypes = require("discord-api-types/v10") +const util = require("util") const {sync, db, select, from} = require("../passthrough") /** @type {import("./actions/send-message")}) */ @@ -26,22 +27,19 @@ const updatePins = sync.require("./actions/update-pins") const api = sync.require("../matrix/api") /** @type {import("../discord/utils")} */ const dUtils = sync.require("../discord/utils") +/** @type {import("../m2d/converters/utils")} */ +const mxUtils = require("../m2d/converters/utils") /** @type {import("./actions/speedbump")} */ const speedbump = sync.require("./actions/speedbump") /** @type {import("./actions/retrigger")} */ const retrigger = sync.require("./actions/retrigger") -/** @type {import("./actions/set-presence")} */ -const setPresence = sync.require("./actions/set-presence") -/** @type {import("./actions/poll-vote")} */ -const vote = sync.require("./actions/poll-vote") -/** @type {import("../m2d/event-dispatcher")} */ -const matrixEventDispatcher = sync.require("../m2d/event-dispatcher") -/** @type {import("../discord/interactions/matrix-info")} */ -const matrixInfoInteraction = sync.require("../discord/interactions/matrix-info") -const {Semaphore} = require("@chriscdn/promise-semaphore") +/** @type {any} */ // @ts-ignore bad types from semaphore +const Semaphore = require("@chriscdn/promise-semaphore") const checkMissedPinsSema = new Semaphore() +let lastReportedEvent = 0 + // Grab Discord events we care about for the bridge, check them, and pass them on module.exports = { @@ -51,16 +49,48 @@ module.exports = { * @param {import("cloudstorm").IGatewayMessage} gatewayMessage */ async onError(client, e, gatewayMessage) { + console.error("hit event-dispatcher's error handler with this exception:") + console.error(e) // TODO: also log errors into a file or into the database, maybe use a library for this? or just wing it? definitely need to be able to store the formatted event body to load back in later + console.error(`while handling this ${gatewayMessage.t} gateway event:`) + console.dir(gatewayMessage.d, {depth: null}) + if (gatewayMessage.t === "TYPING_START") return - matrixEventDispatcher.printError(gatewayMessage.t, "Discord", e, gatewayMessage) + if (Date.now() - lastReportedEvent < 5000) return + lastReportedEvent = Date.now() const channelID = gatewayMessage.d["channel_id"] if (!channelID) return const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get() if (!roomID) return - await matrixEventDispatcher.sendError(roomID, "Discord", gatewayMessage.t, e, gatewayMessage) + let stackLines = null + if (e.stack) { + stackLines = e.stack.split("\n") + let cloudstormLine = stackLines.findIndex(l => l.includes("/node_modules/cloudstorm/")) + if (cloudstormLine !== -1) { + stackLines = stackLines.slice(0, cloudstormLine - 2) + } + } + + const builder = new mxUtils.MatrixStringBuilder() + builder.addLine("\u26a0 Bridged event from Discord not delivered", "\u26a0 Bridged event from Discord not delivered") + builder.addLine(`Gateway event: ${gatewayMessage.t}`) + builder.addLine(e.toString()) + if (stackLines) { + builder.addLine(`Error trace:\n${stackLines.join("\n")}`, `
Error trace
${stackLines.join("\n")}
`) + } + builder.addLine("", `
Original payload
${util.inspect(gatewayMessage.d, false, 4, false)}
`) + await api.sendEvent(roomID, "m.room.message", { + ...builder.get(), + "moe.cadence.ooye.error": { + source: "discord", + payload: gatewayMessage + }, + "m.mentions": { + user_ids: ["@cadence:cadence.moe"] + } + }) }, /** @@ -73,27 +103,18 @@ module.exports = { async checkMissedMessages(client, guild) { if (guild.unavailable) return const bridgedChannels = select("channel_room", "channel_id").pluck().all() - const preparedExists = from("message_room").join("historical_channel_room", "historical_room_index").pluck("message_id").and("WHERE reference_channel_id = ? LIMIT 1").prepare() - const preparedGet = select("event_message", "event_id", {}, "WHERE message_id = ?").pluck() - /** @type {(DiscordTypes.APIChannel & {type: DiscordTypes.GuildChannelType})[]} */ - let channels = [] - channels = channels.concat(guild.channels, guild.threads) - for (const channel of channels) { + const prepared = select("event_message", "event_id", {}, "WHERE message_id = ?").pluck() + for (const channel of guild.channels.concat(guild.threads)) { if (!bridgedChannels.includes(channel.id)) continue if (!("last_message_id" in channel) || !channel.last_message_id) continue - - // Skip if channel is already up-to-date - const latestWasBridged = preparedGet.get(channel.last_message_id) + const latestWasBridged = prepared.get(channel.last_message_id) if (latestWasBridged) continue - // Skip if channel was just added to the bridge (there's no place to resume from if it's brand new) - if (!preparedExists.get(channel.id)) continue - // Permissions check const member = guild.members.find(m => m.user?.id === client.user.id) if (!member) return if (!("permission_overwrites" in channel)) continue - const permissions = dUtils.getPermissions(guild.id, member.roles, guild.roles, client.user.id, channel.permission_overwrites) + const permissions = dUtils.getPermissions(member.roles, guild.roles, client.user.id, channel.permission_overwrites) if (!dUtils.hasAllPermissions(permissions, ["ViewChannel", "ReadMessageHistory"])) continue // We don't have permission to look back in this channel /** More recent messages come first. */ @@ -110,28 +131,17 @@ module.exports = { } } let latestBridgedMessageIndex = messages.findIndex(m => { - return preparedGet.get(m.id) + return prepared.get(m.id) }) // console.log(`[check missed messages] got ${messages.length} messages; last message that IS bridged is at position ${latestBridgedMessageIndex} in the channel`) if (latestBridgedMessageIndex === -1) latestBridgedMessageIndex = 1 // rather than crawling the ENTIRE channel history, let's just bridge the most recent 1 message to make it up to date. - - // We get member data so that we can accurately update any changes to nickname or permissions that have occurred in the meantime - // The rate limit is lax enough that the backlog will still be pretty quick (at time of writing, 5 per 1 second per guild) - /** @type {Map} id -> member: cache members for the run because people talk to each other */ - const members = new Map() - - // Send in order for (let i = Math.min(messages.length, latestBridgedMessageIndex)-1; i >= 0; i--) { - const message = messages[i] - - if (!members.has(message.author.id)) members.set(message.author.id, await client.snow.guild.getGuildMember(guild.id, message.author.id).catch(() => undefined)) - await module.exports.MESSAGE_CREATE(client, { + const simulatedGatewayDispatchData = { guild_id: guild.id, - member: members.get(message.author.id), - // @ts-ignore backfill: true, - ...message - }) + ...messages[i] + } + await module.exports.onMessageCreate(client, simulatedGatewayDispatchData) } } }, @@ -152,7 +162,7 @@ module.exports = { const lastPin = updatePins.convertTimestamp(channel.last_pin_timestamp) // Permissions check - const permissions = dUtils.getPermissions(guild.id, member.roles, guild.roles, client.user.id, channel.permission_overwrites) + const permissions = dUtils.getPermissions(member.roles, guild.roles, client.user.id, channel.permission_overwrites) if (!dUtils.hasAllPermissions(permissions, ["ViewChannel", "ReadMessageHistory"])) continue // We don't have permission to look up the pins in this channel const row = select("channel_room", ["room_id", "last_bridged_pin_timestamp"], {channel_id: channel.id}).get() @@ -169,7 +179,7 @@ module.exports = { */ async checkMissedExpressions(guild) { const data = {guild_id: guild.id, ...guild} - await createSpace.syncSpaceExpressions(data, true) + createSpace.syncSpaceExpressions(data, true) }, /** @@ -178,10 +188,10 @@ module.exports = { * @param {import("./discord-client")} client * @param {DiscordTypes.APIThreadChannel} thread */ - async THREAD_CREATE(client, thread) { + async onThreadCreate(client, thread) { const channelID = thread.parent_id || undefined const parentRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get() - if (!parentRoomID) return // Not interested in a thread if we aren't interested in its wider channel (won't autocreate) + if (!parentRoomID) return // Not interested in a thread if we aren't interested in its wider channel const threadRoomID = await createRoom.syncRoom(thread.id) // Create room (will share the same inflight as the initial message to the thread) await announceThread.announceThread(parentRoomID, threadRoomID, thread) }, @@ -190,50 +200,28 @@ module.exports = { * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayGuildUpdateDispatchData} guild */ - async GUILD_UPDATE(client, guild) { + async onGuildUpdate(client, guild) { const spaceID = select("guild_space", "space_id", {guild_id: guild.id}).pluck().get() if (!spaceID) return await createSpace.syncSpace(guild) }, - /** - * @param {import("./discord-client")} client - * @param {DiscordTypes.GatewayGuildRoleUpdateDispatchData} data - */ - async GUILD_ROLE_UPDATE(client, data) { - const guild = client.guilds.get(data.guild_id) - if (!guild) return - const spaceID = select("guild_space", "space_id", {guild_id: data.guild_id}).pluck().get() - if (!spaceID) return - - if (data.role.id === data.guild_id) { // @everyone role changed - find a way to do this more efficiently in the future to handle many role updates - await createSpace.syncSpaceFully(guild) - } - }, - /** * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayChannelUpdateDispatchData} channelOrThread + * @param {boolean} isThread */ - async CHANNEL_UPDATE(client, channelOrThread) { + async onChannelOrThreadUpdate(client, channelOrThread, isThread) { const roomID = select("channel_room", "room_id", {channel_id: channelOrThread.id}).pluck().get() if (!roomID) return // No target room to update the data on await createRoom.syncRoom(channelOrThread.id) }, - /** - * @param {import("./discord-client")} client - * @param {DiscordTypes.GatewayChannelUpdateDispatchData} thread - */ - async THREAD_UPDATE(client, thread) { - await module.exports.CHANNEL_UPDATE(client, thread) - }, - /** * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayChannelPinsUpdateDispatchData} data */ - async CHANNEL_PINS_UPDATE(client, data) { + async onChannelPinsUpdate(client, data) { const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get() if (!roomID) return // No target room to update pins in const convertedTimestamp = updatePins.convertTimestamp(data.last_pin_timestamp) @@ -244,24 +232,23 @@ module.exports = { * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayChannelDeleteDispatchData} channel */ - async CHANNEL_DELETE(client, channel) { + async onChannelDelete(client, channel) { const guildID = channel["guild_id"] if (!guildID) return // channel must have been a DM channel or something const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() if (!roomID) return // channel wasn't being bridged in the first place // @ts-ignore - await createRoom.unbridgeChannel(channel, guildID) + await createRoom.unbridgeDeletedChannel(channel, guildID) }, /** * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayMessageCreateDispatchData} message */ - async MESSAGE_CREATE(client, message) { + async onMessageCreate(client, message) { if (message.author.username === "Deleted User") return // Nothing we can do for deleted users. const channel = client.channels.get(message.channel_id) if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages. - const guild = client.guilds.get(channel.guild_id) assert(guild) @@ -272,13 +259,11 @@ module.exports = { if (dUtils.isEphemeralMessage(message)) return // Ephemeral messages are for the eyes of the receiver only! - if (!createRoom.existsOrAutocreatable(channel, guild.id)) return // Check that the sending-to room exists or is autocreatable - const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id) if (affected) return // @ts-ignore - await sendMessage.sendMessage(message, channel, guild, row) + await sendMessage.sendMessage(message, channel, guild, row), retrigger.messageFinishedBridging(message.id) }, @@ -287,11 +272,19 @@ module.exports = { * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayMessageUpdateDispatchData} data */ - async MESSAGE_UPDATE(client, data) { + async onMessageUpdate(client, data) { // Based on looking at data they've sent me over the gateway, this is the best way to check for meaningful changes. // If the message content is a string then it includes all interesting fields and is meaningful. // Otherwise, if there are embeds, then the system generated URL preview embeds. - if (!(typeof data.content === "string" || "embeds" in data || "components" in data)) return + if (!(typeof data.content === "string" || "embeds" in data)) return + + // Deal with Eventual Consistency(TM) + if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.onMessageUpdate, client, data)) return + + if (data.webhook_id) { + const row = select("webhook", "webhook_id", {webhook_id: data.webhook_id}).pluck().get() + if (row) return // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it. + } if (dUtils.isEphemeralMessage(data)) return // Ephemeral messages are for the eyes of the receiver only! @@ -299,39 +292,25 @@ module.exports = { const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id) if (affected) return - if (!row) { - // Check that the sending-to room exists, and deal with Eventual Consistency(TM) - if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.MESSAGE_UPDATE, client, data)) return - } - /** @type {DiscordTypes.GatewayMessageCreateDispatchData} */ // @ts-ignore const message = data + const channel = client.channels.get(message.channel_id) if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages. const guild = client.guilds.get(channel.guild_id) assert(guild) - // @ts-ignore - await retrigger.pauseChanges(message.id, editMessage.editMessage(message, guild, row)) + await editMessage.editMessage(message, guild, row) }, /** * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayMessageReactionAddDispatchData} data */ - async MESSAGE_REACTION_ADD(client, data) { + async onReactionAdd(client, data) { if (data.user_id === client.user.id) return // m2d reactions are added by the discord bot user - do not reflect them back to matrix. - if (data.emoji.name === "❓" && select("event_message", "message_id", {message_id: data.message_id, source: 0, part: 0}).get()) { // source 0 = matrix - const guild_id = data.guild_id ?? client.channels.get(data.channel_id)?.["guild_id"] - await Promise.all([ - client.snow.channel.deleteReaction(data.channel_id, data.message_id, data.emoji.name).catch(() => {}), - // @ts-ignore - this is all you need for it to do a matrix-side lookup - matrixInfoInteraction.dm({guild_id, data: {target_id: data.message_id}, member: {user: {id: data.user_id}}}) - ]) - } else { - await addReaction.addReaction(data) - } + await addReaction.addReaction(data) }, /** @@ -346,25 +325,25 @@ module.exports = { * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayMessageDeleteDispatchData} data */ - async MESSAGE_DELETE(client, data) { + async onMessageDelete(client, data) { speedbump.onMessageDelete(data.id) - if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.MESSAGE_DELETE, client, data)) return + if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.onMessageDelete, client, data)) return await deleteMessage.deleteMessage(data) }, - /** + /** * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayMessageDeleteBulkDispatchData} data */ - async MESSAGE_DELETE_BULK(client, data) { - await deleteMessage.deleteMessageBulk(data) - }, + async onMessageDeleteBulk(client, data) { + await deleteMessage.deleteMessageBulk(data) + }, /** * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayTypingStartDispatchData} data */ - async TYPING_START(client, data) { + async onTypingStart(client, data) { const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get() if (!roomID) return const mxid = from("sim").join("sim_member", "mxid").where({user_id: data.user_id, room_id: roomID}).pluck("mxid").get() @@ -377,41 +356,9 @@ module.exports = { /** * @param {import("./discord-client")} client - * @param {DiscordTypes.GatewayGuildEmojisUpdateDispatchData} data + * @param {DiscordTypes.GatewayGuildEmojisUpdateDispatchData | DiscordTypes.GatewayGuildStickersUpdateDispatchData} data */ - async GUILD_EMOJIS_UPDATE(client, data) { + async onExpressionsUpdate(client, data) { await createSpace.syncSpaceExpressions(data, false) - }, - - /** - * @param {import("./discord-client")} client - * @param {DiscordTypes.GatewayGuildStickersUpdateDispatchData} data - */ - async GUILD_STICKERS_UPDATE(client, data) { - await createSpace.syncSpaceExpressions(data, false) - }, - - /** - * @param {import("./discord-client")} client - * @param {DiscordTypes.GatewayMessagePollVoteDispatchData} data - */ - async MESSAGE_POLL_VOTE_ADD(client, data) { - if (retrigger.eventNotFoundThenRetrigger(data.message_id, module.exports.MESSAGE_POLL_VOTE_ADD, client, data)) return - await vote.addVote(data) - }, - - async MESSAGE_POLL_VOTE_REMOVE(client, data) { - if (retrigger.eventNotFoundThenRetrigger(data.message_id, module.exports.MESSAGE_POLL_VOTE_REMOVE, client, data)) return - await vote.removeVote(data) - }, - - /** - * @param {import("./discord-client")} client - * @param {DiscordTypes.GatewayPresenceUpdateDispatchData} data - */ - PRESENCE_UPDATE(client, data) { - const status = data.status - if (!status) return - setPresence.presenceTracker.incomingPresence(data.user.id, data.guild_id, status) } } diff --git a/src/db/migrate.js b/src/db/migrate.js index 46d0c14..7c1faf9 100644 --- a/src/db/migrate.js +++ b/src/db/migrate.js @@ -6,8 +6,7 @@ const {join} = require("path") async function migrate(db) { let files = fs.readdirSync(join(__dirname, "migrations")) files = files.sort() - db.prepare("CREATE TABLE IF NOT EXISTS migration (filename TEXT NOT NULL, PRIMARY KEY (filename)) WITHOUT ROWID").run() - /** @type {string} */ + db.prepare("CREATE TABLE IF NOT EXISTS migration (filename TEXT NOT NULL)").run() let progress = db.prepare("SELECT * FROM migration").pluck().get() if (!progress) { progress = "" @@ -38,8 +37,6 @@ async function migrate(db) { if (migrationRan) { console.log("Database migrations all done.") } - - db.pragma("foreign_keys = on") } module.exports.migrate = migrate diff --git a/src/db/migrations/0002-optimise-profile-content.up.js b/src/db/migrations/0002-optimise-profile-content.up.js index 5b540cb..a8619cf 100644 --- a/src/db/migrations/0002-optimise-profile-content.up.js +++ b/src/db/migrations/0002-optimise-profile-content.up.js @@ -3,7 +3,6 @@ module.exports = async function(db) { const contents = db.prepare("SELECT distinct hashed_profile_content FROM sim_member WHERE hashed_profile_content IS NOT NULL").pluck().all() const stmt = db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE hashed_profile_content = ?") db.transaction(() => { - /* c8 ignore next 6 */ for (let s of contents) { let b = Buffer.isBuffer(s) ? Uint8Array.from(s) : Uint8Array.from(Buffer.from(s)) const unsignedHash = hasher.h64Raw(b) diff --git a/src/db/migrations/0015-add-guild-id-to-channel-room.sql b/src/db/migrations/0015-add-guild-id-to-channel-room.sql deleted file mode 100644 index 81342e4..0000000 --- a/src/db/migrations/0015-add-guild-id-to-channel-room.sql +++ /dev/null @@ -1,5 +0,0 @@ -BEGIN TRANSACTION; - -ALTER TABLE channel_room ADD COLUMN guild_id TEXT; - -COMMIT; diff --git a/src/db/migrations/0016-foreign-keys.sql b/src/db/migrations/0016-foreign-keys.sql deleted file mode 100644 index 7a2b26c..0000000 --- a/src/db/migrations/0016-foreign-keys.sql +++ /dev/null @@ -1,147 +0,0 @@ --- /docs/foreign-keys.md - --- 2 -BEGIN TRANSACTION; - --- *** channel_room *** - --- 4 --- adding UNIQUE to room_id here will auto-generate the usable index we wanted -CREATE TABLE "new_channel_room" ( - "channel_id" TEXT NOT NULL, - "room_id" TEXT NOT NULL UNIQUE, - "name" TEXT NOT NULL, - "nick" TEXT, - "thread_parent" TEXT, - "custom_avatar" TEXT, - "last_bridged_pin_timestamp" INTEGER, - "speedbump_id" TEXT, - "speedbump_checked" INTEGER, - "speedbump_webhook_id" TEXT, - "guild_id" TEXT, - PRIMARY KEY("channel_id"), - FOREIGN KEY("guild_id") REFERENCES "guild_active"("guild_id") ON DELETE CASCADE -) WITHOUT ROWID; --- 5 -INSERT INTO new_channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar, last_bridged_pin_timestamp, speedbump_id, speedbump_checked, speedbump_webhook_id, guild_id) SELECT channel_id, room_id, name, nick, thread_parent, custom_avatar, last_bridged_pin_timestamp, speedbump_id, speedbump_checked, speedbump_webhook_id, guild_id FROM channel_room; --- 6 -DROP TABLE channel_room; --- 7 -ALTER TABLE new_channel_room RENAME TO channel_room; - --- *** message_channel *** - --- 4 -CREATE TABLE "new_message_channel" ( - "message_id" TEXT NOT NULL, - "channel_id" TEXT NOT NULL, - PRIMARY KEY("message_id"), - FOREIGN KEY("channel_id") REFERENCES "channel_room"("channel_id") ON DELETE CASCADE -) WITHOUT ROWID; --- 5 --- don't copy any orphaned messages -INSERT INTO new_message_channel (message_id, channel_id) SELECT message_id, channel_id FROM message_channel WHERE channel_id IN (SELECT channel_id FROM channel_room); --- 6 -DROP TABLE message_channel; --- 7 -ALTER TABLE new_message_channel RENAME TO message_channel; - --- *** event_message *** - --- clean up any orphaned events -DELETE FROM event_message WHERE message_id NOT IN (SELECT message_id FROM message_channel); --- 4 -CREATE TABLE "new_event_message" ( - "event_id" TEXT NOT NULL, - "event_type" TEXT, - "event_subtype" TEXT, - "message_id" TEXT NOT NULL, - "part" INTEGER NOT NULL, - "reaction_part" INTEGER NOT NULL, - "source" INTEGER NOT NULL, - PRIMARY KEY("message_id","event_id"), - FOREIGN KEY("message_id") REFERENCES "message_channel"("message_id") ON DELETE CASCADE -) WITHOUT ROWID; --- 5 -INSERT INTO new_event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) SELECT event_id, event_type, event_subtype, message_id, part, reaction_part, source FROM event_message; --- 6 -DROP TABLE event_message; --- 7 -ALTER TABLE new_event_message RENAME TO event_message; - --- *** guild_space *** - --- 4 -CREATE TABLE "new_guild_space" ( - "guild_id" TEXT NOT NULL, - "space_id" TEXT NOT NULL, - "privacy_level" INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY("guild_id"), - FOREIGN KEY("guild_id") REFERENCES "guild_active"("guild_id") ON DELETE CASCADE -) WITHOUT ROWID; --- 5 -INSERT INTO new_guild_space (guild_id, space_id, privacy_level) SELECT guild_id, space_id, privacy_level FROM guild_space; --- 6 -DROP TABLE guild_space; --- 7 -ALTER TABLE new_guild_space RENAME TO guild_space; - --- *** reaction *** - --- 4 -CREATE TABLE "new_reaction" ( - "hashed_event_id" INTEGER NOT NULL, - "message_id" TEXT NOT NULL, - "encoded_emoji" TEXT NOT NULL, - PRIMARY KEY("hashed_event_id"), - FOREIGN KEY("message_id") REFERENCES "message_channel"("message_id") ON DELETE CASCADE -) WITHOUT ROWID; --- 5 -INSERT INTO new_reaction (hashed_event_id, message_id, encoded_emoji) SELECT hashed_event_id, message_id, encoded_emoji FROM reaction WHERE message_id IN (SELECT message_id FROM message_channel); --- 6 -DROP TABLE reaction; --- 7 -ALTER TABLE new_reaction RENAME TO reaction; - --- *** webhook *** - --- 4 --- using RESTRICT instead of CASCADE as a reminder that the webhooks also need to be deleted using the Discord API, it can't just be entirely automatic -CREATE TABLE "new_webhook" ( - "channel_id" TEXT NOT NULL, - "webhook_id" TEXT NOT NULL, - "webhook_token" TEXT NOT NULL, - PRIMARY KEY("channel_id"), - FOREIGN KEY("channel_id") REFERENCES "channel_room"("channel_id") ON DELETE RESTRICT -) WITHOUT ROWID; --- 5 -INSERT INTO new_webhook (channel_id, webhook_id, webhook_token) SELECT channel_id, webhook_id, webhook_token FROM webhook WHERE channel_id IN (SELECT channel_id FROM channel_room); --- 6 -DROP TABLE webhook; --- 7 -ALTER TABLE new_webhook RENAME TO webhook; - --- *** sim *** - --- 4 --- while we're at it, rebuild this table to give it WITHOUT ROWID, remove UNIQUE, and replace the localpart column with username. no foreign keys needed -CREATE TABLE "new_sim" ( - "user_id" TEXT NOT NULL, - "username" TEXT NOT NULL, - "sim_name" TEXT NOT NULL, - "mxid" TEXT NOT NULL, - PRIMARY KEY("user_id") -) WITHOUT ROWID; --- 5 -INSERT INTO new_sim (user_id, username, sim_name, mxid) SELECT user_id, sim_name, sim_name, mxid FROM sim; --- 6 -DROP TABLE sim; --- 7 -ALTER TABLE new_sim RENAME TO sim; - --- *** end *** - --- 10 -PRAGMA foreign_key_check; --- 11 -COMMIT; diff --git a/src/db/migrations/0018-add-custom-topic-to-channel-room.sql b/src/db/migrations/0018-add-custom-topic-to-channel-room.sql deleted file mode 100644 index c33d21c..0000000 --- a/src/db/migrations/0018-add-custom-topic-to-channel-room.sql +++ /dev/null @@ -1,5 +0,0 @@ -BEGIN TRANSACTION; - -ALTER TABLE channel_room ADD COLUMN custom_topic INTEGER DEFAULT 0; - -COMMIT; diff --git a/src/db/migrations/0019-add-invite.sql b/src/db/migrations/0019-add-invite.sql deleted file mode 100644 index 6ad03f9..0000000 --- a/src/db/migrations/0019-add-invite.sql +++ /dev/null @@ -1,13 +0,0 @@ -BEGIN TRANSACTION; - -CREATE TABLE "invite" ( - "mxid" TEXT NOT NULL, - "room_id" TEXT NOT NULL, - "type" TEXT, - "name" TEXT, - "topic" TEXT, - "avatar" TEXT, - PRIMARY KEY("mxid","room_id") -) WITHOUT ROWID; - -COMMIT; diff --git a/src/db/migrations/0020-add-presence-to-guild-space.sql b/src/db/migrations/0020-add-presence-to-guild-space.sql deleted file mode 100644 index ea4c908..0000000 --- a/src/db/migrations/0020-add-presence-to-guild-space.sql +++ /dev/null @@ -1,5 +0,0 @@ -BEGIN TRANSACTION; - -ALTER TABLE guild_space ADD COLUMN presence INTEGER NOT NULL DEFAULT 1; - -COMMIT; diff --git a/src/db/migrations/0021-add-url-preview-to-guild-space.sql b/src/db/migrations/0021-add-url-preview-to-guild-space.sql deleted file mode 100644 index 64bc0dd..0000000 --- a/src/db/migrations/0021-add-url-preview-to-guild-space.sql +++ /dev/null @@ -1,5 +0,0 @@ -BEGIN TRANSACTION; - -ALTER TABLE guild_space ADD COLUMN url_preview INTEGER NOT NULL DEFAULT 1; - -COMMIT; diff --git a/src/db/migrations/0022-auto-emoji-without-guild.sql b/src/db/migrations/0022-auto-emoji-without-guild.sql deleted file mode 100644 index 1d23c0d..0000000 --- a/src/db/migrations/0022-auto-emoji-without-guild.sql +++ /dev/null @@ -1,11 +0,0 @@ -BEGIN TRANSACTION; - -DROP TABLE auto_emoji; - -CREATE TABLE auto_emoji ( - name TEXT NOT NULL, - emoji_id TEXT NOT NULL, - PRIMARY KEY (name) -) WITHOUT ROWID; - -COMMIT; diff --git a/src/db/migrations/0023-add-original-encoding-to-reaction.sql b/src/db/migrations/0023-add-original-encoding-to-reaction.sql deleted file mode 100644 index e42e4e1..0000000 --- a/src/db/migrations/0023-add-original-encoding-to-reaction.sql +++ /dev/null @@ -1,5 +0,0 @@ -BEGIN TRANSACTION; - -ALTER TABLE reaction ADD COLUMN original_encoding TEXT; - -COMMIT; diff --git a/src/db/migrations/0024-add-direct.sql b/src/db/migrations/0024-add-direct.sql deleted file mode 100644 index 94dc4ae..0000000 --- a/src/db/migrations/0024-add-direct.sql +++ /dev/null @@ -1,9 +0,0 @@ -BEGIN TRANSACTION; - -CREATE TABLE direct ( - mxid TEXT NOT NULL, - room_id TEXT NOT NULL, - PRIMARY KEY (mxid) -) WITHOUT ROWID; - -COMMIT; diff --git a/src/db/migrations/0025-add-webhook-profile-to-guild-space.sql b/src/db/migrations/0025-add-webhook-profile-to-guild-space.sql deleted file mode 100644 index e629fb8..0000000 --- a/src/db/migrations/0025-add-webhook-profile-to-guild-space.sql +++ /dev/null @@ -1,5 +0,0 @@ -BEGIN TRANSACTION; - -ALTER TABLE guild_space ADD COLUMN webhook_profile INTEGER NOT NULL DEFAULT 0; - -COMMIT; diff --git a/src/db/migrations/0026-make-rooms-historical.sql b/src/db/migrations/0026-make-rooms-historical.sql deleted file mode 100644 index ba4775e..0000000 --- a/src/db/migrations/0026-make-rooms-historical.sql +++ /dev/null @@ -1,63 +0,0 @@ -PRAGMA foreign_keys=OFF; -BEGIN TRANSACTION; - --- *** historical_channel_room *** - -CREATE TABLE "historical_channel_room" ( - "historical_room_index" INTEGER NOT NULL, - "reference_channel_id" TEXT NOT NULL, - "room_id" TEXT NOT NULL UNIQUE, - "upgraded_timestamp" INTEGER NOT NULL, - PRIMARY KEY("historical_room_index" AUTOINCREMENT), - FOREIGN KEY("reference_channel_id") REFERENCES "channel_room"("channel_id") ON DELETE CASCADE -); - -INSERT INTO historical_channel_room (reference_channel_id, room_id, upgraded_timestamp) SELECT channel_id, room_id, 0 FROM channel_room; - --- *** message_channel -> message_room *** - -CREATE TABLE "message_room" ( - "message_id" TEXT NOT NULL, - "historical_room_index" INTEGER NOT NULL, - PRIMARY KEY("message_id"), - FOREIGN KEY("historical_room_index") REFERENCES "historical_channel_room"("historical_room_index") ON DELETE CASCADE -) WITHOUT ROWID; -INSERT INTO message_room (message_id, historical_room_index) SELECT message_id, max(historical_room_index) as historical_room_index FROM message_channel INNER JOIN historical_channel_room ON historical_channel_room.reference_channel_id = message_channel.channel_id GROUP BY message_id; - --- *** event_message *** - -CREATE TABLE "new_event_message" ( - "event_id" TEXT NOT NULL, - "event_type" TEXT, - "event_subtype" TEXT, - "message_id" TEXT NOT NULL, - "part" INTEGER NOT NULL, - "reaction_part" INTEGER NOT NULL, - "source" INTEGER NOT NULL, - PRIMARY KEY("message_id","event_id"), - FOREIGN KEY("message_id") REFERENCES "message_room"("message_id") ON DELETE CASCADE -) WITHOUT ROWID; -INSERT INTO new_event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) SELECT event_id, event_type, event_subtype, message_id, part, reaction_part, source from event_message; -DROP TABLE event_message; -ALTER TABLE new_event_message RENAME TO event_message; - --- *** reaction *** - -CREATE TABLE "new_reaction" ( - "hashed_event_id" INTEGER NOT NULL, - "message_id" TEXT NOT NULL, - "encoded_emoji" TEXT NOT NULL, original_encoding TEXT, - PRIMARY KEY("hashed_event_id"), - FOREIGN KEY("message_id") REFERENCES "message_room"("message_id") ON DELETE CASCADE -) WITHOUT ROWID; -INSERT INTO new_reaction (hashed_event_id, message_id, encoded_emoji) SELECT hashed_event_id, message_id, encoded_emoji FROM reaction; -DROP TABLE reaction; -ALTER TABLE new_reaction RENAME TO reaction; - --- *** - -DROP TABLE message_channel; -PRAGMA foreign_key_check; - -COMMIT; -PRAGMA foreign_keys=ON; diff --git a/src/db/migrations/0027-analyze.sql b/src/db/migrations/0027-analyze.sql deleted file mode 100644 index f66e0c1..0000000 --- a/src/db/migrations/0027-analyze.sql +++ /dev/null @@ -1,250 +0,0 @@ --- https://www.sqlite.org/lang_analyze.html - -BEGIN TRANSACTION; - -ANALYZE sqlite_schema; - -DELETE FROM "sqlite_stat1"; -INSERT INTO "sqlite_stat1" ("tbl","idx","stat") VALUES ('reaction','reaction','4741 1'), -('event_message','event_message','537386 1 1'), -('message_room','message_room','510262 1'), -('historical_channel_room','sqlite_autoindex_historical_channel_room_1','991 1'), -('auto_emoji','auto_emoji','2 1'), -('sim','sim','1075 1'), -('webhook','webhook','205 1'), -('channel_room','channel_room','992 1'), -('channel_room','sqlite_autoindex_channel_room_1','992 1'), -('guild_active','guild_active','45 1'), -('media_proxy','media_proxy','19794 1'), -('sim_member','sim_member','5504 6 1'), -('emoji','emoji','3472 1'), -('guild_space','guild_space','43 1'), -('member_power','member_power','1 1 1'), -('sim_proxy','sim_proxy','213 1'), -('migration',NULL,'1'), -('member_cache','member_cache','1117 3 1'), -('file','file','36489 1'), -('lottie','lottie','22 1'); - -DELETE FROM "sqlite_stat4"; -INSERT INTO "sqlite_stat4" ("tbl","idx","neq","nlt","ndlt","sample") VALUES ('reaction','reaction','1','526','526',X'02069c21bd28f26ae025'), - ('reaction','reaction','1','1053','1053',X'0206b8866f4c30c2e1aa'), - ('reaction','reaction','1','1580','1580',X'0206d43fceca129b040e'), - ('reaction','reaction','1','2107','2107',X'0206f121f9a4fe54b557'), - ('reaction','reaction','1','2634','2634',X'020610299199abbd0e9c'), - ('reaction','reaction','1','3161','3161',X'02062be99961e7716037'), - ('reaction','reaction','1','3688','3688',X'020647b48fa5ee5a415c'), - ('reaction','reaction','1','4215','4215',X'020664fdc2d88c77dda3'), - ('event_message','event_message','11 1','14790 14792','14356 14792',X'03336531313532303033373639343137303839303434244a616d4c6d732d4b77454c6b47766866344d524f385576535536336a574a5a4c4474524c4c57664f775873'), - ('event_message','event_message','11 1','33809 33816','32914 33816',X'0333653131353736303336383730353738363637363224544b7141734f58566c6e67506f546f4a427565514e664444756d494a6d38384f486a76766f7949496e7130'), - ('event_message','event_message','1 1','59709 59709','57896 59709',X'033365313136363930353332323132303637353336392442794756564f6767326a416845624267463941755056486178377a34314459514e4459316e34435a4a4455'), - ('event_message','event_message','11 1','116172 116182','111525 116182',X'0333653131393336383733373036313733323736353624786a385f70696e784f624f4349666c70556832305542345973664a547642694b4164675f473168562d5334'), - ('event_message','event_message','1 1','119419 119419','114559 119419',X'03336531313935323132333038393839383730313732244f556670664d5054576c364774734943484d725459556d6464656c636232663374494a662d425769554355'), - ('event_message','event_message','16 1','140286 140287','134379 140287',X'0333653132303933373536353437313834383034323524346b61796e4d68422d336d6967417571347255745f726639353454636b6f657636664c5f3675394f455030'), - ('event_message','event_message','11 1','162080 162086','154932 162086',X'0333653132323434383135393033313937373537373424674c77513179796e4b6d5859496b5a597a4a55627a66557a55552d714c4b5f524f454e4250325f6e44766b'), - ('event_message','event_message','11 1','178659 178659','170672 178659',X'03336531323333353238303533323338333337353537242d39304668552d36455373594b6435484d7237666d6a414a5f6a576149616e356c4776384e655436564959'), - ('event_message','event_message','1 1','179129 179129','171083 179129',X'03336531323333393533373032373637383836343636245a446e5f42385a6b41674c645939495649767445516e47373369706a555a55447943634768697851673859'), - ('event_message','event_message','10 1','180049 180052','171954 180052',X'03336531323334363237303030393333383130323637245171504b7357795254734a49695449744646716a686e506d48764a6e5932584a6c595a506b424e372d766f'), - ('event_message','event_message','11 1','215266 215271','205302 215271',X'03336531323533373435373636373337313231333632244b4936672d57724f5a5757533463534c4c4f353950555176425066754b5f5446504b443233583130504759'), - ('event_message','event_message','11 1','224498 224499','213831 224499',X'0333653132353932393835383232333036303137353824356a573361764d37626d643661756c7367635650506f5257417552476e30503477324939786b5675326f6b'), - ('event_message','event_message','10 1','224519 224523','213833 224523',X'033365313235393239383739353234323635353839352452696c715a6862347a32526b594c596958504375445975546f6b6430544e365a784638737842745670346b'), - ('event_message','event_message','10 1','224615 224616','213843 224616',X'0333653132353933303036363636373831383139323024425a69396d4c73323034344c674a6e56673761557a614467484b4b5545787334587a467954474245585573'), - ('event_message','event_message','1 1','238839 238839','227061 238839',X'0333653132363839343934383836303535393336343224374d3633546d416c526947553847795f416164576f4d4f4e4a334b363441326235385f6e72385961652d51'), - ('event_message','event_message','1 1','298549 298549','283096 298549',X'03336531333037303536353132313931303337343532245830424a3954514e544d3041687554736c7258744b5836383376723749524355747a4b47524a4374493555'), - ('event_message','event_message','11 1','304605 304605','288785 304605',X'03336531333131353731333337393331363537323737242d674e75657465765a426169587949335859717437325743695438396549573269514761416266384f6455'), - ('event_message','event_message','11 1','327028 327037','309699 327037',X'0333653133323736353831313733343439323336383024715055786a61394c36694e756548683046335962304b524b67665730414356394769367a4147464b714973'), - ('event_message','event_message','10 1','329549 329550','312055 329550',X'033365313332393331373735303931323435303537322430325a4779526f33656133786e5356706b52487047325459415464373971684834536632506f4e7a614773'), - ('event_message','event_message','1 1','358259 358259','339179 358259',X'03336531333436303136333531313138313634303539243364757343667558596a506f715a3642774851755a48496e5163504f4e70766c64387476654a4d45685a38'), - ('event_message','event_message','1 1','417969 417969','395237 417969',X'0333653133363831333832343230383333393336363724537a7775656948304b696130376d67304e51322d58627751352d6a7653507649464e645053396464416655'), - ('event_message','event_message','11 1','422263 422270','399248 422270',X'033365313336393833343930353236313034373831382456754f5872464d593547734350377467425f6a763348486f426264666b3859464c4b4f6e48583732497677'), - ('event_message','event_message','10 1','424260 424266','401135 424266',X'03336531333730353132353138353938303939303637246c7268447950715458362d45497a3637552d616a75453839614655394c4151556f5a356d7363725072466f'), - ('event_message','event_message','1 1','477679 477679','451062 477679',X'0333653134303434353430323035313234383133333324524f454b6b5f726b3373344b7451337a75344552774c4b5069484964757676575f514d4b4e66306c385630'), - ('message_room','message_room','1','56695','56695',X'023331313636313031373337333834343630333739'), - ('message_room','message_room','1','113391','113391',X'023331313932353935303036363435363132353434'), - ('message_room','message_room','1','170087','170087',X'023331323331393439393133373937393535363335'), - ('message_room','message_room','1','226783','226783',X'023331323636313430343634333733383239373532'), - ('message_room','message_room','1','283479','283479',X'023331333034303933383132373833373130323539'), - ('message_room','message_room','1','340175','340175',X'023331333434383431363637333537393730343332'), - ('message_room','message_room','1','396871','396871',X'023331333637353035313132313333363638393134'), - ('message_room','message_room','1','453567','453567',X'023331343032363934353234333439373134343833'), - ('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','0 0','0 0',X'034b0221414355774c616c64303030303030303030303a636164656e63652e6d6f650288'), - ('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','24 24','24 24',X'034b02214255635a694c7a57303030303030303030303a636164656e63652e6d6f6501ab'), - ('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','110 110','110 110',X'034b022147486e4d47697875303030303030303030303a636164656e63652e6d6f6500c6'), - ('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','193 193','193 193',X'034b02214b4b535575717666303030303030303030303a636164656e63652e6d6f650350'), - ('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','221 221','221 221',X'034b02214c51715351594b73303030303030303030303a636164656e63652e6d6f6503af'), - ('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','319 319','319 319',X'034b02215170676c734e587a303030303030303030303a636164656e63652e6d6f650366'), - ('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','332 332','332 332',X'034b0221525a585a7064554f303030303030303030303a636164656e63652e6d6f65009f'), - ('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','351 351','351 351',X'034b0221534b6f6c6f636b77303030303030303030303a636164656e63652e6d6f65035f'), - ('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','443 443','443 443',X'034b0221576374435a494d73303030303030303030303a636164656e63652e6d6f650084'), - ('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','551 551','551 551',X'034b0221637779454c6c6b55303030303030303030303a636164656e63652e6d6f6501b0'), - ('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','554 554','554 554',X'034b0221644965496d615167303030303030303030303a636164656e63652e6d6f6503a0'), - ('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','560 560','560 560',X'034b0221645568456f756a71303030303030303030303a636164656e63652e6d6f650090'), - ('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','573 573','573 573',X'034b02216552517465644b67303030303030303030303a636164656e63652e6d6f650099'), - ('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','593 593','593 593',X'034b0221666764594e526d4e303030303030303030303a636164656e63652e6d6f65016b'), - ('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','624 624','624 624',X'034b0221687078416c4c6f71303030303030303030303a636164656e63652e6d6f650297'), - ('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','625 625','625 625',X'034b02216873414570464e47303030303030303030303a636164656e63652e6d6f6500be'), - ('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','665 665','665 665',X'034b01216a71484b51424476303030303030303030303a636164656e63652e6d6f653b'), - ('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','758 758','758 758',X'034b02216f6251554d424b75303030303030303030303a636164656e63652e6d6f6500f7'), - ('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','776 776','776 776',X'034b022170566e596b5a4f46303030303030303030303a636164656e63652e6d6f650232'), - ('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','781 781','781 781',X'034b01217065766e6542516e303030303030303030303a636164656e63652e6d6f6518'), - ('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','857 857','857 857',X'034b02217446564c65724b78303030303030303030303a636164656e63652e6d6f65024e'), - ('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','866 866','866 866',X'034b0221745a61474145557a303030303030303030303a636164656e63652e6d6f6501f8'), - ('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','887 887','887 887',X'034b022175727a464b754d61303030303030303030303a636164656e63652e6d6f65033b'), - ('historical_channel_room','sqlite_autoindex_historical_channel_room_1','1 1','921 921','921 921',X'034b022177574a5548445a74303030303030303030303a636164656e63652e6d6f65025c'), - ('auto_emoji','auto_emoji','1','0','0',X'02114c31'), - ('auto_emoji','auto_emoji','1','1','1',X'02114c32'), - ('sim','sim','1','119','119',X'025531316564343731342d636635652d346333372d393331382d376136353266383732636634'), - ('sim','sim','1','239','239',X'0231313439363932303632313634333230323536'), - ('sim','sim','1','359','359',X'025532323533323035312d633335332d346638662d383835362d653137383831323435303763'), - ('sim','sim','1','479','479',X'0231333036373839323436333237353836383136'), - ('sim','sim','1','599','599',X'0231343132383438323830343635313738363235'), - ('sim','sim','1','719','719',X'0231353638323430303837363238373735343234'), - ('sim','sim','1','839','839',X'0231373234383037393132373233313835373534'), - ('sim','sim','1','959','959',X'0231393431303333313033353936353835303630'), - ('webhook','webhook','1','22','22',X'023331313630383933333337303239353836393536'), - ('webhook','webhook','1','45','45',X'023331323139343938393236343636363632343330'), - ('webhook','webhook','1','68','68',X'023331323432383939363632343734373131303630'), - ('webhook','webhook','1','91','91',X'023331323937323836383730393534323833313533'), - ('webhook','webhook','1','114','114',X'023331333430353438363133363931393332373133'), - ('webhook','webhook','1','137','137',X'023331343034313334383236303530383436393331'), - ('webhook','webhook','1','160','160',X'0231333639373535303430343638303431373238'), - ('webhook','webhook','1','183','183',X'0231363035353930343336333230333738383930'), - ('channel_room','channel_room','1','110','110',X'023331313939353030313137393834363733393133'), - ('channel_room','channel_room','1','221','221',X'023331323734313935333432323131393430353434'), - ('channel_room','channel_room','1','332','332',X'023331333437303036333637393639343433383430'), - ('channel_room','channel_room','1','443','443',X'023331343035323432323838343138303632333636'), - ('channel_room','channel_room','1','554','554',X'023331343036373736363630393936333935323830'), - ('channel_room','channel_room','1','665','665',X'023331343039363536363537383835323635393830'), - ('channel_room','channel_room','1','776','776',X'023331343139353132333134363234383638343632'), - ('channel_room','channel_room','1','887','887',X'0231333734383732393736313738343133353639'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','23 23','23 23',X'034b3121425167434a4d4c78303030303030303030303a636164656e63652e6d6f65393631373335333036303032393732373432'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','96 96','96 96',X'034b332146514f654f667747303030303030303030303a636164656e63652e6d6f6531323137393638383531303939313839323738'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','110 110','110 110',X'034b332147486e4d47697875303030303030303030303a636164656e63652e6d6f6531323432323436333730303938363739383838'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','138 138','138 138',X'034b3321484a79705a6b6863303030303030303030303a636164656e63652e6d6f6531303237323933303239313633333335373130'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','161 161','161 161',X'034b33214962646466626172303030303030303030303a636164656e63652e6d6f6531323937373538303931373331303039353536'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','221 221','221 221',X'034b31214c51715351594b73303030303030303030303a636164656e63652e6d6f65373039303431393733353332363838343235'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','240 240','240 240',X'034b33214d5071594e414a62303030303030303030303a636164656e63652e6d6f6531323139303338323638323835323539393037'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','250 250','250 250',X'034b33214e414f484c4e444c303030303030303030303a636164656e63652e6d6f6531343037323332343832313338333934363634'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','325 325','325 325',X'034b33215178576669464359303030303030303030303a636164656e63652e6d6f6531343034353739343736363837323934363434'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','332 332','332 332',X'034b33215254735654767542303030303030303030303a636164656e63652e6d6f6531343037323235393932313935333432343237'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','430 430','430 430',X'034b33215673656a6b6b5a71303030303030303030303a636164656e63652e6d6f6531323235323636343030363838333431303833'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','443 443','443 443',X'034b3321576241744a736c6b303030303030303030303a636164656e63652e6d6f6531343230323635333433363931313332393339'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','552 552','552 552',X'034b3321637779454c6c6b55303030303030303030303a636164656e63652e6d6f6531343034393538363332363830303939393931'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','554 554','554 554',X'034b332164484e5378484a47303030303030303030303a636164656e63652e6d6f6531343035363439333331343335393939323932'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','565 565','565 565',X'034b3321646c584f50766944303030303030303030303a636164656e63652e6d6f6531323735353037363433323231343039393033'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','579 579','579 579',X'034b332165656c6c7a6a5370303030303030303030303a636164656e63652e6d6f6531343036373736363630393936333935323830'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','619 619','619 619',X'034b332168525179596e6d4e303030303030303030303a636164656e63652e6d6f6531343237323832333338303035353136343233'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','664 664','664 664',X'034b33216a6c566e54585747303030303030303030303a636164656e63652e6d6f6531323139353034373636333137373536343238'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','665 665','665 665',X'034b33216a6c6c4479666d76303030303030303030303a636164656e63652e6d6f6531303835303935353736383731333837313936'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','776 776','776 776',X'034b332170555653686b7978303030303030303030303a636164656e63652e6d6f6531323139343939353736363137303738383735'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','813 813','813 813',X'034b33217179416246555961303030303030303030303a636164656e63652e6d6f6531343035393130313436363731393732333833'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','887 887','887 887',X'034b33217571697357484575303030303030303030303a636164656e63652e6d6f6531323331383036353337373032353736313938'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','924 924','924 924',X'034b332177585242634d4851303030303030303030303a636164656e63652e6d6f6531343233373338343430363833363232353332'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','953 953','953 953',X'034b33217954757a6749556f303030303030303030303a636164656e63652e6d6f6531333338353537373531373232323530333130'), - ('guild_active','guild_active','1','5','5',X'023331313433333336323438373631363437313534'), - ('guild_active','guild_active','1','11','11',X'023331313630383933333336333234393331353834'), - ('guild_active','guild_active','1','17','17',X'023331323839353936343835343631323137333430'), - ('guild_active','guild_active','1','23','23',X'023331333338363530383035363233393834333030'), - ('guild_active','guild_active','1','29','29',X'023331343338363132393630393137353836313233'), - ('guild_active','guild_active','1','35','35',X'0231343937313539373236343535343535373534'), - ('guild_active','guild_active','1','41','41',X'0231383730313138363530373638363730373530'), - ('media_proxy','media_proxy','1','2199','2199',X'02069cb7709d83b92e22'), - ('media_proxy','media_proxy','1','4399','4399',X'0206b953cc685f0b68d2'), - ('media_proxy','media_proxy','1','6599','6599',X'0206d546a2d00310b6cc'), - ('media_proxy','media_proxy','1','8799','8799',X'0206f0d029ff71e1dae5'), - ('media_proxy','media_proxy','1','10999','10999',X'02060e4626697605710f'), - ('media_proxy','media_proxy','1','13199','13199',X'02062adc53c43825bc39'), - ('media_proxy','media_proxy','1','15399','15399',X'02064704c4b0f76fa5ff'), - ('media_proxy','media_proxy','1','17599','17599',X'02066338ce2423770613'), - ('sim_member','sim_member','225 1','14 80','4 80',X'034b4721414956694e775a64303030303030303030303a636164656e63652e6d6f65405f6f6f79655f66726f73745f313139323a636164656e63652e6d6f65'), - ('sim_member','sim_member','32 1','483 488','68 488',X'034b3b21455450534d664d69303030303030303030303a636164656e63652e6d6f65405f6f6f79655f653372613a636164656e63652e6d6f65'), - ('sim_member','sim_member','125 1','598 611','85 611',X'034b4921457a54624a496c49303030303030303030303a636164656e63652e6d6f65405f6f6f79655f61726a756e3034323236393a636164656e63652e6d6f65'), - ('sim_member','sim_member','35 1','818 851','107 851',X'034b472147486e4d47697875303030303030303030303a636164656e63652e6d6f65405f6f6f79655f76616e746164656c69613a636164656e63652e6d6f65'), - ('sim_member','sim_member','63 1','945 1005','141 1005',X'034b412148725979716b6f79303030303030303030303a636164656e63652e6d6f65405f6f6f79655f7669686f776c733a636164656e63652e6d6f65'), - ('sim_member','sim_member','48 1','1024 1025','149 1025',X'034b47214943566475566c64303030303030303030303a636164656e63652e6d6f65405f6f6f79655f5f706b5f6172656866723a636164656e63652e6d6f65'), - ('sim_member','sim_member','39 1','1205 1223','175 1223',X'034b41214a48614a71425870303030303030303030303a636164656e63652e6d6f65405f6f6f79655f6c6f6f6e656c613a636164656e63652e6d6f65'), - ('sim_member','sim_member','48 1','1734 1768','289 1768',X'034b47215074796952785161303030303030303030303a636164656e63652e6d6f65405f6f6f79655f6d6f6d6f7473756b692e3a636164656e63652e6d6f65'), - ('sim_member','sim_member','5 1','1832 1835','299 1835',X'034b4b2151544372636e6953303030303030303030303a636164656e63652e6d6f65405f6f6f79655f72616e646f6d6974796775793a636164656e63652e6d6f65'), - ('sim_member','sim_member','64 1','2097 2100','353 2100',X'034b4521536c7664497a734f303030303030303030303a636164656e63652e6d6f65405f6f6f79655f5f706b5f626369736c3a636164656e63652e6d6f65'), - ('sim_member','sim_member','81 1','2213 2240','361 2240',X'034b4721544f61794476734c303030303030303030303a636164656e63652e6d6f65405f6f6f79655f5f706b5f6f7a707a79633a636164656e63652e6d6f65'), - ('sim_member','sim_member','42 1','2368 2409','373 2409',X'034b49215468436b4b585743303030303030303030303a636164656e63652e6d6f65405f6f6f79655f776172736d6974686c69763a636164656e63652e6d6f65'), - ('sim_member','sim_member','36 1','2422 2447','380 2447',X'034b4b2154716c79516d6966303030303030303030303a636164656e63652e6d6f65405f6f6f79655f6a6f6b65726765726d616e793a636164656e63652e6d6f65'), - ('sim_member','sim_member','65 1','2689 2721','438 2721',X'034b472157755a5549494e74303030303030303030303a636164656e63652e6d6f65405f6f6f79655f5f706b5f77797a63686a3a636164656e63652e6d6f65'), - ('sim_member','sim_member','2 1','3058 3059','497 3059',X'034b3921616f764c6d776a67303030303030303030303a636164656e63652e6d6f65405f6f6f79655f726e6c3a636164656e63652e6d6f65'), - ('sim_member','sim_member','8 1','3666 3671','630 3671',X'034b39216966636d75794e6e303030303030303030303a636164656e63652e6d6f65405f6f6f79655f726e6c3a636164656e63652e6d6f65'), - ('sim_member','sim_member','43 1','3849 3874','668 3874',X'034b4f216b6b4b714249664c303030303030303030303a636164656e63652e6d6f65405f6f6f79655f656c656374726f6e6963353339313a636164656e63652e6d6f65'), - ('sim_member','sim_member','8 1','4280 4283','746 4283',X'034b3f216f705748554e6b46303030303030303030303a636164656e63652e6d6f65405f6f6f79655f636f6f6b69653a636164656e63652e6d6f65'), - ('sim_member','sim_member','158 1','4424 4465','770 4465',X'034b452170757146464b5948303030303030303030303a636164656e63652e6d6f65405f6f6f79655f5f706b5f6a666e747a3a636164656e63652e6d6f65'), - ('sim_member','sim_member','44 1','4810 4810','824 4810',X'034b4121734b4c6f784a4e62303030303030303030303a636164656e63652e6d6f65405f6f6f79655f313030626563733a636164656e63652e6d6f65'), - ('sim_member','sim_member','11 1','4892 4895','841 4895',X'034b45217443744769524448303030303030303030303a636164656e63652e6d6f65405f6f6f79655f646f6f74736b7972653a636164656e63652e6d6f65'), - ('sim_member','sim_member','73 1','5072 5089','888 5089',X'034b47217665764462756174303030303030303030303a636164656e63652e6d6f65405f6f6f79655f5f706b5f6b73706a75653a636164656e63652e6d6f65'), - ('sim_member','sim_member','59 1','5182 5236','903 5236',X'034b43217750454472596b77303030303030303030303a636164656e63652e6d6f65405f6f6f79655f74656368323334613a636164656e63652e6d6f65'), - ('sim_member','sim_member','52 1','5441 5469','968 5469',X'034b41217a66654e574d744b303030303030303030303a636164656e63652e6d6f65405f6f6f79655f6e6f766574746f3a636164656e63652e6d6f65'), - ('emoji','emoji','1','385','385',X'023331313035373039393137313237353737363733'), - ('emoji','emoji','1','771','771',X'023331323230353735323436303531303533353638'), - ('emoji','emoji','1','1157','1157',X'023331333530383339313335363836313033303730'), - ('emoji','emoji','1','1543','1543',X'0231333439373232393637383636393938373834'), - ('emoji','emoji','1','1929','1929',X'0231343933383437383237313138353535313436'), - ('emoji','emoji','1','2315','2315',X'0231363432353731303038313337373536373032'), - ('emoji','emoji','1','2701','2701',X'0231373738313036343330333034393434313238'), - ('emoji','emoji','1','3087','3087',X'0231393030383733373535343037303336343738'), - ('guild_space','guild_space','1','4','4',X'023331313333333135333632353636343535333336'), - ('guild_space','guild_space','1','9','9',X'023331313534383638343234373234343633363837'), - ('guild_space','guild_space','1','14','14',X'023331323139303338323637383430393235383138'), - ('guild_space','guild_space','1','19','19',X'023331323839363030383537323437303535383733'), - ('guild_space','guild_space','1','24','24',X'023331333435363431323031393032323838393837'), - ('guild_space','guild_space','1','29','29',X'0231323733383737363437323234393935383431'), - ('guild_space','guild_space','1','34','34',X'0231353239313736313536333938363832313135'), - ('guild_space','guild_space','1','39','39',X'0231383730313138363530373638363730373530'), - ('member_power','member_power','1 1','0 0','0 0',X'03350f40636164656e63653a636164656e63652e6d6f652a'), - ('sim_proxy','sim_proxy','1','23','23',X'025531363733363165392d656137652d343530392d623533302d356531613863613735336237'), - ('sim_proxy','sim_proxy','1','47','47',X'025532653561626332312d326332622d346133352d386237642d366432383162363036653932'), - ('sim_proxy','sim_proxy','1','71','71',X'025534383131393165322d393462302d346534632d623934352d336330323932623135356238'), - ('sim_proxy','sim_proxy','1','95','95',X'025536346331346631642d663834342d346535622d386665332d336162336163363239616230'), - ('sim_proxy','sim_proxy','1','119','119',X'025538376562363463322d363763352d346432352d383161642d666664333235663266303639'), - ('sim_proxy','sim_proxy','1','143','143',X'025561616630313539652d623165312d343231342d396266652d313334613536303738323231'), - ('sim_proxy','sim_proxy','1','167','167',X'025563396534393633372d663061352d343566352d383234382d366436393565643861316434'), - ('sim_proxy','sim_proxy','1','191','191',X'025565333734613634362d386231332d343365392d393635392d653233326366653866626265'), - ('member_cache','member_cache','4 1','98 99','66 99',X'034b35214a48614a71425870303030303030303030303a636164656e63652e6d6f6540657a7261637574653a6d61747269782e6f7267'), - ('member_cache','member_cache','4 1','119 122','80 122',X'034b43214c684978654c4d54303030303030303030303a636164656e63652e6d6f6540737461727368696e656c756e6163793a6d61747269782e6f7267'), - ('member_cache','member_cache','1 1','124 124','82 124',X'034b2d214d5071594e414a62303030303030303030303a636164656e63652e6d6f6540726e6c3a636164656e63652e6d6f65'), - ('member_cache','member_cache','5 1','128 131','85 131',X'034b3b214e446249714e704a303030303030303030303a636164656e63652e6d6f65406761627269656c766f6e643a6d61747269782e6f7267'), - ('member_cache','member_cache','4 1','138 139','90 139',X'034b3d214f48584445737062303030303030303030303a636164656e63652e6d6f6540616d693a7468652d61706f746865636172792e636c7562'), - ('member_cache','member_cache','5 1','207 209','135 209',X'034b51215450616f6a545444303030303030303030303a636164656e63652e6d6f65406a61636b736f6e6368656e3636363a6a61636b736f6e6368656e3636362e636f6d'), - ('member_cache','member_cache','76 1','216 249','140 249',X'034b2d2154716c79516d6966303030303030303030303a636164656e63652e6d6f65406963656d616e3a656e76732e6e6574'), - ('member_cache','member_cache','4 1','345 345','171 345',X'034b3521586f4c466b65786a303030303030303030303a636164656e63652e6d6f6540636164656e63653a636164656e63652e6d6f65'), - ('member_cache','member_cache','10 1','351 354','174 354',X'034b3521594b46454e797166303030303030303030303a636164656e63652e6d6f654066617269656c6c653a6d61747269782e6f7267'), - ('member_cache','member_cache','1 1','374 374','183 374',X'034b2d21596f54644f55766a303030303030303030303a636164656e63652e6d6f6540726e6c3a636164656e63652e6d6f65'), - ('member_cache','member_cache','152 1','405 499','205 499',X'034b45216342787456527844303030303030303030303a636164656e63652e6d6f65406d61726975733835313030303a6d617269757364617669642e6672'), - ('member_cache','member_cache','4 1','562 563','209 563',X'034b35216356514d45455158303030303030303030303a636164656e63652e6d6f6540657a7261637574653a6d61747269782e6f7267'), - ('member_cache','member_cache','8 1','582 586','223 586',X'034b3721654856655270706e303030303030303030303a636164656e63652e6d6f65406563686f3a66757272797265667567652e636f6d'), - ('member_cache','member_cache','7 1','594 600','227 600',X'034b3b2165724f7079584e46303030303030303030303a636164656e63652e6d6f6540766962656973766572796f3a6d61747269782e6f7267'), - ('member_cache','member_cache','165 1','613 624','235 624',X'034b4921676865544b5a7451303030303030303030303a636164656e63652e6d6f6540616d70666c6f7765723a7468652d61706f746865636172792e636c7562'), - ('member_cache','member_cache','165 1','613 749','235 749',X'034b4521676865544b5a7451303030303030303030303a636164656e63652e6d6f654073706c617473756e653a636861742e6e6575726172696f2e636f6d'), - ('member_cache','member_cache','6 1','778 782','236 782',X'034b3321676b6b686f756d42303030303030303030303a636164656e63652e6d6f65406b6162693a6361746769726c2e776f726b73'), - ('member_cache','member_cache','10 1','786 794','239 794',X'034b332168424a766e654e4f303030303030303030303a636164656e63652e6d6f65406d65636879613a636164656e63652e6d6f65'), - ('member_cache','member_cache','13 1','809 819','249 819',X'034b2b2169537958674e7851303030303030303030303a636164656e63652e6d6f65406d69646f753a656e76732e6e6574'), - ('member_cache','member_cache','4 1','856 858','273 858',X'034b3b216b73724f45554666303030303030303030303a636164656e63652e6d6f65406761627269656c766f6e643a6d61747269782e6f7267'), - ('member_cache','member_cache','12 1','865 874','279 874',X'034b35216c7570486a715444303030303030303030303a636164656e63652e6d6f654068656c6c63703a6f70656e737573652e6f7267'), - ('member_cache','member_cache','4 1','886 887','285 887',X'034b2d216d61676745536775303030303030303030303a636164656e63652e6d6f65406361743a6d61756e69756d2e6e6574'), - ('member_cache','member_cache','71 1','999 999','357 999',X'034b2d2177574f6673767573303030303030303030303a636164656e63652e6d6f654061613a6361747669626572732e6d65'), - ('member_cache','member_cache','14 1','1074 1085','361 1085',X'034b3721776c534544496a44303030303030303030303a636164656e63652e6d6f654073646f6d693a6861636b657273706163652e706c'), - ('file','file','1','4054','4054',X'03816568747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3131323736303636393137383234313032342f313135313733303032323332383035373930372f53637265656e73686f745f32303233303931345f3036303333352e6a7067'), - ('file','file','1','8109','8109',X'03814168747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3131393239333936393731353639313532322f313231383430393538323539343838373638302f494d475f343738322e6a7067'), - ('file','file','1','12164','12164',X'03813b68747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3133343037373735333438353033333437322f313139393131323831343931373333333133332f696d6167652e706e67'), - ('file','file','1','16219','16219',X'03814168747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f313337363732343737393830353034383838322f313339323938363435323538343936303032312f707265766965772e706e67'), - ('file','file','1','20274','20274',X'03816568747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3135393136353731343139343735393638302f313236353735383536303531323338313030392f53637265656e73686f745f32303234303732342d3135353232382e706e67'), - ('file','file','1','24329','24329',X'03813b68747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3238383838323935333331343839333832352f313134373538383535363839343738313539332f696d6167652e706e67'), - ('file','file','1','28384','28384',X'03817168747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3635353231363137333639363238363734362f313330383239363937343136333737353530392f31373331393932363837323838343136383737323137393036333831303733392e6a7067'), - ('file','file','1','32439','32439',X'027f68747470733a2f2f63646e2e646973636f72646170702e636f6d2f656d6f6a69732f313034323532383239323539363632313434322e706e67'), - ('lottie','lottie','1','2','2',X'0231373439303532393434363832353832303336'), - ('lottie','lottie','1','5','5',X'0231373531363036333739333430333635383634'), - ('lottie','lottie','1','8','8',X'0231373534313038373731383532323232353634'), - ('lottie','lottie','1','11','11',X'0231373936313430363338303933343433303932'), - ('lottie','lottie','1','14','14',X'0231373936313431373032363935343835353030'), - ('lottie','lottie','1','17','17',X'0231383136303837373932323931323832393434'), - ('lottie','lottie','1','20','20',X'0231383233393736313032393736323930383636'); - -ANALYZE sqlite_schema; - -COMMIT; diff --git a/src/db/migrations/0028-add-room-upgrade.sql b/src/db/migrations/0028-add-room-upgrade.sql deleted file mode 100644 index fed6f21..0000000 --- a/src/db/migrations/0028-add-room-upgrade.sql +++ /dev/null @@ -1,10 +0,0 @@ -BEGIN TRANSACTION; - -CREATE TABLE room_upgrade_pending ( - new_room_id TEXT NOT NULL, - old_room_id TEXT NOT NULL UNIQUE, - PRIMARY KEY (new_room_id), - FOREIGN KEY (old_room_id) REFERENCES channel_room (room_id) ON DELETE CASCADE -) WITHOUT ROWID; - -COMMIT; diff --git a/src/db/migrations/0029-force-guild-ids.js b/src/db/migrations/0029-force-guild-ids.js deleted file mode 100644 index 354bc6b..0000000 --- a/src/db/migrations/0029-force-guild-ids.js +++ /dev/null @@ -1,61 +0,0 @@ -/* - a. If the bridge bot sim already has the correct ID: - - No rows updated. - - b. If the bridge bot sim has the wrong ID but there's no duplicate: - - One row updated. - - c. If the bridge bot sim has the wrong ID and there's a duplicate: - - One row updated (replaces an existing row). -*/ - -const {discord} = require("../../passthrough") - -const ones = "₀₁₂₃₄₅₆₇₈₉" -const tens = "0123456789" - -/* c8 ignore start */ - -module.exports = async function(db) { - /** @type {{name: string, channel_id: string, thread_parent: string | null}[]} */ - const rows = db.prepare("SELECT name, channel_id, thread_parent FROM channel_room WHERE guild_id IS NULL").all() - - /** @type {Map} channel or thread ID -> guild ID */ - const cache = new Map() - - // Process channels - process.stdout.write(` loading metadata for ${rows.length} channels/threads... `) - for (let counter = 1; counter <= rows.length; counter++) { - process.stdout.write(String(counter).at(-1) === "0" ? tens[(counter/10)%10] : ones[counter%10]) - const row = rows[counter-1] - const id = row.thread_parent || row.channel_id - if (cache.has(id)) continue - - try { - var channel = await discord.snow.channel.getChannel(id) - } catch (e) { - continue - } - - const guildID = channel.guild_id - const channels = await discord.snow.guild.getGuildChannels(guildID) - for (const channel of channels) { - cache.set(channel.id, guildID) - } - } - - // Update channels and threads - process.stdout.write("\n") - db.transaction(() => { - // Fill in missing data - for (const row of rows) { - const guildID = cache.get(row.thread_parent) || cache.get(row.channel_id) - if (guildID) { - db.prepare("UPDATE channel_room SET guild_id = ? WHERE channel_id = ?").run(guildID, row.channel_id) - } else { - db.prepare("DELETE FROM webhook WHERE channel_id = ?").run(row.channel_id) - db.prepare("DELETE FROM channel_room WHERE channel_id = ?").run(row.channel_id) - } - } - })() -} diff --git a/src/db/migrations/0030-require-guild-id.sql b/src/db/migrations/0030-require-guild-id.sql deleted file mode 100644 index 264b69b..0000000 --- a/src/db/migrations/0030-require-guild-id.sql +++ /dev/null @@ -1,44 +0,0 @@ --- https://sqlite.org/lang_altertable.html - --- 1 -PRAGMA foreign_keys=OFF; --- 2 -BEGIN TRANSACTION; - --- 4 -CREATE TABLE "new_channel_room" ( - "channel_id" TEXT NOT NULL, - "room_id" TEXT NOT NULL UNIQUE, - "name" TEXT NOT NULL, - "nick" TEXT, - "thread_parent" TEXT, - "custom_avatar" TEXT, - "last_bridged_pin_timestamp" INTEGER, - "speedbump_id" TEXT, - "speedbump_checked" INTEGER, - "speedbump_webhook_id" TEXT, - "guild_id" TEXT NOT NULL, - "custom_topic" INTEGER DEFAULT 0, - PRIMARY KEY("channel_id"), - FOREIGN KEY("guild_id") REFERENCES "guild_active"("guild_id") ON DELETE CASCADE -) WITHOUT ROWID; - --- 5 -INSERT INTO new_channel_room - (channel_id, room_id, name, nick, thread_parent, custom_avatar, last_bridged_pin_timestamp, speedbump_id, speedbump_checked, speedbump_webhook_id, guild_id, custom_topic) -SELECT channel_id, room_id, name, nick, thread_parent, custom_avatar, last_bridged_pin_timestamp, speedbump_id, speedbump_checked, speedbump_webhook_id, guild_id, custom_topic - FROM channel_room; - --- 6 -DROP TABLE channel_room; - --- 7 -ALTER TABLE new_channel_room RENAME TO channel_room; - --- 10 -PRAGMA foreign_key_check; - --- 11 -COMMIT; --- 12 -PRAGMA foreign_keys=ON; diff --git a/src/db/migrations/0032-add-polls.sql b/src/db/migrations/0032-add-polls.sql deleted file mode 100644 index 0697937..0000000 --- a/src/db/migrations/0032-add-polls.sql +++ /dev/null @@ -1,34 +0,0 @@ -BEGIN TRANSACTION; - -DROP TABLE IF EXISTS "poll"; -DROP TABLE IF EXISTS "poll_option"; -DROP TABLE IF EXISTS "poll_vote"; - -CREATE TABLE "poll" ( - "message_id" TEXT NOT NULL, - "max_selections" INTEGER NOT NULL, - "question_text" TEXT NOT NULL, - "is_closed" INTEGER NOT NULL, - PRIMARY KEY ("message_id"), - FOREIGN KEY ("message_id") REFERENCES "message_room" ("message_id") ON DELETE CASCADE -) WITHOUT ROWID; - -CREATE TABLE "poll_option" ( - "message_id" TEXT NOT NULL, - "matrix_option" TEXT NOT NULL, - "discord_option" TEXT, - "option_text" TEXT NOT NULL, - "seq" INTEGER NOT NULL, - PRIMARY KEY ("message_id", "matrix_option"), - FOREIGN KEY ("message_id") REFERENCES "poll" ("message_id") ON DELETE CASCADE -) WITHOUT ROWID; - -CREATE TABLE "poll_vote" ( - "message_id" TEXT NOT NULL, - "matrix_option" TEXT NOT NULL, - "discord_or_matrix_user_id" TEXT NOT NULL, - PRIMARY KEY ("message_id", "matrix_option", "discord_or_matrix_user_id"), - FOREIGN KEY ("message_id", "matrix_option") REFERENCES "poll_option" ("message_id", "matrix_option") ON DELETE CASCADE -) WITHOUT ROWID; - -COMMIT; diff --git a/src/db/migrations/0033-add-missing-profile-to-member-cache.sql b/src/db/migrations/0033-add-missing-profile-to-member-cache.sql deleted file mode 100644 index ef937e0..0000000 --- a/src/db/migrations/0033-add-missing-profile-to-member-cache.sql +++ /dev/null @@ -1,5 +0,0 @@ -BEGIN TRANSACTION; - -ALTER TABLE member_cache ADD COLUMN missing_profile INTEGER; - -COMMIT; diff --git a/src/db/migrations/0034-slash-not-allowed-in-mxid.sql b/src/db/migrations/0034-slash-not-allowed-in-mxid.sql deleted file mode 100644 index ea2d031..0000000 --- a/src/db/migrations/0034-slash-not-allowed-in-mxid.sql +++ /dev/null @@ -1,5 +0,0 @@ -BEGIN TRANSACTION; - -DELETE FROM sim WHERE sim_name like '%/%'; - -COMMIT; diff --git a/src/db/orm-defs.d.ts b/src/db/orm-defs.d.ts index 79f02ad..02003af 100644 --- a/src/db/orm-defs.d.ts +++ b/src/db/orm-defs.d.ts @@ -1,9 +1,4 @@ export type Models = { - auto_emoji: { - name: string - emoji_id: string - } - channel_room: { channel_id: string room_id: string @@ -15,20 +10,6 @@ export type Models = { speedbump_id: string | null speedbump_webhook_id: string | null speedbump_checked: number | null - guild_id: string | null - custom_topic: number - } - - direct: { - mxid: string - room_id: string - } - - emoji: { - emoji_id: string - name: string - animated: number - mxc_url: string } event_message: { @@ -50,29 +31,11 @@ export type Models = { guild_id: string space_id: string privacy_level: number - presence: 0 | 1 - url_preview: 0 | 1 - webhook_profile: 0 | 1 } guild_active: { guild_id: string - autocreate: 0 | 1 - } - - historical_channel_room: { - historical_room_index: number - reference_channel_id: string - room_id: string - upgraded_timestamp: number - } - - invite: { - mxid: string - room_id: string - type: string | null - name: string | null - avatar: string | null + autocreate: number } lottie: { @@ -80,17 +43,12 @@ export type Models = { mxc_url: string } - media_proxy: { - permitted_hash: number - } - member_cache: { room_id: string mxid: string displayname: string | null avatar_url: string | null, power_level: number - missing_profile: number | null } member_power: { @@ -99,20 +57,15 @@ export type Models = { power_level: number } - message_room: { + message_channel: { message_id: string - historical_room_index: number - } - - room_upgrade_pending: { - new_room_id: string - old_room_id: string + channel_id: string } sim: { user_id: string - username: string sim_name: string + localpart: string mxid: string } @@ -134,32 +87,27 @@ export type Models = { webhook_token: string } + emoji: { + emoji_id: string + name: string + animated: number + mxc_url: string + } + reaction: { hashed_event_id: number message_id: string encoded_emoji: string - original_encoding: string | null } - poll: { // not actually in database yet - message_id: string - max_selections: number - question_text: string - is_closed: number + auto_emoji: { + name: string + emoji_id: string + guild_id: string } - poll_option: { - message_id: string - matrix_option: string - discord_option: string | null - option_text: string // not actually in database yet - seq: number // not actually in database yet - } - - poll_vote: { - message_id: string - matrix_option: string - discord_or_matrix_user_id: string + media_proxy: { + permitted_hash: number } } @@ -176,4 +124,3 @@ export type PickTypeOf> = T extends { [k in K]?: any } ? export type Merge = {[x in AllKeys]: PickTypeOf} export type Nullable = {[k in keyof T]: T[k] | null} export type Numberish = {[k in keyof T]: T[k] extends number ? (number | bigint) : T[k]} -export type ValueOrArray = {[k in keyof T]: T[k][] | T[k]} diff --git a/src/db/orm.js b/src/db/orm.js index 4d9b6f1..646012b 100644 --- a/src/db/orm.js +++ b/src/db/orm.js @@ -8,7 +8,7 @@ const U = require("./orm-defs") * @template {keyof U.Models[Table]} Col * @param {Table} table * @param {Col[] | Col} cols - * @param {Partial>>} where + * @param {Partial>} where * @param {string} [e] */ function select(table, cols, where = {}, e = "") { diff --git a/src/db/orm.test.js b/src/db/orm.test.js index 6f6018e..a53cc66 100644 --- a/src/db/orm.test.js +++ b/src/db/orm.test.js @@ -6,23 +6,23 @@ const data = require("../../test/data") const {db, select, from} = require("../passthrough") test("orm: select: get works", t => { - const row = select("guild_space", "guild_id", {}, "WHERE space_id = ?").get("!jjmvBegULiLucuWEHU:cadence.moe") + const row = select("guild_space", "guild_id", {}, "WHERE space_id = ?").get("!jjWAGMeQdNrVZSSfvz:cadence.moe") t.equal(row?.guild_id, data.guild.general.id) }) test("orm: from: get works", t => { - const row = from("guild_space").select("guild_id").and("WHERE space_id = ?").get("!jjmvBegULiLucuWEHU:cadence.moe") + const row = from("guild_space").select("guild_id").and("WHERE space_id = ?").get("!jjWAGMeQdNrVZSSfvz:cadence.moe") t.equal(row?.guild_id, data.guild.general.id) }) test("orm: select: get pluck works", t => { - const guildID = select("guild_space", "guild_id", {}, "WHERE space_id = ?").pluck().get("!jjmvBegULiLucuWEHU:cadence.moe") + const guildID = select("guild_space", "guild_id", {}, "WHERE space_id = ?").pluck().get("!jjWAGMeQdNrVZSSfvz:cadence.moe") t.equal(guildID, data.guild.general.id) }) test("orm: select: get, where and pluck works", t => { - const emojiName = select("emoji", "name", {emoji_id: "230201364309868544"}).pluck().get() - t.equal(emojiName, "hippo") + const channelID = select("message_channel", "channel_id", {message_id: "1128118177155526666"}).pluck().get() + t.equal(channelID, "112760669178241024") }) test("orm: select: all, where and pluck works on multiple columns", t => { @@ -30,13 +30,8 @@ test("orm: select: all, where and pluck works on multiple columns", t => { t.deepEqual(names, ["cadence [they]"]) }) -test("orm: select: in array works", t => { - const ids = select("emoji", "emoji_id", {name: ["online", "upstinky"]}).pluck().all() - t.deepEqual(ids, ["288858540888686602", "606664341298872324"]) -}) - test("orm: from: get pluck works", t => { - const guildID = from("guild_space").pluck("guild_id").and("WHERE space_id = ?").get("!jjmvBegULiLucuWEHU:cadence.moe") + const guildID = from("guild_space").pluck("guild_id").and("WHERE space_id = ?").get("!jjWAGMeQdNrVZSSfvz:cadence.moe") t.equal(guildID, data.guild.general.id) }) @@ -58,13 +53,3 @@ test("orm: from: join direction works", t => { const hasNoOwnerInner = from("sim").join("sim_proxy", "user_id", "inner").select("user_id", "proxy_owner_id").where({sim_name: "crunch_god"}).get() t.deepEqual(hasNoOwnerInner, undefined) }) - -test("orm: select unsafe works (to select complex column names that can't be type verified)", t => { - const results = from("member_cache") - .join("member_power", "mxid") - .join("channel_room", "room_id") // only include rooms that are bridged - .and("where member_power.room_id = '*' and member_cache.power_level != member_power.power_level") - .selectUnsafe("mxid", "member_cache.room_id", "member_power.power_level") - .all() - t.equal(results[0].power_level, 150) -}) diff --git a/src/discord/interactions/bridge.js b/src/discord/interactions/bridge.js new file mode 100644 index 0000000..1fbc57e --- /dev/null +++ b/src/discord/interactions/bridge.js @@ -0,0 +1,115 @@ +// @ts-check + +const DiscordTypes = require("discord-api-types/v10") +const Ty = require("../../types") +const {discord, sync, db, select, from, as} = require("../../passthrough") +const assert = require("assert/strict") + +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") + +/** @type {Map>} spaceID -> list of rooms */ +const cache = new Map() +/** @type {Map} roomID -> spaceID */ +const reverseCache = new Map() + +// Manage clearing the cache +sync.addTemporaryListener(as, "type:m.room.name", /** @param {Ty.Event.StateOuter} event */ async event => { + if (event.state_key !== "") return + const roomID = event.room_id + const spaceID = reverseCache.get(roomID) + if (!spaceID) return + const childRooms = await cache.get(spaceID) + if (!childRooms) return + if (event.content.name) { + const found = childRooms.find(r => r.value === roomID) + if (!found) return + found.name = event.content.name + } else { + cache.set(spaceID, Promise.resolve(childRooms.filter(r => r.value !== roomID))) + reverseCache.delete(roomID) + } +}) + +// Manage adding to the cache +async function getCachedHierarchy(spaceID) { + return cache.get(spaceID) || (() => { + const entry = (async () => { + const result = await api.getFullHierarchy(spaceID) + /** @type {{name: string, value: string}[]} */ + const childRooms = [] + for (const room of result) { + if (room.name && !room.name.match(/^\[[⛓️🔊]\]/) && room.room_type !== "m.space") { + childRooms.push({name: room.name, value: room.room_id}) + reverseCache.set(room.room_id, spaceID) + } + } + return childRooms + })() + cache.set(spaceID, entry) + return entry + })() +} + +/** @param {DiscordTypes.APIApplicationCommandAutocompleteGuildInteraction} interaction */ +async function interactAutocomplete({id, token, data, guild_id}) { + const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get() + if (!spaceID) { + return discord.snow.interaction.createInteractionResponse(id, token, { + type: DiscordTypes.InteractionResponseType.ApplicationCommandAutocompleteResult, + data: { + choices: [ + { + name: `Error: This server needs to be bridged somewhere first...`, + value: "baby" + } + ] + } + }) + } + + let rooms = await getCachedHierarchy(spaceID) + // @ts-ignore + rooms = rooms.filter(r => r.name.includes(data.options[0].value)) + + await discord.snow.interaction.createInteractionResponse(id, token, { + type: DiscordTypes.InteractionResponseType.ApplicationCommandAutocompleteResult, + data: { + choices: rooms + } + }) +} + +/** @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction */ +async function interactSubmit({id, token, data, guild_id}) { + const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get() + if (!spaceID) { + return discord.snow.interaction.createInteractionResponse(id, token, { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: "Error: This server needs to be bridged somewhere first...", + flags: DiscordTypes.MessageFlags.Ephemeral + } + }) + } + + return discord.snow.interaction.createInteractionResponse(id, token, { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: "Valid input. This would do something but it isn't implemented yet.", + flags: DiscordTypes.MessageFlags.Ephemeral + } + }) +} + +/** @param {DiscordTypes.APIGuildInteraction} interaction */ +async function interact(interaction) { + if (interaction.type === DiscordTypes.InteractionType.ApplicationCommandAutocomplete) { + return interactAutocomplete(interaction) + } else if (interaction.type === DiscordTypes.InteractionType.ApplicationCommand) { + // @ts-ignore + return interactSubmit(interaction) + } +} + +module.exports.interact = interact diff --git a/src/discord/interactions/invite.js b/src/discord/interactions/invite.js index 8940363..689ea1a 100644 --- a/src/discord/interactions/invite.js +++ b/src/discord/interactions/invite.js @@ -2,69 +2,39 @@ const DiscordTypes = require("discord-api-types/v10") const assert = require("assert/strict") -const {InteractionMethods} = require("snowtransfer") -const {id: botID} = require("../../../addbot") -const {discord, sync, db, select} = require("../../passthrough") +const {discord, sync, db, select, from} = require("../../passthrough") -/** @type {import("../../d2m/actions/create-room")} */ -const createRoom = sync.require("../../d2m/actions/create-room") -/** @type {import("../../d2m/actions/create-space")} */ -const createSpace = sync.require("../../d2m/actions/create-space") /** @type {import("../../matrix/api")} */ const api = sync.require("../../matrix/api") -/** @type {import("../../matrix/read-registration")} */ -const {reg} = sync.require("../../matrix/read-registration") /** - * @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction & {channel: DiscordTypes.APIGuildTextChannel}} interaction - * @param {{api: typeof api}} di - * @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters[2]}>} + * @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction + * @returns {Promise} */ -async function* _interact({data, channel, guild_id}, {api}) { - // Check guild exists - it might not exist if the application was added with applications.commands scope and not bot scope - const guild = discord.guilds.get(guild_id) - if (!guild) return yield {createInteractionResponse: { +async function _interact({data, channel, guild_id}) { + // Check guild is bridged + const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get() + const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() + if (!spaceID || !roomID) return { type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, data: { - content: `I can't perform actions in this server because there is no bot presence in the server. You should try re-adding this bot to the server, making sure that it has bot scope (not just commands).\nIf you add the bot from ${reg.ooye.bridge_origin} this should work automatically.`, + content: "This server isn't bridged to Matrix, so you can't invite Matrix users.", flags: DiscordTypes.MessageFlags.Ephemeral } - }} + } // Get named MXID /** @type {DiscordTypes.APIApplicationCommandInteractionDataStringOption[] | undefined} */ // @ts-ignore const options = data.options - const input = options?.[0]?.value || "" + const input = options?.[0].value || "" const mxid = input.match(/@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)/)?.[0] - if (!mxid) return yield {createInteractionResponse: { + if (!mxid) return { type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, data: { content: "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`", flags: DiscordTypes.MessageFlags.Ephemeral } - }} - - // Ensure guild and room are bridged - db.prepare("INSERT OR IGNORE INTO guild_active (guild_id, autocreate) VALUES (?, 1)").run(guild_id) - const existing = createRoom.existsOrAutocreatable(channel, guild_id) - if (existing === 0) return yield {createInteractionResponse: { - type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, - data: { - content: "This channel isn't bridged, so you can't invite Matrix users yet. Try turning on automatic room-creation or link a Matrix room in the website.", - flags: DiscordTypes.MessageFlags.Ephemeral - } - }} - assert(existing) // can't be null or undefined as we just inserted the guild_active row - - yield {createInteractionResponse: { - type: DiscordTypes.InteractionResponseType.DeferredChannelMessageWithSource, - data: { - flags: DiscordTypes.MessageFlags.Ephemeral - } - }} - - const spaceID = await createSpace.ensureSpace(guild) - const roomID = await createRoom.ensureRoom(channel.id) + } // Check for existing invite to the space let spaceMember @@ -72,17 +42,24 @@ async function* _interact({data, channel, guild_id}, {api}) { spaceMember = await api.getStateEvent(spaceID, "m.room.member", mxid) } catch (e) {} if (spaceMember && spaceMember.membership === "invite") { - return yield {editOriginalInteractionResponse: { - content: `\`${mxid}\` already has an invite, which they haven't accepted yet.`, - }} + return { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: `\`${mxid}\` already has an invite, which they haven't accepted yet.`, + flags: DiscordTypes.MessageFlags.Ephemeral + } + } } // Invite Matrix user if not in space if (!spaceMember || spaceMember.membership !== "join") { await api.inviteToRoom(spaceID, mxid) - return yield {editOriginalInteractionResponse: { - content: `You invited \`${mxid}\` to the server.` - }} + return { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: `You invited \`${mxid}\` to the server.` + } + } } // The Matrix user *is* in the space, maybe we want to invite them to this channel? @@ -91,32 +68,39 @@ async function* _interact({data, channel, guild_id}, {api}) { roomMember = await api.getStateEvent(roomID, "m.room.member", mxid) } catch (e) {} if (!roomMember || (roomMember.membership !== "join" && roomMember.membership !== "invite")) { - return yield {editOriginalInteractionResponse: { - content: `\`${mxid}\` is already in this server. Would you like to additionally invite them to this specific channel?`, - components: [{ - type: DiscordTypes.ComponentType.ActionRow, + return { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: `\`${mxid}\` is already in this server. Would you like to additionally invite them to this specific channel?`, + flags: DiscordTypes.MessageFlags.Ephemeral, components: [{ - type: DiscordTypes.ComponentType.Button, - custom_id: "invite_channel", - style: DiscordTypes.ButtonStyle.Primary, - label: "Sure", + type: DiscordTypes.ComponentType.ActionRow, + components: [{ + type: DiscordTypes.ComponentType.Button, + custom_id: "invite_channel", + style: DiscordTypes.ButtonStyle.Primary, + label: "Sure", + }] }] - }] - }} + } + } } // The Matrix user *is* in the space and in the channel. - return yield {editOriginalInteractionResponse: { - content: `\`${mxid}\` is already in this server and this channel.`, - }} + return { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: `\`${mxid}\` is already in this server and this channel.`, + flags: DiscordTypes.MessageFlags.Ephemeral + } + } } /** * @param {DiscordTypes.APIMessageComponentGuildInteraction} interaction - * @param {{api: typeof api}} di * @returns {Promise} */ -async function _interactButton({channel, message}, {api}) { +async function _interactButton({channel, message}) { const mxid = message.content.match(/`(@(?:[^:]+):(?:[a-z0-9:-]+\.[a-z0-9.:-]+))`/)?.[1] assert(mxid) const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() @@ -131,23 +115,14 @@ async function _interactButton({channel, message}, {api}) { } } -/* c8 ignore start */ - -/** @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction & {channel: DiscordTypes.APIGuildTextChannel}} interaction */ +/** @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction */ async function interact(interaction) { - for await (const response of _interact(interaction, {api})) { - if (response.createInteractionResponse) { - // TODO: Test if it is reasonable to remove `await` from these calls. Or zip these calls with the next interaction iteration and use Promise.all. - await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse) - } else if (response.editOriginalInteractionResponse) { - await discord.snow.interaction.editOriginalInteractionResponse(botID, interaction.token, response.editOriginalInteractionResponse) - } - } + await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interact(interaction)) } /** @param {DiscordTypes.APIMessageComponentGuildInteraction} interaction */ async function interactButton(interaction) { - await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interactButton(interaction, {api})) + await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interactButton(interaction)) } module.exports.interact = interact diff --git a/src/discord/interactions/invite.test.js b/src/discord/interactions/invite.test.js deleted file mode 100644 index a2393e5..0000000 --- a/src/discord/interactions/invite.test.js +++ /dev/null @@ -1,256 +0,0 @@ -const {test} = require("supertape") -const DiscordTypes = require("discord-api-types/v10") -const {db, discord} = require("../../passthrough") -const {MatrixServerError} = require("../../matrix/mreq") -const {_interact, _interactButton} = require("./invite") - -/** - * @template T - * @param {AsyncIterable} ai - * @returns {Promise} - */ -async function fromAsync(ai) { - const result = [] - for await (const value of ai) { - result.push(value) - } - return result -} - -test("invite: checks for missing matrix ID", async t => { - const msgs = await fromAsync(_interact({ - data: { - options: [] - }, - channel: discord.channels.get("0"), - guild_id: "112760669178241024" - }, {})) - t.equal(msgs[0].createInteractionResponse.data.content, "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`") -}) - -test("invite: checks for invalid matrix ID", async t => { - const msgs = await fromAsync(_interact({ - data: { - options: [{ - name: "user", - type: DiscordTypes.ApplicationCommandOptionType.String, - value: "@cadence" - }] - }, - channel: discord.channels.get("0"), - guild_id: "112760669178241024" - }, {})) - t.equal(msgs[0].createInteractionResponse.data.content, "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`") -}) - -test("invite: checks if guild exists", async t => { // it might not exist if the application was added with applications.commands scope and not bot scope - const msgs = await fromAsync(_interact({ - data: { - options: [{ - name: "user", - type: DiscordTypes.ApplicationCommandOptionType.String, - value: "@cadence:cadence.moe" - }] - }, - channel: discord.channels.get("0"), - guild_id: "0" - }, {})) - t.match(msgs[0].createInteractionResponse.data.content, /there is no bot presence in the server/) -}) - -test("invite: checks if channel exists or is autocreatable", async t => { - db.prepare("UPDATE guild_active SET autocreate = 0 WHERE guild_id = '112760669178241024'").run() - const msgs = await fromAsync(_interact({ - data: { - options: [{ - name: "user", - type: DiscordTypes.ApplicationCommandOptionType.String, - value: "@cadence:cadence.moe" - }] - }, - channel: discord.channels.get("498323546729086986"), - guild_id: "112760669178241024" - }, {})) - t.equal(msgs[0].createInteractionResponse.data.content, "This channel isn't bridged, so you can't invite Matrix users yet. Try turning on automatic room-creation or link a Matrix room in the website.") - db.prepare("UPDATE guild_active SET autocreate = 1 WHERE guild_id = '112760669178241024'").run() -}) - -test("invite: checks if user is already invited to space", async t => { - let called = 0 - const msgs = await fromAsync(_interact({ - data: { - options: [{ - name: "user", - type: DiscordTypes.ApplicationCommandOptionType.String, - value: "@cadence:cadence.moe" - }] - }, - channel: discord.channels.get("112760669178241024"), - guild_id: "112760669178241024" - }, { - api: { - getStateEvent: async (roomID, type, stateKey) => { - called++ - t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID - t.equal(type, "m.room.member") - t.equal(stateKey, "@cadence:cadence.moe") - return { - displayname: "cadence", - membership: "invite" - } - } - } - })) - t.equal(msgs[1].editOriginalInteractionResponse.content, "`@cadence:cadence.moe` already has an invite, which they haven't accepted yet.") - t.equal(called, 1) -}) - -test("invite: invites if user is not in space", async t => { - let called = 0 - const msgs = await fromAsync(_interact({ - data: { - options: [{ - name: "user", - type: DiscordTypes.ApplicationCommandOptionType.String, - value: "@cadence:cadence.moe" - }] - }, - channel: discord.channels.get("112760669178241024"), - guild_id: "112760669178241024" - }, { - api: { - getStateEvent: async (roomID, type, stateKey) => { - called++ - t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID - t.equal(type, "m.room.member") - t.equal(stateKey, "@cadence:cadence.moe") - throw new MatrixServerError("State event doesn't exist or something") - }, - inviteToRoom: async (roomID, mxid) => { - called++ - t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID - t.equal(mxid, "@cadence:cadence.moe") - } - } - })) - t.equal(msgs[1].editOriginalInteractionResponse.content, "You invited `@cadence:cadence.moe` to the server.") - t.equal(called, 2) -}) - -test("invite: prompts to invite to room (if never joined)", async t => { - let called = 0 - const msgs = await fromAsync(_interact({ - data: { - options: [{ - name: "user", - type: DiscordTypes.ApplicationCommandOptionType.String, - value: "@cadence:cadence.moe" - }] - }, - channel: discord.channels.get("112760669178241024"), - guild_id: "112760669178241024" - }, { - api: { - getStateEvent: async (roomID, type, stateKey) => { - called++ - t.equal(type, "m.room.member") - t.equal(stateKey, "@cadence:cadence.moe") - if (roomID === "!jjmvBegULiLucuWEHU:cadence.moe") { // space ID - return { - displayname: "cadence", - membership: "join" - } - } else { - throw new MatrixServerError("State event doesn't exist or something") - } - } - } - })) - t.equal(msgs[1].editOriginalInteractionResponse.content, "`@cadence:cadence.moe` is already in this server. Would you like to additionally invite them to this specific channel?") - t.equal(called, 2) -}) - -test("invite: prompts to invite to room (if left)", async t => { - let called = 0 - const msgs = await fromAsync(_interact({ - data: { - options: [{ - name: "user", - type: DiscordTypes.ApplicationCommandOptionType.String, - value: "@cadence:cadence.moe" - }] - }, - channel: discord.channels.get("112760669178241024"), - guild_id: "112760669178241024" - }, { - api: { - getStateEvent: async (roomID, type, stateKey) => { - called++ - t.equal(type, "m.room.member") - t.equal(stateKey, "@cadence:cadence.moe") - if (roomID === "!jjmvBegULiLucuWEHU:cadence.moe") { // space ID - return { - displayname: "cadence", - membership: "join" - } - } else { - return { - displayname: "cadence", - membership: "leave" - } - } - } - } - })) - t.equal(msgs[1].editOriginalInteractionResponse.content, "`@cadence:cadence.moe` is already in this server. Would you like to additionally invite them to this specific channel?") - t.equal(called, 2) -}) - -test("invite button: invites to room when button clicked", async t => { - let called = 0 - const msg = await _interactButton({ - channel: discord.channels.get("112760669178241024"), - message: { - content: "`@cadence:cadence.moe` is already in this server. Would you like to additionally invite them to this specific channel?" - } - }, { - api: { - inviteToRoom: async (roomID, mxid) => { - called++ - t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") // room ID - t.equal(mxid, "@cadence:cadence.moe") - } - } - }) - t.equal(msg.data.content, "You invited `@cadence:cadence.moe` to the channel.") - t.equal(called, 1) -}) - -test("invite: no-op if in room and space", async t => { - let called = 0 - const msgs = await fromAsync(_interact({ - data: { - options: [{ - name: "user", - type: DiscordTypes.ApplicationCommandOptionType.String, - value: "@cadence:cadence.moe" - }] - }, - channel: discord.channels.get("112760669178241024"), - guild_id: "112760669178241024" - }, { - api: { - getStateEvent: async (roomID, type, stateKey) => { - called++ - t.equal(type, "m.room.member") - t.equal(stateKey, "@cadence:cadence.moe") - return { - displayname: "cadence", - membership: "join" - } - } - } - })) - t.equal(msgs[1].editOriginalInteractionResponse.content, "`@cadence:cadence.moe` is already in this server and this channel.") - t.equal(called, 2) -}) diff --git a/src/discord/interactions/matrix-info.js b/src/discord/interactions/matrix-info.js index c85cec2..fac3804 100644 --- a/src/discord/interactions/matrix-info.js +++ b/src/discord/interactions/matrix-info.js @@ -1,108 +1,51 @@ // @ts-check const DiscordTypes = require("discord-api-types/v10") -const {discord, sync, select, from} = require("../../passthrough") -const assert = require("assert").strict +const {discord, sync, db, select, from} = require("../../passthrough") /** @type {import("../../matrix/api")} */ const api = sync.require("../../matrix/api") -/** @type {import("../../matrix/utils")} */ -const utils = sync.require("../../matrix/utils") - -/** @type {import("../../web/routes/guild")} */ -const webGuild = sync.require("../../web/routes/guild") - -/** - * @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction - * @param {{api: typeof api}} di - * @returns {Promise} - */ -async function _interact({guild_id, data}, {api}) { - const message = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") - .select("source", "reference_channel_id", "room_id", "event_id").where({message_id: data.target_id}).and("ORDER BY part").get() +/** @param {DiscordTypes.APIContextMenuGuildInteraction} interaction */ +/** @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction */ +async function interact({id, token, guild_id, channel, data}) { + const message = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id") + .select("name", "nick", "source", "room_id", "event_id").where({message_id: data.target_id}).get() if (!message) { - return { + return discord.snow.interaction.createInteractionResponse(id, token, { type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, data: { content: "This message hasn't been bridged to Matrix.", flags: DiscordTypes.MessageFlags.Ephemeral } - } + }) } - const channel_id = message.reference_channel_id - const room = select("channel_room", ["name", "nick"], {channel_id}).get() - assert(room) - const idInfo = `\n-# Room ID: \`${message.room_id}\`\n-# Event ID: \`${message.event_id}\`` - const roomName = room.nick || room.name if (message.source === 1) { // from Discord const userID = data.resolved.messages[data.target_id].author.id - return { + return discord.snow.interaction.createInteractionResponse(id, token, { type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, data: { - content: `Bridged <@${userID}> https://discord.com/channels/${guild_id}/${channel_id}/${data.target_id} on Discord to [${roomName}]() on Matrix.` + content: `Bridged <@${userID}> https://discord.com/channels/${guild_id}/${channel.id}/${data.target_id} on Discord to [${message.nick || message.name}]() on Matrix.` + idInfo, flags: DiscordTypes.MessageFlags.Ephemeral } - } + }) } // from Matrix const event = await api.getEvent(message.room_id, message.event_id) - const via = await utils.getViaServersQuery(message.room_id, api) - const channelsInGuild = discord.guildChannelMap.get(guild_id) - assert(channelsInGuild) - const inChannels = channelsInGuild - // @ts-ignore - .map(/** @returns {DiscordTypes.APIGuildChannel} */ cid => discord.channels.get(cid)) - .sort((a, b) => webGuild._getPosition(a, discord.channels) - webGuild._getPosition(b, discord.channels)) - .filter(channel => from("channel_room").join("member_cache", "room_id").select("mxid").where({channel_id: channel.id, mxid: event.sender}).get()) - const matrixMember = select("member_cache", ["displayname", "avatar_url"], {room_id: message.room_id, mxid: event.sender}).get() - const name = matrixMember?.displayname || event.sender - return { + return discord.snow.interaction.createInteractionResponse(id, token, { type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, data: { - embeds: [{ - author: { - name, - url: `https://matrix.to/#/${event.sender}`, - icon_url: utils.getPublicUrlForMxc(matrixMember?.avatar_url) - }, - description: `This Matrix message was delivered to Discord by **Out Of Your Element**.\n[View on Matrix →]()\n\n**User ID**: [${event.sender}]()`, - color: 0x0dbd8b, - fields: [{ - name: "In Channels", - value: inChannels.map(c => `<#${c.id}>`).join(" • ") - }, { - name: "\u200b", - value: idInfo - }] - }], + content: `Bridged [${event.sender}]()'s message in [${message.nick || message.name}]() on Matrix to https://discord.com/channels/${guild_id}/${channel.id}/${data.target_id} on Discord.` + + idInfo, flags: DiscordTypes.MessageFlags.Ephemeral } - } -} - -/* c8 ignore start */ - -/** @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction */ -async function interact(interaction) { - await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interact(interaction, {api})) -} - -/** @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction */ -async function dm(interaction) { - const channel = await discord.snow.user.createDirectMessageChannel(interaction.member.user.id) - const response = await _interact(interaction, {api}) - assert(response.type === DiscordTypes.InteractionResponseType.ChannelMessageWithSource) - response.data.flags = 0 & 0 // not ephemeral - await discord.snow.channel.createMessage(channel.id, response.data) + }) } module.exports.interact = interact -module.exports._interact = _interact -module.exports.dm = dm diff --git a/src/discord/interactions/matrix-info.test.js b/src/discord/interactions/matrix-info.test.js deleted file mode 100644 index f455700..0000000 --- a/src/discord/interactions/matrix-info.test.js +++ /dev/null @@ -1,87 +0,0 @@ -const {test} = require("supertape") -const data = require("../../../test/data") -const {_interact} = require("./matrix-info") - -test("matrix info: checks if message is bridged", async t => { - const msg = await _interact({ - data: { - target_id: "0" - }, - guild_id: "112760669178241024" - }, {}) - t.equal(msg.data.content, "This message hasn't been bridged to Matrix.") -}) - -test("matrix info: shows info for discord source message", async t => { - const msg = await _interact({ - data: { - target_id: "1141619794500649020", - resolved: { - messages: { - "1141619794500649020": data.message_update.edit_by_webhook - } - } - }, - guild_id: "497159726455455754" - }, {}) - t.equal( - msg.data.content, - "Bridged <@700285844094845050> https://discord.com/channels/497159726455455754/497161350934560778/1141619794500649020 on Discord to [amanda-spam]() on Matrix." - + "\n-# Room ID: `!CzvdIdUQXgUjDVKxeU:cadence.moe`" - + "\n-# Event ID: `$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10`" - ) -}) - -test("matrix info: shows info for matrix source message", async t => { - let called = 0 - const msg = await _interact({ - data: { - target_id: "1128118177155526666", - resolved: { - messages: { - "1141501302736695316": data.message.simple_reply_to_matrix_user - } - } - }, - guild_id: "112760669178241024" - }, { - api: { - async getEvent(roomID, eventID) { - called++ - t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") - t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4") - return { - event_id: eventID, - room_id: roomID, - type: "m.room.message", - content: { - msgtype: "m.text", - body: "so can you reply to my webhook uwu" - }, - sender: "@cadence:cadence.moe" - } - }, - async getJoinedMembers(roomID) { - return { - joined: {} - } - }, - async getStateEventOuter(roomID, type, key) { - return { - content: { - room_version: "11" - } - } - }, - async getStateEvent(roomID, type, key) { - return {} - } - } - }) - t.equal( - msg.data.embeds[0].fields[1].value, - "\n-# Room ID: `!kLRqKKUQXcibIMtOpl:cadence.moe`" - + "\n-# Event ID: `$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4`" - ) - t.equal(called, 1) -}) diff --git a/src/discord/interactions/permissions.js b/src/discord/interactions/permissions.js index 036947f..e010b1b 100644 --- a/src/discord/interactions/permissions.js +++ b/src/discord/interactions/permissions.js @@ -2,44 +2,36 @@ const DiscordTypes = require("discord-api-types/v10") const Ty = require("../../types") -const {discord, sync, select, from} = require("../../passthrough") +const {discord, sync, db, select, from} = require("../../passthrough") const assert = require("assert/strict") -const {id: botID} = require("../../../addbot") -const {InteractionMethods} = require("snowtransfer") /** @type {import("../../matrix/api")} */ const api = sync.require("../../matrix/api") -/** @type {import("../../matrix/utils")} */ -const utils = sync.require("../../matrix/utils") /** * @param {DiscordTypes.APIContextMenuGuildInteraction} interaction - * @param {{api: typeof api, utils: typeof utils}} di - * @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters[2]}>} + * @returns {Promise} */ -async function* _interact({data, guild_id}, {api, utils}) { - // Get message info - const row = from("event_message") - .join("message_room", "message_id").join("historical_channel_room", "historical_room_index") - .select("event_id", "source", "room_id", "reference_channel_id") - .where({message_id: data.target_id}) - .get() +async function _interact({data, channel, guild_id}) { + const row = select("event_message", ["event_id", "source"], {message_id: data.target_id}).get() + assert(row) // Can't operate on Discord users - if (!row || row.source === 1) { // not bridged or sent by a discord user - return yield {createInteractionResponse: { + if (row.source === 1) { // discord + return { type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, data: { - content: `The permissions command can only be used on Matrix users.`, + content: `This command is only meaningful for Matrix users.`, flags: DiscordTypes.MessageFlags.Ephemeral } - }} + } } // Get the message sender, the person that will be inspected/edited - const roomID = select("channel_room", "room_id", {channel_id: row.reference_channel_id}).pluck().get() + const eventID = row.event_id + const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() assert(roomID) - const event = await api.getEvent(row.room_id, row.event_id) + const event = await api.getEvent(roomID, eventID) const sender = event.sender // Get the space, where the power levels will be inspected/edited @@ -47,22 +39,22 @@ async function* _interact({data, guild_id}, {api, utils}) { assert(spaceID) // Get the power level - const {powers: {[event.sender]: userPower, [utils.bot]: botPower}} = await utils.getEffectivePower(spaceID, [event.sender, utils.bot], api) + /** @type {Ty.Event.M_Power_Levels} */ + const powerLevelsContent = await api.getStateEvent(spaceID, "m.room.power_levels", "") + const userPower = powerLevelsContent.users?.[event.sender] || 0 - // Administrators/founders equal to the bot cannot be demoted - if (userPower >= botPower) { - return yield {createInteractionResponse: { + // Administrators equal to the bot cannot be demoted + if (userPower >= 100) { + return { type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, data: { content: `\`${sender}\` has administrator permissions. This cannot be edited.`, flags: DiscordTypes.MessageFlags.Ephemeral } - }} + } } - const adminLabel = botPower === 100 ? "Admin (you cannot undo this!)" : "Admin" - - yield {createInteractionResponse: { + return { type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, data: { content: `Showing permissions for \`${sender}\`. Click to edit.`, @@ -83,10 +75,6 @@ async function* _interact({data, guild_id}, {api, utils}) { label: "Moderator", value: "moderator", default: userPower >= 50 && userPower < 100 - }, { - label: adminLabel, - value: "admin", - default: userPower >= 100 } ] } @@ -94,75 +82,47 @@ async function* _interact({data, guild_id}, {api, utils}) { } ] } - }} + } } /** * @param {DiscordTypes.APIMessageComponentSelectMenuInteraction} interaction - * @param {{api: typeof api}} di - * @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters[2]}>} */ -async function* _interactEdit({data, guild_id, message}, {api}) { +async function interactEdit({data, id, token, guild_id, message}) { // Get the person that will be inspected/edited const mxid = message.content.match(/`(@(?:[^:]+):(?:[a-z0-9:-]+\.[a-z0-9.:-]+))`/)?.[1] assert(mxid) const permission = data.values[0] - const power = - ( permission === "admin" ? 100 - : permission === "moderator" ? 50 - : 0) + const power = permission === "moderator" ? 50 : 0 - yield {createInteractionResponse: { + await discord.snow.interaction.createInteractionResponse(id, token, { type: DiscordTypes.InteractionResponseType.UpdateMessage, data: { content: `Updating \`${mxid}\` to **${permission}**, please wait...`, components: [] } - }} + }) // Get the space, where the power levels will be inspected/edited const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get() assert(spaceID) // Do it - await utils.setUserPowerCascade(spaceID, mxid, power, api) + await api.setUserPowerCascade(spaceID, mxid, power) // ACK - yield {editOriginalInteractionResponse: { + await discord.snow.interaction.editOriginalInteractionResponse(discord.application.id, token, { content: `Updated \`${mxid}\` to **${permission}**.`, components: [] - }} + }) } - -/* c8 ignore start */ - /** @param {DiscordTypes.APIContextMenuGuildInteraction} interaction */ async function interact(interaction) { - for await (const response of _interact(interaction, {api, utils})) { - if (response.createInteractionResponse) { - // TODO: Test if it is reasonable to remove `await` from these calls. Or zip these calls with the next interaction iteration and use Promise.all. - await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse) - } else if (response.editOriginalInteractionResponse) { - await discord.snow.interaction.editOriginalInteractionResponse(botID, interaction.token, response.editOriginalInteractionResponse) - } - } -} - -/** @param {DiscordTypes.APIMessageComponentSelectMenuInteraction} interaction */ -async function interactEdit(interaction) { - for await (const response of _interactEdit(interaction, {api})) { - if (response.createInteractionResponse) { - // TODO: Test if it is reasonable to remove `await` from these calls. Or zip these calls with the next interaction iteration and use Promise.all. - await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse) - } else if (response.editOriginalInteractionResponse) { - await discord.snow.interaction.editOriginalInteractionResponse(botID, interaction.token, response.editOriginalInteractionResponse) - } - } + await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interact(interaction)) } module.exports.interact = interact module.exports.interactEdit = interactEdit module.exports._interact = _interact -module.exports._interactEdit = _interactEdit diff --git a/src/discord/interactions/permissions.test.js b/src/discord/interactions/permissions.test.js deleted file mode 100644 index 5a078b5..0000000 --- a/src/discord/interactions/permissions.test.js +++ /dev/null @@ -1,264 +0,0 @@ -const {test} = require("supertape") -const DiscordTypes = require("discord-api-types/v10") -const {select, db} = require("../../passthrough") -const {_interact, _interactEdit} = require("./permissions") -const {mockGetEffectivePower} = require("../../matrix/utils.test") - -/** - * @template T - * @param {AsyncIterable} ai - * @returns {Promise} - */ -async function fromAsync(ai) { - const result = [] - for await (const value of ai) { - result.push(value) - } - return result -} - -test("permissions: checks if message is bridged", async t => { - const msgs = await fromAsync(_interact({ - data: { - target_id: "0" - }, - guild_id: "0" - }, {})) - t.equal(msgs.length, 1) - t.equal(msgs[0].createInteractionResponse.data.content, "The permissions command can only be used on Matrix users.") -}) - -test("permissions: checks if message is sent by a matrix user", async t => { - const msgs = await fromAsync(_interact({ - data: { - target_id: "1126786462646550579" - }, - guild_id: "112760669178241024" - }, {})) - t.equal(msgs.length, 1) - t.equal(msgs[0].createInteractionResponse.data.content, "The permissions command can only be used on Matrix users.") -}) - -test("permissions: reports permissions of selected matrix user (implicit default)", async t => { - let called = 0 - const msgs = await fromAsync(_interact({ - data: { - target_id: "1128118177155526666" - }, - guild_id: "112760669178241024" - }, { - utils: { - bot: "@_ooye_bot:cadence.moe", - getEffectivePower: mockGetEffectivePower() - }, - api: { - async getEvent(roomID, eventID) { - called++ - t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") // room ID - t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4") - return { - sender: "@cadence:cadence.moe" - } - } - } - })) - t.equal(msgs.length, 1) - t.equal(msgs[0].createInteractionResponse.data.content, "Showing permissions for `@cadence:cadence.moe`. Click to edit.") - t.deepEqual(msgs[0].createInteractionResponse.data.components[0].components[0].options[0], {label: "Default", value: "default", default: true}) - t.equal(called, 1) -}) - -test("permissions: reports permissions of selected matrix user (moderator)", async t => { - let called = 0 - const msgs = await fromAsync(_interact({ - data: { - target_id: "1128118177155526666" - }, - guild_id: "112760669178241024" - }, { - utils: { - bot: "@_ooye_bot:cadence.moe", - getEffectivePower: mockGetEffectivePower(["@_ooye_bot:cadence.moe"], {"@cadence:cadence.moe": 50}) - }, - api: { - async getEvent(roomID, eventID) { - called++ - t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") // room ID - t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4") - return { - sender: "@cadence:cadence.moe" - } - } - } - })) - t.equal(msgs.length, 1) - t.equal(msgs[0].createInteractionResponse.data.content, "Showing permissions for `@cadence:cadence.moe`. Click to edit.") - t.deepEqual(msgs[0].createInteractionResponse.data.components[0].components[0].options[1], {label: "Moderator", value: "moderator", default: true}) - t.equal(called, 1) -}) - -test("permissions: reports permissions of selected matrix user (admin v12 can be demoted)", async t => { - let called = 0 - const msgs = await fromAsync(_interact({ - data: { - target_id: "1128118177155526666" - }, - guild_id: "112760669178241024" - }, { - utils: { - bot: "@_ooye_bot:cadence.moe", - getEffectivePower: mockGetEffectivePower(["@_ooye_bot:cadence.moe"], {"@cadence:cadence.moe": 100}) - }, - api: { - async getEvent(roomID, eventID) { - called++ - t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") // room ID - t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4") - return { - sender: "@cadence:cadence.moe" - } - } - } - })) - t.equal(msgs.length, 1) - t.equal(msgs[0].createInteractionResponse.data.content, "Showing permissions for `@cadence:cadence.moe`. Click to edit.") - t.deepEqual(msgs[0].createInteractionResponse.data.components[0].components[0].options[2], {label: "Admin", value: "admin", default: true}) - t.equal(called, 1) -}) - -test("permissions: reports permissions of selected matrix user (admin v11 cannot be demoted)", async t => { - let called = 0 - const msgs = await fromAsync(_interact({ - data: { - target_id: "1128118177155526666" - }, - guild_id: "112760669178241024" - }, { - utils: { - bot: "@_ooye_bot:cadence.moe", - getEffectivePower: mockGetEffectivePower(["@_ooye_bot:cadence.moe"], {"@cadence:cadence.moe": 100, "@_ooye_bot:cadence.moe": 100}, "11") - }, - api: { - async getEvent(roomID, eventID) { - called++ - t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") // room ID - t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4") - return { - sender: "@cadence:cadence.moe" - } - } - } - })) - t.equal(msgs.length, 1) - t.equal(msgs[0].createInteractionResponse.data.content, "`@cadence:cadence.moe` has administrator permissions. This cannot be edited.") - t.notOk(msgs[0].createInteractionResponse.data.components) - t.equal(called, 1) -}) - -test("permissions: can update user to moderator", async t => { - let called = [] - const msgs = await fromAsync(_interactEdit({ - data: { - target_id: "1128118177155526666", - values: ["moderator"] - }, - message: { - content: "Showing permissions for `@cadence:cadence.moe`. Click to edit." - }, - guild_id: "112760669178241024" - }, { - api: { - async getStateEvent(roomID, type, key) { - called.push("get power levels") - t.equal(type, "m.room.power_levels") - return {} - }, - async getStateEventOuter(roomID, type, key) { - called.push("get room create") - return { - type: "m.room.create", - state_key: "", - sender: "@_ooye_bot:cadence.moe", - event_id: "$create", - origin_server_ts: 0, - room_id: roomID, - content: { - room_version: "11" - } - } - }, - async *generateFullHierarchy(spaceID) { - called.push("generate full hierarchy") - }, - async sendState(roomID, type, key, content) { - called.push("set power levels") - t.ok(["!hierarchy", "!jjmvBegULiLucuWEHU:cadence.moe"].includes(roomID), `expected room ID to be in hierarchy, but was ${roomID}`) - t.equal(type, "m.room.power_levels") - t.equal(key, "") - t.deepEqual(content, { - users: {"@cadence:cadence.moe": 50} - }) - return "$updated" - } - } - })) - t.equal(msgs.length, 2) - t.equal(msgs[0].createInteractionResponse.data.content, "Updating `@cadence:cadence.moe` to **moderator**, please wait...") - t.equal(msgs[1].editOriginalInteractionResponse.content, "Updated `@cadence:cadence.moe` to **moderator**.") - t.deepEqual(called, ["generate full hierarchy", "get room create", "get power levels", "set power levels"]) -}) - -test("permissions: can update user to default", async t => { - let called = [] - const msgs = await fromAsync(_interactEdit({ - data: { - target_id: "1128118177155526666", - values: ["default"] - }, - message: { - content: "Showing permissions for `@cadence:cadence.moe`. Click to edit." - }, - guild_id: "112760669178241024" - }, { - api: { - async getStateEvent(roomID, type, key) { - called.push("get power levels") - t.equal(type, "m.room.power_levels") - return { - users: {"@cadence:cadence.moe": 50} - } - }, - async getStateEventOuter(roomID, type, key) { - called.push("get room create") - return { - type: "m.room.create", - state_key: "", - sender: "@_ooye_bot:cadence.moe", - event_id: "$create", - origin_server_ts: 0, - room_id: roomID, - content: { - room_version: "11" - } - } - }, - async *generateFullHierarchy(spaceID) { - called.push("generate full hierarchy") - }, - async sendState(roomID, type, key, content) { - called.push("set power levels") - t.ok(["!hierarchy", "!jjmvBegULiLucuWEHU:cadence.moe"].includes(roomID), `expected room ID to be in hierarchy, but was ${roomID}`) - t.equal(type, "m.room.power_levels") - t.equal(key, "") - t.deepEqual(content, { - users: {} - }) - return "$updated" - } - } - })) - t.equal(msgs.length, 2) - t.equal(msgs[0].createInteractionResponse.data.content, "Updating `@cadence:cadence.moe` to **default**, please wait...") - t.equal(msgs[1].editOriginalInteractionResponse.content, "Updated `@cadence:cadence.moe` to **default**.") - t.deepEqual(called, ["generate full hierarchy", "get room create", "get power levels", "set power levels"]) -}) diff --git a/src/discord/interactions/ping.js b/src/discord/interactions/ping.js deleted file mode 100644 index 45824be..0000000 --- a/src/discord/interactions/ping.js +++ /dev/null @@ -1,199 +0,0 @@ -// @ts-check - -const assert = require("assert").strict -const Ty = require("../../types") -const DiscordTypes = require("discord-api-types/v10") -const {discord, sync, select, from} = require("../../passthrough") -const {id: botID} = require("../../../addbot") -const {InteractionMethods} = require("snowtransfer") - -/** @type {import("../../matrix/api")} */ -const api = sync.require("../../matrix/api") -/** @type {import("../../matrix/utils")} */ -const utils = sync.require("../../matrix/utils") -/** @type {import("../../web/routes/guild")} */ -const webGuild = sync.require("../../web/routes/guild") - -/** - * @param {DiscordTypes.APIApplicationCommandAutocompleteGuildInteraction} interaction - * @param {{api: typeof api}} di - * @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters[2]}>} - */ -async function* _interactAutocomplete({data, channel}, {api}) { - function exit() { - return {createInteractionResponse: { - /** @type {DiscordTypes.InteractionResponseType.ApplicationCommandAutocompleteResult} */ - type: DiscordTypes.InteractionResponseType.ApplicationCommandAutocompleteResult, - data: { - choices: [] - } - }} - } - - // Check it was used in a bridged channel - const roomID = select("channel_room", "room_id", {channel_id: channel?.id}).pluck().get() - if (!roomID) return yield exit() - - // Check we are in fact autocompleting the first option, the user - if (!data.options?.[0] || data.options[0].type !== DiscordTypes.ApplicationCommandOptionType.String || !data.options[0].focused) { - return yield exit() - } - - /** @type {{displayname: string | null, mxid: string}[][]} */ - const providedMatches = [] - - const input = data.options[0].value - if (input === "") { - const events = await api.getEvents(roomID, "b", {limit: 40}) - const recents = new Set(events.chunk.map(e => e.sender)) - const matches = select("member_cache", ["mxid", "displayname"], {room_id: roomID}, "AND displayname IS NOT NULL LIMIT 25").all() - matches.sort((a, b) => +recents.has(b.mxid) - +recents.has(a.mxid)) - providedMatches.push(matches) - } else if (input.startsWith("@")) { // only autocomplete mxids - const query = input.replaceAll(/[%_$]/g, char => `$${char}`) + "%" - const matches = select("member_cache", ["mxid", "displayname"], {room_id: roomID}, "AND mxid LIKE ? ESCAPE '$' LIMIT 25").all(query) - providedMatches.push(matches) - } else { - const query = "%" + input.replaceAll(/[%_$]/g, char => `$${char}`) + "%" - const displaynameMatches = select("member_cache", ["mxid", "displayname"], {room_id: roomID}, "AND displayname IS NOT NULL AND displayname LIKE ? ESCAPE '$' LIMIT 25").all(query) - // prioritise matches closer to the start - displaynameMatches.sort((a, b) => { - let ai = a.displayname?.toLowerCase().indexOf(input.toLowerCase()) ?? -1 - if (ai === -1) ai = 999 - let bi = b.displayname?.toLowerCase().indexOf(input.toLowerCase()) ?? -1 - if (bi === -1) bi = 999 - return ai - bi - }) - providedMatches.push(displaynameMatches) - let mxidMatches = select("member_cache", ["mxid", "displayname"], {room_id: roomID}, "AND displayname IS NOT NULL AND mxid LIKE ? ESCAPE '$' LIMIT 25").all(query) - mxidMatches = mxidMatches.filter(match => { - // don't include matches in domain part of mxid - if (!match.mxid.match(/^[^:]*/)?.includes(query)) return false - if (displaynameMatches.some(m => m.mxid === match.mxid)) return false - return true - }) - providedMatches.push(mxidMatches) - } - - // merge together - let matches = providedMatches.flat() - - // don't include bot - matches = matches.filter(m => m.mxid !== utils.bot) - - // remove duplicates and count up to 25 - const limitedMatches = [] - const seen = new Set() - for (const match of matches) { - if (limitedMatches.length >= 25) break - if (seen.has(match.mxid)) continue - limitedMatches.push(match) - seen.add(match.mxid) - } - - yield {createInteractionResponse: { - type: DiscordTypes.InteractionResponseType.ApplicationCommandAutocompleteResult, - data: { - choices: limitedMatches.map(row => ({name: (row.displayname || row.mxid).slice(0, 100), value: row.mxid.slice(0, 100)})) - } - }} -} - -/** - * @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction & {channel: DiscordTypes.APIGuildTextChannel}} interaction - * @param {{api: typeof api}} di - * @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters[2]}>} - */ -async function* _interactCommand({data, channel, guild_id}, {api}) { - const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() - if (!roomID) { - return yield {createInteractionResponse: { - type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, - data: { - flags: DiscordTypes.MessageFlags.Ephemeral, - content: "This channel isn't bridged to Matrix." - } - }} - } - - assert(data.options?.[0]?.type === DiscordTypes.ApplicationCommandOptionType.String) - const mxid = data.options[0].value - if (!mxid.match(/^@[^:]*:./)) { - return yield {createInteractionResponse: { - type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, - data: { - flags: DiscordTypes.MessageFlags.Ephemeral, - content: "⚠️ To use `/ping`, you must select an option from autocomplete, or type a full Matrix ID.\n> Tip: This command is not necessary. You can also ping Matrix users just by typing @their name in your message. It won't look like anything, but it does go through." - } - }} - } - - yield {createInteractionResponse: { - type: DiscordTypes.InteractionResponseType.DeferredChannelMessageWithSource - }} - - let member - try { - /** @type {Ty.Event.M_Room_Member} */ - member = await api.getStateEvent(roomID, "m.room.member", mxid) - } catch (e) {} - - if (!member || member.membership !== "join") { - const channelsInGuild = discord.guildChannelMap.get(guild_id) - assert(channelsInGuild) - const inChannels = channelsInGuild - // @ts-ignore - .map(/** @returns {DiscordTypes.APIGuildChannel} */ cid => discord.channels.get(cid)) - .sort((a, b) => webGuild._getPosition(a, discord.channels) - webGuild._getPosition(b, discord.channels)) - .filter(channel => from("channel_room").join("member_cache", "room_id").select("mxid").where({channel_id: channel.id, mxid}).get()) - if (inChannels.length) { - return yield {editOriginalInteractionResponse: { - content: `That person isn't in this channel. They have only joined the following channels:\n${inChannels.map(c => `<#${c.id}>`).join(" • ")}\nYou can ask them to join this channel with \`/invite\`.`, - }} - } else { - return yield {editOriginalInteractionResponse: { - content: "That person isn't in this channel. You can invite them with `/invite`." - }} - } - } - - yield {editOriginalInteractionResponse: { - content: "@" + (member.displayname || mxid) - }} - - yield {createFollowupMessage: { - flags: DiscordTypes.MessageFlags.Ephemeral | DiscordTypes.MessageFlags.IsComponentsV2, - components: [{ - type: DiscordTypes.ComponentType.Container, - components: [{ - type: DiscordTypes.ComponentType.TextDisplay, - content: "Tip: This command is not necessary. You can also ping Matrix users just by typing @their name in your message. It won't look like anything, but it does go through." - }] - }] - }} -} - -/* c8 ignore start */ - -/** @param {(DiscordTypes.APIChatInputApplicationCommandGuildInteraction & {channel: DiscordTypes.APIGuildTextChannel}) | DiscordTypes.APIApplicationCommandAutocompleteGuildInteraction} interaction */ -async function interact(interaction) { - if (interaction.type === DiscordTypes.InteractionType.ApplicationCommandAutocomplete) { - for await (const response of _interactAutocomplete(interaction, {api})) { - if (response.createInteractionResponse) { - await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse) - } - } - } else { - for await (const response of _interactCommand(interaction, {api})) { - if (response.createInteractionResponse) { - await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse) - } else if (response.editOriginalInteractionResponse) { - await discord.snow.interaction.editOriginalInteractionResponse(botID, interaction.token, response.editOriginalInteractionResponse) - } else if (response.createFollowupMessage) { - await discord.snow.interaction.createFollowupMessage(botID, interaction.token, response.createFollowupMessage) - } - } - } -} - -module.exports.interact = interact diff --git a/src/discord/interactions/poll-responses.js b/src/discord/interactions/poll-responses.js deleted file mode 100644 index bcfa167..0000000 --- a/src/discord/interactions/poll-responses.js +++ /dev/null @@ -1,94 +0,0 @@ -// @ts-check - -const DiscordTypes = require("discord-api-types/v10") -const {discord, sync, db, select, from} = require("../../passthrough") -const {id: botID} = require("../../../addbot") -const {InteractionMethods} = require("snowtransfer") - -/** @type {import("../../matrix/api")} */ -const api = sync.require("../../matrix/api") -/** @type {import("../../m2d/converters/poll-components")} */ -const pollComponents = sync.require("../../m2d/converters/poll-components") -const {reg} = require("../../matrix/read-registration") - -/** - * @param {number} percent - */ -function barChart(percent) { - const width = 12 - const bars = Math.floor(percent*width) - return "█".repeat(bars) + "▒".repeat(width-bars) -} - -/** - * @param {string} pollMessageID - * @param {boolean} isClosed - */ -function getCombinedResults(pollMessageID, isClosed) { - /** @type {{matrix_option: string, option_text: string, count: number}[]} */ - const pollResults = db.prepare("SELECT matrix_option, option_text, seq, count(discord_or_matrix_user_id) as count FROM poll_option LEFT JOIN poll_vote USING (message_id, matrix_option) WHERE message_id = ? GROUP BY matrix_option ORDER BY seq").all(pollMessageID) - const combinedVotes = pollResults.reduce((a, c) => a + c.count, 0) - const totalVoters = db.prepare("SELECT count(DISTINCT discord_or_matrix_user_id) as count FROM poll_vote WHERE message_id = ?").pluck().get(pollMessageID) - const topAnswers = pollResults.toSorted((a, b) => b.count - a.count) - - let messageString = "" - for (const option of pollResults) { - const medal = isClosed ? pollComponents.getMedal(topAnswers, option.count) : "" - const countString = `${String(option.count).padStart(String(topAnswers[0].count).length)}` - const votesString = option.count === 1 ? "vote " : "votes" - const label = medal === "🥇" ? `**${option.option_text}**` : option.option_text - messageString += `\`\u200b${countString} ${votesString}\u200b\` ${barChart(option.count/totalVoters)} ${label} ${medal}\n` - } - - return {messageString, combinedVotes, totalVoters} -} - -/** - * @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction - * @param {{api: typeof api}} di - * @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters[2]}>} - */ -async function* _interact({data}, {api}) { - const row = select("poll", "is_closed", {message_id: data.target_id}).get() - - if (!row) { - return yield {createInteractionResponse: { - type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, - data: { - content: "This poll hasn't been bridged to Matrix.", - flags: DiscordTypes.MessageFlags.Ephemeral - } - }} - } - - const {messageString} = getCombinedResults(data.target_id, !!row.is_closed) - - return yield {createInteractionResponse: { - type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, - data: { - embeds: [{ - author: { - name: "Current results including Matrix votes", - icon_url: `${reg.ooye.bridge_origin}/download/file/poll-star-avatar.png` - }, - description: messageString - }], - flags: DiscordTypes.MessageFlags.Ephemeral - } - }} -} - -/* c8 ignore start */ - -/** @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction */ -async function interact(interaction) { - for await (const response of _interact(interaction, {api})) { - if (response.createInteractionResponse) { - await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse) - } - } -} - -module.exports.interact = interact -module.exports._interact = _interact -module.exports.getCombinedResults = getCombinedResults diff --git a/src/discord/interactions/poll.js b/src/discord/interactions/poll.js deleted file mode 100644 index 6d7a015..0000000 --- a/src/discord/interactions/poll.js +++ /dev/null @@ -1,144 +0,0 @@ -// @ts-check - -const DiscordTypes = require("discord-api-types/v10") -const {discord, sync, select, from, db} = require("../../passthrough") -const assert = require("assert/strict") -const {id: botID} = require("../../../addbot") -const {InteractionMethods} = require("snowtransfer") - -/** @type {import("../../matrix/api")} */ -const api = sync.require("../../matrix/api") -/** @type {import("../../matrix/utils")} */ -const utils = sync.require("../../matrix/utils") -/** @type {import("../../m2d/converters/poll-components")} */ -const pollComponents = sync.require("../../m2d/converters/poll-components") -/** @type {import("../../d2m/actions/poll-vote")} */ -const vote = sync.require("../../d2m/actions/poll-vote") - -/** - * @param {DiscordTypes.APIMessageComponentButtonInteraction} interaction - * @param {{api: typeof api}} di - * @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters[2]}>} - */ -async function* _interact({data, message, member, user}, {api}) { - if (!member?.user) return - const userID = member.user.id - - const pollRow = select("poll", ["question_text", "max_selections"], {message_id: message.id}).get() - if (!pollRow) return - - // Definitely supposed to be a poll button click. We can use assertions now. - - const matrixPollEvent = select("event_message", "event_id", {message_id: message.id}).pluck().get() - assert(matrixPollEvent) - - const maxSelections = pollRow.max_selections - const alreadySelected = select("poll_vote", "matrix_option", {discord_or_matrix_user_id: userID, message_id: message.id}).pluck().all() - - // Show modal (if no capacity or if requested) - if (data.custom_id === "POLL_VOTE" || (maxSelections > 1 && alreadySelected.length === maxSelections)) { - const options = select("poll_option", ["matrix_option", "option_text", "seq"], {message_id: message.id}, "ORDER BY seq").all().map(option => ({ - value: option.matrix_option, - label: option.option_text, - default: alreadySelected.includes(option.matrix_option) - })) - const checkboxGroupExtras = maxSelections === 1 && options.length > 1 ? {} : { - /** @type {DiscordTypes.ComponentType.CheckboxGroup} */ - type: DiscordTypes.ComponentType.CheckboxGroup, - min_values: 0, - max_values: maxSelections - } - return yield {createInteractionResponse: { - type: DiscordTypes.InteractionResponseType.Modal, - data: { - custom_id: "POLL_MODAL", - title: "Poll", - components: [{ - type: DiscordTypes.ComponentType.TextDisplay, - content: `-# ${pollComponents.getMultiSelectString(pollRow.max_selections, options.length)}` - }, { - type: DiscordTypes.ComponentType.Label, - label: pollRow.question_text, - component: { - type: DiscordTypes.ComponentType.RadioGroup, - custom_id: "POLL_MODAL_SELECTION", - options, - required: false, - ...checkboxGroupExtras - } - }] - } - }} - } - - if (data.custom_id === "POLL_MODAL") { - // Clicked options via modal - /** @type {DiscordTypes.APIModalSubmitRadioGroupComponent | DiscordTypes.APIModalSubmitCheckboxGroupComponent} */ // @ts-ignore - close enough to the real thing - const component = data.components[1].component - assert.equal(component.custom_id, "POLL_MODAL_SELECTION") - const values = "values" in component ? component.values : [component.value] - - // Replace votes with selection - db.transaction(() => { - db.prepare("DELETE FROM poll_vote WHERE message_id = ? AND discord_or_matrix_user_id = ?").run(message.id, userID) - for (const option of values) { - db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(userID, message.id, option) - } - })() - - // Update counts on message - yield {createInteractionResponse: { - type: DiscordTypes.InteractionResponseType.UpdateMessage, - data: pollComponents.getPollComponentsFromDatabase(message.id) - }} - - // Sync changes to Matrix - await vote.sendVotes(member.user, message.channel_id, message.id, matrixPollEvent) - } else { - // Clicked buttons on message - const optionPrefix = "POLL_OPTION#" // we use a prefix to prevent someone from sending a Matrix poll that intentionally collides with other elements of the embed - const matrixOption = select("poll_option", "matrix_option", {matrix_option: data.custom_id.substring(optionPrefix.length), message_id: message.id}).pluck().get() - assert(matrixOption) - - // Remove a vote - if (alreadySelected.includes(matrixOption)) { - db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ? AND matrix_option = ?").run(userID, message.id, matrixOption) - } - // Replace votes (if only one selection is allowed) - else if (maxSelections === 1 && alreadySelected.length === 1) { - db.transaction(() => { - db.prepare("DELETE FROM poll_vote WHERE message_id = ? AND discord_or_matrix_user_id = ?").run(message.id, userID) - db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(userID, message.id, matrixOption) - })() - } - // Add a vote (if capacity) - else if (alreadySelected.length < maxSelections) { - db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(userID, message.id, matrixOption) - } - - // Update counts on message - yield {createInteractionResponse: { - type: DiscordTypes.InteractionResponseType.UpdateMessage, - data: pollComponents.getPollComponentsFromDatabase(message.id) - }} - - // Sync changes to Matrix - await vote.sendVotes(member.user, message.channel_id, message.id, matrixPollEvent) - } -} - -/* c8 ignore start */ - -/** @param {DiscordTypes.APIMessageComponentButtonInteraction} interaction */ -async function interact(interaction) { - for await (const response of _interact(interaction, {api})) { - if (response.createInteractionResponse) { - await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse) - } else if (response.editOriginalInteractionResponse) { - await discord.snow.interaction.editOriginalInteractionResponse(botID, interaction.token, response.editOriginalInteractionResponse) - } - } -} - -module.exports.interact = interact -module.exports._interact = _interact diff --git a/src/discord/interactions/privacy.js b/src/discord/interactions/privacy.js index 841167e..bb8c6c9 100644 --- a/src/discord/interactions/privacy.js +++ b/src/discord/interactions/privacy.js @@ -3,37 +3,32 @@ const DiscordTypes = require("discord-api-types/v10") const {discord, sync, db, select} = require("../../passthrough") const {id: botID} = require("../../../addbot") -const {InteractionMethods} = require("snowtransfer") /** @type {import("../../d2m/actions/create-space")} */ const createSpace = sync.require("../../d2m/actions/create-space") /** * @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction - * @param {{createSpace: typeof createSpace}} di - * @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters[2]}>} */ -async function* _interact({data, guild_id}, {createSpace}) { +async function interact({id, token, data, guild_id}) { // Check guild is bridged const current = select("guild_space", "privacy_level", {guild_id}).pluck().get() - if (current == null) { - return yield {createInteractionResponse: { - type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, - data: { - content: "This server isn't bridged to Matrix, so you can't set the Matrix privacy level.", - flags: DiscordTypes.MessageFlags.Ephemeral - } - }} + if (current == null) return { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: "This server isn't bridged to Matrix, so you can't set the Matrix privacy level.", + flags: DiscordTypes.MessageFlags.Ephemeral + } } // Get input level /** @type {DiscordTypes.APIApplicationCommandInteractionDataStringOption[] | undefined} */ // @ts-ignore const options = data.options - const input = options?.[0]?.value || "" + const input = options?.[0].value || "" const levels = ["invite", "link", "directory"] const level = levels.findIndex(x => input === x) if (level === -1) { - return yield {createInteractionResponse: { + return discord.snow.interaction.createInteractionResponse(id, token, { type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, data: { content: "**Usage: `/privacy `**. This will set who can join the space on Matrix-side. There are three levels:" @@ -43,37 +38,22 @@ async function* _interact({data, guild_id}, {createSpace}) { + `\n**Current privacy level: \`${levels[current]}\`**`, flags: DiscordTypes.MessageFlags.Ephemeral } - }} + }) } - yield {createInteractionResponse: { + await discord.snow.interaction.createInteractionResponse(id, token, { type: DiscordTypes.InteractionResponseType.DeferredChannelMessageWithSource, data: { flags: DiscordTypes.MessageFlags.Ephemeral } - }} + }) db.prepare("UPDATE guild_space SET privacy_level = ? WHERE guild_id = ?").run(level, guild_id) await createSpace.syncSpaceFully(guild_id) // this is inefficient but OK to call infrequently on user request - yield {editOriginalInteractionResponse: { + await discord.snow.interaction.editOriginalInteractionResponse(botID, token, { content: `Privacy level updated to \`${levels[level]}\`.` - }} -} - -/* c8 ignore start */ - -/** @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction */ -async function interact(interaction) { - for await (const response of _interact(interaction, {createSpace})) { - if (response.createInteractionResponse) { - // TODO: Test if it is reasonable to remove `await` from these calls. Or zip these calls with the next interaction iteration and use Promise.all. - await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse) - } else if (response.editOriginalInteractionResponse) { - await discord.snow.interaction.editOriginalInteractionResponse(botID, interaction.token, response.editOriginalInteractionResponse) - } - } + }) } module.exports.interact = interact -module.exports._interact = _interact diff --git a/src/discord/interactions/privacy.test.js b/src/discord/interactions/privacy.test.js deleted file mode 100644 index a94bbc7..0000000 --- a/src/discord/interactions/privacy.test.js +++ /dev/null @@ -1,86 +0,0 @@ -const {test} = require("supertape") -const DiscordTypes = require("discord-api-types/v10") -const {select, db} = require("../../passthrough") -const {_interact} = require("./privacy") - -/** - * @template T - * @param {AsyncIterable} ai - * @returns {Promise} - */ -async function fromAsync(ai) { - const result = [] - for await (const value of ai) { - result.push(value) - } - return result -} - -test("privacy: checks if guild is bridged", async t => { - const msgs = await fromAsync(_interact({ - data: { - options: [] - }, - guild_id: "0" - }, {})) - t.equal(msgs.length, 1) - t.equal(msgs[0].createInteractionResponse.data.content, "This server isn't bridged to Matrix, so you can't set the Matrix privacy level.") -}) - -test("privacy: reports usage if there is no parameter", async t => { - const msgs = await fromAsync(_interact({ - data: { - options: [] - }, - guild_id: "112760669178241024" - }, {})) - t.equal(msgs.length, 1) - t.match(msgs[0].createInteractionResponse.data.content, /Usage: `\/privacy/) -}) - -test("privacy: reports usage for invalid parameter", async t => { - const msgs = await fromAsync(_interact({ - data: { - options: [ - { - name: "level", - type: DiscordTypes.ApplicationCommandOptionType.String, - value: "info" - } - ] - }, - guild_id: "112760669178241024" - }, {})) - t.equal(msgs.length, 1) - t.match(msgs[0].createInteractionResponse.data.content, /Usage: `\/privacy/) -}) - -test("privacy: updates setting and calls syncSpace for valid parameter", async t => { - let called = 0 - const msgs = await fromAsync(_interact({ - data: { - options: [ - { - name: "level", - type: DiscordTypes.ApplicationCommandOptionType.String, - value: "directory" - } - ] - }, - guild_id: "112760669178241024" - }, { - createSpace: { - async syncSpaceFully(guildID) { - called++ - t.equal(guildID, "112760669178241024") - } - } - })) - t.equal(msgs.length, 2) - t.equal(msgs[0].createInteractionResponse.type, DiscordTypes.InteractionResponseType.DeferredChannelMessageWithSource) - t.equal(msgs[1].editOriginalInteractionResponse.content, "Privacy level updated to `directory`.") - t.equal(called, 1) - t.equal(select("guild_space", "privacy_level", {guild_id: "112760669178241024"}).pluck().get(), 2) - // Undo database changes - db.prepare("UPDATE guild_space SET privacy_level = 0 WHERE guild_id = ?").run("112760669178241024") -}) diff --git a/src/discord/interactions/reactions.js b/src/discord/interactions/reactions.js index bd2f856..67f3a68 100644 --- a/src/discord/interactions/reactions.js +++ b/src/discord/interactions/reactions.js @@ -1,40 +1,28 @@ // @ts-check const DiscordTypes = require("discord-api-types/v10") -const {discord, sync, select, from} = require("../../passthrough") -const {id: botID} = require("../../../addbot") -const {InteractionMethods} = require("snowtransfer") +const {discord, sync, db, select, from} = require("../../passthrough") /** @type {import("../../matrix/api")} */ const api = sync.require("../../matrix/api") -/** @type {import("../../matrix/utils")} */ -const utils = sync.require("../../matrix/utils") +/** @type {import("../../m2d/converters/utils")} */ +const utils = sync.require("../../m2d/converters/utils") -/** - * @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction - * @param {{api: typeof api}} di - * @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters[2]}>} - */ -async function* _interact({data}, {api}) { - const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") +/** @param {DiscordTypes.APIContextMenuGuildInteraction} interaction */ +/** @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction */ +async function interact({id, token, data}) { + const row = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id") .select("event_id", "room_id").where({message_id: data.target_id}).get() if (!row) { - return yield {createInteractionResponse: { + return discord.snow.interaction.createInteractionResponse(id, token, { type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, data: { content: "This message hasn't been bridged to Matrix.", flags: DiscordTypes.MessageFlags.Ephemeral } - }} + }) } - yield {createInteractionResponse: { - type: DiscordTypes.InteractionResponseType.DeferredChannelMessageWithSource, - data: { - flags: DiscordTypes.MessageFlags.Ephemeral - } - }} - const reactions = await api.getFullRelations(row.room_id, row.event_id, "m.annotation") /** @type {Map} */ @@ -49,28 +37,22 @@ async function* _interact({data}, {api}) { } if (inverted.size === 0) { - return yield {editOriginalInteractionResponse: { - content: "Nobody from Matrix reacted to this message.", - }} + return discord.snow.interaction.createInteractionResponse(id, token, { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: "Nobody from Matrix reacted to this message.", + flags: DiscordTypes.MessageFlags.Ephemeral + } + }) } - return yield {editOriginalInteractionResponse: { - content: [...inverted.entries()].map(([key, value]) => `${key} ⮞ ${value.join(" ⬩ ")}`).join("\n"), - }} -} - -/* c8 ignore start */ - -/** @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction */ -async function interact(interaction) { - for await (const response of _interact(interaction, {api})) { - if (response.createInteractionResponse) { - await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse) - } else if (response.editOriginalInteractionResponse) { - await discord.snow.interaction.editOriginalInteractionResponse(botID, interaction.token, response.editOriginalInteractionResponse) + return discord.snow.interaction.createInteractionResponse(id, token, { + type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, + data: { + content: [...inverted.entries()].map(([key, value]) => `${key} ⮞ ${value.join(" ⬩ ")}`).join("\n"), + flags: DiscordTypes.MessageFlags.Ephemeral } - } + }) } module.exports.interact = interact -module.exports._interact = _interact diff --git a/src/discord/interactions/reactions.test.js b/src/discord/interactions/reactions.test.js deleted file mode 100644 index 50ddeca..0000000 --- a/src/discord/interactions/reactions.test.js +++ /dev/null @@ -1,99 +0,0 @@ -const {test} = require("supertape") -const {_interact} = require("./reactions") - -/** - * @template T - * @param {AsyncIterable} ai - * @returns {Promise} - */ -async function fromAsync(ai) { - const result = [] - for await (const value of ai) { - result.push(value) - } - return result -} - -test("reactions: checks if message is bridged", async t => { - const msgs = await fromAsync(_interact({ - data: { - target_id: "0" - } - }, {})) - t.equal(msgs.length, 1) - t.equal(msgs[0].createInteractionResponse.data.content, "This message hasn't been bridged to Matrix.") -}) - -test("reactions: different response if nobody reacted", async t => { - const msgs = await fromAsync(_interact({ - data: { - target_id: "1126786462646550579" - } - }, { - api: { - async getFullRelations(roomID, eventID) { - t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") - t.equal(eventID, "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg") - return [] - } - } - })) - t.equal(msgs.length, 2) - t.equal(msgs[1].editOriginalInteractionResponse.content, "Nobody from Matrix reacted to this message.") -}) - -test("reactions: shows reactions if there are some, ignoring discord users", async t => { - let called = 1 - const msgs = await fromAsync(_interact({ - data: { - target_id: "1126786462646550579" - } - }, { - api: { - async getFullRelations(roomID, eventID) { - t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") - t.equal(eventID, "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg") - return [{ - sender: "@cadence:cadence.moe", - content: { - "m.relates_to": { - key: "🐈", - rel_type: "m.annotation" - } - } - }, { - sender: "@rnl:cadence.moe", - content: { - "m.relates_to": { - key: "🐈", - rel_type: "m.annotation" - } - } - }, { - sender: "@cadence:cadence.moe", - content: { - "m.relates_to": { - key: "🐈‍⬛", - rel_type: "m.annotation" - } - } - }, { - sender: "@_ooye_rnl:cadence.moe", - content: { - "m.relates_to": { - key: "🐈", - rel_type: "m.annotation" - } - } - }] - } - } - })) - t.equal(msgs.length, 2) - t.equal( - msgs[1].editOriginalInteractionResponse.content, - "🐈 ⮞ cadence [they] ⬩ @rnl:cadence.moe" - + "\n🐈‍⬛ ⮞ cadence [they]" - ) - t.equal(called, 1) -}) diff --git a/src/discord/register-interactions.js b/src/discord/register-interactions.js index e3d58c4..cd9203f 100644 --- a/src/discord/register-interactions.js +++ b/src/discord/register-interactions.js @@ -7,124 +7,101 @@ const {id} = require("../../addbot") const matrixInfo = sync.require("./interactions/matrix-info.js") const invite = sync.require("./interactions/invite.js") const permissions = sync.require("./interactions/permissions.js") +const bridge = sync.require("./interactions/bridge.js") const reactions = sync.require("./interactions/reactions.js") const privacy = sync.require("./interactions/privacy.js") -const poll = sync.require("./interactions/poll.js") -const pollResponses = sync.require("./interactions/poll-responses.js") -const ping = sync.require("./interactions/ping.js") // User must have EVERY permission in default_member_permissions to be able to use the command -function registerInteractions() { - discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{ - name: "Matrix info", - contexts: [DiscordTypes.InteractionContextType.Guild], - type: DiscordTypes.ApplicationCommandType.Message, - }, { - name: "Permissions", - contexts: [DiscordTypes.InteractionContextType.Guild], - type: DiscordTypes.ApplicationCommandType.Message, - default_member_permissions: String(DiscordTypes.PermissionFlagsBits.KickMembers | DiscordTypes.PermissionFlagsBits.ManageRoles) - }, { - name: "Responses", - contexts: [DiscordTypes.InteractionContextType.Guild], - type: DiscordTypes.ApplicationCommandType.Message - }, { - name: "invite", - contexts: [DiscordTypes.InteractionContextType.Guild], - type: DiscordTypes.ApplicationCommandType.ChatInput, - description: "Invite a Matrix user to this Discord server", - default_member_permissions: String(DiscordTypes.PermissionFlagsBits.CreateInstantInvite), - options: [ - { - type: DiscordTypes.ApplicationCommandOptionType.String, - description: "The Matrix user to invite, e.g. @username:example.org", - name: "user" - } - ], - }, { - name: "ping", - contexts: [DiscordTypes.InteractionContextType.Guild], - type: DiscordTypes.ApplicationCommandType.ChatInput, - description: "Ping a Matrix user.", - options: [ - { - type: DiscordTypes.ApplicationCommandOptionType.String, - description: "Display name or ID of the Matrix user", - name: "user", - autocomplete: true, - required: true - } - ] - }, { - name: "privacy", - contexts: [DiscordTypes.InteractionContextType.Guild], - type: DiscordTypes.ApplicationCommandType.ChatInput, - description: "Change whether Matrix users can join through direct invites, links, or the public directory.", - default_member_permissions: String(DiscordTypes.PermissionFlagsBits.ManageGuild), - options: [ - { - type: DiscordTypes.ApplicationCommandOptionType.String, - description: "Check or set the new privacy level", - name: "level", - choices: [{ - name: "❓️ Check the current privacy level and get more information.", - value: "info" - }, { - name: "🤝 Only allow joining with a direct in-app invite from another user. No shareable invite links.", - value: "invite" - }, { - name: "🔗 Matrix links can be created and shared like Discord's invite links. In-app invites still work.", - value: "link", - }, { - name: "🌏️ Publicly visible in the Matrix directory, like Server Discovery. Invites and links still work.", - value: "directory" - }] - } - ] - }]).catch(e => { - console.error(e) - }) -} +discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{ + name: "Matrix info", + contexts: [DiscordTypes.InteractionContextType.Guild], + type: DiscordTypes.ApplicationCommandType.Message, +}, { + name: "Permissions", + contexts: [DiscordTypes.InteractionContextType.Guild], + type: DiscordTypes.ApplicationCommandType.Message, + default_member_permissions: String(DiscordTypes.PermissionFlagsBits.KickMembers | DiscordTypes.PermissionFlagsBits.ManageRoles) +}, { + name: "Reactions", + contexts: [DiscordTypes.InteractionContextType.Guild], + type: DiscordTypes.ApplicationCommandType.Message +}, { + name: "invite", + contexts: [DiscordTypes.InteractionContextType.Guild], + type: DiscordTypes.ApplicationCommandType.ChatInput, + description: "Invite a Matrix user to this Discord server", + default_member_permissions: String(DiscordTypes.PermissionFlagsBits.CreateInstantInvite), + options: [ + { + type: DiscordTypes.ApplicationCommandOptionType.String, + description: "The Matrix user to invite, e.g. @username:example.org", + name: "user" + } + ] +}, { + name: "bridge", + contexts: [DiscordTypes.InteractionContextType.Guild], + type: DiscordTypes.ApplicationCommandType.ChatInput, + description: "Start bridging this channel to a Matrix room", + default_member_permissions: String(DiscordTypes.PermissionFlagsBits.ManageChannels), + options: [ + { + type: DiscordTypes.ApplicationCommandOptionType.String, + description: "Destination room to bridge to", + name: "room", + autocomplete: true + } + ] +}, { + name: "privacy", + contexts: [DiscordTypes.InteractionContextType.Guild], + type: DiscordTypes.ApplicationCommandType.ChatInput, + description: "Change whether Matrix users can join through direct invites, links, or the public directory.", + default_member_permissions: String(DiscordTypes.PermissionFlagsBits.ManageGuild), + options: [ + { + type: DiscordTypes.ApplicationCommandOptionType.String, + description: "Check or set the new privacy level", + name: "level", + choices: [{ + name: "❓️ Check the current privacy level and get more information.", + value: "info" + }, { + name: "🤝 Only allow joining with a direct in-app invite from another user. No shareable invite links.", + value: "invite" + }, { + name: "🔗 Matrix links can be created and shared like Discord's invite links. In-app invites still work.", + value: "link", + }, { + name: "🌏️ Publicly visible in the Matrix directory, like Server Discovery. Invites and links still work.", + value: "directory" + }] + } + ] +}]) -/** @param {DiscordTypes.APIInteraction} interaction */ async function dispatchInteraction(interaction) { - const interactionId = interaction.data?.["custom_id"] || interaction.data?.["name"] + const interactionId = interaction.data.custom_id || interaction.data.name try { - if (interaction.type === DiscordTypes.InteractionType.MessageComponent || interaction.type === DiscordTypes.InteractionType.ModalSubmit) { - // All we get is custom_id, don't know which context the button was clicked in. - // So we namespace these ourselves in the custom_id. Currently the only existing namespace is POLL_. - if (interaction.data.custom_id.startsWith("POLL_")) { - await poll.interact(interaction) - } else { - throw new Error(`Unknown message component ${interaction.data.custom_id}`) - } + if (interactionId === "Matrix info") { + await matrixInfo.interact(interaction) + } else if (interactionId === "invite") { + await invite.interact(interaction) + } else if (interactionId === "invite_channel") { + await invite.interactButton(interaction) + } else if (interactionId === "Permissions") { + await permissions.interact(interaction) + } else if (interactionId === "permissions_edit") { + await permissions.interactEdit(interaction) + } else if (interactionId === "bridge") { + await bridge.interact(interaction) + } else if (interactionId === "Reactions") { + await reactions.interact(interaction) + } else if (interactionId === "privacy") { + await privacy.interact(interaction) } else { - if (interactionId === "Matrix info") { - await matrixInfo.interact(interaction) - } else if (interactionId === "invite") { - await invite.interact(interaction) - } else if (interactionId === "invite_channel") { - await invite.interactButton(interaction) - } else if (interactionId === "Permissions") { - await permissions.interact(interaction) - } else if (interactionId === "permissions_edit") { - await permissions.interactEdit(interaction) - } else if (interactionId === "Responses") { - /** @type {DiscordTypes.APIMessageApplicationCommandGuildInteraction} */ // @ts-ignore - const messageInteraction = interaction - if (select("poll", "message_id", {message_id: messageInteraction.data.target_id}).get()) { - await pollResponses.interact(messageInteraction) - } else { - await reactions.interact(messageInteraction) - } - } else if (interactionId === "ping") { - await ping.interact(interaction) - } else if (interactionId === "privacy") { - await privacy.interact(interaction) - } else { - throw new Error(`Unknown interaction ${interactionId}`) - } + throw new Error(`Unknown interaction ${interactionId}`) } } catch (e) { let stackLines = null @@ -135,18 +112,13 @@ async function dispatchInteraction(interaction) { stackLines = stackLines.slice(0, cloudstormLine - 2) } } - try { - await discord.snow.interaction.createFollowupMessage(id, interaction.token, { - content: `Interaction failed: **${interactionId}**` - + `\nError trace:\n\`\`\`\n${stackLines.join("\n")}\`\`\`` - + `Interaction data:\n\`\`\`\n${JSON.stringify(interaction.data, null, 2)}\`\`\``, - flags: DiscordTypes.MessageFlags.Ephemeral - }) - } catch (_) { - throw e - } + await discord.snow.interaction.createFollowupMessage(id, interaction.token, { + content: `Interaction failed: **${interactionId}**` + + `\nError trace:\n\`\`\`\n${stackLines.join("\n")}\`\`\`` + + `Interaction data:\n\`\`\`\n${JSON.stringify(interaction.data, null, 2)}\`\`\``, + flags: DiscordTypes.MessageFlags.Ephemeral + }) } } module.exports.dispatchInteraction = dispatchInteraction -module.exports.registerInteractions = registerInteractions diff --git a/src/discord/utils.js b/src/discord/utils.js index a51b155..dc96ff8 100644 --- a/src/discord/utils.js +++ b/src/discord/utils.js @@ -15,18 +15,19 @@ require("xxhash-wasm")().then(h => hasher = h) const EPOCH = 1420070400000 /** - * @param {string} guildID * @param {string[]} userRoles * @param {DiscordTypes.APIGuild["roles"]} guildRoles * @param {string} [userID] * @param {DiscordTypes.APIGuildChannel["permission_overwrites"]} [channelOverwrites] */ -function getPermissions(guildID, userRoles, guildRoles, userID, channelOverwrites) { +function getPermissions(userRoles, guildRoles, userID, channelOverwrites) { let allowed = BigInt(0) + let everyoneID // Guild allows for (const role of guildRoles) { - if (role.id === guildID) { + if (role.name === "@everyone") { allowed |= BigInt(role.permissions) + everyoneID = role.id } if (userRoles.includes(role.id)) { allowed |= BigInt(role.permissions) @@ -37,9 +38,9 @@ function getPermissions(guildID, userRoles, guildRoles, userID, channelOverwrite /** @type {((overwrite: Required) => any)[]} */ const actions = [ // Channel @everyone deny - overwrite => overwrite.id === guildID && (allowed &= ~BigInt(overwrite.deny)), + overwrite => overwrite.id === everyoneID && (allowed &= ~BigInt(overwrite.deny)), // Channel @everyone allow - overwrite => overwrite.id === guildID && (allowed |= BigInt(overwrite.allow)), + overwrite => overwrite.id === everyoneID && (allowed |= BigInt(overwrite.allow)), // Role deny overwrite => userRoles.includes(overwrite.id) && (allowed &= ~BigInt(overwrite.deny)), // Role allow @@ -112,7 +113,7 @@ function isWebhookMessage(message) { * @param {Pick} message */ function isEphemeralMessage(message) { - return Boolean(message.flags && (message.flags & DiscordTypes.MessageFlags.Ephemeral)) + return message.flags && (message.flags & DiscordTypes.MessageFlags.Ephemeral) } /** @param {string} snowflake */ @@ -135,44 +136,6 @@ function getPublicUrlForCdn(url) { return `${reg.ooye.bridge_origin}/download/discord${match[1]}/${match[2]}/${match[3]}/${match[4]}` } -/** - * @param {string} oldTimestamp - * @param {string} newTimestamp - * @returns {string} "a x-day-old unbridged message" - */ -function howOldUnbridgedMessage(oldTimestamp, newTimestamp) { - const dateDifference = new Date(newTimestamp).getTime() - new Date(oldTimestamp).getTime() - const oneHour = 60 * 60 * 1000 - if (dateDifference < oneHour) { - return "an unbridged message" - } else if (dateDifference < 25 * oneHour) { - var dateDisplay = `a ${Math.floor(dateDifference / oneHour)}-hour-old unbridged message` - } else { - var dateDisplay = `a ${Math.round(dateDifference / (24 * oneHour))}-day-old unbridged message` - } - return dateDisplay -} - -/** - * Modifies the input, removing items that don't pass the filter. Returns the items that didn't pass. - * @param {T[]} xs - * @param {(x: T, i?: number) => any} fn - * @template T - * @returns T[] - */ -function filterTo(xs, fn) { - /** @type {T[]} */ - const filtered = [] - for (let i = xs.length-1; i >= 0; i--) { - const x = xs[i] - if (!fn(x, i)) { - filtered.unshift(x) - xs.splice(i, 1) - } - } - return filtered -} - module.exports.getPermissions = getPermissions module.exports.hasPermission = hasPermission module.exports.hasSomePermissions = hasSomePermissions @@ -182,5 +145,3 @@ module.exports.isEphemeralMessage = isEphemeralMessage module.exports.snowflakeToTimestampExact = snowflakeToTimestampExact module.exports.timestampToSnowflakeInexact = timestampToSnowflakeInexact module.exports.getPublicUrlForCdn = getPublicUrlForCdn -module.exports.howOldUnbridgedMessage = howOldUnbridgedMessage -module.exports.filterTo = filterTo diff --git a/src/discord/utils.test.js b/src/discord/utils.test.js index 88e51c9..7c5f0c8 100644 --- a/src/discord/utils.test.js +++ b/src/discord/utils.test.js @@ -79,68 +79,7 @@ test("getPermissions: channel overwrite to allow role works", t => { { type: 0, id: "1168988246680801360", deny: "0", allow: "1024" }, { type: 1, id: "353373325575323648", deny: "0", allow: "1024" } ] - const permissions = utils.getPermissions("1154868424724463687", userRoles, guildRoles, userID, overwrites) - const want = BigInt(1 << 10 | 1 << 16) - t.equal((permissions & want), want) -}) - -test("getPermissions: channel overwrite to allow user works", t => { - const guildRoles = [ - { - version: 1695412489043, - unicode_emoji: null, - tags: {}, - position: 0, - permissions: "559623605571137", - name: "@everyone", - mentionable: false, - managed: false, - id: "1154868424724463687", - icon: null, - hoist: false, - flags: 0, - color: 0 - }, - { - version: 1695412604262, - unicode_emoji: null, - tags: { bot_id: "466378653216014359" }, - position: 1, - permissions: "536995904", - name: "PluralKit", - mentionable: false, - managed: true, - id: "1154868908336099444", - icon: null, - hoist: false, - flags: 0, - color: 0 - }, - { - version: 1698778936921, - unicode_emoji: null, - tags: {}, - position: 1, - permissions: "536870912", - name: "web hookers", - mentionable: false, - managed: false, - id: "1168988246680801360", - icon: null, - hoist: false, - flags: 0, - color: 0 - } - ] - const userRoles = [] - const userID = "353373325575323648" - const overwrites = [ - { type: 0, id: "1154868908336099444", deny: "0", allow: "1024" }, - { type: 0, id: "1154868424724463687", deny: "1024", allow: "0" }, - { type: 0, id: "1168988246680801360", deny: "0", allow: "1024" }, - { type: 1, id: "353373325575323648", deny: "0", allow: "1024" } - ] - const permissions = utils.getPermissions("1154868424724463687", userRoles, guildRoles, userID, overwrites) + const permissions = utils.getPermissions(userRoles, guildRoles, userID, overwrites) const want = BigInt(1 << 10 | 1 << 16) t.equal((permissions & want), want) }) @@ -168,34 +107,3 @@ test("hasAllPermissions: doesn't detect not the permissions", t => { const canRemoveMembers = utils.hasAllPermissions(userPermissions, ["KickMembers", "BanMembers"]) t.equal(canRemoveMembers, false) }) - -test("isEphemeralMessage: detects ephemeral message", t => { - t.equal(utils.isEphemeralMessage(data.special_message.ephemeral_message), true) -}) - -test("isEphemeralMessage: doesn't detect normal message", t => { - t.equal(utils.isEphemeralMessage(data.message.simple_plaintext), false) -}) - -test("getPublicUrlForCdn: no-op on non-discord URL", t => { - t.equal(utils.getPublicUrlForCdn("https://cadence.moe"), "https://cadence.moe") -}) - -test("how old: now", t => { - t.equal(utils.howOldUnbridgedMessage(new Date().toISOString(), new Date().toISOString()), "an unbridged message") -}) - -test("how old: hours", t => { - t.equal(utils.howOldUnbridgedMessage("2026-01-01T00:00:00", "2026-01-01T03:10:00"), "a 3-hour-old unbridged message") -}) - -test("how old: days", t => { - t.equal(utils.howOldUnbridgedMessage("2024-01-01", "2025-01-01"), "a 366-day-old unbridged message") -}) - -test("filterTo: works", t => { - const fruit = ["apple", "banana", "apricot"] - const rest = utils.filterTo(fruit, f => f[0] === "b") - t.deepEqual(fruit, ["banana"]) - t.deepEqual(rest, ["apple", "apricot"]) -}) diff --git a/src/m2d/actions/add-reaction.js b/src/m2d/actions/add-reaction.js index e4981fb..cfd471b 100644 --- a/src/m2d/actions/add-reaction.js +++ b/src/m2d/actions/add-reaction.js @@ -4,52 +4,28 @@ const assert = require("assert").strict const Ty = require("../../types") const passthrough = require("../../passthrough") -const {discord, as, sync, db, select, from} = passthrough -/** @type {import("../../matrix/utils")} */ -const utils = sync.require("../../matrix/utils") +const {discord, sync, db, select} = passthrough +/** @type {import("../converters/utils")} */ +const utils = sync.require("../converters/utils") /** @type {import("../converters/emoji")} */ const emoji = sync.require("../converters/emoji") -/** @type {import("../../d2m/actions/retrigger")} */ -const retrigger = sync.require("../../d2m/actions/retrigger") /** * @param {Ty.Event.Outer} event */ async function addReaction(event) { - // Wait until the corresponding channel and message have already been bridged - if (retrigger.eventNotFoundThenRetrigger(event.content["m.relates_to"].event_id, () => as.emit("type:m.reaction", event))) return - - // These will exist because it passed retrigger - const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") - .select("message_id", "reference_channel_id").where({event_id: event.content["m.relates_to"].event_id}).and("ORDER BY reaction_part ASC").get() - assert(row) - const messageID = row.message_id - const channelID = row.reference_channel_id + const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() + if (!channelID) return // We just assume the bridge has already been created + const messageID = select("event_message", "message_id", {event_id: event.content["m.relates_to"].event_id}, "ORDER BY reaction_part").pluck().get() + if (!messageID) return // Nothing can be done if the parent message was never bridged. const key = event.content["m.relates_to"].key - const discordPreferredEncoding = await emoji.encodeEmoji(key, event.content.shortcode) + const discordPreferredEncoding = emoji.encodeEmoji(key, event.content.shortcode) if (!discordPreferredEncoding) return - try { - await discord.snow.channel.createReaction(channelID, messageID, discordPreferredEncoding) // acting as the discord bot itself - } catch (e) { - if (e.message?.includes("Maximum number of reactions reached")) { - // we'll silence this particular error to avoid spamming the chat - // not adding it to the database otherwise a m->d removal would try calling the API - return - } - if (e.message?.includes("Unknown Emoji")) { - // happens if a matrix user tries to add on to a super reaction - return - } - if (e.message?.includes("Unknown Message")) { - // happens under a race condition where a message is deleted after it passes the database check above - return - } - throw e - } + await discord.snow.channel.createReaction(channelID, messageID, discordPreferredEncoding) // acting as the discord bot itself - db.prepare("REPLACE INTO reaction (hashed_event_id, message_id, encoded_emoji, original_encoding) VALUES (?, ?, ?, ?)").run(utils.getEventIDHash(event.event_id), messageID, discordPreferredEncoding, key) + db.prepare("REPLACE INTO reaction (hashed_event_id, message_id, encoded_emoji) VALUES (?, ?, ?)").run(utils.getEventIDHash(event.event_id), messageID, discordPreferredEncoding) } module.exports.addReaction = addReaction diff --git a/src/m2d/actions/channel-webhook.js b/src/m2d/actions/channel-webhook.js index 09c642b..13c3ab1 100644 --- a/src/m2d/actions/channel-webhook.js +++ b/src/m2d/actions/channel-webhook.js @@ -2,7 +2,7 @@ const assert = require("assert").strict const DiscordTypes = require("discord-api-types/v10") -const stream = require("stream") +const {Readable} = require("stream") const passthrough = require("../../passthrough") const {discord, db, select} = passthrough @@ -57,7 +57,7 @@ async function withWebhook(channelID, callback) { /** * @param {string} channelID - * @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]}} data + * @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]}} data * @param {string} [threadID] */ async function sendMessageWithWebhook(channelID, data, threadID) { @@ -70,7 +70,7 @@ async function sendMessageWithWebhook(channelID, data, threadID) { /** * @param {string} channelID * @param {string} messageID - * @param {DiscordTypes.RESTPatchAPIWebhookWithTokenMessageJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]}} data + * @param {DiscordTypes.RESTPatchAPIWebhookWithTokenMessageJSONBody & {files?: {name: string, file: Buffer | Readable}[]}} data * @param {string} [threadID] */ async function editMessageWithWebhook(channelID, messageID, data, threadID) { diff --git a/src/m2d/actions/emoji-sheet.js b/src/m2d/actions/emoji-sheet.js index ed5ab88..c81960d 100644 --- a/src/m2d/actions/emoji-sheet.js +++ b/src/m2d/actions/emoji-sheet.js @@ -1,14 +1,15 @@ // @ts-check -const stream = require("stream") +const assert = require("assert") +const fetch = require("node-fetch").default + +const utils = require("../converters/utils") const {sync} = require("../../passthrough") /** @type {import("../converters/emoji-sheet")} */ const emojiSheetConverter = sync.require("../converters/emoji-sheet") /** @type {import("../../matrix/api")} */ const api = sync.require("../../matrix/api") -/** @type {import("../../matrix/mreq")} */ -const mreq = sync.require("../../matrix/mreq") /** * Downloads the emoji from the web and converts to uncompressed PNG data. @@ -17,19 +18,16 @@ const mreq = sync.require("../../matrix/mreq") */ async function getAndConvertEmoji(mxc) { const abortController = new AbortController() + /** @type {import("node-fetch").Response} */ // If it turns out to be a GIF, we want to abandon the connection without downloading the whole thing. // If we were using connection pooling, we would be forced to download the entire GIF. // So we set no agent to ensure we are not connection pooling. - const res = await api.getMedia(mxc, {signal: abortController.signal}) - if (res.status !== 200) { - const root = await res.json() - throw new mreq.MatrixServerError(root, {mxc}) - } - const readable = stream.Readable.fromWeb(res.body) - return emojiSheetConverter.convertImageStream(readable, () => { + // @ts-ignore the signal is slightly different from the type it wants (still works fine) + const res = await api.getMedia(mxc, {agent: false, signal: abortController.signal}) + return emojiSheetConverter.convertImageStream(res.body, () => { abortController.abort() - readable.emit("end") - readable.on("error", () => {}) // DOMException [AbortError]: This operation was aborted + res.body.pause() + res.body.emit("end") }) } diff --git a/src/m2d/actions/redact.js b/src/m2d/actions/redact.js index 3135d31..ffbb261 100644 --- a/src/m2d/actions/redact.js +++ b/src/m2d/actions/redact.js @@ -1,39 +1,22 @@ // @ts-check -const DiscordTypes = require("discord-api-types/v10") +const assert = require("assert").strict const Ty = require("../../types") const passthrough = require("../../passthrough") -const {discord, as, sync, db, select, from} = passthrough -/** @type {import("../../matrix/utils")} */ -const utils = sync.require("../../matrix/utils") -/** @type {import("../../d2m/actions/retrigger")} */ -const retrigger = sync.require("../../d2m/actions/retrigger") +const {discord, sync, db, select, from} = passthrough +/** @type {import("../converters/utils")} */ +const utils = sync.require("../converters/utils") /** * @param {Ty.Event.Outer_M_Room_Redaction} event */ async function deleteMessage(event) { - const rows = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") - .select("reference_channel_id", "message_id").where({event_id: event.redacts}).all() - if (!rows.length) return + const rows = from("event_message").join("message_channel", "message_id").select("channel_id", "message_id").where({event_id: event.redacts}).all() + db.prepare("DELETE FROM event_message WHERE event_id = ?").run(event.event_id) for (const row of rows) { - await discord.snow.channel.deleteMessage(row.reference_channel_id, row.message_id, event.content.reason) - db.prepare("DELETE FROM event_message WHERE message_id = ?").run(row.message_id) - } - db.prepare("DELETE FROM message_room WHERE message_id = ?").run(rows[0].message_id) -} - -/** - * @param {Ty.Event.Outer_M_Room_Redaction} event - */ -async function suppressEmbeds(event) { - const rows = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") - .select("reference_channel_id", "message_id").where({event_id: event.redacts}).all() - if (!rows.length) return - db.prepare("DELETE FROM event_message WHERE event_id = ?").run(event.redacts) - for (const row of rows) { - await discord.snow.channel.editMessage(row.reference_channel_id, row.message_id, {flags: DiscordTypes.MessageFlags.SuppressEmbeds}) + db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(row.message_id) + await discord.snow.channel.deleteMessage(row.channel_id, row.message_id, event.content.reason) } } @@ -42,10 +25,9 @@ async function suppressEmbeds(event) { */ async function removeReaction(event) { const hash = utils.getEventIDHash(event.redacts) - const row = from("reaction").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") - .select("reference_channel_id", "message_id", "encoded_emoji").where({hashed_event_id: hash}).get() + const row = from("reaction").join("message_channel", "message_id").select("channel_id", "message_id", "encoded_emoji").where({hashed_event_id: hash}).get() if (!row) return - await discord.snow.channel.deleteReactionSelf(row.reference_channel_id, row.message_id, row.encoded_emoji) + await discord.snow.channel.deleteReactionSelf(row.channel_id, row.message_id, row.encoded_emoji) db.prepare("DELETE FROM reaction WHERE hashed_event_id = ?").run(hash) } @@ -54,18 +36,8 @@ async function removeReaction(event) { * @param {Ty.Event.Outer_M_Room_Redaction} event */ async function handle(event) { - // If this is for removing a reaction, try it + await deleteMessage(event) await removeReaction(event) - - // Or, it might be for removing a message or suppressing embeds. But to do that, the message needs to be bridged first. - if (retrigger.eventNotFoundThenRetrigger(event.redacts, () => as.emit("type:m.room.redaction", event))) return - - const row = select("event_message", ["event_type", "event_subtype", "part"], {event_id: event.redacts}).get() - if (row && row.event_type === "m.room.message" && row.event_subtype === "m.notice" && row.part === 1) { - await suppressEmbeds(event) - } else { - await deleteMessage(event) - } } module.exports.handle = handle diff --git a/src/m2d/actions/send-event.js b/src/m2d/actions/send-event.js index 00557a1..0a270a0 100644 --- a/src/m2d/actions/send-event.js +++ b/src/m2d/actions/send-event.js @@ -2,11 +2,12 @@ const Ty = require("../../types") const DiscordTypes = require("discord-api-types/v10") -const stream = require("stream") +const {Readable} = require("stream") const assert = require("assert").strict const crypto = require("crypto") +const fetch = require("node-fetch").default const passthrough = require("../../passthrough") -const {sync, discord, db, from, select} = passthrough +const {sync, discord, db, select} = passthrough /** @type {import("./channel-webhook")} */ const channelWebhook = sync.require("./channel-webhook") @@ -14,8 +15,6 @@ const channelWebhook = sync.require("./channel-webhook") const eventToMessage = sync.require("../converters/event-to-message") /** @type {import("../../matrix/api")}) */ const api = sync.require("../../matrix/api") -/** @type {import("../../matrix/utils")}) */ -const utils = sync.require("../../matrix/utils") /** @type {import("../../d2m/actions/register-user")} */ const registerUser = sync.require("../../d2m/actions/register-user") /** @type {import("../../d2m/actions/edit-message")} */ @@ -24,8 +23,8 @@ const editMessage = sync.require("../../d2m/actions/edit-message") const emojiSheet = sync.require("../actions/emoji-sheet") /** - * @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[], pendingFiles?: ({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer | stream.Readable})[]}} message - * @returns {Promise} + * @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[], pendingFiles?: ({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer | Readable})[]}} message + * @returns {Promise} */ async function resolvePendingFiles(message) { if (!message.pendingFiles) return message @@ -39,14 +38,16 @@ async function resolvePendingFiles(message) { if ("key" in p) { // Encrypted file const d = crypto.createDecipheriv("aes-256-ctr", Buffer.from(p.key, "base64url"), Buffer.from(p.iv, "base64url")) - await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb(res.body).pipe(d)) + // @ts-ignore + await api.getMedia(p.mxc).then(res => res.body.pipe(d)) return { name: p.name, file: d } } else { // Unencrypted file - const body = await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb(res.body)) + /** @type {Readable} */ // @ts-ignore + const body = await api.getMedia(p.mxc).then(res => res.body) return { name: p.name, file: body @@ -61,39 +62,24 @@ async function resolvePendingFiles(message) { return newMessage } -/** @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker | Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Start | Ty.Event.Outer_Org_Matrix_Msc3381_Poll_End} event */ +/** @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker} event */ async function sendEvent(event) { - const row = from("channel_room").where({room_id: event.room_id}).select("channel_id", "thread_parent").get() - if (!row) return [] // allow the bot to exist in unbridged rooms, just don't do anything with it + const row = select("channel_room", ["channel_id", "thread_parent"], {room_id: event.room_id}).get() + if (!row) return // allow the bot to exist in unbridged rooms, just don't do anything with it let channelID = row.channel_id let threadID = undefined if (row.thread_parent) { threadID = channelID channelID = row.thread_parent // it's the thread's parent... get with the times... } - /** @type {DiscordTypes.APIGuildTextChannel} */ // @ts-ignore - const channel = discord.channels.get(channelID) // @ts-ignore - const guild = discord.guilds.get(channel.guild_id) + const guildID = discord.channels.get(channelID).guild_id + const guild = discord.guilds.get(guildID) assert(guild) - const historicalRoomIndex = select("historical_channel_room", "historical_room_index", {room_id: event.room_id}).pluck().get() - assert(historicalRoomIndex) // no need to sync the matrix member to the other side. but if I did need to, this is where I'd do it - const di = {api, snow: discord.snow, mxcDownloader: emojiSheet.getAndConvertEmoji} - - if (event.type === "org.matrix.msc3381.poll.end") { - // Validity already checked by dispatcher. Poll is definitely closed. Update it and DI necessary data. - const messageID = select("event_message", "message_id", {event_id: event.content["m.relates_to"].event_id, event_type: "org.matrix.msc3381.poll.start", source: 0}).pluck().get() - assert(messageID) - db.prepare("UPDATE poll SET is_closed = 1 WHERE message_id = ?").run(messageID) - di.pollEnd = { - messageID - } - } - - let {messagesToEdit, messagesToSend, messagesToDelete, ensureJoined} = await eventToMessage.eventToMessage(event, guild, channel, di) + let {messagesToEdit, messagesToSend, messagesToDelete, ensureJoined} = await eventToMessage.eventToMessage(event, guild, {api, snow: discord.snow, mxcDownloader: emojiSheet.getAndConvertEmoji}) messagesToEdit = await Promise.all(messagesToEdit.map(async e => { e.message = await resolvePendingFiles(e.message) @@ -115,25 +101,16 @@ async function sendEvent(event) { } for (const id of messagesToDelete) { - db.prepare("DELETE FROM message_room WHERE message_id = ?").run(id) + db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(id) + db.prepare("DELETE FROM event_message WHERE message_id = ?").run(id) await channelWebhook.deleteMessageWithWebhook(channelID, id, threadID) } - // Poll ends do not follow the normal laws of parts. - // Normally when editing and adding extra parts, the new parts should always have part = 1 and reaction_part = 1 (because the existing part, which is being edited, already took 0). - // However for polls, the edit is actually for a different message. The message being sent is truly a new message, and should have parts = 0. - // So in that case, just override these variables to have the right values. - if (di.pollEnd) { - eventPart = 0 - } - for (const message of messagesToSend) { - const reactionPart = (messagesToEdit.length === 0 || di.pollEnd) && message === messagesToSend[messagesToSend.length - 1] ? 0 : 1 + const reactionPart = messagesToEdit.length === 0 && message === messagesToSend[messagesToSend.length - 1] ? 0 : 1 const messageResponse = await channelWebhook.sendMessageWithWebhook(channelID, message, threadID) - db.transaction(() => { - db.prepare("INSERT INTO message_room (message_id, historical_room_index) VALUES (?, ?)").run(messageResponse.id, historicalRoomIndex) - db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 0)").run(event.event_id, event.type, event.content["msgtype"] || null, messageResponse.id, eventPart, reactionPart) // source 0 = matrix - })() + db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(messageResponse.id, threadID || channelID) + db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 0)").run(event.event_id, event.type, event.content["msgtype"] || null, messageResponse.id, eventPart, reactionPart) // source 0 = matrix eventPart = 1 messageResponses.push(messageResponse) @@ -158,25 +135,6 @@ async function sendEvent(event) { } } - if (event.type === "org.matrix.msc3381.poll.start") { // Need to store answer mapping in the database. - db.transaction(() => { - const messageID = messageResponses[0].id - db.prepare("INSERT INTO poll (message_id, max_selections, question_text, is_closed) VALUES (?, ?, ?, 0)").run( - messageID, - event.content["org.matrix.msc3381.poll.start"].max_selections, - event.content["org.matrix.msc3381.poll.start"].question["org.matrix.msc1767.text"] - ) - for (const [i, option] of Object.entries(event.content["org.matrix.msc3381.poll.start"].answers)) { - db.prepare("INSERT INTO poll_option (message_id, matrix_option, option_text, seq) VALUES (?, ?, ?, ?)").run( - messageID, - option.id, - option["org.matrix.msc1767.text"], - i - ) - } - })() - } - for (const user of ensureJoined) { registerUser.ensureSimJoined(user, event.room_id) } diff --git a/src/m2d/actions/setup-emojis.js b/src/m2d/actions/setup-emojis.js deleted file mode 100644 index 4664135..0000000 --- a/src/m2d/actions/setup-emojis.js +++ /dev/null @@ -1,25 +0,0 @@ -// @ts-check - -const fs = require("fs") -const {join} = require("path") - -const passthrough = require("../../passthrough") - -async function setupEmojis() { - const {id} = require("../../../addbot") - const {discord, db} = passthrough - const emojis = await discord.snow.assets.getAppEmojis(id) - for (const name of ["L1", "L2", "poll_win"]) { - const existing = emojis.items.find(e => e.name === name) - if (existing) { - db.prepare("REPLACE INTO auto_emoji (name, emoji_id) VALUES (?, ?)").run(existing.name, existing.id) - } else { - const filename = join(__dirname, "../../../docs/img", `${name}.png`) - const data = fs.readFileSync(filename, null) - const uploaded = await discord.snow.assets.createAppEmoji(id, {name, image: "data:image/png;base64," + data.toString("base64")}) - db.prepare("REPLACE INTO auto_emoji (name, emoji_id) VALUES (?, ?)").run(uploaded.name, uploaded.id) - } - } -} - -module.exports.setupEmojis = setupEmojis diff --git a/src/m2d/actions/sticker.js b/src/m2d/actions/sticker.js deleted file mode 100644 index 341d8b0..0000000 --- a/src/m2d/actions/sticker.js +++ /dev/null @@ -1,40 +0,0 @@ -// @ts-check - -const {Readable} = require("stream") -const {ReadableStream} = require("stream/web") - -const {sync} = require("../../passthrough") -const sharp = require("sharp") -/** @type {import("../../matrix/api")} */ -const api = sync.require("../../matrix/api") -/** @type {import("../../matrix/mreq")} */ -const mreq = sync.require("../../matrix/mreq") -const streamMimeType = require("stream-mime-type") - -const WIDTH = 160 -const HEIGHT = 160 -/** - * Downloads the sticker from the web and converts to webp data. - * @param {string} mxc a single mxc:// URL - * @returns {Promise} sticker webp data, or undefined if the downloaded sticker is not valid - */ -async function getAndResizeSticker(mxc) { - const res = await api.getMedia(mxc) - if (res.status !== 200) { - const root = await res.json() - throw new mreq.MatrixServerError(root, {mxc}) - } - - const streamIn = Readable.fromWeb(res.body) - const { stream, mime } = await streamMimeType.getMimeType(streamIn) - const animated = ["image/gif", "image/webp"].includes(mime) - - const transformer = sharp({animated: animated}) - .resize(WIDTH, HEIGHT, {fit: "inside", background: {r: 0, g: 0, b: 0, alpha: 0}}) - .webp() - stream.pipe(transformer) - return Readable.toWeb(transformer) -} - - -module.exports.getAndResizeSticker = getAndResizeSticker diff --git a/src/m2d/actions/update-pins.js b/src/m2d/actions/update-pins.js deleted file mode 100644 index d06f6e8..0000000 --- a/src/m2d/actions/update-pins.js +++ /dev/null @@ -1,26 +0,0 @@ -// @ts-check - -const {sync, from, discord} = require("../../passthrough") - -/** @type {import("../converters/diff-pins")} */ -const diffPins = sync.require("../converters/diff-pins") - -/** - * @param {string[]} pins - * @param {string[]} prev - */ -async function updatePins(pins, prev) { - const diff = diffPins.diffPins(pins, prev) - for (const [event_id, added] of diff) { - const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") - .select("reference_channel_id", "message_id").get() - if (!row) continue - if (added) { - discord.snow.channel.addChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message pinned on Matrix") - } else { - discord.snow.channel.removeChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message unpinned on Matrix") - } - } -} - -module.exports.updatePins = updatePins diff --git a/src/m2d/actions/vote.js b/src/m2d/actions/vote.js deleted file mode 100644 index 926b957..0000000 --- a/src/m2d/actions/vote.js +++ /dev/null @@ -1,42 +0,0 @@ -// @ts-check - -const Ty = require("../../types") -const DiscordTypes = require("discord-api-types/v10") -const {Readable} = require("stream") -const assert = require("assert").strict -const crypto = require("crypto") -const passthrough = require("../../passthrough") -const {sync, discord, db, select} = passthrough - -const {reg} = require("../../matrix/read-registration") -/** @type {import("../../matrix/api")} */ -const api = sync.require("../../matrix/api") -/** @type {import("../../matrix/utils")} */ -const utils = sync.require("../../matrix/utils") -/** @type {import("../converters/poll-components")} */ -const pollComponents = sync.require("../converters/poll-components") -/** @type {import("./channel-webhook")} */ -const webhook = sync.require("./channel-webhook") - -/** @param {Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Response} event */ -async function updateVote(event) { - const messageRow = select("event_message", ["message_id", "source"], {event_id: event.content["m.relates_to"].event_id, event_type: "org.matrix.msc3381.poll.start"}).get() - const messageID = messageRow?.message_id - if (!messageID) return // Nothing can be done if the parent message was never bridged. - - db.transaction(() => { - db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ?").run(event.sender, messageID) // Clear all the existing votes, since this overwrites. - for (const answer of event.content["org.matrix.msc3381.poll.response"].answers) { - db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(event.sender, messageID, answer) - } - })() - - // If poll was started on Matrix, the Discord version is using components, so we can update that to the current status - if (messageRow.source === 0) { - const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() - assert(channelID) - await webhook.editMessageWithWebhook(channelID, messageID, pollComponents.getPollComponentsFromDatabase(messageID)) - } -} - -module.exports.updateVote = updateVote \ No newline at end of file diff --git a/src/m2d/converters/diff-pins.js b/src/m2d/converters/diff-pins.js deleted file mode 100644 index e6e038b..0000000 --- a/src/m2d/converters/diff-pins.js +++ /dev/null @@ -1,17 +0,0 @@ -// @ts-check - -/** - * @param {string[]} pins - * @param {string[]} prev - * @returns {[string, boolean][]} - */ -function diffPins(pins, prev) { - /** @type {[string, boolean][]} */ - const result = [] - return result.concat( - prev.filter(id => !pins.includes(id)).map(id => [id, false]), // removed - pins.filter(id => !prev.includes(id)).map(id => [id, true]) // added - ) -} - -module.exports.diffPins = diffPins diff --git a/src/m2d/converters/diff-pins.test.js b/src/m2d/converters/diff-pins.test.js deleted file mode 100644 index edb9c47..0000000 --- a/src/m2d/converters/diff-pins.test.js +++ /dev/null @@ -1,11 +0,0 @@ -// @ts-check - -const {test} = require("supertape") -const diffPins = require("./diff-pins") - -test("diff pins: diff is as expected", t => { - t.deepEqual( - diffPins.diffPins(["same", "new"], ["same", "old"]), - [["old", false], ["new", true]] - ) -}) diff --git a/src/m2d/converters/emoji-sheet.js b/src/m2d/converters/emoji-sheet.js index 16d5065..db5b06f 100644 --- a/src/m2d/converters/emoji-sheet.js +++ b/src/m2d/converters/emoji-sheet.js @@ -1,7 +1,6 @@ // @ts-check const assert = require("assert").strict -const stream = require("stream") const {pipeline} = require("stream").promises const sharp = require("sharp") const {GIFrame} = require("@cloudrac3r/giframe") @@ -49,7 +48,7 @@ async function compositeMatrixEmojis(mxcs, mxcDownloader) { } /** - * @param {stream.Readable} streamIn + * @param {import("node-fetch").Response["body"]} streamIn * @param {() => any} stopStream * @returns {Promise} Uncompressed PNG image */ diff --git a/src/m2d/converters/emoji.js b/src/m2d/converters/emoji.js index 63e53a9..bb6a3b0 100644 --- a/src/m2d/converters/emoji.js +++ b/src/m2d/converters/emoji.js @@ -1,98 +1,57 @@ // @ts-check -const fsp = require("fs").promises -const {join} = require("path") -const emojisp = fsp.readFile(join(__dirname, "emojis.txt"), "utf8").then(content => content.split("\n")) +const assert = require("assert").strict +const Ty = require("../../types") const passthrough = require("../../passthrough") -const {select} = passthrough - +const {sync, select} = passthrough /** * @param {string} input * @param {string | null | undefined} shortcode * @returns {string?} */ -function encodeCustomEmoji(input, shortcode) { - // Custom emoji - let row = select("emoji", ["emoji_id", "name"], {mxc_url: input}).get() - if (!row && shortcode) { - // Use the name to try to find a known emoji with the same name. - const name = shortcode.replace(/^:|:$/g, "") - row = select("emoji", ["emoji_id", "name"], {name: name}).get() - } - if (!row) { - // We don't have this emoji and there's no realistic way to just-in-time upload a new emoji somewhere. Sucks! - return null - } - return encodeURIComponent(`${row.name}:${row.emoji_id}`) -} - -/** - * @param {string} input - * @returns {Promise} URL encoded! - */ -async function encodeDefaultEmoji(input) { - // Default emoji - - // Shortcut: If there are ASCII letters then it's not an emoji, it's a freeform Matrix text reaction. - // (Regional indicator letters are not ASCII. ASCII digits might be part of an emoji.) - if (input.match(/[A-Za-z]/)) return null - - // Check against the dataset - const emojis = await emojisp - const encoded = encodeURIComponent(input) - - // Best case scenario: they reacted with an exact replica of a valid emoji. - if (emojis.includes(input)) return encoded - - // Maybe it has some extraneous \ufe0f or \ufe0e (at the end or in the middle), and it'll be valid if they're removed. - const trimmed = input.replace(/\ufe0e|\ufe0f/g, "") - const trimmedEncoded = encodeURIComponent(trimmed) - if (trimmed !== input) { - if (emojis.includes(trimmed)) return trimmedEncoded - } - - // Okay, well, maybe it was already missing one and it actually needs an extra \ufe0f, and it'll be valid if that's added. - else { - const appended = input + "\ufe0f" - const appendedEncoded = encodeURIComponent(appended) - if (emojis.includes(appended)) return appendedEncoded - } - - // Hmm, so adding or removing that from the end didn't help, but maybe there needs to be one in the middle? We can try some heuristics. - // These heuristics come from executing scripts/emoji-surrogates-statistics.js. - if (trimmedEncoded.length <= 21 && trimmed.match(/^[*#0-9]/)) { // ->19: Keycap digit? 0️⃣ 1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣ 6️⃣ 7️⃣ 8️⃣ 9️⃣ *️⃣ #️⃣ - const keycap = trimmed[0] + "\ufe0f" + trimmed.slice(1) - if (emojis.includes(keycap)) return encodeURIComponent(keycap) - } else if (trimmedEncoded.length === 27 && trimmed[0] === "⛹") { // ->45: ⛹️‍♀️ ⛹️‍♂️ - const balling = trimmed[0] + "\ufe0f" + trimmed.slice(1) + "\ufe0f" - if (emojis.includes(balling)) return encodeURIComponent(balling) - } else if (trimmedEncoded.length === 30) { // ->39: ⛓️‍💥 ❤️‍🩹 ❤️‍🔥 or ->48: 🏳️‍⚧️ 🏌️‍♀️ 🕵️‍♀️ 🏋️‍♀️ and gender variants - const thriving = trimmed[0] + "\ufe0f" + trimmed.slice(1) - if (emojis.includes(thriving)) return encodeURIComponent(thriving) - const powerful = trimmed.slice(0, 2) + "\ufe0f" + trimmed.slice(2) + "\ufe0f" - if (emojis.includes(powerful)) return encodeURIComponent(powerful) - } else if (trimmedEncoded.length === 51 && trimmed[3] === "❤") { // ->60: 👩‍❤️‍👨 👩‍❤️‍👩 👨‍❤️‍👨 - const yellowRomance = trimmed.slice(0, 3) + "❤\ufe0f" + trimmed.slice(4) - if (emojis.includes(yellowRomance)) return encodeURIComponent(yellowRomance) - } - - // there are a few more longer ones but I got bored - return null -} - -/** - * @param {string} input - * @param {string | null | undefined} shortcode - * @returns {Promise} - */ -async function encodeEmoji(input, shortcode) { +function encodeEmoji(input, shortcode) { + let discordPreferredEncoding if (input.startsWith("mxc://")) { - return encodeCustomEmoji(input, shortcode) + // Custom emoji + let row = select("emoji", ["emoji_id", "name"], {mxc_url: input}).get() + if (!row && shortcode) { + // Use the name to try to find a known emoji with the same name. + const name = shortcode.replace(/^:|:$/g, "") + row = select("emoji", ["emoji_id", "name"], {name: name}).get() + } + if (!row) { + // We don't have this emoji and there's no realistic way to just-in-time upload a new emoji somewhere. + // Sucks! + return null + } + // Cool, we got an exact or a candidate emoji. + discordPreferredEncoding = encodeURIComponent(`${row.name}:${row.emoji_id}`) } else { - return encodeDefaultEmoji(input) + // Default emoji + // https://github.com/discord/discord-api-docs/issues/2723#issuecomment-807022205 ???????????? + const encoded = encodeURIComponent(input) + const encodedTrimmed = encoded.replace(/%EF%B8%8F/g, "") + + const forceTrimmedList = [ + "%F0%9F%91%8D", // 👍 + "%F0%9F%91%8E", // 👎️ + "%E2%AD%90", // ⭐ + "%F0%9F%90%88", // 🐈 + "%E2%9D%93", // ❓ + "%F0%9F%8F%86", // 🏆️ + "%F0%9F%93%9A", // 📚️ + ] + + discordPreferredEncoding = + ( forceTrimmedList.includes(encodedTrimmed) ? encodedTrimmed + : encodedTrimmed !== encoded && [...input].length === 2 ? encoded + : encodedTrimmed) + + console.log("add reaction from matrix:", input, encoded, encodedTrimmed, "chosen:", discordPreferredEncoding) } + return discordPreferredEncoding } module.exports.encodeEmoji = encodeEmoji diff --git a/src/m2d/converters/emoji.test.js b/src/m2d/converters/emoji.test.js deleted file mode 100644 index fafb163..0000000 --- a/src/m2d/converters/emoji.test.js +++ /dev/null @@ -1,64 +0,0 @@ -// @ts-check - -const {test} = require("supertape") -const {encodeEmoji} = require("./emoji") - -test("emoji: valid", async t => { - t.equal(await encodeEmoji("🦄", null), "%F0%9F%A6%84") -}) - -test("emoji: freeform text", async t => { - t.equal(await encodeEmoji("ha", null), null) -}) - -test("emoji: suspicious unicode", async t => { - t.equal(await encodeEmoji("Ⓐ", null), null) -}) - -test("emoji: needs u+fe0f added", async t => { - t.equal(await encodeEmoji("☺", null), "%E2%98%BA%EF%B8%8F") -}) - -test("emoji: needs u+fe0f removed", async t => { - t.equal(await encodeEmoji("⭐️", null), "%E2%AD%90") -}) - -test("emoji: number key needs u+fe0f in the middle", async t => { - t.equal(await encodeEmoji("3⃣", null), "3%EF%B8%8F%E2%83%A3") -}) - -test("emoji: hash key needs u+fe0f in the middle", async t => { - t.equal(await encodeEmoji("#⃣", null), "%23%EF%B8%8F%E2%83%A3") -}) - -test("emoji: broken chains needs u+fe0f in the middle", async t => { - t.equal(await encodeEmoji("⛓‍💥", null), "%E2%9B%93%EF%B8%8F%E2%80%8D%F0%9F%92%A5") -}) - -test("emoji: balling needs u+fe0f in the middle", async t => { - t.equal(await encodeEmoji("⛹‍♀", null), "%E2%9B%B9%EF%B8%8F%E2%80%8D%E2%99%80%EF%B8%8F") -}) - -test("emoji: trans flag needs u+fe0f in the middle", async t => { - t.equal(await encodeEmoji("🏳‍⚧", null), "%F0%9F%8F%B3%EF%B8%8F%E2%80%8D%E2%9A%A7%EF%B8%8F") -}) - -test("emoji: spy needs u+fe0f in the middle", async t => { - t.equal(await encodeEmoji("🕵‍♀", null), "%F0%9F%95%B5%EF%B8%8F%E2%80%8D%E2%99%80%EF%B8%8F") -}) - -test("emoji: couple needs u+fe0f in the middle", async t => { - t.equal(await encodeEmoji("👩‍❤‍👩", null), "%F0%9F%91%A9%E2%80%8D%E2%9D%A4%EF%B8%8F%E2%80%8D%F0%9F%91%A9") -}) - -test("emoji: exact known emojis are returned", async t => { - t.equal(await encodeEmoji("mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC", "hippo"), "hippo%3A230201364309868544") -}) - -test("emoji: inexact emojis are guessed by name", async t => { - t.equal(await encodeEmoji("mxc://example.invalid/a", "hippo"), "hippo%3A230201364309868544") -}) - -test("emoji: unknown custom emoji returns null", async t => { - t.equal(await encodeEmoji("mxc://example.invalid/a", "silly"), null) -}) diff --git a/src/m2d/converters/emojis.txt b/src/m2d/converters/emojis.txt deleted file mode 100644 index 2ac8997..0000000 --- a/src/m2d/converters/emojis.txt +++ /dev/null @@ -1,3799 +0,0 @@ -😀 -😃 -😄 -😁 -😆 -🥹 -😅 -😂 -🤣 -🥲 -☺️ -😊 -😇 -🙂 -🙃 -😉 -😌 -😍 -🥰 -😘 -😗 -😙 -😚 -😋 -😛 -😝 -😜 -🤪 -🤨 -🧐 -🤓 -😎 -🥸 -🤩 -🥳 -😏 -😒 -😞 -😔 -😟 -😕 -🙁 -☹️ -😣 -😖 -😫 -😩 -🥺 -😢 -😭 -😤 -😠 -😡 -🤬 -🤯 -😳 -🥵 -🥶 -😶‍🌫️ -😱 -😨 -😰 -😥 -😓 -🤗 -🤔 -🫣 -🤭 -🫢 -🫡 -🤫 -🫠 -🤥 -😶 -🫥 -😐 -🫤 -😑 -🫨 -🙂‍↔️ -🙂‍↕️ -😬 -🙄 -😯 -😦 -😧 -😮 -😲 -🥱 -😴 -🤤 -😪 -😮‍💨 -😵 -😵‍💫 -🤐 -🥴 -🤢 -🤮 -🤧 -😷 -🤒 -🤕 -🤑 -🤠 -😈 -👿 -👹 -👺 -🤡 -💩 -👻 -💀 -☠️ -👽 -👾 -🤖 -🎃 -😺 -😸 -😹 -😻 -😼 -😽 -🙀 -😿 -😾 -🤝🏻 -🫱🏻‍🫲🏼 -🫱🏻‍🫲🏽 -🫱🏻‍🫲🏾 -🫱🏻‍🫲🏿 -🫱🏼‍🫲🏻 -🤝🏼 -🫱🏼‍🫲🏽 -🫱🏼‍🫲🏾 -🫱🏼‍🫲🏿 -🫱🏽‍🫲🏻 -🫱🏽‍🫲🏼 -🤝🏽 -🫱🏽‍🫲🏾 -🫱🏽‍🫲🏿 -🫱🏾‍🫲🏻 -🫱🏾‍🫲🏼 -🫱🏾‍🫲🏽 -🤝🏾 -🫱🏾‍🫲🏿 -🫱🏿‍🫲🏻 -🫱🏿‍🫲🏼 -🫱🏿‍🫲🏽 -🫱🏿‍🫲🏾 -🤝🏿 -🤝 -🫶🏻 -🫶🏼 -🫶🏽 -🫶🏾 -🫶🏿 -🫶 -🤲🏻 -🤲🏼 -🤲🏽 -🤲🏾 -🤲🏿 -🤲 -👐🏻 -👐🏼 -👐🏽 -👐🏾 -👐🏿 -👐 -🙌🏻 -🙌🏼 -🙌🏽 -🙌🏾 -🙌🏿 -🙌 -👏🏻 -👏🏼 -👏🏽 -👏🏾 -👏🏿 -👏 -👍🏻 -👍🏼 -👍🏽 -👍🏾 -👍🏿 -👍 -👎🏻 -👎🏼 -👎🏽 -👎🏾 -👎🏿 -👎 -👊🏻 -👊🏼 -👊🏽 -👊🏾 -👊🏿 -👊 -✊🏻 -✊🏼 -✊🏽 -✊🏾 -✊🏿 -✊ -🤛🏻 -🤛🏼 -🤛🏽 -🤛🏾 -🤛🏿 -🤛 -🤜🏻 -🤜🏼 -🤜🏽 -🤜🏾 -🤜🏿 -🤜 -🫷🏻 -🫷🏼 -🫷🏽 -🫷🏾 -🫷🏿 -🫷 -🫸🏻 -🫸🏼 -🫸🏽 -🫸🏾 -🫸🏿 -🫸 -🤞🏻 -🤞🏼 -🤞🏽 -🤞🏾 -🤞🏿 -🤞 -✌🏻 -✌🏼 -✌🏽 -✌🏾 -✌🏿 -✌️ -🫰🏻 -🫰🏼 -🫰🏽 -🫰🏾 -🫰🏿 -🫰 -🤟🏻 -🤟🏼 -🤟🏽 -🤟🏾 -🤟🏿 -🤟 -🤘🏻 -🤘🏼 -🤘🏽 -🤘🏾 -🤘🏿 -🤘 -👌🏻 -👌🏼 -👌🏽 -👌🏾 -👌🏿 -👌 -🤌🏼 -🤌🏻 -🤌🏽 -🤌🏾 -🤌🏿 -🤌 -🤏🏻 -🤏🏼 -🤏🏽 -🤏🏾 -🤏🏿 -🤏 -🫳🏻 -🫳🏼 -🫳🏽 -🫳🏾 -🫳🏿 -🫳 -🫴🏻 -🫴🏼 -🫴🏽 -🫴🏾 -🫴🏿 -🫴 -👈🏻 -👈🏼 -👈🏽 -👈🏾 -👈🏿 -👈 -👉🏻 -👉🏼 -👉🏽 -👉🏾 -👉🏿 -👉 -👆🏻 -👆🏼 -👆🏽 -👆🏾 -👆🏿 -👆 -👇🏻 -👇🏼 -👇🏽 -👇🏾 -👇🏿 -👇 -☝🏻 -☝🏼 -☝🏽 -☝🏾 -☝🏿 -☝️ -✋🏻 -✋🏼 -✋🏽 -✋🏾 -✋🏿 -✋ -🤚🏻 -🤚🏼 -🤚🏽 -🤚🏾 -🤚🏿 -🤚 -🖐🏻 -🖐🏼 -🖐🏽 -🖐🏾 -🖐🏿 -🖐️ -🖖🏻 -🖖🏼 -🖖🏽 -🖖🏾 -🖖🏿 -🖖 -👋🏻 -👋🏼 -👋🏽 -👋🏾 -👋🏿 -👋 -🤙🏻 -🤙🏼 -🤙🏽 -🤙🏾 -🤙🏿 -🤙 -🫲🏻 -🫲🏼 -🫲🏽 -🫲🏾 -🫲🏿 -🫲 -🫱🏻 -🫱🏼 -🫱🏽 -🫱🏾 -🫱🏿 -🫱 -💪🏻 -💪🏼 -💪🏽 -💪🏾 -💪🏿 -💪 -🦾 -🖕🏻 -🖕🏼 -🖕🏽 -🖕🏾 -🖕🏿 -🖕 -✍🏻 -✍🏼 -✍🏽 -✍🏾 -✍🏿 -✍️ -🙏🏻 -🙏🏼 -🙏🏽 -🙏🏾 -🙏🏿 -🙏 -🫵🏻 -🫵🏼 -🫵🏽 -🫵🏾 -🫵🏿 -🫵 -🦶🏻 -🦶🏼 -🦶🏽 -🦶🏾 -🦶🏿 -🦶 -🦵🏻 -🦵🏼 -🦵🏽 -🦵🏾 -🦵🏿 -🦵 -🦿 -💄 -💋 -👄 -🫦 -🦷 -👅 -👂🏻 -👂🏼 -👂🏽 -👂🏾 -👂🏿 -👂 -🦻🏻 -🦻🏼 -🦻🏽 -🦻🏾 -🦻🏿 -🦻 -👃🏻 -👃🏼 -👃🏽 -👃🏾 -👃🏿 -👃 -👣 -👁️ -👀 -🫀 -🫁 -🧠 -🗣️ -👤 -👥 -🫂 -👶🏻 -👶🏼 -👶🏽 -👶🏾 -👶🏿 -👶 -🧒🏻 -🧒🏼 -🧒🏽 -🧒🏾 -🧒🏿 -🧒 -👧🏻 -👧🏼 -👧🏽 -👧🏾 -👧🏿 -👧 -👦🏻 -👦🏼 -👦🏽 -👦🏾 -👦🏿 -👦 -🧑🏻 -🧑🏼 -🧑🏽 -🧑🏾 -🧑🏿 -🧑 -👩🏻 -👩🏼 -👩🏽 -👩🏾 -👩🏿 -👩 -👨🏻 -👨🏼 -👨🏽 -👨🏾 -👨🏿 -👨 -🧑🏻‍🦱 -🧑🏼‍🦱 -🧑🏽‍🦱 -🧑🏾‍🦱 -🧑🏿‍🦱 -🧑‍🦱 -👩🏻‍🦱 -👩🏼‍🦱 -👩🏽‍🦱 -👩🏾‍🦱 -👩🏿‍🦱 -👩‍🦱 -👨🏻‍🦱 -👨🏼‍🦱 -👨🏽‍🦱 -👨🏾‍🦱 -👨🏿‍🦱 -👨‍🦱 -🧑🏻‍🦰 -🧑🏼‍🦰 -🧑🏽‍🦰 -🧑🏾‍🦰 -🧑🏿‍🦰 -🧑‍🦰 -👩🏻‍🦰 -👩🏼‍🦰 -👩🏽‍🦰 -👩🏾‍🦰 -👩🏿‍🦰 -👩‍🦰 -👨🏻‍🦰 -👨🏼‍🦰 -👨🏽‍🦰 -👨🏾‍🦰 -👨🏿‍🦰 -👨‍🦰 -👱🏻 -👱🏼 -👱🏽 -👱🏾 -👱🏿 -👱 -👱🏻‍♀️ -👱🏼‍♀️ -👱🏽‍♀️ -👱🏾‍♀️ -👱🏿‍♀️ -👱‍♀️ -👱🏻‍♂️ -👱🏼‍♂️ -👱🏽‍♂️ -👱🏾‍♂️ -👱🏿‍♂️ -👱‍♂️ -🧑🏻‍🦳 -🧑🏼‍🦳 -🧑🏽‍🦳 -🧑🏾‍🦳 -🧑🏿‍🦳 -🧑‍🦳 -👩🏻‍🦳 -👩🏼‍🦳 -👩🏽‍🦳 -👩🏾‍🦳 -👩🏿‍🦳 -👩‍🦳 -👨🏻‍🦳 -👨🏼‍🦳 -👨🏽‍🦳 -👨🏾‍🦳 -👨🏿‍🦳 -👨‍🦳 -🧑🏻‍🦲 -🧑🏼‍🦲 -🧑🏽‍🦲 -🧑🏾‍🦲 -🧑🏿‍🦲 -🧑‍🦲 -👩🏻‍🦲 -👩🏼‍🦲 -👩🏽‍🦲 -👩🏾‍🦲 -👩🏿‍🦲 -👩‍🦲 -👨🏻‍🦲 -👨🏼‍🦲 -👨🏽‍🦲 -👨🏾‍🦲 -👨🏿‍🦲 -👨‍🦲 -🧔🏻 -🧔🏼 -🧔🏽 -🧔🏾 -🧔🏿 -🧔 -🧔🏻‍♀️ -🧔🏼‍♀️ -🧔🏽‍♀️ -🧔🏾‍♀️ -🧔🏿‍♀️ -🧔‍♀️ -🧔🏻‍♂️ -🧔🏼‍♂️ -🧔🏽‍♂️ -🧔🏾‍♂️ -🧔🏿‍♂️ -🧔‍♂️ -🧓🏻 -🧓🏼 -🧓🏽 -🧓🏾 -🧓🏿 -🧓 -👵🏻 -👵🏼 -👵🏽 -👵🏾 -👵🏿 -👵 -👴🏻 -👴🏼 -👴🏽 -👴🏾 -👴🏿 -👴 -👲🏻 -👲🏼 -👲🏽 -👲🏾 -👲🏿 -👲 -👳🏻 -👳🏼 -👳🏽 -👳🏾 -👳🏿 -👳 -👳🏻‍♀️ -👳🏼‍♀️ -👳🏽‍♀️ -👳🏾‍♀️ -👳🏿‍♀️ -👳‍♀️ -👳🏻‍♂️ -👳🏼‍♂️ -👳🏽‍♂️ -👳🏾‍♂️ -👳🏿‍♂️ -👳‍♂️ -🧕🏻 -🧕🏼 -🧕🏽 -🧕🏾 -🧕🏿 -🧕 -👮🏻 -👮🏼 -👮🏽 -👮🏾 -👮🏿 -👮 -👮🏻‍♀️ -👮🏼‍♀️ -👮🏽‍♀️ -👮🏾‍♀️ -👮🏿‍♀️ -👮‍♀️ -👮🏻‍♂️ -👮🏼‍♂️ -👮🏽‍♂️ -👮🏾‍♂️ -👮🏿‍♂️ -👮‍♂️ -👷🏻 -👷🏼 -👷🏽 -👷🏾 -👷🏿 -👷 -👷🏻‍♀️ -👷🏼‍♀️ -👷🏽‍♀️ -👷🏾‍♀️ -👷🏿‍♀️ -👷‍♀️ -👷🏻‍♂️ -👷🏼‍♂️ -👷🏽‍♂️ -👷🏾‍♂️ -👷🏿‍♂️ -👷‍♂️ -💂🏻 -💂🏼 -💂🏽 -💂🏾 -💂🏿 -💂 -💂🏻‍♀️ -💂🏼‍♀️ -💂🏽‍♀️ -💂🏾‍♀️ -💂🏿‍♀️ -💂‍♀️ -💂🏻‍♂️ -💂🏼‍♂️ -💂🏽‍♂️ -💂🏾‍♂️ -💂🏿‍♂️ -💂‍♂️ -🕵🏻 -🕵🏼 -🕵🏽 -🕵🏾 -🕵🏿 -🕵️ -🕵🏻‍♀️ -🕵🏼‍♀️ -🕵🏽‍♀️ -🕵🏾‍♀️ -🕵🏿‍♀️ -🕵️‍♀️ -🕵🏻‍♂️ -🕵🏼‍♂️ -🕵🏽‍♂️ -🕵🏾‍♂️ -🕵🏿‍♂️ -🕵️‍♂️ -🧑🏻‍⚕️ -🧑🏼‍⚕️ -🧑🏽‍⚕️ -🧑🏾‍⚕️ -🧑🏿‍⚕️ -🧑‍⚕️ -👩🏻‍⚕️ -👩🏼‍⚕️ -👩🏽‍⚕️ -👩🏾‍⚕️ -👩🏿‍⚕️ -👩‍⚕️ -👨🏻‍⚕️ -👨🏼‍⚕️ -👨🏽‍⚕️ -👨🏾‍⚕️ -👨🏿‍⚕️ -👨‍⚕️ -🧑🏻‍🌾 -🧑🏼‍🌾 -🧑🏽‍🌾 -🧑🏾‍🌾 -🧑🏿‍🌾 -🧑‍🌾 -👩🏻‍🌾 -👩🏼‍🌾 -👩🏽‍🌾 -👩🏾‍🌾 -👩🏿‍🌾 -👩‍🌾 -👨🏻‍🌾 -👨🏼‍🌾 -👨🏽‍🌾 -👨🏾‍🌾 -👨🏿‍🌾 -👨‍🌾 -🧑🏻‍🍳 -🧑🏼‍🍳 -🧑🏽‍🍳 -🧑🏾‍🍳 -🧑🏿‍🍳 -🧑‍🍳 -👩🏻‍🍳 -👩🏼‍🍳 -👩🏽‍🍳 -👩🏾‍🍳 -👩🏿‍🍳 -👩‍🍳 -👨🏻‍🍳 -👨🏼‍🍳 -👨🏽‍🍳 -👨🏾‍🍳 -👨🏿‍🍳 -👨‍🍳 -🧑🏻‍🎓 -🧑🏼‍🎓 -🧑🏽‍🎓 -🧑🏾‍🎓 -🧑🏿‍🎓 -🧑‍🎓 -👩🏻‍🎓 -👩🏼‍🎓 -👩🏽‍🎓 -👩🏾‍🎓 -👩🏿‍🎓 -👩‍🎓 -👨🏻‍🎓 -👨🏼‍🎓 -👨🏽‍🎓 -👨🏾‍🎓 -👨🏿‍🎓 -👨‍🎓 -🧑🏻‍🎤 -🧑🏼‍🎤 -🧑🏽‍🎤 -🧑🏾‍🎤 -🧑🏿‍🎤 -🧑‍🎤 -👩🏻‍🎤 -👩🏼‍🎤 -👩🏽‍🎤 -👩🏾‍🎤 -👩🏿‍🎤 -👩‍🎤 -👨🏻‍🎤 -👨🏼‍🎤 -👨🏽‍🎤 -👨🏾‍🎤 -👨🏿‍🎤 -👨‍🎤 -🧑🏻‍🏫 -🧑🏼‍🏫 -🧑🏽‍🏫 -🧑🏾‍🏫 -🧑🏿‍🏫 -🧑‍🏫 -👩🏻‍🏫 -👩🏼‍🏫 -👩🏽‍🏫 -👩🏾‍🏫 -👩🏿‍🏫 -👩‍🏫 -👨🏻‍🏫 -👨🏼‍🏫 -👨🏽‍🏫 -👨🏾‍🏫 -👨🏿‍🏫 -👨‍🏫 -🧑🏻‍🏭 -🧑🏼‍🏭 -🧑🏽‍🏭 -🧑🏾‍🏭 -🧑🏿‍🏭 -🧑‍🏭 -👩🏻‍🏭 -👩🏼‍🏭 -👩🏽‍🏭 -👩🏾‍🏭 -👩🏿‍🏭 -👩‍🏭 -👨🏻‍🏭 -👨🏼‍🏭 -👨🏽‍🏭 -👨🏾‍🏭 -👨🏿‍🏭 -👨‍🏭 -🧑🏻‍💻 -🧑🏼‍💻 -🧑🏽‍💻 -🧑🏾‍💻 -🧑🏿‍💻 -🧑‍💻 -👩🏻‍💻 -👩🏼‍💻 -👩🏽‍💻 -👩🏾‍💻 -👩🏿‍💻 -👩‍💻 -👨🏻‍💻 -👨🏼‍💻 -👨🏽‍💻 -👨🏾‍💻 -👨🏿‍💻 -👨‍💻 -🧑🏻‍💼 -🧑🏼‍💼 -🧑🏽‍💼 -🧑🏾‍💼 -🧑🏿‍💼 -🧑‍💼 -👩🏻‍💼 -👩🏼‍💼 -👩🏽‍💼 -👩🏾‍💼 -👩🏿‍💼 -👩‍💼 -👨🏻‍💼 -👨🏼‍💼 -👨🏽‍💼 -👨🏾‍💼 -👨🏿‍💼 -👨‍💼 -🧑🏻‍🔧 -🧑🏼‍🔧 -🧑🏽‍🔧 -🧑🏾‍🔧 -🧑🏿‍🔧 -🧑‍🔧 -👩🏻‍🔧 -👩🏼‍🔧 -👩🏽‍🔧 -👩🏾‍🔧 -👩🏿‍🔧 -👩‍🔧 -👨🏻‍🔧 -👨🏼‍🔧 -👨🏽‍🔧 -👨🏾‍🔧 -👨🏿‍🔧 -👨‍🔧 -🧑🏻‍🔬 -🧑🏼‍🔬 -🧑🏽‍🔬 -🧑🏾‍🔬 -🧑🏿‍🔬 -🧑‍🔬 -👩🏻‍🔬 -👩🏼‍🔬 -👩🏽‍🔬 -👩🏾‍🔬 -👩🏿‍🔬 -👩‍🔬 -👨🏻‍🔬 -👨🏼‍🔬 -👨🏽‍🔬 -👨🏾‍🔬 -👨🏿‍🔬 -👨‍🔬 -🧑🏻‍🎨 -🧑🏼‍🎨 -🧑🏽‍🎨 -🧑🏾‍🎨 -🧑🏿‍🎨 -🧑‍🎨 -👩🏻‍🎨 -👩🏼‍🎨 -👩🏽‍🎨 -👩🏾‍🎨 -👩🏿‍🎨 -👩‍🎨 -👨🏻‍🎨 -👨🏼‍🎨 -👨🏽‍🎨 -👨🏾‍🎨 -👨🏿‍🎨 -👨‍🎨 -🧑🏻‍🚒 -🧑🏼‍🚒 -🧑🏽‍🚒 -🧑🏾‍🚒 -🧑🏿‍🚒 -🧑‍🚒 -👩🏻‍🚒 -👩🏼‍🚒 -👩🏽‍🚒 -👩🏾‍🚒 -👩🏿‍🚒 -👩‍🚒 -👨🏻‍🚒 -👨🏼‍🚒 -👨🏽‍🚒 -👨🏾‍🚒 -👨🏿‍🚒 -👨‍🚒 -🧑🏻‍✈️ -🧑🏼‍✈️ -🧑🏽‍✈️ -🧑🏾‍✈️ -🧑🏿‍✈️ -🧑‍✈️ -👩🏻‍✈️ -👩🏼‍✈️ -👩🏽‍✈️ -👩🏾‍✈️ -👩🏿‍✈️ -👩‍✈️ -👨🏻‍✈️ -👨🏼‍✈️ -👨🏽‍✈️ -👨🏾‍✈️ -👨🏿‍✈️ -👨‍✈️ -🧑🏻‍🚀 -🧑🏼‍🚀 -🧑🏽‍🚀 -🧑🏾‍🚀 -🧑🏿‍🚀 -🧑‍🚀 -👩🏻‍🚀 -👩🏼‍🚀 -👩🏽‍🚀 -👩🏾‍🚀 -👩🏿‍🚀 -👩‍🚀 -👨🏻‍🚀 -👨🏼‍🚀 -👨🏽‍🚀 -👨🏾‍🚀 -👨🏿‍🚀 -👨‍🚀 -🧑🏻‍⚖️ -🧑🏼‍⚖️ -🧑🏽‍⚖️ -🧑🏾‍⚖️ -🧑🏿‍⚖️ -🧑‍⚖️ -👩🏻‍⚖️ -👩🏼‍⚖️ -👩🏽‍⚖️ -👩🏾‍⚖️ -👩🏿‍⚖️ -👩‍⚖️ -👨🏻‍⚖️ -👨🏼‍⚖️ -👨🏽‍⚖️ -👨🏾‍⚖️ -👨🏿‍⚖️ -👨‍⚖️ -👰🏻 -👰🏼 -👰🏽 -👰🏾 -👰🏿 -👰 -👰🏻‍♀️ -👰🏼‍♀️ -👰🏽‍♀️ -👰🏾‍♀️ -👰🏿‍♀️ -👰‍♀️ -👰🏻‍♂️ -👰🏼‍♂️ -👰🏽‍♂️ -👰🏾‍♂️ -👰🏿‍♂️ -👰‍♂️ -🤵🏻 -🤵🏼 -🤵🏽 -🤵🏾 -🤵🏿 -🤵 -🤵🏻‍♀️ -🤵🏼‍♀️ -🤵🏽‍♀️ -🤵🏾‍♀️ -🤵🏿‍♀️ -🤵‍♀️ -🤵🏻‍♂️ -🤵🏼‍♂️ -🤵🏽‍♂️ -🤵🏾‍♂️ -🤵🏿‍♂️ -🤵‍♂️ -🫅🏻 -🫅🏼 -🫅🏽 -🫅🏾 -🫅🏿 -🫅 -👸🏻 -👸🏼 -👸🏽 -👸🏾 -👸🏿 -👸 -🤴🏻 -🤴🏼 -🤴🏽 -🤴🏾 -🤴🏿 -🤴 -🦸🏻 -🦸🏼 -🦸🏽 -🦸🏾 -🦸🏿 -🦸 -🦸🏻‍♀️ -🦸🏼‍♀️ -🦸🏽‍♀️ -🦸🏾‍♀️ -🦸🏿‍♀️ -🦸‍♀️ -🦸🏻‍♂️ -🦸🏼‍♂️ -🦸🏽‍♂️ -🦸🏾‍♂️ -🦸🏿‍♂️ -🦸‍♂️ -🦹🏻 -🦹🏼 -🦹🏽 -🦹🏾 -🦹🏿 -🦹 -🦹🏻‍♀️ -🦹🏼‍♀️ -🦹🏽‍♀️ -🦹🏾‍♀️ -🦹🏿‍♀️ -🦹‍♀️ -🦹🏻‍♂️ -🦹🏼‍♂️ -🦹🏽‍♂️ -🦹🏾‍♂️ -🦹🏿‍♂️ -🦹‍♂️ -🥷🏻 -🥷🏼 -🥷🏽 -🥷🏾 -🥷🏿 -🥷 -🧑🏻‍🎄 -🧑🏼‍🎄 -🧑🏽‍🎄 -🧑🏾‍🎄 -🧑🏿‍🎄 -🧑‍🎄 -🤶🏻 -🤶🏼 -🤶🏽 -🤶🏾 -🤶🏿 -🤶 -🎅🏻 -🎅🏼 -🎅🏽 -🎅🏾 -🎅🏿 -🎅 -🧙🏻 -🧙🏼 -🧙🏽 -🧙🏾 -🧙🏿 -🧙 -🧙🏻‍♀️ -🧙🏼‍♀️ -🧙🏽‍♀️ -🧙🏾‍♀️ -🧙🏿‍♀️ -🧙‍♀️ -🧙🏻‍♂️ -🧙🏼‍♂️ -🧙🏽‍♂️ -🧙🏾‍♂️ -🧙🏿‍♂️ -🧙‍♂️ -🧝🏻 -🧝🏼 -🧝🏽 -🧝🏾 -🧝🏿 -🧝 -🧝🏻‍♀️ -🧝🏼‍♀️ -🧝🏽‍♀️ -🧝🏾‍♀️ -🧝🏿‍♀️ -🧝‍♀️ -🧝🏻‍♂️ -🧝🏼‍♂️ -🧝🏽‍♂️ -🧝🏾‍♂️ -🧝🏿‍♂️ -🧝‍♂️ -🧌 -🧛🏻 -🧛🏼 -🧛🏽 -🧛🏾 -🧛🏿 -🧛 -🧛🏻‍♀️ -🧛🏼‍♀️ -🧛🏽‍♀️ -🧛🏾‍♀️ -🧛🏿‍♀️ -🧛‍♀️ -🧛🏻‍♂️ -🧛🏼‍♂️ -🧛🏽‍♂️ -🧛🏾‍♂️ -🧛🏿‍♂️ -🧛‍♂️ -🧟 -🧟‍♀️ -🧟‍♂️ -🧞 -🧞‍♀️ -🧞‍♂️ -🧜🏻 -🧜🏼 -🧜🏽 -🧜🏾 -🧜🏿 -🧜 -🧜🏻‍♀️ -🧜🏼‍♀️ -🧜🏽‍♀️ -🧜🏾‍♀️ -🧜🏿‍♀️ -🧜‍♀️ -🧜🏻‍♂️ -🧜🏼‍♂️ -🧜🏽‍♂️ -🧜🏾‍♂️ -🧜🏿‍♂️ -🧜‍♂️ -🧚🏻 -🧚🏼 -🧚🏽 -🧚🏾 -🧚🏿 -🧚 -🧚🏻‍♀️ -🧚🏼‍♀️ -🧚🏽‍♀️ -🧚🏾‍♀️ -🧚🏿‍♀️ -🧚‍♀️ -🧚🏻‍♂️ -🧚🏼‍♂️ -🧚🏽‍♂️ -🧚🏾‍♂️ -🧚🏿‍♂️ -🧚‍♂️ -👼🏻 -👼🏼 -👼🏽 -👼🏾 -👼🏿 -👼 -🫄🏻 -🫄🏼 -🫄🏽 -🫄🏾 -🫄🏿 -🫄 -🤰🏻 -🤰🏼 -🤰🏽 -🤰🏾 -🤰🏿 -🤰 -🫃🏻 -🫃🏼 -🫃🏽 -🫃🏾 -🫃🏿 -🫃 -🤱🏻 -🤱🏼 -🤱🏽 -🤱🏾 -🤱🏿 -🤱 -🧑🏻‍🍼 -🧑🏼‍🍼 -🧑🏽‍🍼 -🧑🏾‍🍼 -🧑🏿‍🍼 -🧑‍🍼 -👩🏻‍🍼 -👩🏼‍🍼 -👩🏽‍🍼 -👩🏾‍🍼 -👩🏿‍🍼 -👩‍🍼 -👨🏻‍🍼 -👨🏼‍🍼 -👨🏽‍🍼 -👨🏾‍🍼 -👨🏿‍🍼 -👨‍🍼 -🙇🏻 -🙇🏼 -🙇🏽 -🙇🏾 -🙇🏿 -🙇 -🙇🏻‍♀️ -🙇🏼‍♀️ -🙇🏽‍♀️ -🙇🏾‍♀️ -🙇🏿‍♀️ -🙇‍♀️ -🙇🏻‍♂️ -🙇🏼‍♂️ -🙇🏽‍♂️ -🙇🏾‍♂️ -🙇🏿‍♂️ -🙇‍♂️ -💁🏻 -💁🏼 -💁🏽 -💁🏾 -💁🏿 -💁 -💁🏻‍♀️ -💁🏼‍♀️ -💁🏽‍♀️ -💁🏾‍♀️ -💁🏿‍♀️ -💁‍♀️ -💁🏻‍♂️ -💁🏼‍♂️ -💁🏽‍♂️ -💁🏾‍♂️ -💁🏿‍♂️ -💁‍♂️ -🙅🏻 -🙅🏼 -🙅🏽 -🙅🏾 -🙅🏿 -🙅 -🙅🏻‍♀️ -🙅🏼‍♀️ -🙅🏽‍♀️ -🙅🏾‍♀️ -🙅🏿‍♀️ -🙅‍♀️ -🙅🏻‍♂️ -🙅🏼‍♂️ -🙅🏽‍♂️ -🙅🏾‍♂️ -🙅🏿‍♂️ -🙅‍♂️ -🙆🏻 -🙆🏼 -🙆🏽 -🙆🏾 -🙆🏿 -🙆 -🙆🏻‍♀️ -🙆🏼‍♀️ -🙆🏽‍♀️ -🙆🏾‍♀️ -🙆🏿‍♀️ -🙆‍♀️ -🙆🏻‍♂️ -🙆🏼‍♂️ -🙆🏽‍♂️ -🙆🏾‍♂️ -🙆🏿‍♂️ -🙆‍♂️ -🙋🏻 -🙋🏼 -🙋🏽 -🙋🏾 -🙋🏿 -🙋 -🙋🏻‍♀️ -🙋🏼‍♀️ -🙋🏽‍♀️ -🙋🏾‍♀️ -🙋🏿‍♀️ -🙋‍♀️ -🙋🏻‍♂️ -🙋🏼‍♂️ -🙋🏽‍♂️ -🙋🏾‍♂️ -🙋🏿‍♂️ -🙋‍♂️ -🧏🏻 -🧏🏼 -🧏🏽 -🧏🏾 -🧏🏿 -🧏 -🧏🏻‍♀️ -🧏🏼‍♀️ -🧏🏽‍♀️ -🧏🏾‍♀️ -🧏🏿‍♀️ -🧏‍♀️ -🧏🏻‍♂️ -🧏🏼‍♂️ -🧏🏽‍♂️ -🧏🏾‍♂️ -🧏🏿‍♂️ -🧏‍♂️ -🤦🏻 -🤦🏼 -🤦🏽 -🤦🏾 -🤦🏿 -🤦 -🤦🏻‍♀️ -🤦🏼‍♀️ -🤦🏽‍♀️ -🤦🏾‍♀️ -🤦🏿‍♀️ -🤦‍♀️ -🤦🏻‍♂️ -🤦🏼‍♂️ -🤦🏽‍♂️ -🤦🏾‍♂️ -🤦🏿‍♂️ -🤦‍♂️ -🤷🏻 -🤷🏼 -🤷🏽 -🤷🏾 -🤷🏿 -🤷 -🤷🏻‍♀️ -🤷🏼‍♀️ -🤷🏽‍♀️ -🤷🏾‍♀️ -🤷🏿‍♀️ -🤷‍♀️ -🤷🏻‍♂️ -🤷🏼‍♂️ -🤷🏽‍♂️ -🤷🏾‍♂️ -🤷🏿‍♂️ -🤷‍♂️ -🙎🏻 -🙎🏼 -🙎🏽 -🙎🏾 -🙎🏿 -🙎 -🙎🏻‍♀️ -🙎🏼‍♀️ -🙎🏽‍♀️ -🙎🏾‍♀️ -🙎🏿‍♀️ -🙎‍♀️ -🙎🏻‍♂️ -🙎🏼‍♂️ -🙎🏽‍♂️ -🙎🏾‍♂️ -🙎🏿‍♂️ -🙎‍♂️ -🙍🏻 -🙍🏼 -🙍🏽 -🙍🏾 -🙍🏿 -🙍 -🙍🏻‍♀️ -🙍🏼‍♀️ -🙍🏽‍♀️ -🙍🏾‍♀️ -🙍🏿‍♀️ -🙍‍♀️ -🙍🏻‍♂️ -🙍🏼‍♂️ -🙍🏽‍♂️ -🙍🏾‍♂️ -🙍🏿‍♂️ -🙍‍♂️ -💇🏻 -💇🏼 -💇🏽 -💇🏾 -💇🏿 -💇 -💇🏻‍♀️ -💇🏼‍♀️ -💇🏽‍♀️ -💇🏾‍♀️ -💇🏿‍♀️ -💇‍♀️ -💇🏻‍♂️ -💇🏼‍♂️ -💇🏽‍♂️ -💇🏾‍♂️ -💇🏿‍♂️ -💇‍♂️ -💆🏻 -💆🏼 -💆🏽 -💆🏾 -💆🏿 -💆 -💆🏻‍♀️ -💆🏼‍♀️ -💆🏽‍♀️ -💆🏾‍♀️ -💆🏿‍♀️ -💆‍♀️ -💆🏻‍♂️ -💆🏼‍♂️ -💆🏽‍♂️ -💆🏾‍♂️ -💆🏿‍♂️ -💆‍♂️ -🧖🏻 -🧖🏼 -🧖🏽 -🧖🏾 -🧖🏿 -🧖 -🧖🏻‍♀️ -🧖🏼‍♀️ -🧖🏽‍♀️ -🧖🏾‍♀️ -🧖🏿‍♀️ -🧖‍♀️ -🧖🏻‍♂️ -🧖🏼‍♂️ -🧖🏽‍♂️ -🧖🏾‍♂️ -🧖🏿‍♂️ -🧖‍♂️ -💅🏻 -💅🏼 -💅🏽 -💅🏾 -💅🏿 -💅 -🤳🏻 -🤳🏼 -🤳🏽 -🤳🏾 -🤳🏿 -🤳 -💃🏻 -💃🏼 -💃🏽 -💃🏾 -💃🏿 -💃 -🕺🏻 -🕺🏼 -🕺🏽 -🕺🏿 -🕺🏾 -🕺 -👯 -👯‍♀️ -👯‍♂️ -🕴🏻 -🕴🏼 -🕴🏽 -🕴🏾 -🕴🏿 -🕴️ -🧑🏻‍🦽 -🧑🏼‍🦽 -🧑🏽‍🦽 -🧑🏾‍🦽 -🧑🏿‍🦽 -🧑‍🦽 -👩🏻‍🦽 -👩🏼‍🦽 -👩🏽‍🦽 -👩🏾‍🦽 -👩🏿‍🦽 -👩‍🦽 -👨🏻‍🦽 -👨🏼‍🦽 -👨🏽‍🦽 -👨🏾‍🦽 -👨🏿‍🦽 -👨‍🦽 -🧑🏻‍🦽‍➡️ -🧑🏼‍🦽‍➡️ -🧑🏽‍🦽‍➡️ -🧑🏾‍🦽‍➡️ -🧑🏿‍🦽‍➡️ -🧑‍🦽‍➡️ -👨🏼‍🦽‍➡️ -👨🏻‍🦽‍➡️ -👨🏽‍🦽‍➡️ -👨🏾‍🦽‍➡️ -👨🏿‍🦽‍➡️ -👨‍🦽‍➡️ -👩🏻‍🦽‍➡️ -👩🏼‍🦽‍➡️ -👩🏽‍🦽‍➡️ -👩🏾‍🦽‍➡️ -👩🏿‍🦽‍➡️ -👩‍🦽‍➡️ -🧑🏻‍🦼 -🧑🏼‍🦼 -🧑🏽‍🦼 -🧑🏾‍🦼 -🧑🏿‍🦼 -🧑‍🦼 -👩🏻‍🦼 -👩🏼‍🦼 -👩🏽‍🦼 -👩🏾‍🦼 -👩🏿‍🦼 -👩‍🦼 -👨🏻‍🦼 -👨🏼‍🦼 -👨🏽‍🦼 -👨🏾‍🦼 -👨🏿‍🦼 -👨‍🦼 -🧑🏻‍🦼‍➡️ -🧑🏼‍🦼‍➡️ -🧑🏽‍🦼‍➡️ -🧑🏾‍🦼‍➡️ -🧑🏿‍🦼‍➡️ -🧑‍🦼‍➡️ -👨🏻‍🦼‍➡️ -👨🏼‍🦼‍➡️ -👨🏽‍🦼‍➡️ -👨🏾‍🦼‍➡️ -👨🏿‍🦼‍➡️ -👨‍🦼‍➡️ -👩🏻‍🦼‍➡️ -👩🏼‍🦼‍➡️ -👩🏽‍🦼‍➡️ -👩🏾‍🦼‍➡️ -👩🏿‍🦼‍➡️ -👩‍🦼‍➡️ -🚶🏻 -🚶🏼 -🚶🏽 -🚶🏾 -🚶🏿 -🚶 -🚶🏻‍♀️ -🚶🏼‍♀️ -🚶🏽‍♀️ -🚶🏾‍♀️ -🚶🏿‍♀️ -🚶‍♀️ -🚶🏻‍♂️ -🚶🏼‍♂️ -🚶🏽‍♂️ -🚶🏾‍♂️ -🚶🏿‍♂️ -🚶‍♂️ -🚶🏻‍➡️ -🚶🏼‍➡️ -🚶🏽‍➡️ -🚶🏾‍➡️ -🚶🏿‍➡️ -🚶‍➡️ -🚶🏻‍♀️‍➡️ -🚶🏼‍♀️‍➡️ -🚶🏽‍♀️‍➡️ -🚶🏾‍♀️‍➡️ -🚶🏿‍♀️‍➡️ -🚶‍♀️‍➡️ -🚶🏻‍♂️‍➡️ -🚶🏼‍♂️‍➡️ -🚶🏽‍♂️‍➡️ -🚶🏾‍♂️‍➡️ -🚶🏿‍♂️‍➡️ -🚶‍♂️‍➡️ -🧑🏻‍🦯 -🧑🏼‍🦯 -🧑🏽‍🦯 -🧑🏾‍🦯 -🧑🏿‍🦯 -🧑‍🦯 -👩🏻‍🦯 -👩🏼‍🦯 -👩🏽‍🦯 -👩🏾‍🦯 -👩🏿‍🦯 -👩‍🦯 -👨🏻‍🦯 -👨🏼‍🦯 -👨🏽‍🦯 -👨🏾‍🦯 -👨🏿‍🦯 -👨‍🦯 -🧑🏻‍🦯‍➡️ -🧑🏼‍🦯‍➡️ -🧑🏽‍🦯‍➡️ -🧑🏾‍🦯‍➡️ -🧑🏿‍🦯‍➡️ -🧑‍🦯‍➡️ -👨🏻‍🦯‍➡️ -👨🏼‍🦯‍➡️ -👨🏽‍🦯‍➡️ -👨🏾‍🦯‍➡️ -👨🏿‍🦯‍➡️ -👨‍🦯‍➡️ -👩🏻‍🦯‍➡️ -👩🏼‍🦯‍➡️ -👩🏽‍🦯‍➡️ -👩🏾‍🦯‍➡️ -👩🏿‍🦯‍➡️ -👩‍🦯‍➡️ -🧎🏻 -🧎🏼 -🧎🏽 -🧎🏾 -🧎🏿 -🧎 -🧎🏻‍♀️ -🧎🏼‍♀️ -🧎🏽‍♀️ -🧎🏾‍♀️ -🧎🏿‍♀️ -🧎‍♀️ -🧎🏻‍♂️ -🧎🏼‍♂️ -🧎🏽‍♂️ -🧎🏾‍♂️ -🧎🏿‍♂️ -🧎‍♂️ -🧎🏻‍➡️ -🧎🏼‍➡️ -🧎🏽‍➡️ -🧎🏾‍➡️ -🧎🏿‍➡️ -🧎‍➡️ -🧎🏻‍♀️‍➡️ -🧎🏼‍♀️‍➡️ -🧎🏽‍♀️‍➡️ -🧎🏾‍♀️‍➡️ -🧎🏿‍♀️‍➡️ -🧎‍♀️‍➡️ -🧎🏻‍♂️‍➡️ -🧎🏼‍♂️‍➡️ -🧎🏽‍♂️‍➡️ -🧎🏾‍♂️‍➡️ -🧎🏿‍♂️‍➡️ -🧎‍♂️‍➡️ -🏃🏻 -🏃🏼 -🏃🏽 -🏃🏾 -🏃🏿 -🏃 -🏃🏻‍♀️ -🏃🏼‍♀️ -🏃🏽‍♀️ -🏃🏾‍♀️ -🏃🏿‍♀️ -🏃‍♀️ -🏃🏻‍♂️ -🏃🏼‍♂️ -🏃🏽‍♂️ -🏃🏾‍♂️ -🏃🏿‍♂️ -🏃‍♂️ -🏃🏻‍➡️ -🏃🏼‍➡️ -🏃🏽‍➡️ -🏃🏾‍➡️ -🏃🏿‍➡️ -🏃‍➡️ -🏃🏻‍♀️‍➡️ -🏃🏼‍♀️‍➡️ -🏃🏽‍♀️‍➡️ -🏃🏾‍♀️‍➡️ -🏃🏿‍♀️‍➡️ -🏃‍♀️‍➡️ -🏃🏻‍♂️‍➡️ -🏃🏼‍♂️‍➡️ -🏃🏽‍♂️‍➡️ -🏃🏾‍♂️‍➡️ -🏃🏿‍♂️‍➡️ -🏃‍♂️‍➡️ -🧍🏻 -🧍🏼 -🧍🏽 -🧍🏾 -🧍🏿 -🧍 -🧍🏻‍♀️ -🧍🏼‍♀️ -🧍🏽‍♀️ -🧍🏾‍♀️ -🧍🏿‍♀️ -🧍‍♀️ -🧍🏻‍♂️ -🧍🏼‍♂️ -🧍🏽‍♂️ -🧍🏾‍♂️ -🧍🏿‍♂️ -🧍‍♂️ -🧑🏻‍🤝‍🧑🏻 -🧑🏻‍🤝‍🧑🏼 -🧑🏻‍🤝‍🧑🏽 -🧑🏻‍🤝‍🧑🏾 -🧑🏻‍🤝‍🧑🏿 -🧑🏼‍🤝‍🧑🏻 -🧑🏼‍🤝‍🧑🏼 -🧑🏼‍🤝‍🧑🏽 -🧑🏼‍🤝‍🧑🏾 -🧑🏼‍🤝‍🧑🏿 -🧑🏽‍🤝‍🧑🏻 -🧑🏽‍🤝‍🧑🏼 -🧑🏽‍🤝‍🧑🏽 -🧑🏽‍🤝‍🧑🏾 -🧑🏽‍🤝‍🧑🏿 -🧑🏾‍🤝‍🧑🏻 -🧑🏾‍🤝‍🧑🏼 -🧑🏾‍🤝‍🧑🏽 -🧑🏾‍🤝‍🧑🏾 -🧑🏾‍🤝‍🧑🏿 -🧑🏿‍🤝‍🧑🏻 -🧑🏿‍🤝‍🧑🏼 -🧑🏿‍🤝‍🧑🏽 -🧑🏿‍🤝‍🧑🏾 -🧑🏿‍🤝‍🧑🏿 -🧑‍🤝‍🧑 -👫🏻 -👩🏻‍🤝‍👨🏼 -👩🏻‍🤝‍👨🏽 -👩🏻‍🤝‍👨🏾 -👩🏻‍🤝‍👨🏿 -👩🏼‍🤝‍👨🏻 -👫🏼 -👩🏼‍🤝‍👨🏽 -👩🏼‍🤝‍👨🏾 -👩🏼‍🤝‍👨🏿 -👩🏽‍🤝‍👨🏻 -👩🏽‍🤝‍👨🏼 -👫🏽 -👩🏽‍🤝‍👨🏾 -👩🏽‍🤝‍👨🏿 -👩🏾‍🤝‍👨🏻 -👩🏾‍🤝‍👨🏼 -👩🏾‍🤝‍👨🏽 -👫🏾 -👩🏾‍🤝‍👨🏿 -👩🏿‍🤝‍👨🏻 -👩🏿‍🤝‍👨🏼 -👩🏿‍🤝‍👨🏽 -👩🏿‍🤝‍👨🏾 -👫🏿 -👫 -👭🏻 -👩🏻‍🤝‍👩🏼 -👩🏻‍🤝‍👩🏽 -👩🏻‍🤝‍👩🏾 -👩🏻‍🤝‍👩🏿 -👩🏼‍🤝‍👩🏻 -👭🏼 -👩🏼‍🤝‍👩🏽 -👩🏼‍🤝‍👩🏾 -👩🏼‍🤝‍👩🏿 -👩🏽‍🤝‍👩🏻 -👩🏽‍🤝‍👩🏼 -👭🏽 -👩🏽‍🤝‍👩🏾 -👩🏽‍🤝‍👩🏿 -👩🏾‍🤝‍👩🏻 -👩🏾‍🤝‍👩🏼 -👩🏾‍🤝‍👩🏽 -👭🏾 -👩🏾‍🤝‍👩🏿 -👩🏿‍🤝‍👩🏻 -👩🏿‍🤝‍👩🏼 -👩🏿‍🤝‍👩🏽 -👩🏿‍🤝‍👩🏾 -👭🏿 -👭 -👬🏻 -👨🏻‍🤝‍👨🏼 -👨🏻‍🤝‍👨🏽 -👨🏻‍🤝‍👨🏾 -👨🏻‍🤝‍👨🏿 -👨🏼‍🤝‍👨🏻 -👬🏼 -👨🏼‍🤝‍👨🏽 -👨🏼‍🤝‍👨🏾 -👨🏼‍🤝‍👨🏿 -👨🏽‍🤝‍👨🏻 -👨🏽‍🤝‍👨🏼 -👬🏽 -👨🏽‍🤝‍👨🏾 -👨🏽‍🤝‍👨🏿 -👨🏾‍🤝‍👨🏻 -👨🏾‍🤝‍👨🏼 -👨🏾‍🤝‍👨🏽 -👬🏾 -👨🏾‍🤝‍👨🏿 -👨🏿‍🤝‍👨🏻 -👨🏿‍🤝‍👨🏼 -👨🏿‍🤝‍👨🏽 -👨🏿‍🤝‍👨🏾 -👬🏿 -👬 -💑🏻 -🧑🏻‍❤️‍🧑🏼 -🧑🏻‍❤️‍🧑🏽 -🧑🏻‍❤️‍🧑🏾 -🧑🏻‍❤️‍🧑🏿 -🧑🏼‍❤️‍🧑🏻 -💑🏼 -🧑🏼‍❤️‍🧑🏽 -🧑🏼‍❤️‍🧑🏾 -🧑🏼‍❤️‍🧑🏿 -🧑🏽‍❤️‍🧑🏻 -🧑🏽‍❤️‍🧑🏼 -💑🏽 -🧑🏽‍❤️‍🧑🏾 -🧑🏽‍❤️‍🧑🏿 -🧑🏾‍❤️‍🧑🏻 -🧑🏾‍❤️‍🧑🏼 -🧑🏾‍❤️‍🧑🏽 -💑🏾 -🧑🏾‍❤️‍🧑🏿 -🧑🏿‍❤️‍🧑🏻 -🧑🏿‍❤️‍🧑🏼 -🧑🏿‍❤️‍🧑🏽 -🧑🏿‍❤️‍🧑🏾 -💑🏿 -💑 -👩🏻‍❤️‍👨🏻 -👩🏻‍❤️‍👨🏼 -👩🏻‍❤️‍👨🏽 -👩🏻‍❤️‍👨🏾 -👩🏻‍❤️‍👨🏿 -👩🏼‍❤️‍👨🏻 -👩🏼‍❤️‍👨🏼 -👩🏼‍❤️‍👨🏽 -👩🏼‍❤️‍👨🏾 -👩🏼‍❤️‍👨🏿 -👩🏽‍❤️‍👨🏻 -👩🏽‍❤️‍👨🏼 -👩🏽‍❤️‍👨🏽 -👩🏽‍❤️‍👨🏾 -👩🏽‍❤️‍👨🏿 -👩🏾‍❤️‍👨🏻 -👩🏾‍❤️‍👨🏼 -👩🏾‍❤️‍👨🏽 -👩🏾‍❤️‍👨🏾 -👩🏾‍❤️‍👨🏿 -👩🏿‍❤️‍👨🏻 -👩🏿‍❤️‍👨🏼 -👩🏿‍❤️‍👨🏽 -👩🏿‍❤️‍👨🏾 -👩🏿‍❤️‍👨🏿 -👩‍❤️‍👨 -👩🏻‍❤️‍👩🏻 -👩🏻‍❤️‍👩🏼 -👩🏻‍❤️‍👩🏽 -👩🏻‍❤️‍👩🏾 -👩🏻‍❤️‍👩🏿 -👩🏼‍❤️‍👩🏻 -👩🏼‍❤️‍👩🏼 -👩🏼‍❤️‍👩🏽 -👩🏼‍❤️‍👩🏾 -👩🏼‍❤️‍👩🏿 -👩🏽‍❤️‍👩🏻 -👩🏽‍❤️‍👩🏼 -👩🏽‍❤️‍👩🏽 -👩🏽‍❤️‍👩🏾 -👩🏽‍❤️‍👩🏿 -👩🏾‍❤️‍👩🏻 -👩🏾‍❤️‍👩🏼 -👩🏾‍❤️‍👩🏽 -👩🏾‍❤️‍👩🏾 -👩🏾‍❤️‍👩🏿 -👩🏿‍❤️‍👩🏻 -👩🏿‍❤️‍👩🏼 -👩🏿‍❤️‍👩🏽 -👩🏿‍❤️‍👩🏾 -👩🏿‍❤️‍👩🏿 -👩‍❤️‍👩 -👨🏻‍❤️‍👨🏻 -👨🏻‍❤️‍👨🏼 -👨🏻‍❤️‍👨🏽 -👨🏻‍❤️‍👨🏾 -👨🏻‍❤️‍👨🏿 -👨🏼‍❤️‍👨🏻 -👨🏼‍❤️‍👨🏼 -👨🏼‍❤️‍👨🏽 -👨🏼‍❤️‍👨🏾 -👨🏼‍❤️‍👨🏿 -👨🏽‍❤️‍👨🏻 -👨🏽‍❤️‍👨🏼 -👨🏽‍❤️‍👨🏽 -👨🏽‍❤️‍👨🏾 -👨🏽‍❤️‍👨🏿 -👨🏾‍❤️‍👨🏻 -👨🏾‍❤️‍👨🏼 -👨🏾‍❤️‍👨🏽 -👨🏾‍❤️‍👨🏾 -👨🏾‍❤️‍👨🏿 -👨🏿‍❤️‍👨🏻 -👨🏿‍❤️‍👨🏼 -👨🏿‍❤️‍👨🏽 -👨🏿‍❤️‍👨🏾 -👨🏿‍❤️‍👨🏿 -👨‍❤️‍👨 -💏🏻 -🧑🏻‍❤️‍💋‍🧑🏼 -🧑🏻‍❤️‍💋‍🧑🏽 -🧑🏻‍❤️‍💋‍🧑🏾 -🧑🏻‍❤️‍💋‍🧑🏿 -🧑🏼‍❤️‍💋‍🧑🏻 -💏🏼 -🧑🏼‍❤️‍💋‍🧑🏽 -🧑🏼‍❤️‍💋‍🧑🏾 -🧑🏼‍❤️‍💋‍🧑🏿 -🧑🏽‍❤️‍💋‍🧑🏻 -🧑🏽‍❤️‍💋‍🧑🏼 -💏🏽 -🧑🏽‍❤️‍💋‍🧑🏾 -🧑🏽‍❤️‍💋‍🧑🏿 -🧑🏾‍❤️‍💋‍🧑🏻 -🧑🏾‍❤️‍💋‍🧑🏼 -🧑🏾‍❤️‍💋‍🧑🏽 -💏🏾 -🧑🏾‍❤️‍💋‍🧑🏿 -🧑🏿‍❤️‍💋‍🧑🏻 -🧑🏿‍❤️‍💋‍🧑🏼 -🧑🏿‍❤️‍💋‍🧑🏽 -🧑🏿‍❤️‍💋‍🧑🏾 -💏🏿 -💏 -👩🏻‍❤️‍💋‍👨🏻 -👩🏻‍❤️‍💋‍👨🏼 -👩🏻‍❤️‍💋‍👨🏽 -👩🏻‍❤️‍💋‍👨🏾 -👩🏻‍❤️‍💋‍👨🏿 -👩🏼‍❤️‍💋‍👨🏻 -👩🏼‍❤️‍💋‍👨🏼 -👩🏼‍❤️‍💋‍👨🏽 -👩🏼‍❤️‍💋‍👨🏾 -👩🏼‍❤️‍💋‍👨🏿 -👩🏽‍❤️‍💋‍👨🏻 -👩🏽‍❤️‍💋‍👨🏼 -👩🏽‍❤️‍💋‍👨🏽 -👩🏽‍❤️‍💋‍👨🏾 -👩🏽‍❤️‍💋‍👨🏿 -👩🏾‍❤️‍💋‍👨🏻 -👩🏾‍❤️‍💋‍👨🏼 -👩🏾‍❤️‍💋‍👨🏽 -👩🏾‍❤️‍💋‍👨🏾 -👩🏾‍❤️‍💋‍👨🏿 -👩🏿‍❤️‍💋‍👨🏻 -👩🏿‍❤️‍💋‍👨🏼 -👩🏿‍❤️‍💋‍👨🏽 -👩🏿‍❤️‍💋‍👨🏾 -👩🏿‍❤️‍💋‍👨🏿 -👩‍❤️‍💋‍👨 -👩🏻‍❤️‍💋‍👩🏻 -👩🏻‍❤️‍💋‍👩🏼 -👩🏻‍❤️‍💋‍👩🏽 -👩🏻‍❤️‍💋‍👩🏾 -👩🏻‍❤️‍💋‍👩🏿 -👩🏼‍❤️‍💋‍👩🏻 -👩🏼‍❤️‍💋‍👩🏼 -👩🏼‍❤️‍💋‍👩🏽 -👩🏼‍❤️‍💋‍👩🏾 -👩🏼‍❤️‍💋‍👩🏿 -👩🏽‍❤️‍💋‍👩🏻 -👩🏽‍❤️‍💋‍👩🏼 -👩🏽‍❤️‍💋‍👩🏽 -👩🏽‍❤️‍💋‍👩🏾 -👩🏽‍❤️‍💋‍👩🏿 -👩🏾‍❤️‍💋‍👩🏻 -👩🏾‍❤️‍💋‍👩🏼 -👩🏾‍❤️‍💋‍👩🏽 -👩🏾‍❤️‍💋‍👩🏾 -👩🏾‍❤️‍💋‍👩🏿 -👩🏿‍❤️‍💋‍👩🏻 -👩🏿‍❤️‍💋‍👩🏼 -👩🏿‍❤️‍💋‍👩🏽 -👩🏿‍❤️‍💋‍👩🏾 -👩🏿‍❤️‍💋‍👩🏿 -👩‍❤️‍💋‍👩 -👨🏻‍❤️‍💋‍👨🏻 -👨🏻‍❤️‍💋‍👨🏼 -👨🏻‍❤️‍💋‍👨🏽 -👨🏻‍❤️‍💋‍👨🏾 -👨🏻‍❤️‍💋‍👨🏿 -👨🏼‍❤️‍💋‍👨🏻 -👨🏼‍❤️‍💋‍👨🏼 -👨🏼‍❤️‍💋‍👨🏽 -👨🏼‍❤️‍💋‍👨🏾 -👨🏼‍❤️‍💋‍👨🏿 -👨🏽‍❤️‍💋‍👨🏻 -👨🏽‍❤️‍💋‍👨🏼 -👨🏽‍❤️‍💋‍👨🏽 -👨🏽‍❤️‍💋‍👨🏾 -👨🏽‍❤️‍💋‍👨🏿 -👨🏾‍❤️‍💋‍👨🏻 -👨🏾‍❤️‍💋‍👨🏼 -👨🏾‍❤️‍💋‍👨🏽 -👨🏾‍❤️‍💋‍👨🏾 -👨🏾‍❤️‍💋‍👨🏿 -👨🏿‍❤️‍💋‍👨🏻 -👨🏿‍❤️‍💋‍👨🏼 -👨🏿‍❤️‍💋‍👨🏽 -👨🏿‍❤️‍💋‍👨🏾 -👨🏿‍❤️‍💋‍👨🏿 -👨‍❤️‍💋‍👨 -🧑‍🧑‍🧒‍🧒 -🧑‍🧑‍🧒 -🧑‍🧒‍🧒 -🧑‍🧒 -👪 -👨‍👩‍👦 -👨‍👩‍👧 -👨‍👩‍👧‍👦 -👨‍👩‍👦‍👦 -👨‍👩‍👧‍👧 -👩‍👩‍👦 -👩‍👩‍👧 -👩‍👩‍👧‍👦 -👩‍👩‍👦‍👦 -👩‍👩‍👧‍👧 -👨‍👨‍👦 -👨‍👨‍👧 -👨‍👨‍👧‍👦 -👨‍👨‍👦‍👦 -👨‍👨‍👧‍👧 -👩‍👦 -👩‍👧 -👩‍👧‍👦 -👩‍👦‍👦 -👩‍👧‍👧 -👨‍👦 -👨‍👧 -👨‍👧‍👦 -👨‍👦‍👦 -👨‍👧‍👧 -🪢 -🧶 -🧵 -🪡 -🧥 -🥼 -🦺 -👚 -👕 -👖 -🩲 -🩳 -👔 -👗 -👙 -🩱 -👘 -🥻 -🩴 -🥿 -👠 -👡 -👢 -👞 -👟 -🥾 -🧦 -🧤 -🧣 -🎩 -🧢 -👒 -🎓 -⛑️ -🪖 -👑 -💍 -👝 -👛 -👜 -💼 -🎒 -🧳 -👓 -🕶️ -🥽 -🌂 -🐶 -🐱 -🐭 -🐹 -🐰 -🦊 -🐻 -🐼 -🐻‍❄️ -🐨 -🐯 -🦁 -🐮 -🐷 -🐽 -🐸 -🐵 -🙈 -🙉 -🙊 -🐒 -🐔 -🐧 -🐦 -🐤 -🐣 -🐥 -🪿 -🦆 -🐦‍⬛ -🦅 -🦉 -🦇 -🐺 -🐗 -🐴 -🦄 -🫎 -🐝 -🪱 -🐛 -🦋 -🐌 -🐞 -🐜 -🪰 -🪲 -🪳 -🦟 -🦗 -🕷️ -🕸️ -🦂 -🐢 -🐍 -🦎 -🦖 -🦕 -🐙 -🦑 -🪼 -🦐 -🦞 -🦀 -🐡 -🐠 -🐟 -🐬 -🐳 -🐋 -🦈 -🦭 -🐊 -🐅 -🐆 -🦓 -🦍 -🦧 -🦣 -🐘 -🦛 -🦏 -🐪 -🐫 -🦒 -🦘 -🦬 -🐃 -🐂 -🐄 -🫏 -🐎 -🐖 -🐏 -🐑 -🦙 -🐐 -🦌 -🐕 -🐩 -🦮 -🐕‍🦺 -🐈 -🐈‍⬛ -🪶 -🪽 -🐓 -🦃 -🦤 -🦚 -🦜 -🦢 -🦩 -🕊️ -🐇 -🦝 -🦨 -🦡 -🦫 -🦦 -🦥 -🐁 -🐀 -🐿️ -🦔 -🐾 -🐉 -🐲 -🐦‍🔥 -🌵 -🎄 -🌲 -🌳 -🌴 -🪵 -🌱 -🌿 -☘️ -🍀 -🎍 -🪴 -🎋 -🍃 -🍂 -🍁 -🪺 -🪹 -🍄 -🍄‍🟫 -🐚 -🪸 -🪨 -🌾 -💐 -🌷 -🌹 -🥀 -🪻 -🪷 -🌺 -🌸 -🌼 -🌻 -🌞 -🌝 -🌛 -🌜 -🌚 -🌕 -🌖 -🌗 -🌘 -🌑 -🌒 -🌓 -🌔 -🌙 -🌎 -🌍 -🌏 -🪐 -💫 -⭐ -🌟 -✨ -⚡ -☄️ -💥 -🔥 -🌪️ -🌈 -☀️ -🌤️ -⛅ -🌥️ -☁️ -🌦️ -🌧️ -⛈️ -🌩️ -🌨️ -❄️ -☃️ -⛄ -🌬️ -💨 -💧 -💦 -🫧 -☔ -☂️ -🌊 -🌫️ -🍏 -🍎 -🍐 -🍊 -🍋 -🍋‍🟩 -🍌 -🍉 -🍇 -🍓 -🫐 -🍈 -🍒 -🍑 -🥭 -🍍 -🥥 -🥝 -🍅 -🍆 -🥑 -🫛 -🥦 -🥬 -🥒 -🌶️ -🫑 -🌽 -🥕 -🫒 -🧄 -🧅 -🥔 -🍠 -🫚 -🥐 -🥯 -🍞 -🥖 -🥨 -🧀 -🥚 -🍳 -🧈 -🥞 -🧇 -🥓 -🥩 -🍗 -🍖 -🦴 -🌭 -🍔 -🍟 -🍕 -🫓 -🥪 -🥙 -🧆 -🌮 -🌯 -🫔 -🥗 -🥘 -🫕 -🥫 -🫙 -🍝 -🍜 -🍲 -🍛 -🍣 -🍱 -🥟 -🦪 -🍤 -🍙 -🍚 -🍘 -🍥 -🥠 -🥮 -🍢 -🍡 -🍧 -🍨 -🍦 -🥧 -🧁 -🍰 -🎂 -🍮 -🍭 -🍬 -🍫 -🍿 -🍩 -🍪 -🌰 -🥜 -🫘 -🍯 -🥛 -🫗 -🍼 -🫖 -☕ -🍵 -🧉 -🧃 -🥤 -🧋 -🍶 -🍺 -🍻 -🥂 -🍷 -🥃 -🍸 -🍹 -🍾 -🧊 -🥄 -🍴 -🍽️ -🥣 -🥡 -🥢 -🧂 -⚽ -🏀 -🏈 -⚾ -🥎 -🎾 -🏐 -🏉 -🥏 -🎱 -🪀 -🏓 -🏸 -🏒 -🏑 -🥍 -🏏 -🪃 -🥅 -⛳ -🪁 -🛝 -🏹 -🎣 -🤿 -🥊 -🥋 -🎽 -🛹 -🛼 -🛷 -⛸️ -🥌 -🎿 -⛷️ -🏂🏻 -🏂🏼 -🏂🏽 -🏂🏾 -🏂🏿 -🏂 -🪂 -🏋🏻 -🏋🏼 -🏋🏽 -🏋🏾 -🏋🏿 -🏋️ -🏋🏻‍♀️ -🏋🏼‍♀️ -🏋🏽‍♀️ -🏋🏾‍♀️ -🏋🏿‍♀️ -🏋️‍♀️ -🏋🏻‍♂️ -🏋🏼‍♂️ -🏋🏽‍♂️ -🏋🏾‍♂️ -🏋🏿‍♂️ -🏋️‍♂️ -🤼 -🤼‍♀️ -🤼‍♂️ -🤸🏻 -🤸🏼 -🤸🏽 -🤸🏾 -🤸🏿 -🤸 -🤸🏻‍♀️ -🤸🏼‍♀️ -🤸🏽‍♀️ -🤸🏾‍♀️ -🤸🏿‍♀️ -🤸‍♀️ -🤸🏻‍♂️ -🤸🏼‍♂️ -🤸🏽‍♂️ -🤸🏾‍♂️ -🤸🏿‍♂️ -🤸‍♂️ -⛹🏻 -⛹🏼 -⛹🏽 -⛹🏾 -⛹🏿 -⛹️ -⛹🏻‍♀️ -⛹🏼‍♀️ -⛹🏽‍♀️ -⛹🏾‍♀️ -⛹🏿‍♀️ -⛹️‍♀️ -⛹🏻‍♂️ -⛹🏼‍♂️ -⛹🏽‍♂️ -⛹🏾‍♂️ -⛹🏿‍♂️ -⛹️‍♂️ -🤺 -🤾🏻 -🤾🏼 -🤾🏽 -🤾🏾 -🤾🏿 -🤾 -🤾🏻‍♀️ -🤾🏼‍♀️ -🤾🏽‍♀️ -🤾🏾‍♀️ -🤾🏿‍♀️ -🤾‍♀️ -🤾🏻‍♂️ -🤾🏼‍♂️ -🤾🏽‍♂️ -🤾🏾‍♂️ -🤾🏿‍♂️ -🤾‍♂️ -🏌🏻 -🏌🏼 -🏌🏽 -🏌🏾 -🏌🏿 -🏌️ -🏌🏻‍♀️ -🏌🏼‍♀️ -🏌🏽‍♀️ -🏌🏾‍♀️ -🏌🏿‍♀️ -🏌️‍♀️ -🏌🏻‍♂️ -🏌🏼‍♂️ -🏌🏽‍♂️ -🏌🏾‍♂️ -🏌🏿‍♂️ -🏌️‍♂️ -🏇🏻 -🏇🏼 -🏇🏽 -🏇🏾 -🏇🏿 -🏇 -🧘🏻 -🧘🏼 -🧘🏽 -🧘🏾 -🧘🏿 -🧘 -🧘🏻‍♀️ -🧘🏼‍♀️ -🧘🏽‍♀️ -🧘🏾‍♀️ -🧘🏿‍♀️ -🧘‍♀️ -🧘🏻‍♂️ -🧘🏼‍♂️ -🧘🏽‍♂️ -🧘🏾‍♂️ -🧘🏿‍♂️ -🧘‍♂️ -🏄🏻 -🏄🏼 -🏄🏽 -🏄🏾 -🏄🏿 -🏄 -🏄🏻‍♀️ -🏄🏼‍♀️ -🏄🏽‍♀️ -🏄🏾‍♀️ -🏄🏿‍♀️ -🏄‍♀️ -🏄🏻‍♂️ -🏄🏼‍♂️ -🏄🏽‍♂️ -🏄🏾‍♂️ -🏄🏿‍♂️ -🏄‍♂️ -🏊🏻 -🏊🏼 -🏊🏽 -🏊🏾 -🏊🏿 -🏊 -🏊🏻‍♀️ -🏊🏼‍♀️ -🏊🏽‍♀️ -🏊🏾‍♀️ -🏊🏿‍♀️ -🏊‍♀️ -🏊🏻‍♂️ -🏊🏼‍♂️ -🏊🏽‍♂️ -🏊🏾‍♂️ -🏊🏿‍♂️ -🏊‍♂️ -🤽🏻 -🤽🏼 -🤽🏽 -🤽🏾 -🤽🏿 -🤽 -🤽🏻‍♀️ -🤽🏼‍♀️ -🤽🏽‍♀️ -🤽🏾‍♀️ -🤽🏿‍♀️ -🤽‍♀️ -🤽🏻‍♂️ -🤽🏼‍♂️ -🤽🏽‍♂️ -🤽🏾‍♂️ -🤽🏿‍♂️ -🤽‍♂️ -🚣🏻 -🚣🏼 -🚣🏽 -🚣🏾 -🚣🏿 -🚣 -🚣🏻‍♀️ -🚣🏼‍♀️ -🚣🏽‍♀️ -🚣🏾‍♀️ -🚣🏿‍♀️ -🚣‍♀️ -🚣🏻‍♂️ -🚣🏼‍♂️ -🚣🏽‍♂️ -🚣🏾‍♂️ -🚣🏿‍♂️ -🚣‍♂️ -🧗🏻 -🧗🏼 -🧗🏽 -🧗🏾 -🧗🏿 -🧗 -🧗🏻‍♀️ -🧗🏼‍♀️ -🧗🏽‍♀️ -🧗🏾‍♀️ -🧗🏿‍♀️ -🧗‍♀️ -🧗🏻‍♂️ -🧗🏼‍♂️ -🧗🏽‍♂️ -🧗🏾‍♂️ -🧗🏿‍♂️ -🧗‍♂️ -🚵🏻 -🚵🏼 -🚵🏽 -🚵🏾 -🚵🏿 -🚵 -🚵🏻‍♀️ -🚵🏼‍♀️ -🚵🏽‍♀️ -🚵🏾‍♀️ -🚵🏿‍♀️ -🚵‍♀️ -🚵🏻‍♂️ -🚵🏼‍♂️ -🚵🏽‍♂️ -🚵🏾‍♂️ -🚵🏿‍♂️ -🚵‍♂️ -🚴🏻 -🚴🏼 -🚴🏽 -🚴🏾 -🚴🏿 -🚴 -🚴🏻‍♀️ -🚴🏼‍♀️ -🚴🏽‍♀️ -🚴🏾‍♀️ -🚴🏿‍♀️ -🚴‍♀️ -🚴🏻‍♂️ -🚴🏼‍♂️ -🚴🏽‍♂️ -🚴🏾‍♂️ -🚴🏿‍♂️ -🚴‍♂️ -🏆 -🥇 -🥈 -🥉 -🏅 -🎖️ -🏵️ -🎗️ -🎫 -🎟️ -🎪 -🤹🏻 -🤹🏼 -🤹🏽 -🤹🏾 -🤹🏿 -🤹 -🤹🏻‍♀️ -🤹🏼‍♀️ -🤹🏽‍♀️ -🤹🏾‍♀️ -🤹🏿‍♀️ -🤹‍♀️ -🤹🏻‍♂️ -🤹🏼‍♂️ -🤹🏽‍♂️ -🤹🏾‍♂️ -🤹🏿‍♂️ -🤹‍♂️ -🎭 -🩰 -🎨 -🎬 -🎤 -🎧 -🎼 -🎹 -🪇 -🥁 -🪘 -🎷 -🎺 -🪗 -🎸 -🪕 -🎻 -🪈 -🎲 -♟️ -🎯 -🎳 -🎮 -🎰 -🧩 -🚗 -🚕 -🚙 -🛻 -🚐 -🚌 -🚎 -🏎️ -🚓 -🚑 -🚒 -🚚 -🚛 -🚜 -🦯 -🦽 -🦼 -🩼 -🛴 -🚲 -🛵 -🏍️ -🛺 -🛞 -🚨 -🚔 -🚍 -🚘 -🚖 -🚡 -🚠 -🚟 -🚃 -🚋 -🚞 -🚝 -🚄 -🚅 -🚈 -🚂 -🚆 -🚇 -🚊 -🚉 -✈️ -🛫 -🛬 -🛩️ -💺 -🛰️ -🚀 -🛸 -🚁 -🛶 -⛵ -🚤 -🛥️ -🛳️ -⛴️ -🚢 -🛟 -⚓ -🪝 -⛽ -🚧 -🚦 -🚥 -🚏 -🗺️ -🗿 -🗽 -🗼 -🏰 -🏯 -🏟️ -🎡 -🎢 -🎠 -⛲ -⛱️ -🏖️ -🏝️ -🏜️ -🌋 -⛰️ -🏔️ -🗻 -🏕️ -⛺ -🏠 -🏡 -🏘️ -🏚️ -🛖 -🏗️ -🏭 -🏢 -🏬 -🏣 -🏤 -🏥 -🏦 -🏨 -🏪 -🏫 -🏩 -💒 -🏛️ -⛪ -🕌 -🕍 -🛕 -🕋 -⛩️ -🛤️ -🛣️ -🗾 -🎑 -🏞️ -🌅 -🌄 -🌠 -🎇 -🎆 -🌇 -🌆 -🏙️ -🌃 -🌌 -🌉 -🌁 -⌚ -📱 -📲 -💻 -⌨️ -🖥️ -🖨️ -🖱️ -🖲️ -🕹️ -🗜️ -💽 -💾 -💿 -📀 -📼 -📷 -📸 -📹 -🎥 -📽️ -🎞️ -📞 -☎️ -📟 -📠 -📺 -📻 -🎙️ -🎚️ -🎛️ -🧭 -⏱️ -⏲️ -⏰ -🕰️ -⌛ -⏳ -📡 -🔋 -🪫 -🔌 -💡 -🔦 -🕯️ -🪔 -🧯 -🛢️ -💸 -💵 -💴 -💶 -💷 -🪙 -💰 -💳 -🪪 -💎 -⚖️ -🪜 -🧰 -🪛 -🔧 -🔨 -⚒️ -🛠️ -⛏️ -🪚 -🔩 -⚙️ -🪤 -🧱 -⛓️ -🔗 -⛓️‍💥 -🧲 -🔫 -💣 -🧨 -🪓 -🔪 -🗡️ -⚔️ -🛡️ -🚬 -⚰️ -🪦 -⚱️ -🏺 -🔮 -📿 -🧿 -🪬 -💈 -⚗️ -🔭 -🔬 -🕳️ -🩻 -🩹 -🩺 -💊 -💉 -🩸 -🧬 -🦠 -🧫 -🧪 -🌡️ -🧹 -🪠 -🧺 -🧻 -🚽 -🚰 -🚿 -🛁 -🛀🏻 -🛀🏼 -🛀🏽 -🛀🏾 -🛀🏿 -🛀 -🧼 -🪥 -🪒 -🪮 -🧽 -🪣 -🧴 -🛎️ -🔑 -🗝️ -🚪 -🪑 -🛋️ -🛏️ -🛌🏻 -🛌🏼 -🛌🏽 -🛌🏾 -🛌🏿 -🛌 -🧸 -🪆 -🖼️ -🪞 -🪟 -🛍️ -🛒 -🎁 -🎈 -🎏 -🎀 -🪄 -🪅 -🎊 -🎉 -🎎 -🪭 -🏮 -🎐 -🪩 -🧧 -✉️ -📩 -📨 -📧 -💌 -📥 -📤 -📦 -🏷️ -🪧 -📪 -📫 -📬 -📭 -📮 -📯 -📜 -📃 -📄 -📑 -🧾 -📊 -📈 -📉 -🗒️ -🗓️ -📆 -📅 -🗑️ -📇 -🗃️ -🗳️ -🗄️ -📋 -📁 -📂 -🗂️ -🗞️ -📰 -📓 -📔 -📒 -📕 -📗 -📘 -📙 -📚 -📖 -🔖 -🧷 -📎 -🖇️ -📐 -📏 -🧮 -📌 -📍 -✂️ -🖊️ -🖋️ -✒️ -🖌️ -🖍️ -📝 -✏️ -🔍 -🔎 -🔏 -🔐 -🔒 -🔓 -🩷 -❤️ -🧡 -💛 -💚 -🩵 -💙 -💜 -🖤 -🩶 -🤍 -🤎 -💔 -❣️ -💕 -💞 -💓 -💗 -💖 -💘 -💝 -❤️‍🩹 -❤️‍🔥 -💟 -☮️ -✝️ -☪️ -🕉️ -☸️ -🪯 -✡️ -🔯 -🕎 -☯️ -☦️ -🛐 -⛎ -♈ -♉ -♊ -♋ -♌ -♍ -♎ -♏ -♐ -♑ -♒ -♓ -🆔 -⚛️ -🉑 -☢️ -☣️ -📴 -📳 -🈶 -🈚 -🈸 -🈺 -🈷️ -✴️ -🆚 -💮 -🉐 -㊙️ -㊗️ -🈴 -🈵 -🈹 -🈲 -🅰️ -🅱️ -🆎 -🆑 -🅾️ -🆘 -❌ -⭕ -🛑 -⛔ -📛 -🚫 -💯 -💢 -♨️ -🚷 -🚯 -🚳 -🚱 -🔞 -📵 -🚭 -❗ -❕ -❓ -❔ -‼️ -⁉️ -🔅 -🔆 -〽️ -⚠️ -🚸 -🔱 -⚜️ -🔰 -♻️ -✅ -🈯 -💹 -❇️ -✳️ -❎ -🌐 -💠 -Ⓜ️ -🌀 -💤 -🏧 -🚾 -♿ -🅿️ -🛗 -🈳 -🈂️ -🛂 -🛃 -🛄 -🛅 -🛜 -🚹 -🚺 -🚼 -🚻 -🚮 -🎦 -📶 -🈁 -🔣 -ℹ️ -🔤 -🔡 -🔠 -🆖 -🆗 -🆙 -🆒 -🆕 -🆓 -0️⃣ -1️⃣ -2️⃣ -3️⃣ -4️⃣ -5️⃣ -6️⃣ -7️⃣ -8️⃣ -9️⃣ -🔟 -🔢 -#️⃣ -*️⃣ -⏏️ -▶️ -⏸️ -⏯️ -⏹️ -⏺️ -⏭️ -⏮️ -⏩ -⏪ -⏫ -⏬ -◀️ -🔼 -🔽 -➡️ -⬅️ -⬆️ -⬇️ -↗️ -↘️ -↙️ -↖️ -↕️ -↔️ -↪️ -↩️ -⤴️ -⤵️ -🔀 -🔁 -🔂 -🔄 -🔃 -🎵 -🎶 -➕ -➖ -➗ -✖️ -🟰 -♾️ -💲 -💱 -™️ -©️ -®️ -〰️ -➰ -➿ -🔚 -🔙 -🔛 -🔝 -🔜 -✔️ -☑️ -🔘 -⚪ -⚫ -🔴 -🔵 -🟤 -🟣 -🟢 -🟡 -🟠 -🔺 -🔻 -🔸 -🔹 -🔶 -🔷 -🔳 -🔲 -▪️ -▫️ -◾ -◽ -◼️ -◻️ -⬛ -⬜ -🟧 -🟦 -🟥 -🟫 -🟪 -🟩 -🟨 -🔈 -🔇 -🔉 -🔊 -🔔 -🔕 -📣 -📢 -🗨️ -👁‍🗨 -💬 -💭 -🗯️ -♠️ -♣️ -♥️ -♦️ -🃏 -🎴 -🀄 -🕐 -🕑 -🕒 -🕓 -🕔 -🕕 -🕖 -🕗 -🕘 -🕙 -🕚 -🕛 -🕜 -🕝 -🕞 -🕟 -🕠 -🕡 -🕢 -🕣 -🕤 -🕥 -🕦 -🕧 -♀️ -♂️ -⚧ -⚕️ -🇿 -🇾 -🇽 -🇼 -🇻 -🇺 -🇹 -🇸 -🇷 -🇶 -🇵 -🇴 -🇳 -🇲 -🇱 -🇰 -🇯 -🇮 -🇭 -🇬 -🇫 -🇪 -🇩 -🇨 -🇧 -🇦 -🏳️ -🏴 -🏴‍☠️ -🏁 -🚩 -🏳️‍🌈 -🏳️‍⚧️ -🇺🇳 -🇦🇫 -🇦🇽 -🇦🇱 -🇩🇿 -🇦🇸 -🇦🇩 -🇦🇴 -🇦🇮 -🇦🇶 -🇦🇬 -🇦🇷 -🇦🇲 -🇦🇼 -🇦🇺 -🇦🇹 -🇦🇿 -🇧🇸 -🇧🇭 -🇧🇩 -🇧🇧 -🇧🇾 -🇧🇪 -🇧🇿 -🇧🇯 -🇧🇲 -🇧🇹 -🇧🇴 -🇧🇦 -🇧🇼 -🇧🇷 -🇮🇴 -🇻🇬 -🇧🇳 -🇧🇬 -🇧🇫 -🇧🇮 -🇰🇭 -🇨🇲 -🇨🇦 -🇮🇨 -🇨🇻 -🇧🇶 -🇰🇾 -🇨🇫 -🇹🇩 -🇨🇱 -🇨🇳 -🇨🇽 -🇨🇨 -🇨🇴 -🇰🇲 -🇨🇬 -🇨🇩 -🇨🇰 -🇨🇷 -🇨🇮 -🇭🇷 -🇨🇺 -🇨🇼 -🇨🇾 -🇨🇿 -🇩🇰 -🇩🇯 -🇩🇲 -🇩🇴 -🇪🇨 -🇪🇬 -🇸🇻 -🇬🇶 -🇪🇷 -🇪🇪 -🇪🇹 -🇪🇺 -🇫🇰 -🇫🇴 -🇫🇯 -🇫🇮 -🇫🇷 -🇬🇫 -🇵🇫 -🇹🇫 -🇬🇦 -🇬🇲 -🇬🇪 -🇩🇪 -🇬🇭 -🇬🇮 -🇬🇷 -🇬🇱 -🇬🇩 -🇬🇵 -🇬🇺 -🇬🇹 -🇬🇬 -🇬🇳 -🇬🇼 -🇬🇾 -🇭🇹 -🇭🇳 -🇭🇰 -🇭🇺 -🇮🇸 -🇮🇳 -🇮🇩 -🇮🇷 -🇮🇶 -🇮🇪 -🇮🇲 -🇮🇱 -🇮🇹 -🇯🇲 -🇯🇵 -🎌 -🇯🇪 -🇯🇴 -🇰🇿 -🇰🇪 -🇰🇮 -🇽🇰 -🇰🇼 -🇰🇬 -🇱🇦 -🇱🇻 -🇱🇧 -🇱🇸 -🇱🇷 -🇱🇾 -🇱🇮 -🇱🇹 -🇱🇺 -🇲🇴 -🇲🇰 -🇲🇬 -🇲🇼 -🇲🇾 -🇲🇻 -🇲🇱 -🇲🇹 -🇲🇭 -🇲🇶 -🇲🇷 -🇲🇺 -🇾🇹 -🇲🇽 -🇫🇲 -🇲🇩 -🇲🇨 -🇲🇳 -🇲🇪 -🇲🇸 -🇲🇦 -🇲🇿 -🇲🇲 -🇳🇦 -🇳🇷 -🇳🇵 -🇳🇱 -🇳🇨 -🇳🇿 -🇳🇮 -🇳🇪 -🇳🇬 -🇳🇺 -🇳🇫 -🇰🇵 -🇲🇵 -🇳🇴 -🇴🇲 -🇵🇰 -🇵🇼 -🇵🇸 -🇵🇦 -🇵🇬 -🇵🇾 -🇵🇪 -🇵🇭 -🇵🇳 -🇵🇱 -🇵🇹 -🇵🇷 -🇶🇦 -🇷🇪 -🇷🇴 -🇷🇺 -🇷🇼 -🇼🇸 -🇸🇲 -🇸🇹 -🇸🇦 -🇸🇳 -🇷🇸 -🇸🇨 -🇸🇱 -🇸🇬 -🇸🇽 -🇸🇰 -🇸🇮 -🇬🇸 -🇸🇧 -🇸🇴 -🇿🇦 -🇰🇷 -🇸🇸 -🇪🇸 -🇱🇰 -🇧🇱 -🇸🇭 -🇰🇳 -🇱🇨 -🇵🇲 -🇻🇨 -🇸🇩 -🇸🇷 -🇸🇿 -🇸🇪 -🇨🇭 -🇸🇾 -🇹🇼 -🇹🇯 -🇹🇿 -🇹🇭 -🇹🇱 -🇹🇬 -🇹🇰 -🇹🇴 -🇹🇹 -🇹🇳 -🇹🇷 -🇹🇲 -🇹🇨 -🇻🇮 -🇹🇻 -🇺🇬 -🇺🇦 -🇦🇪 -🇬🇧 -🏴󠁧󠁢󠁥󠁮󠁧󠁿 -🏴󠁧󠁢󠁳󠁣󠁴󠁿 -🏴󠁧󠁢󠁷󠁬󠁳󠁿 -🇺🇸 -🇺🇾 -🇺🇿 -🇻🇺 -🇻🇦 -🇻🇪 -🇻🇳 -🇼🇫 -🇪🇭 -🇾🇪 -🇿🇲 -🇿🇼 -🇦🇨 -🇧🇻 -🇨🇵 -🇪🇦 -🇩🇬 -🇭🇲 -🇲🇫 -🇸🇯 -🇹🇦 -🇺🇲 diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 81ad48c..346d8c9 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -1,34 +1,24 @@ // @ts-check -/// const Ty = require("../../types") const DiscordTypes = require("discord-api-types/v10") -const stream = require("stream") +const {Readable} = require("stream") const chunk = require("chunk-text") const TurndownService = require("@cloudrac3r/turndown") const domino = require("domino") const assert = require("assert").strict const entities = require("entities") -const pb = require("prettier-bytes") -const {tag} = require("@cloudrac3r/html-template-tag") const passthrough = require("../../passthrough") const {sync, db, discord, select, from} = passthrough -const {reg} = require("../../matrix/read-registration") -/** @type {import("../../matrix/utils")} */ -const mxUtils = sync.require("../../matrix/utils") +/** @type {import("../converters/utils")} */ +const mxUtils = sync.require("../converters/utils") /** @type {import("../../discord/utils")} */ const dUtils = sync.require("../../discord/utils") /** @type {import("../../matrix/file")} */ const file = sync.require("../../matrix/file") /** @type {import("./emoji-sheet")} */ const emojiSheet = sync.require("./emoji-sheet") -/** @type {import("./poll-components")} */ -const pollComponents = sync.require("./poll-components") -/** @type {import("../actions/setup-emojis")} */ -const setupEmojis = sync.require("../actions/setup-emojis") -/** @type {import("../../d2m/converters/user-to-mxid")} */ -const userToMxid = sync.require("../../d2m/converters/user-to-mxid") /** @type {[RegExp, string][]} */ const markdownEscapes = [ @@ -69,7 +59,7 @@ turndownService.escape = function (string) { return string.replace(/\s+|\S+/g, part => { // match chunks of spaces or non-spaces if (part.match(/\s/)) return part // don't process spaces - if (part.match(/^` const href = node.getAttribute("href") - let shouldSuppress = node.hasAttribute("data-suppress") - if (href.match(/^https?:\/\/matrix.to\//)) shouldSuppress = false // avoid double-escaping - const suppressedHref = shouldSuppress ? "<" + href + ">" : href content = content.replace(/ @.*/, "") - if (href === content) return suppressedHref + if (href === content) return href if (decodeURIComponent(href).startsWith("https://matrix.to/#/@") && content[0] !== "@") content = "@" + content - return "[" + content + "](" + suppressedHref + ")" + return "[" + content + "](" + href + ")" } }) @@ -167,27 +154,6 @@ turndownService.addRule("listItem", { } }) -turndownService.addRule("table", { - filter: "table", - replacement: function (content, node, options) { - const trs = node.querySelectorAll("tr").cache - /** @type {{text: string, tag: string}[][]} */ - const tableText = trs.map(tr => [...tr.querySelectorAll("th, td")].map(cell => ({text: cell.textContent, tag: cell.tagName}))) - const tableTextByColumn = tableText[0].map((col, i) => tableText.map(row => row[i])) - const columnWidths = tableTextByColumn.map(col => Math.max(...col.map(cell => cell.text.length))) - const resultRows = tableText.map((row, rowIndex) => - row.map((cell, colIndex) => - cell.text.padEnd(columnWidths[colIndex]) - ).join(" ") - ) - const tableHasHeader = tableText[0].slice(1).some(cell => cell.tag === "TH") - if (tableHasHeader) { - resultRows.splice(1, 0, "-".repeat(columnWidths.reduce((a, c) => a + c + 2))) - } - return "```\n" + resultRows.join("\n") + "```" - } -}) - /** @type {string[]} SPRITE SHEET EMOJIS FEATURE: mxc urls for the currently processing message */ let endOfMessageEmojis = [] turndownService.addRule("emoji", { @@ -249,8 +215,7 @@ function convertEmoji(mxcUrl, nameForGuess, allowSpriteSheetIndicator, allowLink if (!found) row = null } // Or, if we don't have an emoji right now, we search for the name instead. - const isLocalMxc = mxcUrl?.match(/^mxc:\/\/([^/]+)/)?.[1] === reg.ooye.server_name - if (!row && nameForGuess && isLocalMxc) { + if (!row && nameForGuess) { const nameForGuessLower = nameForGuess.toLowerCase() for (const guild of discord.guilds.values()) { /** @type {{name: string, id: string, animated: number}[]} */ @@ -290,21 +255,10 @@ function convertEmoji(mxcUrl, nameForGuess, allowSpriteSheetIndicator, allowLink * @returns {Promise<{displayname?: string?, avatar_url?: string?}>} */ async function getMemberFromCacheOrHomeserver(roomID, mxid, api) { - const row = select("member_cache", ["displayname", "avatar_url", "missing_profile"], {room_id: roomID, mxid}).get() - if (row && !row.missing_profile) return row + const row = select("member_cache", ["displayname", "avatar_url"], {room_id: roomID, mxid}).get() + if (row) return row return api.getStateEvent(roomID, "m.room.member", mxid).then(event => { - const room = select("channel_room", "room_id", {room_id: roomID}).get() - if (room) { - // save the member to the cache so we don't have to check with the homeserver next time - // the cache will be kept in sync by the `m.room.member` event listener - const displayname = event?.displayname || null - const avatar_url = event?.avatar_url || null - db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?, missing_profile = NULL").run( - roomID, mxid, - displayname, avatar_url, - displayname, avatar_url - ) - } + db.prepare("REPLACE INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?)").run(roomID, mxid, event?.displayname || null, event?.avatar_url || null) return event }).catch(() => { return {displayname: null, avatar_url: null} @@ -340,48 +294,34 @@ function splitDisplayName(displayName) { * Convert a Matrix user ID into a Discord user ID for mentioning, where if the user is a PK proxy, it will mention the proxy owner. * @param {string} mxid */ -function getUserOrProxyOwnerMention(mxid) { - const row = from("sim").join("sim_proxy", "user_id", "left").select("user_id", "username", "proxy_owner_id").where({mxid}).get() +function getUserOrProxyOwnerID(mxid) { + const row = from("sim").join("sim_proxy", "user_id", "left").select("user_id", "proxy_owner_id").where({mxid}).get() if (!row) return null - if (userToMxid.isWebhookUserID(row.user_id)) return `**@${row.username}**` - return `<@${row.proxy_owner_id || row.user_id}>` + return row.proxy_owner_id || row.user_id } /** * At the time of this executing, we know what the end of message emojis are, and we know that at least one of them is unknown. - * This function will strip them from the content and add a link that Discord will preview with a sprite sheet of emojis. + * This function will strip them from the content and generate the correct pending file of the sprite sheet. * @param {string} content - * @returns {string} new content with emoji sheet link + * @param {{id: string, name: string}[]} attachments + * @param {({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} pendingFiles + * @param {(mxc: string) => Promise} mxcDownloader function that will download the mxc URLs and convert to uncompressed PNG data. use `getAndConvertEmoji` or a mock. */ -function linkEndOfMessageSpriteSheet(content) { +async function uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles, mxcDownloader) { if (!content.includes("<::>")) return content // No unknown emojis, nothing to do // Remove known and unknown emojis from the end of the message const r = /\s*$/ - while (content.match(r)) { content = content.replace(r, "") } - - // Use a markdown link to hide the URL. If this is the only thing in the message, Discord will hide it entirely, same as lone URLs. Good for us. - content = content.trimEnd() - content += " [\u2800](" // U+2800 Braille Pattern Blank is invisible on all known platforms but is digitally not a whitespace character - const afterLink = ")" - - // Make emojis URL params - const params = new URLSearchParams() - for (const mxc of endOfMessageEmojis) { - // We can do up to 2000 chars max. (In this maximal case it will get chunked to a separate message.) Ignore additional emojis. - const withoutMxc = mxUtils.makeMxcPublic(mxc) - assert(withoutMxc) - const emojisLength = params.toString().length + encodeURIComponent(withoutMxc).length + 2 - if (content.length + emojisLength + afterLink.length > 2000) { - break - } - params.append("e", withoutMxc) - } - - const url = `${reg.ooye.bridge_origin}/download/sheet?${params.toString()}` - return content + url + afterLink + // Create a sprite sheet of known and unknown emojis from the end of the message + const buffer = await emojiSheet.compositeMatrixEmojis(endOfMessageEmojis, mxcDownloader) + // Attach it + const name = "emojis.png" + attachments.push({id: String(attachments.length), name}) + pendingFiles.push({name, buffer}) + return content } /** @@ -390,9 +330,9 @@ function linkEndOfMessageSpriteSheet(content) { */ async function handleRoomOrMessageLinks(input, di) { let offset = 0 - for (const match of [...input.matchAll(/("?https:\/\/matrix.to\/#\/((?:#|%23|!)[^"/, ?)]+)(?:\/(\$[^"/ ?)]+))?(?:\?[^",:!? )]*?)?)(">|[,<\n )]|$)/g)]) { + for (const match of [...input.matchAll(/("?https:\/\/matrix.to\/#\/(![^"/, ?)]+)(?:\/(\$[^"/ ?)]+))?(?:\?[^",:!? )]*?)?)(">|[,<\n )]|$)/g)]) { assert(typeof match.index === "number") - let [_, attributeValue, roomID, eventID, endMarker] = match + const [_, attributeValue, roomID, eventID, endMarker] = match let result const resultType = endMarker === '">' ? "html" : "plain" @@ -410,17 +350,7 @@ async function handleRoomOrMessageLinks(input, di) { // Don't process links that are part of the reply fallback, they'll be removed entirely by turndown if (input.slice(match.index + match[0].length + offset).startsWith("In reply to")) continue - // Resolve room alias to room ID if needed - roomID = decodeURIComponent(roomID) - if (roomID[0] === "#") { - try { - roomID = await di.api.getAlias(roomID) - } catch (e) { - continue // Room alias is unresolvable, so it can't be converted - } - } - - const channelID = select("historical_channel_room", "reference_channel_id", {room_id: roomID}).pluck().get() + const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get() if (!channelID) continue if (!eventID) { // 1: It's a room link, so <#link> to the channel @@ -465,8 +395,9 @@ async function checkWrittenMentions(content, senderMxid, roomID, guild, di) { let writtenMentionMatch = content.match(/(?:^|[^"[<>/A-Za-z0-9])@([A-Za-z][A-Za-z0-9._\[\]\(\)-]+):?/d) // /d flag for indices requires node.js 16+ if (writtenMentionMatch) { if (writtenMentionMatch[1] === "room") { // convert @room to @everyone - const {powers: {[senderMxid]: userPower}, powerLevels} = await mxUtils.getEffectivePower(roomID, [senderMxid], di.api) - if (userPower >= (powerLevels.notifications?.room ?? 50)) { + const powerLevels = await di.api.getStateEvent(roomID, "m.room.power_levels", "") + const userPower = powerLevels.users?.[senderMxid] || 0 + if (userPower >= powerLevels.notifications?.room) { return { // @ts-ignore - typescript doesn't know about indices yet content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `@everyone` + content.slice(writtenMentionMatch.indices[1][1]), @@ -474,7 +405,7 @@ async function checkWrittenMentions(content, senderMxid, roomID, guild, di) { allowedMentionsParse: ["everyone"] } } - } else if (writtenMentionMatch[1].length < 40) { // the API supports up to 100 characters, but really if you're searching more than 40, something messed up + } else { const results = await di.snow.guild.searchGuildMembers(guild.id, {query: writtenMentionMatch[1]}) if (results[0]) { assert(results[0].user) @@ -506,41 +437,12 @@ const attachmentEmojis = new Map([ ["m.file", "📄"] ]) -/** @param {DiscordTypes.APIGuild} guild */ -function getFileSizeForGuild(guild) { - // guild.features may include strings such as MAX_FILE_SIZE_50_MB and MAX_FILE_SIZE_100_MB, which are the current server boost amounts - const fileSizeFeature = guild?.features.map(f => Number(f.match(/^MAX_FILE_SIZE_([0-9]+)_MB$/)?.[1])).filter(f => f).sort()[0] - if (fileSizeFeature) { - return fileSizeFeature * 1024 * 1024 // discord uses big megabytes - } else { - return 10 * 1024 * 1024 // default file size is 10 MB - } -} - -async function getL1L2ReplyLine(called = false) { - // @ts-ignore - const autoEmoji = new Map(select("auto_emoji", ["name", "emoji_id"], {}, "WHERE name = 'L1' OR name = 'L2'").raw().all()) - if (autoEmoji.size === 2) { - return `<:L1:${autoEmoji.get("L1")}><:L2:${autoEmoji.get("L2")}>` - } - /* c8 ignore start */ - if (called) { - // Don't know how this could happen, but just making sure we don't enter an infinite loop. - console.warn("Warning: OOYE is missing data to format replies. To fix this: `npm run setup`") - return "" - } - await setupEmojis.setupEmojis() - return getL1L2ReplyLine(true) - /* c8 ignore stop */ -} - /** - * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker | Ty.Event.Outer_M_Room_Message_Encrypted_File | Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Start | Ty.Event.Outer_Org_Matrix_Msc3381_Poll_End} event - * @param {DiscordTypes.APIGuild} guild - * @param {DiscordTypes.APIGuildTextChannel} channel - * @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, pollEnd?: {messageID: string}}} di simple-as-nails dependency injection for the matrix API + * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker | Ty.Event.Outer_M_Room_Message_Encrypted_File} event + * @param {import("discord-api-types/v10").APIGuild} guild + * @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, mxcDownloader: (mxc: string) => Promise}} di simple-as-nails dependency injection for the matrix API */ -async function eventToMessage(event, guild, channel, di) { +async function eventToMessage(event, guild, di) { let displayName = event.sender let avatarURL = undefined const allowedMentionsParse = ["users", "roles"] @@ -553,7 +455,7 @@ async function eventToMessage(event, guild, channel, di) { // Try to extract an accurate display name and avatar URL from the member event const member = await getMemberFromCacheOrHomeserver(event.room_id, event.sender, di?.api) if (member.displayname) displayName = member.displayname - if (member.avatar_url) avatarURL = mxUtils.getPublicUrlForMxc(member.avatar_url) + if (member.avatar_url) avatarURL = mxUtils.getPublicUrlForMxc(member.avatar_url) || undefined // If the display name is too long to be put into the webhook (80 characters is the maximum), // put the excess characters into displayNameRunoff, later to be put at the top of the message let [displayNameShortened, displayNameRunoff] = splitDisplayName(displayName) @@ -562,104 +464,15 @@ async function eventToMessage(event, guild, channel, di) { displayNameRunoff = "" } - let content = event.content["body"] || "" // ultimate fallback - /** @type {{id: string, filename: string}[]} */ + let content = event.content.body // ultimate fallback const attachments = [] /** @type {({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} */ const pendingFiles = [] /** @type {DiscordTypes.APIUser[]} */ const ensureJoined = [] - /** @type {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody[]} */ - const pollMessages = [] // Convert content depending on what the message is - // Handle images first - might need to handle their `body`/`formatted_body` as well, which will fall through to the text processor - let shouldProcessTextEvent = event.type === "m.room.message" && (event.content.msgtype === "m.text" || event.content.msgtype === "m.emote") - if (event.type === "m.room.message" && (event.content.msgtype === "m.file" || event.content.msgtype === "m.video" || event.content.msgtype === "m.audio" || event.content.msgtype === "m.image")) { - // Build message content in addition to the uploaded file - const fileIsSpoiler = event.content["page.codeberg.everypizza.msc4193.spoiler"] || event.content["town.robin.msc3725.content_warning"] - const fileSpoilerReason = event.content["page.codeberg.everypizza.msc4193.spoiler.reason"] || event.content["town.robin.msc3725.content_warning"]?.description - content = "" - const captionContent = new mxUtils.MatrixStringBuilder() - - // Caption from Matrix message - const fileHasCaption = (event.content.body && event.content.filename && event.content.body !== event.content.filename) || event.content.formatted_body - if (fileHasCaption) { - captionContent.addLine(event.content.body || "", event.content.formatted_body || tag`${event.content.body || ""}`) - } - - // Spoiler message - if (fileIsSpoiler && typeof fileSpoilerReason === "string") { - captionContent.addLine(`(Spoiler: ${fileSpoilerReason})`) - } - - // File link as alternative to uploading - if (!("file" in event.content) && event.content.info?.size > getFileSizeForGuild(guild)) { - // Upload (unencrypted) file as link, because it's too large for Discord - // Do this by constructing a sample Matrix message with the link and then use the text processor to convert that + the original caption. - const url = mxUtils.getPublicUrlForMxc(event.content.url) - assert(url) - const filename = event.content.filename || event.content.body - const emoji = attachmentEmojis.has(event.content.msgtype) ? attachmentEmojis.get(event.content.msgtype) + " " : "" - if (fileIsSpoiler) { - captionContent.addLine(`${emoji}Uploaded SPOILER file: <${url}> (${pb(event.content.info.size)})`, tag`${emoji}Uploaded SPOILER file: ${filename} (${pb(event.content.info.size)})`) // the space is necessary to work around a bug in Discord's URL previewer. the preview still gets blurred in the client. - } else { - captionContent.addLine(`${emoji}Uploaded file: ${url} (${pb(event.content.info.size)})`, tag`${emoji}Uploaded file: ${filename} (${pb(event.content.info.size)})`) - } - } else { - // Upload file as file - let filename = event.content.filename || event.content.body - if (fileIsSpoiler) filename = "SPOILER_" + filename - if ("file" in event.content) { - // Encrypted - assert.equal(event.content.file.key.alg, "A256CTR") - attachments.push({id: "0", filename}) - pendingFiles.push({name: filename, mxc: event.content.file.url, key: event.content.file.key.k, iv: event.content.file.iv}) - } else { - // Unencrypted - attachments.push({id: "0", filename}) - pendingFiles.push({name: filename, mxc: event.content.url}) - } - } - - // Add result to content - const result = captionContent.get() - if (result.body) { - Object.assign(event.content, {body: result.body, format: result.format, formatted_body: result.formatted_body}) - shouldProcessTextEvent = true - } - } - - if (event.type === "m.sticker") { - const withoutMxc = mxUtils.makeMxcPublic(event.content.url) - assert(withoutMxc) - const url = `${reg.ooye.bridge_origin}/download/sticker/${withoutMxc}/_.webp` - content = `[${event.content.body || "\u2800"}](${url})` - - } else if (event.type === "org.matrix.msc3381.poll.start") { - const pollContent = event.content["org.matrix.msc3381.poll.start"] // just for convenience - const isClosed = false; - const maxSelections = pollContent.max_selections || 1 - const questionText = pollContent.question["org.matrix.msc1767.text"] - const pollOptions = pollContent.answers.map(answer => ({ - matrix_option: answer.id, - option_text: answer["org.matrix.msc1767.text"], - count: 0 // no votes initially - })) - content = "" - pollMessages.push(pollComponents.getPollComponents(isClosed, maxSelections, questionText, pollOptions)) - - } else if (event.type === "org.matrix.msc3381.poll.end") { - assert(di.pollEnd) - content = "" - messageIDsToEdit.push(di.pollEnd.messageID) - pollMessages.push(pollComponents.getPollComponentsFromDatabase(di.pollEnd.messageID)) - pollMessages.push({ - ...await pollComponents.getPollEndMessageFromDatabase(channel.id, di.pollEnd.messageID), - avatar_url: `${reg.ooye.bridge_origin}/download/file/poll-star-avatar.png` - }) - - } else { + if (event.type === "m.room.message" && (event.content.msgtype === "m.text" || event.content.msgtype === "m.emote")) { // Handling edits. If the edit was an edit of a reply, edits do not include the reply reference, so we need to fetch up to 2 more events. // this event ---is an edit of--> original event ---is a reply to--> past event await (async () => { @@ -673,12 +486,13 @@ async function eventToMessage(event, guild, channel, di) { if (!messageIDsToEdit.length) return // Ok, it's an edit. - event = {...event, content: event.content["m.new_content"]} + event.content = event.content["m.new_content"] // Is it editing a reply? We need special handling if it is. // Get the original event, then check if it was a reply const originalEvent = await di.api.getEvent(event.room_id, originalEventId) - const repliedToEventId = originalEvent?.content?.["m.relates_to"]?.["m.in_reply_to"]?.event_id + if (!originalEvent) return + const repliedToEventId = originalEvent.content["m.relates_to"]?.["m.in_reply_to"]?.event_id if (!repliedToEventId) return // After all that, it's an edit of a reply. @@ -734,33 +548,41 @@ async function eventToMessage(event, guild, channel, di) { return } - replyLine = await getL1L2ReplyLine() - const row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") - .select("reference_channel_id", "message_id").where({event_id: repliedToEventId}).and("ORDER BY part").get() + // @ts-ignore + const autoEmoji = new Map(select("auto_emoji", ["name", "emoji_id"], {}, "WHERE name = 'L1' OR name = 'L2'").raw().all()) + replyLine = `<:L1:${autoEmoji.get("L1")}><:L2:${autoEmoji.get("L2")}>` + const row = from("event_message").join("message_channel", "message_id").select("channel_id", "message_id").where({event_id: repliedToEventId}).and("ORDER BY part").get() if (row) { - replyLine += `https://discord.com/channels/${guild.id}/${row.reference_channel_id}/${row.message_id} ` + replyLine += `https://discord.com/channels/${guild.id}/${row.channel_id}/${row.message_id} ` + } + const sender = repliedToEvent.sender + const authorID = getUserOrProxyOwnerID(sender) + if (authorID) { + replyLine += `<@${authorID}>` + } else { + let senderName = select("member_cache", "displayname", {mxid: sender}).pluck().get() + if (!senderName) { + const match = sender.match(/@([^:]*)/) + assert(match) + senderName = match[1] + } + replyLine += `**Ⓜ${senderName}**` } // If the event has been edited, the homeserver will include the relation in `unsigned`. if (repliedToEvent.unsigned?.["m.relations"]?.["m.replace"]?.content?.["m.new_content"]) { repliedToEvent = repliedToEvent.unsigned["m.relations"]["m.replace"] // Note: this changes which event_id is in repliedToEvent. repliedToEvent.content = repliedToEvent.content["m.new_content"] } - /** @type {string} */ - let repliedToContent = repliedToEvent.content.formatted_body || repliedToEvent.content.body - const fileReplyContentAlternative = attachmentEmojis.get(repliedToEvent.content.msgtype) let contentPreview + const fileReplyContentAlternative = attachmentEmojis.get(repliedToEvent.content.msgtype) if (fileReplyContentAlternative) { contentPreview = " " + fileReplyContentAlternative } else if (repliedToEvent.unsigned?.redacted_because) { contentPreview = " (in reply to a deleted message)" - } else if (typeof repliedToContent !== "string") { - // in reply to a weird metadata event like m.room.name, m.room.member... - // I'm not implementing text fallbacks for arbitrary room events. this should cover most cases - // this has never ever happened in the wild anyway - repliedToEvent.sender = "" - contentPreview = " (channel details edited)" } else { // Generate a reply preview for a standard message + /** @type {string} */ + let repliedToContent = repliedToEvent.content.formatted_body || repliedToEvent.content.body repliedToContent = repliedToContent.replace(/.*<\/mx-reply>/s, "") // Remove everything before replies, so just use the actual message body repliedToContent = repliedToContent.replace(/^\s*
.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards repliedToContent = repliedToContent.replace(/(?:\n|
)+/g, " ") // Should all be on one line @@ -771,226 +593,213 @@ async function eventToMessage(event, guild, channel, di) { return convertEmoji(mxcUrlMatch?.[1], titleTextMatch?.[1], false, false) }) repliedToContent = repliedToContent.replace(/<[^:>][^>]*>/g, "") // Completely strip all HTML tags and formatting. + repliedToContent = repliedToContent.replace(/\bhttps?:\/\/[^ )]*/g, "<$&>") repliedToContent = entities.decodeHTML5Strict(repliedToContent) // Remove entities like & " const contentPreviewChunks = chunk(repliedToContent, 50) if (contentPreviewChunks.length) { contentPreview = ": " + contentPreviewChunks[0] - contentPreview = contentPreview.replace(/\bhttps?:\/\/[^ )]*/g, "<$&>") if (contentPreviewChunks.length > 1) contentPreview = contentPreview.replace(/[,.']$/, "") + "..." } else { + console.log("Unable to generate reply preview for this replied-to event because we stripped all of it:", repliedToEvent) contentPreview = "" } } - const sender = repliedToEvent.sender - const authorMention = getUserOrProxyOwnerMention(sender) - if (authorMention) { - replyLine += authorMention - } else { - let senderName = select("member_cache", "displayname", {mxid: sender}).pluck().get() - if (!senderName) senderName = sender.match(/@([^:]*)/)?.[1] - if (senderName) replyLine += `**Ⓜ${senderName}**` - } replyLine = `-# > ${replyLine}${contentPreview}\n` })() - if (shouldProcessTextEvent) { - if (event.content.format === "org.matrix.custom.html" && event.content.formatted_body) { - let input = event.content.formatted_body - if (event.content.msgtype === "m.emote") { - input = `* ${displayName} ${input}` - } - - // Handling mentions of Discord users - input = input.replace(/("https:\/\/matrix.to\/#\/((?:@|%40)[^"]+)")>/g, (whole, attributeValue, mxid) => { - mxid = decodeURIComponent(mxid) - if (mxUtils.eventSenderIsFromDiscord(mxid)) { - // Handle mention of an OOYE sim user by their mxid - const id = select("sim", "user_id", {mxid}).pluck().get() - if (!id) return whole - return `${attributeValue} data-user-id="${id}">` - } else { - // Handle mention of a Matrix user by their mxid - // Check if this Matrix user is actually the sim user from another old bridge in the room? - const match = mxid.match(/[^:]*discord[^:]*_([0-9]{6,}):/) // try to match @_discord_123456, @_discordpuppet_123456, etc. - if (match) return `${attributeValue} data-user-id="${match[1]}">` - // Nope, just a real Matrix user. - return whole - } - }) - - // Handling mentions of rooms and room-messages - input = await handleRoomOrMessageLinks(input, di) - - // Stripping colons after mentions - input = input.replace(/( data-user-id.*?<\/a>):?/g, "$1") - input = input.replace(/("https:\/\/matrix.to.*?<\/a>):?/g, "$1") - - // Element adds a bunch of
before
but doesn't render them. I can't figure out how this even works in the browser, so let's just delete those. - input = input.replace(/(?:\n|
\s*)*<\/blockquote>/g, "
") - - // The matrix spec hasn't decided whether \n counts as a newline or not, but I'm going to count it, because if it's in the data it's there for a reason. - // But I should not count it if it's between block elements. - input = input.replace(/(<\/?([^ >]+)[^>]*>)?\n(<\/?([^ >]+)[^>]*>)?/g, (whole, beforeContext, beforeTag, afterContext, afterTag) => { - if (typeof beforeTag !== "string" && typeof afterTag !== "string") { - return "
" - } - beforeContext = beforeContext || "" - beforeTag = beforeTag || "" - afterContext = afterContext || "" - afterTag = afterTag || "" - if (!mxUtils.BLOCK_ELEMENTS.includes(beforeTag.toUpperCase()) && !mxUtils.BLOCK_ELEMENTS.includes(afterTag.toUpperCase())) { - return beforeContext + "
" + afterContext - } else { - return whole - } - }) - - // Note: Element's renderers on Web and Android currently collapse whitespace, like the browser does. Turndown also collapses whitespace which is good for me. - // If later I'm using a client that doesn't collapse whitespace and I want turndown to follow suit, uncomment the following line of code, and it Just Works: - // input = input.replace(/ /g, " ") - // There is also a corresponding test to uncomment, named "event2message: whitespace is retained" - - // Handling written @mentions: we need to look for candidate Discord members to join to the room - // This shouldn't apply to code blocks, links, or inside attributes. So editing the HTML tree instead of regular expressions is a sensible choice here. - // We're using the domino parser because Turndown uses the same and can reuse this tree. - const doc = domino.createDocument( - // DOM parsers arrange elements in the and . Wrapping in a custom element ensures elements are reliably arranged in a single element. - '' + input + '' - ); - const root = doc.getElementById("turndown-root"); - async function forEachNode(event, node) { - for (; node; node = node.nextSibling) { - // Check written mentions - if (node.nodeType === 3 && node.nodeValue.includes("@") && !nodeIsChildOf(node, ["A", "CODE", "PRE"])) { - const result = await checkWrittenMentions(node.nodeValue, event.sender, event.room_id, guild, di) - if (result) { - node.nodeValue = result.content - ensureJoined.push(...result.ensureJoined) - allowedMentionsParse.push(...result.allowedMentionsParse) - } - } - // Check for incompatible backticks in code blocks - let preNode - if (node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"]))) { - if (preNode.firstChild?.nodeName === "CODE") { - const ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1] || "txt" - const filename = `inline_code.${ext}` - // Build the replacement node - const replacementCode = doc.createElement("code") - replacementCode.textContent = `[${filename}]` - // Build its containing node - const replacement = doc.createElement("span") - replacement.appendChild(doc.createTextNode(" ")) - replacement.appendChild(replacementCode) - replacement.appendChild(doc.createTextNode(" ")) - // Replace the code block with the - preNode.replaceWith(replacement) - // Upload the code as an attachment - const content = getCodeContent(preNode.firstChild) - attachments.push({id: String(attachments.length), filename}) - pendingFiles.push({name: filename, buffer: Buffer.from(content, "utf8")}) - } - } - // Suppress link embeds - if (node.nodeType === 1 && node.tagName === "A") { - // Suppress if sender tried to add angle brackets - const inBody = event.content.body.indexOf(node.getAttribute("href")) - let shouldSuppress = inBody !== -1 && event.content.body[inBody-1] === "<" - if (!shouldSuppress && guild?.roles) { - // Suppress if regular users don't have permission - const permissions = dUtils.getPermissions(guild.id, [], guild.roles) - const canEmbedLinks = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.EmbedLinks) - shouldSuppress = !canEmbedLinks - } - if (shouldSuppress) { - node.setAttribute("data-suppress", "") - } - } - await forEachNode(event, node.firstChild) - } - } - await forEachNode(event, root) - - // SPRITE SHEET EMOJIS FEATURE: Emojis at the end of the message that we don't know about will be reuploaded as a sprite sheet. - // First we need to determine which emojis are at the end. - endOfMessageEmojis = [] - let match - let last = input.length - while ((match = input.slice(0, last).match(/]*>\s*$/))) { - if (!match[0].includes("data-mx-emoticon")) break - const mxcUrl = match[0].match(/\bsrc="(mxc:\/\/[^"]+)"/) - if (mxcUrl) endOfMessageEmojis.unshift(mxcUrl[1]) - assert(typeof match.index === "number", "Your JavaScript implementation does not comply with TC39: https://tc39.es/ecma262/multipage/text-processing.html#sec-regexpbuiltinexec") - last = match.index - } - - // @ts-ignore bad type from turndown - content = turndownService.turndown(root) - - // Put < > around any surviving matrix.to links to hide the URL previews - content = content.replace(/\bhttps?:\/\/matrix\.to\/[^<>\n )]*/g, "<$&>") - - // It's designed for commonmark, we need to replace the space-space-newline with just newline - content = content.replace(/ \n/g, "\n") - - // If there's a blockquote at the start of the message body and this message is a reply, they should be visually separated - if (replyLine && content.startsWith("> ")) content = "\n" + content - - // SPRITE SHEET EMOJIS FEATURE: - content = await linkEndOfMessageSpriteSheet(content) - } else { - // Looks like we're using the plaintext body! - content = event.content.body - - if (event.content.msgtype === "m.emote") { - content = `* ${displayName} ${content}` - } - - content = await handleRoomOrMessageLinks(content, di) // Replace matrix.to links with discord.com equivalents where possible - - let offset = 0 - for (const match of [...content.matchAll(/\bhttps?:\/\/[^ )>\n]+/g)]) { - assert(typeof match.index === "number") - - // Respect sender's angle brackets - const alreadySuppressed = content[match.index-1+offset] === "<" && content[match.index+match.length+offset] === ">" - if (alreadySuppressed) continue - - // Suppress matrix.to links always - let shouldSuppress = !!match[0].match(/^https?:\/\/matrix\.to\//) - - // Suppress if regular users don't have permission - if (!shouldSuppress && guild?.roles) { - const permissions = dUtils.getPermissions(guild.id, [], guild.roles, undefined, channel.permission_overwrites) - const canEmbedLinks = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.EmbedLinks) - shouldSuppress = !canEmbedLinks - } - - if (shouldSuppress) { - content = content.slice(0, match.index + offset) + "<" + match[0] + ">" + content.slice(match.index + match[0].length + offset) - offset += 2 - } - } - - const result = await checkWrittenMentions(content, event.sender, event.room_id, guild, di) - if (result) { - content = result.content - ensureJoined.push(...result.ensureJoined) - allowedMentionsParse.push(...result.allowedMentionsParse) - } - - // Markdown needs to be escaped, though take care not to escape the middle of links - // @ts-ignore bad type from turndown - content = turndownService.escape(content) + if (event.content.format === "org.matrix.custom.html" && event.content.formatted_body) { + let input = event.content.formatted_body + if (event.content.msgtype === "m.emote") { + input = `* ${displayName} ${input}` } + + // Handling mentions of Discord users + input = input.replace(/("https:\/\/matrix.to\/#\/((?:@|%40)[^"]+)")>/g, (whole, attributeValue, mxid) => { + mxid = decodeURIComponent(mxid) + if (mxUtils.eventSenderIsFromDiscord(mxid)) { + // Handle mention of an OOYE sim user by their mxid + const id = select("sim", "user_id", {mxid}).pluck().get() + if (!id) return whole + return `${attributeValue} data-user-id="${id}">` + } else { + // Handle mention of a Matrix user by their mxid + // Check if this Matrix user is actually the sim user from another old bridge in the room? + const match = mxid.match(/[^:]*discord[^:]*_([0-9]{6,}):/) // try to match @_discord_123456, @_discordpuppet_123456, etc. + if (match) return `${attributeValue} data-user-id="${match[1]}">` + // Nope, just a real Matrix user. + return whole + } + }) + + // Handling mentions of rooms and room-messages + input = await handleRoomOrMessageLinks(input, di) + + // Stripping colons after mentions + input = input.replace(/( data-user-id.*?<\/a>):?/g, "$1") + input = input.replace(/("https:\/\/matrix.to.*?<\/a>):?/g, "$1") + + // Element adds a bunch of
before
but doesn't render them. I can't figure out how this even works in the browser, so let's just delete those. + input = input.replace(/(?:\n|
\s*)*<\/blockquote>/g, "") + + // The matrix spec hasn't decided whether \n counts as a newline or not, but I'm going to count it, because if it's in the data it's there for a reason. + // But I should not count it if it's between block elements. + input = input.replace(/(<\/?([^ >]+)[^>]*>)?\n(<\/?([^ >]+)[^>]*>)?/g, (whole, beforeContext, beforeTag, afterContext, afterTag) => { + // console.error(beforeContext, beforeTag, afterContext, afterTag) + if (typeof beforeTag !== "string" && typeof afterTag !== "string") { + return "
" + } + beforeContext = beforeContext || "" + beforeTag = beforeTag || "" + afterContext = afterContext || "" + afterTag = afterTag || "" + if (!mxUtils.BLOCK_ELEMENTS.includes(beforeTag.toUpperCase()) && !mxUtils.BLOCK_ELEMENTS.includes(afterTag.toUpperCase())) { + return beforeContext + "
" + afterContext + } else { + return whole + } + }) + + // Note: Element's renderers on Web and Android currently collapse whitespace, like the browser does. Turndown also collapses whitespace which is good for me. + // If later I'm using a client that doesn't collapse whitespace and I want turndown to follow suit, uncomment the following line of code, and it Just Works: + // input = input.replace(/ /g, " ") + // There is also a corresponding test to uncomment, named "event2message: whitespace is retained" + + // Handling written @mentions: we need to look for candidate Discord members to join to the room + // This shouldn't apply to code blocks, links, or inside attributes. So editing the HTML tree instead of regular expressions is a sensible choice here. + // We're using the domino parser because Turndown uses the same and can reuse this tree. + const doc = domino.createDocument( + // DOM parsers arrange elements in the and . Wrapping in a custom element ensures elements are reliably arranged in a single element. + '' + input + '' + ); + const root = doc.getElementById("turndown-root"); + async function forEachNode(node) { + for (; node; node = node.nextSibling) { + // Check written mentions + if (node.nodeType === 3 && node.nodeValue.includes("@") && !nodeIsChildOf(node, ["A", "CODE", "PRE"])) { + const result = await checkWrittenMentions(node.nodeValue, event.sender, event.room_id, guild, di) + if (result) { + node.nodeValue = result.content + ensureJoined.push(...result.ensureJoined) + allowedMentionsParse.push(...result.allowedMentionsParse) + } + } + // Check for incompatible backticks in code blocks + let preNode + if (node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"]))) { + if (preNode.firstChild?.nodeName === "CODE") { + const ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1] || "txt" + const filename = `inline_code.${ext}` + // Build the replacement node + const replacementCode = doc.createElement("code") + replacementCode.textContent = `[${filename}]` + // Build its containing node + const replacement = doc.createElement("span") + replacement.appendChild(doc.createTextNode(" ")) + replacement.appendChild(replacementCode) + replacement.appendChild(doc.createTextNode(" ")) + // Replace the code block with the + preNode.replaceWith(replacement) + // Upload the code as an attachment + const content = getCodeContent(preNode.firstChild) + attachments.push({id: String(attachments.length), filename}) + pendingFiles.push({name: filename, buffer: Buffer.from(content, "utf8")}) + } + } + await forEachNode(node.firstChild) + } + } + await forEachNode(root) + + // SPRITE SHEET EMOJIS FEATURE: Emojis at the end of the message that we don't know about will be reuploaded as a sprite sheet. + // First we need to determine which emojis are at the end. + endOfMessageEmojis = [] + let match + let last = input.length + while ((match = input.slice(0, last).match(/]*>\s*$/))) { + if (!match[0].includes("data-mx-emoticon")) break + const mxcUrl = match[0].match(/\bsrc="(mxc:\/\/[^"]+)"/) + if (mxcUrl) endOfMessageEmojis.unshift(mxcUrl[1]) + assert(typeof match.index === "number", "Your JavaScript implementation does not comply with TC39: https://tc39.es/ecma262/multipage/text-processing.html#sec-regexpbuiltinexec") + last = match.index + } + + // @ts-ignore bad type from turndown + content = turndownService.turndown(root) + + // Put < > around any surviving matrix.to links to hide the URL previews + content = content.replace(/\bhttps?:\/\/matrix\.to\/[^<>\n )]*/g, "<$&>") + + // It's designed for commonmark, we need to replace the space-space-newline with just newline + content = content.replace(/ \n/g, "\n") + + // If there's a blockquote at the start of the message body and this message is a reply, they should be visually separated + if (replyLine && content.startsWith("> ")) content = "\n" + content + + // SPRITE SHEET EMOJIS FEATURE: + content = await uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles, di?.mxcDownloader) + } else { + // Looks like we're using the plaintext body! + content = event.content.body + + if (event.content.msgtype === "m.emote") { + content = `* ${displayName} ${content}` + } + + content = await handleRoomOrMessageLinks(content, di) // Replace matrix.to links with discord.com equivalents where possible + content = content.replace(/\bhttps?:\/\/matrix\.to\/[^<>\n )]*/, "<$&>") // Put < > around any surviving matrix.to links to hide the URL previews + + const result = await checkWrittenMentions(content, event.sender, event.room_id, guild, di) + if (result) { + content = result.content + ensureJoined.push(...result.ensureJoined) + allowedMentionsParse.push(...result.allowedMentionsParse) + } + + // Markdown needs to be escaped, though take care not to escape the middle of links + // @ts-ignore bad type from turndown + content = turndownService.escape(content) } + } else if (event.type === "m.room.message" && (event.content.msgtype === "m.file" || event.content.msgtype === "m.video" || event.content.msgtype === "m.audio" || event.content.msgtype === "m.image")) { + content = "" + const filename = event.content.filename || event.content.body + // A written `event.content.body` will be bridged to Discord's image `description` which is like alt text. + // Bridging as description rather than message content in order to match Matrix clients (Element, Neochat) which treat this as alt text or title text. + const description = (event.content.body !== event.content.filename && event.content.filename && event.content.body) || undefined + if ("url" in event.content) { + // Unencrypted + attachments.push({id: "0", description, filename}) + pendingFiles.push({name: filename, mxc: event.content.url}) + } else { + // Encrypted + assert.equal(event.content.file.key.alg, "A256CTR") + attachments.push({id: "0", description, filename}) + pendingFiles.push({name: filename, mxc: event.content.file.url, key: event.content.file.key.k, iv: event.content.file.iv}) + } + } else if (event.type === "m.sticker") { + content = "" + let filename = event.content.body + if (event.type === "m.sticker") { + let mimetype + if (event.content.info?.mimetype?.includes("/")) { + mimetype = event.content.info.mimetype + } else { + const res = await di.api.getMedia(event.content.url, {method: "HEAD"}) + if (res.status === 200) { + mimetype = res.headers.get("content-type") + } + if (!mimetype) throw new Error(`Server error ${res.status} or missing content-type while detecting sticker mimetype`) + } + filename += "." + mimetype.split("/")[1] + } + attachments.push({id: "0", filename}) + pendingFiles.push({name: filename, mxc: event.content.url}) } content = displayNameRunoff + replyLine + content // Split into 2000 character chunks const chunks = chunk(content, 2000) - /** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]})[]} */ + /** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]})[]} */ const messages = chunks.map(content => ({ content, allowed_mentions: { @@ -1013,16 +822,6 @@ async function eventToMessage(event, guild, channel, di) { messages[0].pendingFiles = pendingFiles } - if (pollMessages.length) { - for (const pollMessage of pollMessages) { - messages.push({ - username: displayNameShortened, - avatar_url: avatarURL, - ...pollMessage, - }) - } - } - const messagesToEdit = [] const messagesToSend = [] for (let i = 0; i < messages.length; i++) { diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index 629f2b8..a97fd26 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -1,11 +1,21 @@ const assert = require("assert").strict +const fs = require("fs") const {test} = require("supertape") -const DiscordTypes = require("discord-api-types/v10") const {eventToMessage} = require("./event-to-message") +const {convertImageStream} = require("./emoji-sheet") const data = require("../../../test/data") const {MatrixServerError} = require("../../matrix/mreq") const {select, discord} = require("../../passthrough") +/* c8 ignore next 7 */ +function slow() { + if (process.argv.includes("--slow")) { + return test + } else { + return test.skip + } +} + /** * @param {string} roomID * @param {string} eventID @@ -38,6 +48,25 @@ function sameFirstContentAndWhitespace(t, a, b) { t.equal(a2, b2) } +/** + * MOCK: Gets the emoji from the filesystem and converts to uncompressed PNG data. + * @param {string} mxc a single mxc:// URL + * @returns {Promise} uncompressed PNG data, or undefined if the downloaded emoji is not valid +*/ +async function mockGetAndConvertEmoji(mxc) { + const id = mxc.match(/\/([^./]*)$/)?.[1] + let s + if (fs.existsSync(`test/res/${id}.png`)) { + s = fs.createReadStream(`test/res/${id}.png`) + } else { + s = fs.createReadStream(`test/res/${id}.gif`) + } + return convertImageStream(s, () => { + s.pause() + s.emit("end") + }) +} + test("event2message: body is used when there is no formatted_body", async t => { t.deepEqual( await eventToMessage({ @@ -85,7 +114,7 @@ test("event2message: any markdown in body is escaped, except strikethrough", asy unsigned: { age: 405299 } - }, {}, {}, { + }, {}, { snow: { guild: { searchGuildMembers: () => [] @@ -273,287 +302,6 @@ test("event2message: markdown in link text does not attempt to be escaped becaus ) }) -test("event2message: markdown in link url does not attempt to be escaped (plaintext body, not suppressed)", async t => { - t.deepEqual( - await eventToMessage({ - content: { - msgtype: "m.text", - body: "the wikimedia commons freaks are gonna love this one https://commons.wikimedia.org/wiki/File:Car_covered_in_traffic_cones.jpg" - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message" - }), - { - ensureJoined: [], - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "the wikimedia commons freaks are gonna love this one https://commons.wikimedia.org/wiki/File:Car_covered_in_traffic_cones.jpg", - avatar_url: undefined, - allowed_mentions: { - parse: ["users", "roles"] - } - }] - } - ) -}) - -test("event2message: markdown in link url does not attempt to be escaped (plaintext body, link suppressed)", async t => { - t.deepEqual( - await eventToMessage({ - content: { - msgtype: "m.text", - body: "the wikimedia commons freaks are gonna love this one https://commons.wikimedia.org/wiki/File:Car_covered_in_traffic_cones.jpg" - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message" - }, { - id: "123", - roles: [{ - id: "123", - name: "@everyone", - permissions: DiscordTypes.PermissionFlagsBits.SendMessages - }] - }, {}), - { - ensureJoined: [], - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "the wikimedia commons freaks are gonna love this one ", - avatar_url: undefined, - allowed_mentions: { - parse: ["users", "roles"] - } - }] - } - ) -}) - -test("event2message: embeds are suppressed if the guild does not have embed links permission (formatted body)", async t => { - t.deepEqual( - await eventToMessage({ - content: { - body: "posting one of my favourite songs recently (starts at timestamp) https://youtu.be/RhV2X7WQMPA?t=364", - format: "org.matrix.custom.html", - formatted_body: `posting one of my favourite songs recently (starts at timestamp) https://youtu.be/RhV2X7WQMPA?t=364`, - msgtype: "m.text" - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message", - }, { - id: "123", - roles: [{ - id: "123", - name: "@everyone", - permissions: DiscordTypes.PermissionFlagsBits.SendMessages - }] - }), - { - ensureJoined: [], - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "posting one of my favourite songs recently (starts at timestamp) ", - avatar_url: undefined, - allowed_mentions: { - parse: ["users", "roles"] - } - }] - } - ) -}) - -test("event2message: embeds are suppressed if the guild does not have embed links permission (plaintext body)", async t => { - t.deepEqual( - await eventToMessage({ - content: { - body: "posting one of my favourite songs recently (starts at timestamp) https://youtu.be/RhV2X7WQMPA?t=364", - msgtype: "m.text" - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message", - }, { - id: "123", - roles: [{ - id: "123", - name: "@everyone", - permissions: DiscordTypes.PermissionFlagsBits.SendMessages - }] - }, {}), - { - ensureJoined: [], - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "posting one of my favourite songs recently (starts at timestamp) ", - avatar_url: undefined, - allowed_mentions: { - parse: ["users", "roles"] - } - }] - } - ) -}) - -test("event2message: embeds are suppressed if the channel does not have embed links permission (plaintext body)", async t => { - t.deepEqual( - await eventToMessage({ - content: { - body: "posting one of my favourite songs recently (starts at timestamp) https://youtu.be/RhV2X7WQMPA?t=364", - msgtype: "m.text" - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message", - }, { - id: "123", - roles: [{ - id: "123", - name: "@everyone", - permissions: DiscordTypes.PermissionFlagsBits.SendMessages | DiscordTypes.PermissionFlagsBits.EmbedLinks - }] - }, { - permission_overwrites: [{ - id: "123", - type: 0, - deny: String(DiscordTypes.PermissionFlagsBits.EmbedLinks), - allow: "0" - }] - }), - { - ensureJoined: [], - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "posting one of my favourite songs recently (starts at timestamp) ", - avatar_url: undefined, - allowed_mentions: { - parse: ["users", "roles"] - } - }] - } - ) -}) - -test("event2message: links retain angle brackets (formatted body)", async t => { - t.deepEqual( - await eventToMessage({ - content: { - body: "posting one of my favourite songs recently (starts at timestamp) ", - format: "org.matrix.custom.html", - formatted_body: `posting one of my favourite songs recently (starts at timestamp) https://youtu.be/RhV2X7WQMPA?t=364`, - msgtype: "m.text" - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message", - }), - { - ensureJoined: [], - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "posting one of my favourite songs recently (starts at timestamp) ", - avatar_url: undefined, - allowed_mentions: { - parse: ["users", "roles"] - } - }] - } - ) -}) - -test("event2message: links retain angle brackets (plaintext body)", async t => { - t.deepEqual( - await eventToMessage({ - content: { - body: "posting one of my favourite songs recently (starts at timestamp) ", - msgtype: "m.text" - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message", - }), - { - ensureJoined: [], - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "posting one of my favourite songs recently (starts at timestamp) ", - avatar_url: undefined, - allowed_mentions: { - parse: ["users", "roles"] - } - }] - } - ) -}) - -test("event2message: links don't have angle brackets added by accident", async t => { - t.deepEqual( - await eventToMessage({ - "content": { - "body": "Wanted to automate WG→AWG config enrichment and ended up basically coding a batch INI processor.\nhttps://github.com/Erquint/wgcbp", - "m.mentions": {}, - "msgtype": "m.text" - }, - "origin_server_ts": 1767848218369, - "sender": "@erquint:agiadn.org", - "type": "m.room.message", - "unsigned": { - "membership": "join" - }, - "event_id": "$DxPjyI88VYsJGKuGmhFivFeKn-i5MEBEnAhabmsBaXQ", - "room_id": "!zq94fae5bVKUubZLp7:agiadn.org" - }, {}, {}, { - api: { - async getStateEvent(roomID, type, key) { - return { - displayname: "Erquint" - } - } - } - }), - { - ensureJoined: [], - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "Erquint", - content: "Wanted to automate WG→AWG config enrichment and ended up basically coding a batch INI processor.\nhttps://github.com/Erquint/wgcbp", - avatar_url: undefined, - allowed_mentions: { - parse: ["users", "roles"] - } - }] - } - ) -}) - test("event2message: basic html is converted to markdown", async t => { t.deepEqual( await eventToMessage({ @@ -656,135 +404,6 @@ test("event2message: spoiler reasons work", async t => { ) }) -test("event2message: media spoilers work", async t => { - t.deepEqual( - await eventToMessage({ - content: { - body: "pitstop.png", - filename: "pitstop.png", - info: { - h: 870, - mimetype: "image/png", - size: 729990, - w: 674, - "xyz.amorgan.blurhash": "UqOMmRM{_Mx[xZaxR*tQ.8ayxtWBRkRkWUWB" - }, - msgtype: "m.image", - "page.codeberg.everypizza.msc4193.spoiler": true, - url: "mxc://agiadn.org/JY5NvEFojTvYDp5znjGIkkQ7Ez7GwsdT" - }, - origin_server_ts: 1764885561299, - room_id: "!zq94fae5bVKUubZLp7:agiadn.org", - sender: "@underscore_x:agiadn.org", - type: "m.room.message", - event_id: "$6P7u-lpu2u73ZrHUru2UG1rPfsh8PfYLPK21o3SNIN4", - user_id: "@underscore_x:agiadn.org" - }), - { - ensureJoined: [], - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "underscore_x", - content: "", - avatar_url: undefined, - attachments: [{id: "0", filename: "SPOILER_pitstop.png"}], - pendingFiles: [{ - mxc: "mxc://agiadn.org/JY5NvEFojTvYDp5znjGIkkQ7Ez7GwsdT", - name: "SPOILER_pitstop.png", - }] - }] - } - ) -}) - -test("event2message: media spoilers with reason work", async t => { - t.deepEqual( - await eventToMessage({ - content: { - body: "pitstop.png", - filename: "pitstop.png", - info: { - h: 870, - mimetype: "image/png", - size: 729990, - w: 674, - "xyz.amorgan.blurhash": "UqOMmRM{_Mx[xZaxR*tQ.8ayxtWBRkRkWUWB" - }, - msgtype: "m.image", - "page.codeberg.everypizza.msc4193.spoiler": true, - "page.codeberg.everypizza.msc4193.spoiler.reason": "golden witch solutions", - url: "mxc://agiadn.org/JY5NvEFojTvYDp5znjGIkkQ7Ez7GwsdT" - }, - origin_server_ts: 1764885561299, - room_id: "!zq94fae5bVKUubZLp7:agiadn.org", - sender: "@underscore_x:agiadn.org", - type: "m.room.message", - event_id: "$6P7u-lpu2u73ZrHUru2UG1rPfsh8PfYLPK21o3SNIN4", - user_id: "@underscore_x:agiadn.org" - }), - { - ensureJoined: [], - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "underscore_x", - allowed_mentions: { - parse: ["users", "roles"] - }, - content: "(Spoiler: golden witch solutions)", - avatar_url: undefined, - attachments: [{id: "0", filename: "SPOILER_pitstop.png"}], - pendingFiles: [{ - mxc: "mxc://agiadn.org/JY5NvEFojTvYDp5znjGIkkQ7Ez7GwsdT", - name: "SPOILER_pitstop.png", - }] - }] - } - ) -}) - -test("event2message: spoiler files too large for Discord are linked and retain reason", async t => { - t.deepEqual( - await eventToMessage({ - content: { - body: "pitstop.png", - filename: "pitstop.png", - info: { - h: 870, - mimetype: "image/png", - size: 40000000, - w: 674, - "xyz.amorgan.blurhash": "UqOMmRM{_Mx[xZaxR*tQ.8ayxtWBRkRkWUWB" - }, - msgtype: "m.image", - "page.codeberg.everypizza.msc4193.spoiler": true, - "page.codeberg.everypizza.msc4193.spoiler.reason": "golden witch secrets", - url: "mxc://agiadn.org/JY5NvEFojTvYDp5znjGIkkQ7Ez7GwsdT" - }, - origin_server_ts: 1764885561299, - room_id: "!zq94fae5bVKUubZLp7:agiadn.org", - sender: "@underscore_x:agiadn.org", - type: "m.room.message", - event_id: "$6P7u-lpu2u73ZrHUru2UG1rPfsh8PfYLPK21o3SNIN4", - user_id: "@underscore_x:agiadn.org" - }), - { - ensureJoined: [], - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "underscore_x", - allowed_mentions: { - parse: ["users", "roles"] - }, - content: "(Spoiler: golden witch secrets)\n🖼️ _Uploaded **SPOILER** file: ||[pitstop.png](https://bridge.example.org/download/matrix/agiadn.org/JY5NvEFojTvYDp5znjGIkkQ7Ez7GwsdT )|| (40 MB)_", - avatar_url: undefined - }] - } - ) -}) - test("event2message: markdown syntax is escaped", async t => { t.deepEqual( await eventToMessage({ @@ -940,7 +559,7 @@ test("event2message: lists are bridged correctly", async t => { "transaction_id": "m1692967313951.441" }, "event_id": "$l-xQPY5vNJo3SNxU9d8aOWNVD1glMslMyrp4M_JEF70", - "room_id": "!kLRqKKUQXcibIMtOpl:cadence.moe" + "room_id": "!BpMdOUkWWhFxmTrENV:cadence.moe" }), { ensureJoined: [], @@ -1043,7 +662,7 @@ test("event2message: code block contents are formatted correctly and not escaped formatted_body: "
input = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,\n_input_ = input = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,\n
\n

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

\n" }, event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe" }), { ensureJoined: [], @@ -1073,7 +692,7 @@ test("event2message: code blocks use double backtick as delimiter when necessary formatted_body: "backtick in ` the middle, backtick at the edge`" }, event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe" }), { ensureJoined: [], @@ -1103,7 +722,7 @@ test("event2message: inline code is converted to code block if it contains both formatted_body: "` one two ``" }, event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe" }), { ensureJoined: [], @@ -1133,7 +752,7 @@ test("event2message: code blocks are uploaded as attachments instead if they con formatted_body: 'So if you run code like this
System.out.println("```");
it should print a markdown formatted code block' }, event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe" }), { ensureJoined: [], @@ -1165,7 +784,7 @@ test("event2message: code blocks are uploaded as attachments instead if they con formatted_body: 'So if you run code like this
System.out.println("```");
it should print a markdown formatted code block' }, event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe" }), { ensureJoined: [], @@ -1202,7 +821,7 @@ test("event2message: characters are encoded properly in code blocks", async t => + '\n
' }, event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe" }), { ensureJoined: [], @@ -1283,7 +902,7 @@ test("event2message: lists have appropriate line breaks", async t => { 'm.mentions': {}, msgtype: 'm.text' }, - room_id: '!TqlyQmifxGUggEmdBN:cadence.moe', + room_id: '!cBxtVRxDlZvSVhJXVK:cadence.moe', sender: '@Milan:tchncs.de', type: 'm.room.message', }), @@ -1324,7 +943,7 @@ test("event2message: ordered list start attribute works", async t => { 'm.mentions': {}, msgtype: 'm.text' }, - room_id: '!TqlyQmifxGUggEmdBN:cadence.moe', + room_id: '!cBxtVRxDlZvSVhJXVK:cadence.moe', sender: '@Milan:tchncs.de', type: 'm.room.message', }), @@ -1433,7 +1052,7 @@ test("event2message: rich reply to a sim user", async t => { }, "event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8", "room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe" - }, data.guild.general, data.channel.general, { + }, data.guild.general, { api: { getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", { type: "m.room.message", @@ -1469,7 +1088,7 @@ test("event2message: rich reply to a rich reply to a multi-line message should c content: { body: "> <@cadence:cadence.moe> I just checked in a fix that will probably work, can you try reproducing this on the latest `main` branch and see if I fixed it?\n\nwill try later (tomorrow if I don't forgor)", format: "org.matrix.custom.html", - formatted_body: "
In reply to @cadence:cadence.moe
I just checked in a fix that will probably work, can you try reproducing this on the latest main branch and see if I fixed it?
will try later (tomorrow if I don't forgor)", + formatted_body: "
In reply to @cadence:cadence.moe
I just checked in a fix that will probably work, can you try reproducing this on the latest main branch and see if I fixed it?
will try later (tomorrow if I don't forgor)", "m.relates_to": { "m.in_reply_to": { event_id: "$A0Rj559NKOh2VndCZSTJXcvgi42gZWVfVQt73wA2Hn0" @@ -1483,7 +1102,7 @@ test("event2message: rich reply to a rich reply to a multi-line message should c unsigned: {}, event_id: "$Q5kNrPxGs31LfWOhUul5I03jNjlxKOwRmWVuivaqCHY", room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" - }, data.guild.general, data.channel.general, { + }, data.guild.general, { api: { getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$A0Rj559NKOh2VndCZSTJXcvgi42gZWVfVQt73wA2Hn0", { "type": "m.room.message", @@ -1492,7 +1111,7 @@ test("event2message: rich reply to a rich reply to a multi-line message should c "msgtype": "m.text", "body": "> <@solonovamax:matrix.org> multipart messages will be deleted if the message is edited to require less space\n> \n> \n> steps to reproduce:\n> \n> 1. send a message that is longer than 2000 characters (discord character limit)\n> - bot will split message into two messages on discord\n> 2. edit message to be under 2000 characters (discord character limit)\n> - bot will delete one of the messages on discord, and then edit the other one to include the edited content\n> - the bot will *then* delete the message on matrix (presumably) because one of the messages on discord was deleted (by \n\nI just checked in a fix that will probably work, can you try reproducing this on the latest `main` branch and see if I fixed it?", "format": "org.matrix.custom.html", - "formatted_body": "
In reply to @solonovamax:matrix.org

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

\n

steps to reproduce:

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

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

\n

steps to reproduce:

\n
    \n
  1. send a message that is longer than 2000 characters (discord character limit)
  2. \n
\n
    \n
  • bot will split message into two messages on discord
  • \n
\n
    \n
  1. edit message to be under 2000 characters (discord character limit)
  2. \n
\n
    \n
  • bot will delete one of the messages on discord, and then edit the other one to include the edited content
  • \n
  • the bot will then delete the message on matrix (presumably) because one of the messages on discord was deleted (by
  • \n
\n
I just checked in a fix that will probably work, can you try reproducing this on the latest main branch and see if I fixed it?", "m.relates_to": { "m.in_reply_to": { "event_id": "$u4OD19vd2GETkOyhgFVla92oDKI4ojwBf2-JeVCG7EI" @@ -1504,7 +1123,7 @@ test("event2message: rich reply to a rich reply to a multi-line message should c "age": 19069564 }, "event_id": "$A0Rj559NKOh2VndCZSTJXcvgi42gZWVfVQt73wA2Hn0", - "room_id": "!TqlyQmifxGUggEmdBN:cadence.moe" + "room_id": "!cBxtVRxDlZvSVhJXVK:cadence.moe" }) }, snow: { @@ -1558,7 +1177,7 @@ test("event2message: rich reply to an already-edited message will quote the new }, "event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8", "room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe" - }, data.guild.general, data.channel.general, { + }, data.guild.general, { api: { getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$DSQvWxOBB2DYaei6b83-fb33dQGYt5LJd_s8Nl2a43Q", { type: "m.room.message", @@ -1641,7 +1260,7 @@ test("event2message: rich reply to a missing event will quote from formatted_bod }, "event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8", "room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe" - }, data.guild.general, data.channel.general, { + }, data.guild.general, { api: { async getEvent(roomID, eventID) { called++ @@ -1691,7 +1310,7 @@ test("event2message: rich reply to a missing event without formatted_body will u }, "event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8", "room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe" - }, data.guild.general, data.channel.general, { + }, data.guild.general, { api: { async getEvent(roomID, eventID) { called++ @@ -1742,7 +1361,7 @@ test("event2message: rich reply to a missing event and no reply fallback will no }, "event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8", "room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe" - }, data.guild.general, data.channel.general, { + }, data.guild.general, { api: { async getEvent(roomID, eventID) { called++ @@ -1787,7 +1406,7 @@ test("event2message: should avoid using blockquote contents as reply preview in }, event_id: "$BpGx8_vqHyN6UQDARPDU51ftrlRBhleutRSgpAJJ--g", room_id: "!fGgIymcYWOqjbSRUdV:cadence.moe" - }, data.guild.general, data.channel.general, { + }, data.guild.general, { api: { getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", { "type": "m.room.message", @@ -1838,7 +1457,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.channel.general, { + }, data.guild.general, { api: { getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$qmyjr-ISJtnOM5WTWLI0fT7uSlqRLgpyin2d2NCglCU", { "type": "m.room.message", @@ -1887,7 +1506,7 @@ test("event2message: should include a reply preview when message ends with a blo }, event_id: "$n6sg1X9rLeMzCYufJTRvaLzFeLQ-oEXjCWkHtRxcem4", room_id: "!fGgIymcYWOqjbSRUdV:cadence.moe" - }, data.guild.general, data.channel.general, { + }, data.guild.general, { api: { getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$uXM2I6w-XMtim14-OSZ_8Z2uQ6MDAZLT37eYIiEU6KQ", { type: 'm.room.message', @@ -1976,7 +1595,7 @@ test("event2message: should include a reply preview when replying to a descripti }, event_id: "$qCOlszCawu5hlnF2a2PGyXeGGvtoNJdXyRAEaTF0waA", room_id: "!CzvdIdUQXgUjDVKxeU:cadence.moe" - }, data.guild.general, data.channel.general, { + }, data.guild.general, { api: { getEvent: mockGetEvent(t, "!CzvdIdUQXgUjDVKxeU:cadence.moe", "$zJFjTvNn1w_YqpR4o4ISKUFisNRgZcu1KSMI_LADPVQ", { type: "m.room.message", @@ -2061,7 +1680,7 @@ test("event2message: entities are not escaped in main message or reply preview", }, event_id: "$2I7odT9okTdpwDcqOjkJb_A3utdO4V8Cp3LK6-Rvwcs", room_id: "!fGgIymcYWOqjbSRUdV:cadence.moe" - }, data.guild.general, data.channel.general, { + }, data.guild.general, { api: { getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$yIWjZPi6Xk56fBxJwqV4ANs_hYLjnWI2cNKbZ2zwk60", { type: "m.room.message", @@ -2113,7 +1732,7 @@ test("event2message: reply preview converts emoji formatting when replying to a }, event_id: "$bCMLaLiMfoRajaGTgzaxAci-g8hJfkspVJIKwYktnvc", room_id: "!TqlyQmifxGUggEmdBN:cadence.moe" - }, data.guild.general, data.channel.general, { + }, data.guild.general, { api: { getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$zmO-dtPO6FubBkDxJZ5YmutPIsG1RgV5JJku-9LeGWs", { type: "m.room.message", @@ -2163,7 +1782,7 @@ test("event2message: reply preview can guess custom emoji based on the name if i }, event_id: "$bCMLaLiMfoRajaGTgzaxAci-g8hJfkspVJIKwYktnvc", room_id: "!TqlyQmifxGUggEmdBN:cadence.moe" - }, data.guild.general, data.channel.general, { + }, data.guild.general, { api: { getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$zmO-dtPO6FubBkDxJZ5YmutPIsG1RgV5JJku-9LeGWs", { type: "m.room.message", @@ -2213,7 +1832,7 @@ test("event2message: reply preview uses emoji title text when replying to an unk }, event_id: "$bCMLaLiMfoRajaGTgzaxAci-g8hJfkspVJIKwYktnvc", room_id: "!TqlyQmifxGUggEmdBN:cadence.moe" - }, data.guild.general, data.channel.general, { + }, data.guild.general, { api: { getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$zmO-dtPO6FubBkDxJZ5YmutPIsG1RgV5JJku-9LeGWs", { type: "m.room.message", @@ -2263,7 +1882,7 @@ test("event2message: reply preview ignores garbage image", async t => { }, event_id: "$bCMLaLiMfoRajaGTgzaxAci-g8hJfkspVJIKwYktnvc", room_id: "!TqlyQmifxGUggEmdBN:cadence.moe" - }, data.guild.general, data.channel.general, { + }, data.guild.general, { api: { getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$zmO-dtPO6FubBkDxJZ5YmutPIsG1RgV5JJku-9LeGWs", { type: "m.room.message", @@ -2313,7 +1932,7 @@ test("event2message: reply to empty message doesn't show an extra line or anythi }, event_id: "$bCMLaLiMfoRajaGTgzaxAci-g8hJfkspVJIKwYktnvc", room_id: "!TqlyQmifxGUggEmdBN:cadence.moe" - }, data.guild.general, data.channel.general, { + }, data.guild.general, { api: { getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$zmO-dtPO6FubBkDxJZ5YmutPIsG1RgV5JJku-9LeGWs", { type: "m.room.message", @@ -2373,7 +1992,7 @@ test("event2message: editing a rich reply to a sim user", async t => { }, "event_id": "$XEgssz13q-a7NLO7UZO2Oepq7tSiDBD7YRfr7Xu_QiA", "room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe" - }, data.guild.general, data.channel.general, { + }, data.guild.general, { api: { getEvent: (roomID, eventID) => { assert.ok(eventID === "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04" || eventID === "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8") @@ -2454,7 +2073,7 @@ test("event2message: editing a plaintext body message", async t => { }, "event_id": "$KxGwvVNzNcmlVbiI2m5kX-jMFNi3Jle71-uu1j7P7vM", "room_id": "!BnKuBPCvyfOkhcUjEu:cadence.moe" - }, data.guild.general, data.channel.general, { + }, data.guild.general, { api: { getEvent: mockGetEvent(t, "!BnKuBPCvyfOkhcUjEu:cadence.moe", "$7LIdiJCEqjcWUrpzWzS8TELOlFfBEe4ytgS7zn2lbSs", { type: "m.room.message", @@ -2509,7 +2128,7 @@ test("event2message: editing a plaintext message to be longer", async t => { }, "event_id": "$KxGwvVNzNcmlVbiI2m5kX-jMFNi3Jle71-uu1j7P7vM", "room_id": "!BnKuBPCvyfOkhcUjEu:cadence.moe" - }, data.guild.general, data.channel.general, { + }, data.guild.general, { api: { getEvent: mockGetEvent(t, "!BnKuBPCvyfOkhcUjEu:cadence.moe", "$7LIdiJCEqjcWUrpzWzS8TELOlFfBEe4ytgS7zn2lbSs", { type: "m.room.message", @@ -2571,7 +2190,7 @@ test("event2message: editing a plaintext message to be shorter", async t => { }, "event_id": "$KxGwvVNzNcmlVbiI2m5kX-jMFNi3Jle71-uu1j7P7vM", "room_id": "!BnKuBPCvyfOkhcUjEu:cadence.moe" - }, data.guild.general, data.channel.general, { + }, data.guild.general, { api: { getEvent: mockGetEvent(t, "!BnKuBPCvyfOkhcUjEu:cadence.moe", "$7LIdiJCEqjcWUrpzWzS8TELOlFfBEe4ytgS7zn2lbSt", { type: "m.room.message", @@ -2630,7 +2249,7 @@ test("event2message: editing a formatted body message", async t => { }, "event_id": "$KxGwvVNzNcmlVbiI2m5kX-jMFNi3Jle71-uu1j7P7vM", "room_id": "!BnKuBPCvyfOkhcUjEu:cadence.moe" - }, data.guild.general, data.channel.general, { + }, data.guild.general, { api: { getEvent: mockGetEvent(t, "!BnKuBPCvyfOkhcUjEu:cadence.moe", "$7LIdiJCEqjcWUrpzWzS8TELOlFfBEe4ytgS7zn2lbSs", { type: "m.room.message", @@ -2686,7 +2305,7 @@ test("event2message: rich reply to a matrix user's long message with formatting" }, "event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8", "room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe" - }, data.guild.general, data.channel.general, { + }, data.guild.general, { api: { getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", { "type": "m.room.message", @@ -2741,7 +2360,7 @@ test("event2message: rich reply to an image", async t => { }, "event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8", "room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe" - }, data.guild.general, data.channel.general, { + }, data.guild.general, { api: { getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", { type: "m.room.message", @@ -2803,7 +2422,7 @@ test("event2message: rich reply to a spoiler should ensure the spoiler is hidden }, "event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8", "room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe" - }, data.guild.general, data.channel.general, { + }, data.guild.general, { api: { getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", { type: "m.room.message", @@ -2854,7 +2473,7 @@ test("event2message: with layered rich replies, the preview should only be the r }, event_id: "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8", room_id: "!fGgIymcYWOqjbSRUdV:cadence.moe" - }, data.guild.general, data.channel.general, { + }, data.guild.general, { api: { getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", { "type": "m.room.message", @@ -2915,7 +2534,7 @@ test("event2message: if event is a reply and starts with a quote, they should be }, room_id: "!TqlyQmifxGUggEmdBN:cadence.moe", event_id: "$nCvtZeBFedYuEavt4OftloCHc0kaFW2ktHCfIOklhjU", - }, data.guild.general, data.channel.general, { + }, data.guild.general, { api: { getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$tTYQcke93fwocsc1K6itwUq85EG0RZ0ksCuIglKioks", { sender: "@aflower:syndicated.gay", @@ -2966,7 +2585,7 @@ test("event2message: rich reply to a deleted event", async t => { }, event_id: "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8", room_id: "!TqlyQmifxGUggEmdBN:cadence.moe" - }, data.guild.general, data.channel.general, { + }, data.guild.general, { api: { getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$f-noT-d-Eo_Xgpc05Ww89ErUXku4NwKWYGHLzWKo1kU", { type: "m.room.message", @@ -3006,145 +2625,6 @@ test("event2message: rich reply to a deleted event", async t => { ) }) -test("event2message: rich reply to a state event with no body", async t => { - t.deepEqual( - await eventToMessage({ - type: "m.room.message", - sender: "@ampflower:matrix.org", - content: { - msgtype: "m.text", - body: "> <@ampflower:matrix.org> changed the room topic\n\nnice room topic", - format: "org.matrix.custom.html", - formatted_body: "
In reply to @ampflower:matrix.org changed the room topic
nice room topic", - "m.relates_to": { - "m.in_reply_to": { - event_id: "$f-noT-d-Eo_Xgpc05Ww89ErUXku4NwKWYGHLzWKo1kU" - } - } - }, - event_id: "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8", - room_id: "!TqlyQmifxGUggEmdBN:cadence.moe" - }, data.guild.general, data.channel.general, { - api: { - getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$f-noT-d-Eo_Xgpc05Ww89ErUXku4NwKWYGHLzWKo1kU", { - type: "m.room.topic", - sender: "@ampflower:matrix.org", - content: { - topic: "you're cute" - }, - user_id: "@ampflower:matrix.org" - }) - } - }), - { - ensureJoined: [], - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "Ampflower 🌺", - content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647> (channel details edited)\nnice room topic", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/PRfhXYBTOalvgQYtmCLeUXko", - allowed_mentions: { - parse: ["users", "roles"] - } - }] - } - ) -}) - -test("event2message: rich reply with an image", async t => { - let called = 0 - t.deepEqual( - await eventToMessage({ - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - body: "image.png", - info: { - size: 470379, - mimetype: "image/png", - thumbnail_info: { - w: 800, - h: 450, - mimetype: "image/png", - size: 183014 - }, - w: 1920, - h: 1080, - "xyz.amorgan.blurhash": "L24_wtVt00xuxvR%NFX74Toz?waL", - thumbnail_url: "mxc://cadence.moe/lPtnjlleowWCXGOHKVDyoXGn" - }, - msgtype: "m.image", - "m.relates_to": { - "m.in_reply_to": { - event_id: "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4" - } - }, - url: "mxc://cadence.moe/yxMobQMbSqNHpajxgSHtaooG" - }, - origin_server_ts: 1764127662631, - unsigned: { - membership: "join", - age: 97, - transaction_id: "m1764127662540.2" - }, - event_id: "$QOxkw7u8vjTrrdKxEUO13JWSixV7UXAZU1freT1SkHc", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" - }, data.guild.general, data.channel.general, { - api: { - getEvent(roomID, eventID) { - called++ - t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") - t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4") - return { - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: "you have to check every diff above insane on this set https://osu.ppy.sh/beatmapsets/2263303#osu/4826296" - }, - origin_server_ts: 1763639396419, - unsigned: { - membership: "join", - age: 486586696, - transaction_id: "m1763639396324.578" - }, - event_id: "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" - } - } - } - }), - { - ensureJoined: [], - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [ - { - content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/112760669178241024/1128118177155526666 **Ⓜcadence [they]**: you have to check every diff above insane on this...", - allowed_mentions: { - parse: ["users", "roles"] - }, - attachments: [ - { - filename: "image.png", - id: "0", - }, - ], - avatar_url: undefined, - pendingFiles: [ - { - mxc: "mxc://cadence.moe/yxMobQMbSqNHpajxgSHtaooG", - name: "image.png", - }, - ], - username: "cadence [they]", - }, - ] - } - ) -}) - test("event2message: raw mentioning discord users in plaintext body works", async t => { t.deepEqual( await eventToMessage({ @@ -3347,47 +2827,6 @@ test("event2message: mentioning matrix users works", async t => { ) }) -test("event2message: matrix mentions are not double-escaped when embed links permission is denied", async t => { - t.deepEqual( - await eventToMessage({ - content: { - msgtype: "m.text", - body: "wrong body", - format: "org.matrix.custom.html", - formatted_body: `I'm just testing mentions` - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message", - unsigned: { - age: 405299 - } - }, { - id: "123", - roles: [{ - id: "123", - name: "@everyone", - permissions: DiscordTypes.PermissionFlagsBits.SendMessages - }] - }), - { - ensureJoined: [], - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "I'm just [@▲]() testing mentions", - avatar_url: undefined, - allowed_mentions: { - parse: ["users", "roles"] - } - }] - } - ) -}) - test("event2message: multiple mentions are both escaped", async t => { t.deepEqual( await eventToMessage({ @@ -3518,133 +2957,6 @@ test("event2message: mentioning bridged rooms works (plaintext body)", async t = ) }) -test("event2message: mentioning bridged rooms by alias works", async t => { - let called = 0 - t.deepEqual( - await eventToMessage({ - content: { - msgtype: "m.text", - body: "wrong body", - format: "org.matrix.custom.html", - formatted_body: `I'm just worm-farm testing channel mentions` - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message", - unsigned: { - age: 405299 - } - }, {}, {}, { - api: { - async getAlias(alias) { - called++ - t.equal(alias, "#worm-farm:cadence.moe") - return "!BnKuBPCvyfOkhcUjEu:cadence.moe" - } - } - }), - { - ensureJoined: [], - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "I'm just <#1100319550446252084> testing channel mentions", - avatar_url: undefined, - allowed_mentions: { - parse: ["users", "roles"] - } - }] - } - ) - t.equal(called, 1) -}) - -test("event2message: mentioning bridged rooms by alias works (plaintext body)", async t => { - let called = 0 - t.deepEqual( - await eventToMessage({ - content: { - msgtype: "m.text", - body: `I'm just https://matrix.to/#/#worm-farm:cadence.moe?via=cadence.moe testing channel mentions` - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message", - unsigned: { - age: 405299 - } - }, {}, {}, { - api: { - async getAlias(alias) { - called++ - t.equal(alias, "#worm-farm:cadence.moe") - return "!BnKuBPCvyfOkhcUjEu:cadence.moe" - } - } - }), - { - ensureJoined: [], - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "I'm just <#1100319550446252084> testing channel mentions", - avatar_url: undefined, - allowed_mentions: { - parse: ["users", "roles"] - } - }] - } - ) - t.equal(called, 1) -}) - -test("event2message: mentioning bridged rooms by alias skips the link when alias is unresolvable", async t => { - let called = 0 - t.deepEqual( - await eventToMessage({ - content: { - msgtype: "m.text", - body: `I'm just https://matrix.to/#/#worm-farm:cadence.moe?via=cadence.moe and https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe?via=cadence.moe testing channel mentions` - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message", - unsigned: { - age: 405299 - } - }, {}, {}, { - api: { - async getAlias(alias) { - called++ - throw new MatrixServerError("Alias doesn't exist or something") - } - } - }), - { - ensureJoined: [], - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "I'm just and <#1100319550446252084> testing channel mentions", - avatar_url: undefined, - allowed_mentions: { - parse: ["users", "roles"] - } - }] - } - ) - t.equal(called, 1) -}) - test("event2message: mentioning known bridged events works (plaintext body)", async t => { t.deepEqual( await eventToMessage({ @@ -3797,7 +3109,7 @@ test("event2message: mentioning unknown bridged events can approximate with time unsigned: { age: 405299 } - }, {}, {}, { + }, {}, { api: { async getEvent(roomID, eventID) { called++ @@ -3844,7 +3156,7 @@ test("event2message: mentioning events falls back to original link when server d unsigned: { age: 405299 } - }, {}, {}, { + }, {}, { api: { async getEvent(roomID, eventID) { called++ @@ -3890,7 +3202,7 @@ test("event2message: mentioning events falls back to original link when the chan unsigned: { age: 405299 } - }, {}, {}, { + }, {}, { api: { /* c8 ignore next 3 */ async getEvent() { @@ -4046,17 +3358,17 @@ test("event2message: caches the member if the member is not known", async t => { }, event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", origin_server_ts: 1688301929913, - room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe", + room_id: "!should_be_newly_cached:cadence.moe", sender: "@should_be_newly_cached:cadence.moe", type: "m.room.message", unsigned: { age: 405299 } - }, {}, {}, { + }, {}, { api: { getStateEvent: async (roomID, type, stateKey) => { called++ - t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") + t.equal(roomID, "!should_be_newly_cached:cadence.moe") t.equal(type, "m.room.member") t.equal(stateKey, "@should_be_newly_cached:cadence.moe") return { @@ -4080,60 +3392,12 @@ test("event2message: caches the member if the member is not known", async t => { } ) - t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe"}).all(), [ + t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!should_be_newly_cached:cadence.moe"}).all(), [ {avatar_url: "mxc://cadence.moe/this_is_the_avatar", displayname: null, mxid: "@should_be_newly_cached:cadence.moe"} ]) t.equal(called, 1, "getStateEvent should be called once") }) -test("event2message: does not cache the member if the room is not known", async t => { - let called = 0 - t.deepEqual( - await eventToMessage({ - content: { - body: "testing the member state cache", - msgtype: "m.text" - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!not_real:cadence.moe", - sender: "@should_not_be_cached:cadence.moe", - type: "m.room.message", - unsigned: { - age: 405299 - } - }, {}, {}, { - api: { - getStateEvent: async (roomID, type, stateKey) => { - called++ - t.equal(roomID, "!not_real:cadence.moe") - t.equal(type, "m.room.member") - t.equal(stateKey, "@should_not_be_cached:cadence.moe") - return { - avatar_url: "mxc://cadence.moe/this_is_the_avatar" - } - } - } - }), - { - ensureJoined: [], - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "should_not_be_cached", - content: "testing the member state cache", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/this_is_the_avatar", - allowed_mentions: { - parse: ["users", "roles"] - } - }] - } - ) - - t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!not_real:cadence.moe"}).all(), []) - t.equal(called, 1, "getStateEvent should be called once") -}) - test("event2message: skips caching the member if the member does not exist, somehow", async t => { let called = 0 t.deepEqual( @@ -4150,7 +3414,7 @@ test("event2message: skips caching the member if the member does not exist, some unsigned: { age: 405299 } - }, {}, {}, { + }, {}, { api: { getStateEvent: async (roomID, type, stateKey) => { called++ @@ -4189,17 +3453,17 @@ test("event2message: overly long usernames are shifted into the message content" }, event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", origin_server_ts: 1688301929913, - room_id: "!cqeGDbPiMFAhLsqqqq:cadence.moe", + room_id: "!should_be_newly_cached_2:cadence.moe", sender: "@should_be_newly_cached_2:cadence.moe", type: "m.room.message", unsigned: { age: 405299 } - }, {}, {}, { + }, {}, { api: { getStateEvent: async (roomID, type, stateKey) => { called++ - t.equal(roomID, "!cqeGDbPiMFAhLsqqqq:cadence.moe") + t.equal(roomID, "!should_be_newly_cached_2:cadence.moe") t.equal(type, "m.room.member") t.equal(stateKey, "@should_be_newly_cached_2:cadence.moe") return { @@ -4222,7 +3486,7 @@ test("event2message: overly long usernames are shifted into the message content" }] } ) - t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!cqeGDbPiMFAhLsqqqq:cadence.moe"}).all(), [ + t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!should_be_newly_cached_2:cadence.moe"}).all(), [ {avatar_url: null, displayname: "I am BLACK I am WHITE I am SHORT I am LONG I am EVERYTHING YOU THINK IS IMPORTANT and I DON'T MATTER", mxid: "@should_be_newly_cached_2:cadence.moe"} ]) t.equal(called, 1, "getStateEvent should be called once") @@ -4237,7 +3501,7 @@ test("event2message: overly long usernames are not treated specially when the ms }, event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", origin_server_ts: 1688301929913, - room_id: "!cqeGDbPiMFAhLsqqqq:cadence.moe", + room_id: "!should_be_newly_cached_2:cadence.moe", sender: "@should_be_newly_cached_2:cadence.moe", type: "m.room.message", unsigned: { @@ -4285,7 +3549,7 @@ test("event2message: text attachments work", async t => { username: "cadence [they]", content: "", avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", - attachments: [{id: "0", filename: "chiki-powerups.txt"}], + attachments: [{id: "0", description: undefined, filename: "chiki-powerups.txt"}], pendingFiles: [{name: "chiki-powerups.txt", mxc: "mxc://cadence.moe/zyThGlYQxvlvBVbVgKDDbiHH"}] }] } @@ -4321,14 +3585,14 @@ test("event2message: image attachments work", async t => { username: "cadence [they]", content: "", avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", - attachments: [{id: "0", filename: "cool cat.png"}], + attachments: [{id: "0", description: undefined, filename: "cool cat.png"}], pendingFiles: [{name: "cool cat.png", mxc: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}] }] } ) }) -test("event2message: image attachments can have a plaintext caption", async t => { +test("event2message: image attachments can have a custom description", async t => { t.deepEqual( await eventToMessage({ type: "m.room.message", @@ -4355,62 +3619,10 @@ test("event2message: image attachments can have a plaintext caption", async t => messagesToEdit: [], messagesToSend: [{ username: "cadence [they]", - content: "Cat emoji surrounded by pink hearts", + content: "", avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", - attachments: [{id: "0", filename: "cool cat.png"}], - pendingFiles: [{name: "cool cat.png", mxc: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}], - allowed_mentions: { - parse: ["users", "roles"] - } - }] - } - ) -}) - -test("event2message: image attachments can have a formatted caption", async t => { - t.deepEqual( - await eventToMessage({ - content: { - body: "this event has `formatting`", - filename: "5740.jpg", - format: "org.matrix.custom.html", - formatted_body: "this event has formatting", - info: { - h: 1340, - mimetype: "image/jpeg", - size: 226689, - thumbnail_info: { - h: 670, - mimetype: "image/jpeg", - size: 80157, - w: 540 - }, - thumbnail_url: "mxc://thomcat.rocks/XhLsOCDBYyearsLQgUUrbAvw", - w: 1080, - "xyz.amorgan.blurhash": "KHJQG*55ic-.}?0M58J.9v" - }, - msgtype: "m.image", - url: "mxc://thomcat.rocks/RTHsXmcMPXmuHqVNsnbKtRbh" - }, - origin_server_ts: 1740607766895, - sender: "@cadence:cadence.moe", - type: "m.room.message", - event_id: "$NqNqVgukiQm1nynm9vIr9FIq31hZpQ3udOd7cBIW46U", - room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe" - }), - { - ensureJoined: [], - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "this event has `formatting`", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", - attachments: [{id: "0", filename: "5740.jpg"}], - pendingFiles: [{name: "5740.jpg", mxc: "mxc://thomcat.rocks/RTHsXmcMPXmuHqVNsnbKtRbh"}], - allowed_mentions: { - parse: ["users", "roles"] - } + attachments: [{id: "0", description: "Cat emoji surrounded by pink hearts", filename: "cool cat.png"}], + pendingFiles: [{name: "cool cat.png", mxc: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}] }] } ) @@ -4459,7 +3671,7 @@ test("event2message: encrypted image attachments work", async t => { username: "cadence [they]", content: "", avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", - attachments: [{id: "0", filename: "image.png"}], + attachments: [{id: "0", description: undefined, filename: "image.png"}], pendingFiles: [{ name: "image.png", mxc: "mxc://heyquark.com/LOGkUTlVFrqfiExlGZNgCJJX", @@ -4471,251 +3683,6 @@ test("event2message: encrypted image attachments work", async t => { ) }) -test("event2message: evil encrypted image attachment works", async t => { - t.deepEqual( - await eventToMessage({ - sender: "@austin:tchncs.de", - type: "m.room.message", - content: { - body: "Screenshot 2025-06-29 at 13.36.46.png", - file: { - hashes: { - sha256: "Vh1apd8wSFu/BpUdQbIrKUzFB0Uu+l1octgZL+aVGTQ" - }, - iv: "sd33K7pSZNMAAAAAAAAAAA", - key: { - alg: "A256CTR", - ext: true, - k: "-nyqk1eqI-g-ND59P9qHp310-Qyc2A5gSAYm1BxopSg", - key_ops: [ - "encrypt", - "decrypt" - ], - kty: "oct" - }, - url: "mxc://tchncs.de/eac5f83fa97cd74062daf75dfa04d6e5356897281939377544214085632", - v: "v2" - }, - info: { - h: 682, - mimetype: "image/png", - "org.matrix.msc4230.is_animated": false, - size: 1813154, - thumbnail_file: { - hashes: { - sha256: "o3xykQwfsTUf5Y8qP5fjT7qBv5lAT3rtkmPpise5eQw" - }, - iv: "SNxIZsJkju4AAAAAAAAAAA", - key: { - alg: "A256CTR", - ext: true, - k: "CcibYjzzSDexOWBbcBh_kCDiLibg8vUZthz5CnxV0es", - key_ops: [ - "encrypt", - "decrypt" - ], - kty: "oct" - }, - url: "mxc://tchncs.de/ecd811d913ed1b240ebfc81517a5de2c3a1e9d401939377537079574528", - v: "v2" - }, - thumbnail_info: { - h: 600, - mimetype: "image/png", - size: 451773, - w: 507 - }, - thumbnail_url: null, - w: 577, - "xyz.amorgan.blurhash": "TqN1Ais=t1~qRjWFxURiWCM{ofof" - }, - "m.mentions": {}, - msgtype: "m.image", - url: null - }, - event_id: "$UKMbzTlqlyLYN78utVEtiivABFvOe39nx5trHwqNmeQ", - room_id: "!iSyXgNxQcEuXoXpsSn:pussthecat.org" - }), - { - ensureJoined: [], - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "Austin Huang", - content: "", - avatar_url: "https://bridge.example.org/download/matrix/tchncs.de/090a2b5e07eed2f71e84edad5207221e6c8f8b8e", - attachments: [{id: "0", filename: "Screenshot 2025-06-29 at 13.36.46.png"}], - pendingFiles: [{ - name: "Screenshot 2025-06-29 at 13.36.46.png", - mxc: "mxc://tchncs.de/eac5f83fa97cd74062daf75dfa04d6e5356897281939377544214085632", - key: "-nyqk1eqI-g-ND59P9qHp310-Qyc2A5gSAYm1BxopSg", - iv: "sd33K7pSZNMAAAAAAAAAAA" - }] - }] - } - ) -}) - -test("event2message: large attachments are uploaded if the server boost level is sufficient", async t => { - t.deepEqual( - await eventToMessage({ - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - body: "cool cat.png", - filename: "cool cat.png", - info: { - size: 90_000_000, - mimetype: "image/png", - w: 480, - h: 480, - "xyz.amorgan.blurhash": "URTHsVaTpdj2eKZgkkkXp{pHl7feo@lSl9Z$" - }, - msgtype: "m.image", - url: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn" - }, - event_id: "$CXQy3Wmg1A-gL_xAesC1HQcQTEXwICLdSwwUx55FBTI", - room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe" - }, { - features: ["MAX_FILE_SIZE_100_MB"] - }), - { - ensureJoined: [], - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", - attachments: [{id: "0", filename: "cool cat.png"}], - pendingFiles: [{name: "cool cat.png", mxc: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}] - }] - } - ) -}) - -test("event2message: files too large for Discord are linked as as URL", async t => { - t.deepEqual( - await eventToMessage({ - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - body: "cool cat.png", - filename: "cool cat.png", - info: { - size: 40_000_000, - mimetype: "image/png", - w: 480, - h: 480, - "xyz.amorgan.blurhash": "URTHsVaTpdj2eKZgkkkXp{pHl7feo@lSl9Z$" - }, - msgtype: "m.image", - url: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn" - }, - event_id: "$CXQy3Wmg1A-gL_xAesC1HQcQTEXwICLdSwwUx55FBTI", - room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe" - }), - { - ensureJoined: [], - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "🖼️ _Uploaded file: [cool cat.png](https://bridge.example.org/download/matrix/cadence.moe/IvxVJFLEuksCNnbojdSIeEvn) (40 MB)_", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", - allowed_mentions: { - parse: ["users", "roles"] - } - }] - } - ) -}) - -test("event2message: files too large for Discord can have a plaintext caption", async t => { - t.deepEqual( - await eventToMessage({ - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - body: "Cat emoji surrounded by pink hearts", - filename: "cool cat.png", - info: { - size: 40_000_000, - mimetype: "image/png", - w: 480, - h: 480, - "xyz.amorgan.blurhash": "URTHsVaTpdj2eKZgkkkXp{pHl7feo@lSl9Z$" - }, - msgtype: "m.image", - url: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn" - }, - event_id: "$CXQy3Wmg1A-gL_xAesC1HQcQTEXwICLdSwwUx55FBTI", - room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe" - }), - { - ensureJoined: [], - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "Cat emoji surrounded by pink hearts\n🖼️ _Uploaded file: [cool cat.png](https://bridge.example.org/download/matrix/cadence.moe/IvxVJFLEuksCNnbojdSIeEvn) (40 MB)_", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", - allowed_mentions: { - parse: ["users", "roles"] - } - }] - } - ) -}) - -test("event2message: files too large for Discord can have a formatted caption", async t => { - t.deepEqual( - await eventToMessage({ - content: { - body: "this event has `formatting`", - filename: "5740.jpg", - format: "org.matrix.custom.html", - formatted_body: "this event has formatting", - info: { - h: 1340, - mimetype: "image/jpeg", - size: 40_000_000, - thumbnail_info: { - h: 670, - mimetype: "image/jpeg", - size: 80157, - w: 540 - }, - thumbnail_url: "mxc://thomcat.rocks/XhLsOCDBYyearsLQgUUrbAvw", - w: 1080, - "xyz.amorgan.blurhash": "KHJQG*55ic-.}?0M58J.9v" - }, - msgtype: "m.image", - url: "mxc://thomcat.rocks/RTHsXmcMPXmuHqVNsnbKtRbh" - }, - origin_server_ts: 1740607766895, - sender: "@cadence:cadence.moe", - type: "m.room.message", - event_id: "$NqNqVgukiQm1nynm9vIr9FIq31hZpQ3udOd7cBIW46U", - room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe" - }), - { - ensureJoined: [], - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "this event has `formatting`\n🖼️ _Uploaded file: [5740.jpg](https://bridge.example.org/download/matrix/thomcat.rocks/RTHsXmcMPXmuHqVNsnbKtRbh) (40 MB)_", - avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", - allowed_mentions: { - parse: ["users", "roles"] - } - }] - } - ) -}) - - test("event2message: stickers work", async t => { t.deepEqual( await eventToMessage({ @@ -4768,7 +3735,7 @@ test("event2message: stickers fetch mimetype from server when mimetype not provi }, event_id: "$mL-eEVWCwOvFtoOiivDP7gepvf-fTYH6_ioK82bWDI0", room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" - }, {}, {}, { + }, {}, { api: { async getMedia(mxc, options) { called++ @@ -4811,7 +3778,7 @@ test("event2message: stickers with unknown mimetype are not allowed", async t => }, event_id: "$mL-eEVWCwOvFtoOiivDP7gepvf-fTYH6_ioK82bWDI0", room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" - }, {}, {}, { + }, {}, { api: { async getMedia(mxc, options) { called++ @@ -4975,7 +3942,7 @@ test("event2message: guessed @mentions in plaintext may join members to mention" room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" }, { id: "112760669178241024" - }, {}, { + }, { snow: { guild: { async searchGuildMembers(guildID, options) { @@ -5028,7 +3995,7 @@ test("event2message: guessed @mentions in formatted body may join members to men room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" }, { id: "112760669178241024" - }, {}, { + }, { snow: { guild: { async searchGuildMembers(guildID, options) { @@ -5072,7 +4039,7 @@ test("event2message: guessed @mentions feature will not activate on links or cod }, event_id: "$u5gSwSzv_ZQS3eM00mnTBCor8nx_A_AwuQz7e59PZk8", room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" - }, {}, {}, { + }, {}, { snow: { guild: { /* c8 ignore next 4 */ @@ -5141,9 +4108,9 @@ test("event2message: @room converts to @everyone and is allowed when the room do }, room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", event_id: "$SiXetU9h9Dg-M9Frcw_C6ahnoXZ3QPZe3MVJR5tcB9A" - }, data.guild.general, data.channel.general, { + }, data.guild.general, { api: { - async getStateEvent(roomID, type, key) { + getStateEvent(roomID, type, key) { called++ t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") t.equal(type, "m.room.power_levels") @@ -5154,19 +4121,6 @@ test("event2message: @room converts to @everyone and is allowed when the room do room: 0 } } - }, - async getStateEventOuter(roomID, type, key) { - t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") - t.equal(type, "m.room.create") - t.equal(key, "") - return { - type: "m.room.create", - state_key: "", - sender: "@_ooye_bot:cadence.moe", - content: { - room_version: "11" - } - } } } }), @@ -5187,6 +4141,7 @@ test("event2message: @room converts to @everyone and is allowed when the room do }) test("event2message: @room converts to @everyone but is not allowed when the room restricts who can use it", async t => { + let called = 0 t.deepEqual( await eventToMessage({ type: "m.room.message", @@ -5199,9 +4154,10 @@ test("event2message: @room converts to @everyone but is not allowed when the roo }, room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", event_id: "$SiXetU9h9Dg-M9Frcw_C6ahnoXZ3QPZe3MVJR5tcB9A" - }, data.guild.general, data.channel.general, { + }, data.guild.general, { api: { - async getStateEvent(roomID, type, key) { + getStateEvent(roomID, type, key) { + called++ t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") t.equal(type, "m.room.power_levels") t.equal(key, "") @@ -5211,19 +4167,6 @@ test("event2message: @room converts to @everyone but is not allowed when the roo room: 20 } } - }, - async getStateEventOuter(roomID, type, key) { - t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") - t.equal(type, "m.room.create") - t.equal(key, "") - return { - type: "m.room.create", - state_key: "", - sender: "@_ooye_bot:cadence.moe", - content: { - room_version: "11" - } - } } } }), @@ -5244,6 +4187,7 @@ test("event2message: @room converts to @everyone but is not allowed when the roo }) test("event2message: @room converts to @everyone and is allowed if the user has sufficient power to use it", async t => { + let called = 0 t.deepEqual( await eventToMessage({ type: "m.room.message", @@ -5256,9 +4200,10 @@ test("event2message: @room converts to @everyone and is allowed if the user has }, room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", event_id: "$SiXetU9h9Dg-M9Frcw_C6ahnoXZ3QPZe3MVJR5tcB9A" - }, data.guild.general, data.channel.general, { + }, data.guild.general, { api: { - async getStateEvent(roomID, type, key) { + getStateEvent(roomID, type, key) { + called++ t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") t.equal(type, "m.room.power_levels") t.equal(key, "") @@ -5270,19 +4215,6 @@ test("event2message: @room converts to @everyone and is allowed if the user has room: 20 } } - }, - async getStateEventOuter(roomID, type, key) { - t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") - t.equal(type, "m.room.create") - t.equal(key, "") - return { - type: "m.room.create", - state_key: "", - sender: "@_ooye_bot:cadence.moe", - content: { - room_version: "11" - } - } } } }), @@ -5332,158 +4264,102 @@ test("event2message: @room in the middle of a link is not converted", async t => ) }) -test("event2message: table", async t => { - t.deepEqual( - await eventToMessage({ - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: "wrong body", - format: "org.matrix.custom.html", - formatted_body: "content
Col 1Col 2Col 3
AppleBananaCherry
AardvarkBeeCrocodile
ArgonBoronCarbon
more content" - }, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - event_id: "$SiXetU9h9Dg-M9Frcw_C6ahnoXZ3QPZe3MVJR5tcB9A" - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "content```" - + "\nCol 1 Col 2 Col 3 " - + "\n---------------------------" - + "\nApple Banana Cherry " - + "\nAardvark Bee Crocodile" - + "\nArgon Boron Carbon ```" - + "more content", - avatar_url: undefined, - allowed_mentions: { - parse: ["users", "roles"] - } - }], - ensureJoined: [] - } - ) +slow()("event2message: unknown emoji at the end is reuploaded as a sprite sheet", async t => { + const messages = await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "wrong body", + format: "org.matrix.custom.html", + formatted_body: 'a b \":ms_robot_grin:\"' + }, + event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + }, {}, {mxcDownloader: mockGetAndConvertEmoji}) + const testResult = { + content: messages.messagesToSend[0].content, + fileName: messages.messagesToSend[0].pendingFiles[0].name, + fileContentStart: messages.messagesToSend[0].pendingFiles[0].buffer.subarray(0, 90).toString("base64") + } + t.deepEqual(testResult, { + content: "a b", + fileName: "emojis.png", + fileContentStart: "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAPoAAAD6AG1e1JrAAALoklEQVR4nM1ZaVBU2RU+LZSIGnAvFUtcRkSk6abpbkDH" + }) }) -test("event2message: unknown emoji at the end is used for sprite sheet", async t => { - t.deepEqual( - await eventToMessage({ - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: "wrong body", - format: "org.matrix.custom.html", - formatted_body: 'a b \":ms_robot_grin:\"' - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "a b [\u2800](https://bridge.example.org/download/sheet?e=cadence.moe%2FRLMgJGfgTPjIQtvvWZsYjhjy)", - avatar_url: undefined, - allowed_mentions: { - parse: ["users", "roles"] - } - }], - ensureJoined: [] - } - ) +slow()("event2message: known emoji from an unreachable server at the end is reuploaded as a sprite sheet", async t => { + const messages = await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "wrong body", + format: "org.matrix.custom.html", + formatted_body: 'a b \":emoji_from_unreachable_server:\"' + }, + event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + }, {}, {mxcDownloader: mockGetAndConvertEmoji}) + const testResult = { + content: messages.messagesToSend[0].content, + fileName: messages.messagesToSend[0].pendingFiles[0].name, + fileContentStart: messages.messagesToSend[0].pendingFiles[0].buffer.subarray(0, 90).toString("base64") + } + t.deepEqual(testResult, { + content: "a b", + fileName: "emojis.png", + fileContentStart: "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAOoUlEQVR4nM1aCXBbx3l+Eu8bN0CAuO+TAHGTFAmAJHgT" + }) }) -test("event2message: known emoji from an unreachable server at the end is used for sprite sheet", async t => { - t.deepEqual( - await eventToMessage({ - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: "wrong body", - format: "org.matrix.custom.html", - formatted_body: 'a b \":emoji_from_unreachable_server:\"' - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "a b [\u2800](https://bridge.example.org/download/sheet?e=cadence.moe%2FbZFuuUSEebJYXUMSxuuSuLTa)", - avatar_url: undefined, - allowed_mentions: { - parse: ["users", "roles"] - } - }], - ensureJoined: [] - } - ) +slow()("event2message: known and unknown emojis in the end are reuploaded as a sprite sheet", async t => { + const messages = await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "wrong body", + format: "org.matrix.custom.html", + formatted_body: 'known unknown: \":hippo:\" \":ms_robot_dress:\" and known unknown: \":hipposcope:\" \":ms_robot_cat:\"' + }, + event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + }, {}, {mxcDownloader: mockGetAndConvertEmoji}) + const testResult = { + content: messages.messagesToSend[0].content, + fileName: messages.messagesToSend[0].pendingFiles[0].name, + fileContentStart: messages.messagesToSend[0].pendingFiles[0].buffer.subarray(0, 90).toString("base64") + } + t.deepEqual(testResult, { + content: "known unknown: <:hippo:230201364309868544> [:ms_robot_dress:](https://bridge.example.org/download/matrix/cadence.moe/wcouHVjbKJJYajkhJLsyeJAA) and known unknown:", + fileName: "emojis.png", + fileContentStart: "iVBORw0KGgoAAAANSUhEUgAAAGAAAAAwCAYAAADuFn/PAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAeXRFWHRSYXcACklQVEMgcHJvZmlsZQogICAgICA0Ngoz" + }) }) -test("event2message: known and unknown emojis in the end are used for sprite sheet", async t => { - t.deepEqual( - await eventToMessage({ - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: "wrong body", - format: "org.matrix.custom.html", - formatted_body: 'known unknown: \":hippo:\" \":ms_robot_dress:\" and known unknown: \":hipposcope:\" \":ms_robot_cat:\"' - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "known unknown: <:hippo:230201364309868544> [:ms_robot_dress:](https://bridge.example.org/download/matrix/cadence.moe/wcouHVjbKJJYajkhJLsyeJAA) and known unknown: [\u2800](https://bridge.example.org/download/sheet?e=cadence.moe%2FWbYqNlACRuicynBfdnPYtmvc&e=cadence.moe%2FHYcztccFIPgevDvoaWNsEtGJ)", - avatar_url: undefined, - allowed_mentions: { - parse: ["users", "roles"] - } - }], - ensureJoined: [] - } - ) -}) - -test("event2message: all unknown chess emojis are used for sprite sheet", async t => { - t.deepEqual( - await eventToMessage({ - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: "testing :chess_good_move::chess_incorrect::chess_blund::chess_brilliant_move::chess_blundest::chess_draw_black::chess_good_move::chess_incorrect::chess_blund::chess_brilliant_move::chess_blundest::chess_draw_black:", - format: "org.matrix.custom.html", - formatted_body: "testing \":chess_good_move:\"\":chess_incorrect:\"\":chess_blund:\"\":chess_brilliant_move:\"\":chess_blundest:\"\":chess_draw_black:\"\":chess_good_move:\"\":chess_incorrect:\"\":chess_blund:\"\":chess_brilliant_move:\"\":chess_blundest:\"\":chess_draw_black:\"" - }, - event_id: "$Me6iE8C8CZyrDEOYYrXKSYRuuh_25Jj9kZaNrf7LKr4", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "testing [\u2800](https://bridge.example.org/download/sheet?e=cadence.moe%2FlHfmJpzgoNyNtYHdAmBHxXix&e=cadence.moe%2FMtRdXixoKjKKOyHJGWLsWLNU&e=cadence.moe%2FHXfFuougamkURPPMflTJRxGc&e=cadence.moe%2FikYKbkhGhMERAuPPbsnQzZiX&e=cadence.moe%2FAYPpqXzVJvZdzMQJGjioIQBZ&e=cadence.moe%2FUVuzvpVUhqjiueMxYXJiFEAj&e=cadence.moe%2FlHfmJpzgoNyNtYHdAmBHxXix&e=cadence.moe%2FMtRdXixoKjKKOyHJGWLsWLNU&e=cadence.moe%2FHXfFuougamkURPPMflTJRxGc&e=cadence.moe%2FikYKbkhGhMERAuPPbsnQzZiX&e=cadence.moe%2FAYPpqXzVJvZdzMQJGjioIQBZ&e=cadence.moe%2FUVuzvpVUhqjiueMxYXJiFEAj)", - avatar_url: undefined, - allowed_mentions: { - parse: ["users", "roles"] - } - }], - ensureJoined: [] - } - ) +slow()("event2message: all unknown chess emojis are reuploaded as a sprite sheet", async t => { + const messages = await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "testing :chess_good_move::chess_incorrect::chess_blund::chess_brilliant_move::chess_blundest::chess_draw_black::chess_good_move::chess_incorrect::chess_blund::chess_brilliant_move::chess_blundest::chess_draw_black:", + format: "org.matrix.custom.html", + formatted_body: "testing \":chess_good_move:\"\":chess_incorrect:\"\":chess_blund:\"\":chess_brilliant_move:\"\":chess_blundest:\"\":chess_draw_black:\"\":chess_good_move:\"\":chess_incorrect:\"\":chess_blund:\"\":chess_brilliant_move:\"\":chess_blundest:\"\":chess_draw_black:\"" + }, + event_id: "$Me6iE8C8CZyrDEOYYrXKSYRuuh_25Jj9kZaNrf7LKr4", + room_id: "!maggESguZBqGBZtSnr:cadence.moe" + }, {}, {mxcDownloader: mockGetAndConvertEmoji}) + const testResult = { + content: messages.messagesToSend[0].content, + fileName: messages.messagesToSend[0].pendingFiles[0].name, + fileContentStart: messages.messagesToSend[0].pendingFiles[0].buffer.subarray(0, 90).toString("base64") + } + t.deepEqual(testResult, { + content: "testing", + fileName: "emojis.png", + fileContentStart: "iVBORw0KGgoAAAANSUhEUgAAAYAAAABgCAYAAAAU9KWJAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48" + }) }) diff --git a/src/m2d/converters/poll-components.js b/src/m2d/converters/poll-components.js deleted file mode 100644 index a8233e0..0000000 --- a/src/m2d/converters/poll-components.js +++ /dev/null @@ -1,227 +0,0 @@ -// @ts-check - -const assert = require("assert").strict -const DiscordTypes = require("discord-api-types/v10") -const {sync, db, discord, select, from} = require("../../passthrough") - -/** @type {import("../actions/setup-emojis")} */ -const setupEmojis = sync.require("../actions/setup-emojis") - -/** - * @param {{count: number}[]} topAnswers - * @param {number} count - * @returns {string} - */ -function getMedal(topAnswers, count) { - const winningOrTied = count && topAnswers[0].count === count - const secondOrTied = !winningOrTied && count && topAnswers[1]?.count === count && topAnswers.slice(-1)[0].count !== count - const thirdOrTied = !winningOrTied && !secondOrTied && count && topAnswers[2]?.count === count && topAnswers.slice(-1)[0].count !== count - const medal = - ( winningOrTied ? "🥇" - : secondOrTied ? "🥈" - : thirdOrTied ? "🥉" - : "") - return medal -} - -/** - * @param {boolean} isClosed - * @param {{matrix_option: string, option_text: string, count: number}[]} pollOptions already sorted correctly - * @returns {DiscordTypes.APIMessageTopLevelComponent[]} -*/ -function optionsToComponents(isClosed, pollOptions) { - const topAnswers = pollOptions.toSorted((a, b) => b.count - a.count) - /** @type {DiscordTypes.APIMessageTopLevelComponent[]} */ - return pollOptions.map(option => { - const medal = getMedal(topAnswers, option.count) - return { - type: DiscordTypes.ComponentType.Container, - components: [{ - type: DiscordTypes.ComponentType.Section, - components: [{ - type: DiscordTypes.ComponentType.TextDisplay, - content: medal && isClosed ? `${medal} ${option.option_text}` : option.option_text - }], - accessory: { - type: DiscordTypes.ComponentType.Button, - style: medal === "🥇" && isClosed ? DiscordTypes.ButtonStyle.Success : DiscordTypes.ButtonStyle.Secondary, - label: option.count.toString(), - custom_id: `POLL_OPTION#${option.matrix_option}`, - disabled: isClosed - } - }] - } - }) -} - -/** - * @param {number} maxSelections - * @param {number} optionCount - */ -function getMultiSelectString(maxSelections, optionCount) { - if (maxSelections === 1) { - return "Select one answer" - } else if (maxSelections >= optionCount) { - return "Select one or more answers" - } else { - return `Select up to ${maxSelections} answers` - } -} - -/** - * @param {number} maxSelections - * @param {number} optionCount - */ -function getMultiSelectClosedString(maxSelections, optionCount) { - if (maxSelections === 1) { - return "Single choice" - } else if (maxSelections >= optionCount) { - return "Multiple choice" - } else { - return `Multiple choice (up to ${maxSelections})` - } -} - -/** - * @param {boolean} isClosed - * @param {number} maxSelections - * @param {string} questionText - * @param {{matrix_option: string, option_text: string, count: number}[]} pollOptions already sorted correctly - * @returns {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody} - */ -function getPollComponents(isClosed, maxSelections, questionText, pollOptions) { - /** @type {DiscordTypes.APIMessageTopLevelComponent[]} array because it can move around */ - const multiSelectInfoComponent = [{ - type: DiscordTypes.ComponentType.TextDisplay, - content: isClosed ? `-# ${getMultiSelectClosedString(maxSelections, pollOptions.length)}` : `-# ${getMultiSelectString(maxSelections, pollOptions.length)}` - }] - /** @type {DiscordTypes.APIMessageTopLevelComponent} */ - let headingComponent - if (isClosed) { - headingComponent = { // This one is for the poll heading. - type: DiscordTypes.ComponentType.Section, - components: [ - { - type: DiscordTypes.ComponentType.TextDisplay, - content: `## ${questionText}` - } - ], - accessory: { - type: DiscordTypes.ComponentType.Button, - style: DiscordTypes.ButtonStyle.Secondary, - custom_id: "POLL_VOTE", - label: "Voting closed", - disabled: true - } - } - } else { - headingComponent = { // This one is for the poll heading. - type: DiscordTypes.ComponentType.Section, - components: [ - { - type: DiscordTypes.ComponentType.TextDisplay, - content: `## ${questionText}` - }, - // @ts-ignore - multiSelectInfoComponent.pop() - ], - accessory: { - type: DiscordTypes.ComponentType.Button, - style: DiscordTypes.ButtonStyle.Primary, - custom_id: "POLL_VOTE", - label: "Vote!" - } - } - } - const optionComponents = optionsToComponents(isClosed, pollOptions) - return { - flags: DiscordTypes.MessageFlags.IsComponentsV2, - components: [headingComponent, ...optionComponents, ...multiSelectInfoComponent] - } -} - -/** @param {string} messageID */ -function getPollComponentsFromDatabase(messageID) { - const pollRow = select("poll", ["max_selections", "is_closed", "question_text"], {message_id: messageID}).get() - assert(pollRow) - /** @type {{matrix_option: string, option_text: string, count: number}[]} */ - const pollResults = db.prepare("SELECT matrix_option, option_text, seq, count(discord_or_matrix_user_id) as count FROM poll_option LEFT JOIN poll_vote USING (message_id, matrix_option) WHERE message_id = ? GROUP BY matrix_option ORDER BY seq").all(messageID) - return getPollComponents(!!pollRow.is_closed, pollRow.max_selections, pollRow.question_text, pollResults) -} - -/** - * @param {string} channelID - * @param {string} messageID - * @param {string} questionText - * @param {{matrix_option: string, option_text: string, count: number}[]} pollOptions already sorted correctly - * @returns {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody} - */ -function getPollEndMessage(channelID, messageID, questionText, pollOptions) { - const topAnswers = pollOptions.toSorted((a, b) => b.count - a.count) - const totalVotes = pollOptions.reduce((a, c) => a + c.count, 0) - const tied = topAnswers[0].count === topAnswers[1].count - const titleString = `-# The poll **${questionText}** has closed.` - let winnerString = "" - let resultsString = "" - if (totalVotes == 0) { - winnerString = "There was no winner" - } else if (tied) { - winnerString = "It's a draw!" - resultsString = `${Math.round((topAnswers[0].count/totalVotes)*100)}%` - } else { - const pollWin = select("auto_emoji", ["name", "emoji_id"], {name: "poll_win"}).get() - winnerString = `${topAnswers[0].option_text} <:${pollWin?.name}:${pollWin?.emoji_id}>` - resultsString = `Winning answer • ${Math.round((topAnswers[0].count/totalVotes)*100)}%` - } - // @ts-ignore - const guildID = discord.channels.get(channelID).guild_id - let mainContent = `**${winnerString}**` - if (resultsString) { - mainContent += `\n-# ${resultsString}` - } - return { - flags: DiscordTypes.MessageFlags.IsComponentsV2, - components: [{ - type: DiscordTypes.ComponentType.TextDisplay, - content: titleString - }, { - type: DiscordTypes.ComponentType.Container, - components: [{ - type: DiscordTypes.ComponentType.Section, - components: [{ - type: DiscordTypes.ComponentType.TextDisplay, - content: `**${winnerString}**\n-# ${resultsString}` - }], - accessory: { - type: DiscordTypes.ComponentType.Button, - style: DiscordTypes.ButtonStyle.Link, - url: `https://discord.com/channels/${guildID}/${channelID}/${messageID}`, - label: "View Poll" - } - }] - }] - } -} - -/** - * @param {string} channelID - * @param {string} messageID - */ -async function getPollEndMessageFromDatabase(channelID, messageID) { - const pollWin = select("auto_emoji", ["name", "emoji_id"], {name: "poll_win"}).get() - if (!pollWin) { - await setupEmojis.setupEmojis() - } - - const pollRow = select("poll", ["max_selections", "question_text"], {message_id: messageID}).get() - assert(pollRow) - /** @type {{matrix_option: string, option_text: string, count: number}[]} */ - const pollResults = db.prepare("SELECT matrix_option, option_text, seq, count(discord_or_matrix_user_id) as count FROM poll_option LEFT JOIN poll_vote USING (message_id, matrix_option) WHERE message_id = ? GROUP BY matrix_option ORDER BY seq").all(messageID) - return getPollEndMessage(channelID, messageID, pollRow.question_text, pollResults) -} - -module.exports.getMultiSelectString = getMultiSelectString -module.exports.getPollComponents = getPollComponents -module.exports.getPollComponentsFromDatabase = getPollComponentsFromDatabase -module.exports.getPollEndMessageFromDatabase = getPollEndMessageFromDatabase -module.exports.getMedal = getMedal diff --git a/src/m2d/converters/utils.js b/src/m2d/converters/utils.js new file mode 100644 index 0000000..c538627 --- /dev/null +++ b/src/m2d/converters/utils.js @@ -0,0 +1,243 @@ +// @ts-check + +const assert = require("assert").strict + +const passthrough = require("../../passthrough") +const {db} = passthrough + +const {reg} = require("../../matrix/read-registration") +const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) + +/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore +let hasher = null +// @ts-ignore +require("xxhash-wasm")().then(h => hasher = h) + +const BLOCK_ELEMENTS = [ + "ADDRESS", "ARTICLE", "ASIDE", "AUDIO", "BLOCKQUOTE", "BODY", "CANVAS", + "CENTER", "DD", "DETAILS", "DIR", "DIV", "DL", "DT", "FIELDSET", "FIGCAPTION", "FIGURE", + "FOOTER", "FORM", "FRAMESET", "H1", "H2", "H3", "H4", "H5", "H6", "HEADER", + "HGROUP", "HR", "HTML", "ISINDEX", "LI", "MAIN", "MENU", "NAV", "NOFRAMES", + "NOSCRIPT", "OL", "OUTPUT", "P", "PRE", "SECTION", "SUMMARY", "TABLE", "TBODY", "TD", + "TFOOT", "TH", "THEAD", "TR", "UL" +] +const NEWLINE_ELEMENTS = BLOCK_ELEMENTS.concat(["BR"]) + +/** + * Determine whether an event is the bridged representation of a discord message. + * Such messages shouldn't be bridged again. + * @param {string} sender + */ +function eventSenderIsFromDiscord(sender) { + // If it's from a user in the bridge's namespace, then it originated from discord + // This includes messages sent by the appservice's bot user, because that is what's used for webhooks + // TODO: It would be nice if bridge system messages wouldn't trigger this check and could be bridged from matrix to discord, while webhook reflections would remain ignored... + // TODO that only applies to the above todo: But you'd have to watch out for the /icon command, where the bridge bot would set the room avatar, and that shouldn't be reflected into the room a second time. + if (userRegex.some(x => sender.match(x))) { + return true + } + + return false +} + +/** + * Event IDs are really big and have more entropy than we need. + * If we want to store the event ID in the database, we can store a more compact version by hashing it with this. + * I choose a 64-bit non-cryptographic hash as only a 32-bit hash will see birthday collisions unreasonably frequently: https://en.wikipedia.org/wiki/Birthday_attack#Mathematics + * xxhash outputs an unsigned 64-bit integer. + * Converting to a signed 64-bit integer with no bit loss so that it can be stored in an SQLite integer field as-is: https://www.sqlite.org/fileformat2.html#record_format + * This should give very efficient storage with sufficient entropy. + * @param {string} eventID + */ +function getEventIDHash(eventID) { + assert(hasher, "xxhash is not ready yet") + if (eventID[0] === "$" && eventID.length >= 13) { + eventID = eventID.slice(1) // increase entropy per character to potentially help xxhash + } + const unsignedHash = hasher.h64(eventID) + const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range + return signedHash +} + +class MatrixStringBuilder { + constructor() { + this.body = "" + this.formattedBody = "" + } + + /** + * @param {string} body + * @param {string} [formattedBody] + * @param {any} [condition] + */ + add(body, formattedBody, condition = true) { + if (condition) { + if (formattedBody == undefined) formattedBody = body + this.body += body + this.formattedBody += formattedBody + } + return this + } + + /** + * @param {string} body + * @param {string} [formattedBody] + * @param {any} [condition] + */ + addLine(body, formattedBody, condition = true) { + if (condition) { + if (formattedBody == undefined) formattedBody = body + if (this.body.length && this.body.slice(-1) !== "\n") this.body += "\n" + this.body += body + const match = this.formattedBody.match(/<\/?([a-zA-Z]+[a-zA-Z0-9]*)[^>]*>\s*$/) + if (this.formattedBody.length && (!match || !NEWLINE_ELEMENTS.includes(match[1].toUpperCase()))) this.formattedBody += "
" + this.formattedBody += formattedBody + } + return this + } + + /** + * @param {string} body + * @param {string} [formattedBody] + * @param {any} [condition] + */ + addParagraph(body, formattedBody, condition = true) { + if (condition) { + if (formattedBody == undefined) formattedBody = body + if (this.body.length && this.body.slice(-1) !== "\n") this.body += "\n\n" + this.body += body + formattedBody = `

${formattedBody}

` + this.formattedBody += formattedBody + } + return this + } + + get() { + return { + msgtype: "m.text", + body: this.body, + format: "org.matrix.custom.html", + formatted_body: this.formattedBody + } + } +} + +/** + * Context: Room IDs are not routable on their own. Room permalinks need a list of servers to try. The client is responsible for coming up with a list of servers. + * ASSUMPTION 1: The bridge bot is a member of the target room and can therefore access its power levels and member list for calculation. + * ASSUMPTION 2: Because the bridge bot is a member of the target room, the target room is bridged. + * https://spec.matrix.org/v1.9/appendices/#routing + * https://gitdab.com/cadence/out-of-your-element/issues/11 + * @param {string} roomID + * @param {{[K in "getStateEvent" | "getJoinedMembers"]: import("../../matrix/api")[K]}} api + */ +async function getViaServers(roomID, api) { + const candidates = [] + const {joined} = await api.getJoinedMembers(roomID) + // Candidate 0: The bot's own server name + candidates.push(reg.ooye.server_name) + // Candidate 1: Highest joined non-sim non-bot power level user in the room + // https://github.com/matrix-org/matrix-react-sdk/blob/552c65db98b59406fb49562e537a2721c8505517/src/utils/permalinks/Permalinks.ts#L172 + try { + /** @type {{users?: {[mxid: string]: number}}} */ + const powerLevels = await api.getStateEvent(roomID, "m.room.power_levels", "") + if (powerLevels.users) { + const sorted = Object.entries(powerLevels.users).sort((a, b) => b[1] - a[1]) // Highest... + for (const power of sorted) { + const mxid = power[0] + if (!(mxid in joined)) continue // joined... + if (userRegex.some(r => mxid.match(r))) continue // non-sim non-bot... + const match = mxid.match(/:(.*)/) + assert(match) + if (!candidates.includes(match[1])) { + candidates.push(match[1]) + break + } + } + } + } catch (e) { + // power levels event not found + } + // Candidates 2-3: Most popular servers in the room + /** @type {Map} */ + const servers = new Map() + // We can get the most popular servers if we know the members, so let's process those... + Object.keys(joined) + .filter(mxid => !mxid.startsWith("@_")) // Quick check + .filter(mxid => !userRegex.some(r => mxid.match(r))) // Full check + .slice(0, 1000) // Just sample the first thousand real members + .map(mxid => { + const match = mxid.match(/:(.*)/) + assert(match) + return match[1] + }) + .filter(server => !server.match(/([a-f0-9:]+:+)+[a-f0-9]+/)) // No IPv6 servers + .filter(server => !server.match(/[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/)) // No IPv4 servers + // I don't care enough to check ACLs + .forEach(server => { + const existing = servers.get(server) + if (!existing) servers.set(server, 1) + else servers.set(server, existing + 1) + }) + const serverList = [...servers.entries()].sort((a, b) => b[1] - a[1]) + for (const server of serverList) { + if (!candidates.includes(server[0])) { + candidates.push(server[0]) + if (candidates.length >= 4) break // Can have at most 4 candidate via servers + } + } + return candidates +} + +/** + * Context: Room IDs are not routable on their own. Room permalinks need a list of servers to try. The client is responsible for coming up with a list of servers. + * ASSUMPTION 1: The bridge bot is a member of the target room and can therefore access its power levels and member list for calculation. + * ASSUMPTION 2: Because the bridge bot is a member of the target room, the target room is bridged. + * https://spec.matrix.org/v1.9/appendices/#routing + * https://gitdab.com/cadence/out-of-your-element/issues/11 + * @param {string} roomID + * @param {{[K in "getStateEvent" | "getJoinedMembers"]: import("../../matrix/api")[K]}} api + * @returns {Promise} + */ +async function getViaServersQuery(roomID, api) { + const list = await getViaServers(roomID, api) + const qs = new URLSearchParams() + for (const server of list) { + qs.append("via", server) + } + return qs +} + +/** + * Since the introduction of authenticated media, this can no longer just be the /_matrix/media/r0/download URL + * because Discord and Discord users cannot use those URLs. Media now has to be proxied through the bridge. + * To avoid the bridge acting as a proxy for *any* media, there is a list of permitted media stored in the database. + * (The other approach would be signing the URLs with a MAC (or similar) and adding the signature, but I'm not a + * cryptographer, so I don't want to.) To reduce database disk space usage, instead of storing each permitted URL, + * we just store its xxhash as a signed (as in +/-, not signature) 64-bit integer, which fits in an SQLite integer field. + * @see https://matrix.org/blog/2024/06/26/sunsetting-unauthenticated-media/ background + * @see https://matrix.org/blog/2024/06/20/matrix-v1.11-release/ implementation details + * @see https://www.sqlite.org/fileformat2.html#record_format SQLite integer field size + * @param {string} mxc + * @returns {string?} + */ +function getPublicUrlForMxc(mxc) { + assert(hasher, "xxhash is not ready yet") + const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/) + if (!mediaParts) return null + + const serverAndMediaID = `${mediaParts[1]}/${mediaParts[2]}` + const unsignedHash = hasher.h64(serverAndMediaID) + const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range + db.prepare("INSERT OR IGNORE INTO media_proxy (permitted_hash) VALUES (?)").run(signedHash) + + return `${reg.ooye.bridge_origin}/download/matrix/${serverAndMediaID}` +} + +module.exports.BLOCK_ELEMENTS = BLOCK_ELEMENTS +module.exports.eventSenderIsFromDiscord = eventSenderIsFromDiscord +module.exports.getPublicUrlForMxc = getPublicUrlForMxc +module.exports.getEventIDHash = getEventIDHash +module.exports.MatrixStringBuilder = MatrixStringBuilder +module.exports.getViaServers = getViaServers +module.exports.getViaServersQuery = getViaServersQuery diff --git a/src/m2d/converters/utils.test.js b/src/m2d/converters/utils.test.js new file mode 100644 index 0000000..650f420 --- /dev/null +++ b/src/m2d/converters/utils.test.js @@ -0,0 +1,178 @@ +// @ts-check + +const e = new Error("Custom error") + +const {test} = require("supertape") +const {eventSenderIsFromDiscord, getEventIDHash, MatrixStringBuilder, getViaServers} = require("./utils") +const util = require("util") + +/** @param {string[]} mxids */ +function joinedList(mxids) { + /** @type {{[mxid: string]: {display_name: null, avatar_url: null}}} */ + const joined = {} + for (const mxid of mxids) { + joined[mxid] = { + display_name: null, + avatar_url: null + } + } + return {joined} +} + +test("sender type: matrix user", t => { + t.notOk(eventSenderIsFromDiscord("@cadence:cadence.moe")) +}) + +test("sender type: ooye bot", t => { + t.ok(eventSenderIsFromDiscord("@_ooye_bot:cadence.moe")) +}) + +test("sender type: ooye puppet", t => { + t.ok(eventSenderIsFromDiscord("@_ooye_sheep:cadence.moe")) +}) + +test("event hash: hash is the same each time", t => { + const eventID = "$example" + t.equal(getEventIDHash(eventID), getEventIDHash(eventID)) +}) + +test("event hash: hash is different for different inputs", t => { + t.notEqual(getEventIDHash("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe1"), getEventIDHash("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe2")) +}) + +test("MatrixStringBuilder: add, addLine, add same text", t => { + const gatewayMessage = {t: "MY_MESSAGE", d: {display: "Custom message data"}} + let stackLines = e.stack?.split("\n") + + const builder = new MatrixStringBuilder() + builder.addLine("\u26a0 Bridged event from Discord not delivered", "\u26a0 Bridged event from Discord not delivered") + builder.addLine(`Gateway event: ${gatewayMessage.t}`) + builder.addLine(e.toString()) + if (stackLines) { + stackLines = stackLines.slice(0, 2) + stackLines[1] = stackLines[1].replace(/\\/g, "/").replace(/(\s*at ).*(\/m2d\/)/, "$1.$2") + builder.addLine(`Error trace:`, `
Error trace`) + builder.add(`\n${stackLines.join("\n")}`, `
${stackLines.join("\n")}
`) + } + builder.addLine("", `
Original payload
${util.inspect(gatewayMessage.d, false, 4, false)}
`) + + t.deepEqual(builder.get(), { + msgtype: "m.text", + body: "\u26a0 Bridged event from Discord not delivered" + + "\nGateway event: MY_MESSAGE" + + "\nError: Custom error" + + "\nError trace:" + + "\nError: Custom error" + + "\n at ./m2d/converters/utils.test.js:3:11)\n", + format: "org.matrix.custom.html", + formatted_body: "\u26a0 Bridged event from Discord not delivered" + + "
Gateway event: MY_MESSAGE" + + "
Error: Custom error" + + "
Error trace
Error: Custom error\n    at ./m2d/converters/utils.test.js:3:11)
" + + `
Original payload
{ display: 'Custom message data' }
` + }) +}) + +test("MatrixStringBuilder: complete code coverage", t => { + const builder = new MatrixStringBuilder() + builder.add("Line 1") + builder.addParagraph("Line 2") + builder.add("Line 3") + builder.addParagraph("Line 4") + + t.deepEqual(builder.get(), { + msgtype: "m.text", + body: "Line 1\n\nLine 2Line 3\n\nLine 4", + format: "org.matrix.custom.html", + formatted_body: "Line 1

Line 2

Line 3

Line 4

" + }) +}) + +test("getViaServers: returns the server name if the room only has sim users", async t => { + const result = await getViaServers("!baby", { + getStateEvent: async () => ({}), + getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe"]) + }) + t.deepEqual(result, ["cadence.moe"]) +}) + +test("getViaServers: also returns the most popular servers in order", async t => { + const result = await getViaServers("!baby", { + getStateEvent: async () => ({}), + getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid"]) + }) + t.deepEqual(result, ["cadence.moe", "thecollective.invalid", "selfhosted.invalid"]) +}) + +test("getViaServers: does not return IP address servers", async t => { + const result = await getViaServers("!baby", { + getStateEvent: async () => ({}), + getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:45.77.232.172:8443", "@cadence:[::1]:8443", "@cadence:123example.456example.invalid"]) + }) + t.deepEqual(result, ["cadence.moe", "123example.456example.invalid"]) +}) + +test("getViaServers: also returns the highest power level user (100)", async t => { + const result = await getViaServers("!baby", { + getStateEvent: async () => ({ + users: { + "@moderator:tractor.invalid": 50, + "@singleuser:selfhosted.invalid": 100, + "@_ooye_bot:cadence.moe": 100 + } + }), + getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid", "@moderator:tractor.invalid"]) + }) + t.deepEqual(result, ["cadence.moe", "selfhosted.invalid", "thecollective.invalid", "tractor.invalid"]) +}) + +test("getViaServers: also returns the highest power level user (50)", async t => { + const result = await getViaServers("!baby", { + getStateEvent: async () => ({ + users: { + "@moderator:tractor.invalid": 50, + "@_ooye_bot:cadence.moe": 100 + } + }), + getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid", "@singleuser:selfhosted.invalid"]) + }) + t.deepEqual(result, ["cadence.moe", "tractor.invalid", "thecollective.invalid", "selfhosted.invalid"]) +}) + +test("getViaServers: returns at most 4 results", async t => { + const result = await getViaServers("!baby", { + getStateEvent: async () => ({ + users: { + "@moderator:tractor.invalid": 50, + "@singleuser:selfhosted.invalid": 100, + "@_ooye_bot:cadence.moe": 100 + } + }), + getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@cadence:123example.456example.invalid"]) + }) + t.deepEqual(result.length, 4) +}) + +test("getViaServers: returns results even when power levels can't be fetched", async t => { + const result = await getViaServers("!baby", { + getStateEvent: async () => { + throw new Error("event not found or something") + }, + getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@cadence:123example.456example.invalid"]) + }) + t.deepEqual(result.length, 4) +}) + +test("getViaServers: only considers power levels of currently joined members", async t => { + const result = await getViaServers("!baby", { + getStateEvent: async () => ({ + users: { + "@moderator:tractor.invalid": 50, + "@former_moderator:missing.invalid": 100, + "@_ooye_bot:cadence.moe": 100 + } + }), + getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid", "@singleuser:selfhosted.invalid"]) + }) + t.deepEqual(result, ["cadence.moe", "tractor.invalid", "thecollective.invalid", "selfhosted.invalid"]) +}) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 70e293b..301dcc4 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -7,8 +7,6 @@ const util = require("util") const Ty = require("../types") const {discord, db, sync, as, select} = require("../passthrough") -const {tag} = require("@cloudrac3r/html-template-tag") -const {Semaphore} = require("@chriscdn/promise-semaphore") /** @type {import("./actions/send-event")} */ const sendEvent = sync.require("./actions/send-event") @@ -16,187 +14,81 @@ const sendEvent = sync.require("./actions/send-event") const addReaction = sync.require("./actions/add-reaction") /** @type {import("./actions/redact")} */ const redact = sync.require("./actions/redact") -/** @type {import("./actions/update-pins")}) */ -const updatePins = sync.require("./actions/update-pins") -/** @type {import("./actions/vote")}) */ -const vote = sync.require("./actions/vote") /** @type {import("../matrix/matrix-command-handler")} */ const matrixCommandHandler = sync.require("../matrix/matrix-command-handler") -/** @type {import("../matrix/utils")} */ -const utils = sync.require("../matrix/utils") +/** @type {import("./converters/utils")} */ +const utils = sync.require("./converters/utils") /** @type {import("../matrix/api")}) */ const api = sync.require("../matrix/api") -/** @type {import("../d2m/actions/create-room")} */ -const createRoom = sync.require("../d2m/actions/create-room") -/** @type {import("../matrix/room-upgrade")} */ -const roomUpgrade = require("../matrix/room-upgrade") -/** @type {import("../d2m/actions/retrigger")} */ -const retrigger = sync.require("../d2m/actions/retrigger") const {reg} = require("../matrix/read-registration") let lastReportedEvent = 0 -/** - * This function is adapted from Evan Kaufman's fantastic work. - * The original function and my adapted function are both MIT licensed. - * @url https://github.com/EvanK/npm-loggable-error/ - * @param {number} [depth] - * @returns {string} -*/ -function stringifyErrorStack(err, depth = 0) { - let collapsed = " ".repeat(depth); - if (!(err instanceof Error)) { - return collapsed + err - } - - // add full stack trace if one exists, otherwise convert to string - let stackLines = String(err?.stack ?? err).replace(/^/gm, " ".repeat(depth)).trim().split("\n") - let cloudstormLine = stackLines.findIndex(l => l.includes("/node_modules/cloudstorm/")) - if (cloudstormLine !== -1) { - stackLines = stackLines.slice(0, cloudstormLine - 2) - } - collapsed += stackLines.join("\n") - - const props = Object.getOwnPropertyNames(err).filter(p => !["message", "stack"].includes(p)) - - // only break into object notation if we have additional props to dump - if (props.length) { - const dedent = " ".repeat(depth); - const indent = " ".repeat(depth + 2); - - collapsed += " {\n"; - - // loop and print each (indented) prop name - for (let property of props) { - collapsed += `${indent}[${property}]: `; - - // if another error object, stringify it too - if (err[property] instanceof Error) { - collapsed += stringifyErrorStack(err[property], depth + 2).trimStart(); - } - // otherwise stringify as JSON - else { - collapsed += JSON.stringify(err[property]); - } - - collapsed += "\n"; - } - - collapsed += `${dedent}}\n`; - } - - return collapsed; -} - -function printError(type, source, e, payload) { - console.error(`Error while processing a ${type} ${source} event:`) - console.error(e) - console.dir(payload, {depth: null}) -} - -/** - * @param {string} roomID - * @param {"Discord" | "Matrix"} source - * @param {any} type - * @param {any} e - * @param {any} payload - */ -async function sendError(roomID, source, type, e, payload) { - if (source === "Matrix") { - printError(type, source, e, payload) - } - - if (Date.now() - lastReportedEvent < 5000) return null - lastReportedEvent = Date.now() - - let errorIntroLine = e.toString() - if (e.cause) { - errorIntroLine += ` (cause: ${e.cause})` - } - - const builder = new utils.MatrixStringBuilder() - - const cloudflareErrorTitle = errorIntroLine.match(/.*?discord\.com \| ([^<]*)<\/title>/s)?.[1] - if (cloudflareErrorTitle) { - builder.addLine( - `\u26a0 Matrix event not delivered to Discord. Discord might be down right now. Cloudflare error: ${cloudflareErrorTitle}`, - `\u26a0 <strong>Matrix event not delivered to Discord</strong><br>Discord might be down right now. Cloudflare error: ${cloudflareErrorTitle}` - ) - } else { - // What - const what = source === "Discord" ? "Bridged event from Discord not delivered" : "Matrix event not delivered to Discord" - builder.addLine(`\u26a0 ${what}`, `\u26a0 <strong>${what}</strong>`) - - // Who - builder.addLine(`Event type: ${type}`) - - // Why - builder.addLine(errorIntroLine) - - // Where - const stack = stringifyErrorStack(e) - builder.addLine(`Error trace:\n${stack}`, tag`<details><summary>Error trace</summary><pre>${stack}</pre></details>`) - - // How - builder.addLine("", tag`<details><summary>Original payload</summary><pre>${util.inspect(payload, false, 4, false)}</pre></details>`) - } - - // Send - try { - await api.sendEvent(roomID, "m.room.message", { - ...builder.get(), - "moe.cadence.ooye.error": { - source: source.toLowerCase(), - payload - }, - "m.mentions": { - user_ids: ["@cadence:cadence.moe"] - } - }) - } catch (e) {} -} - function guard(type, fn) { return async function(event, ...args) { try { return await fn(event, ...args) } catch (e) { - await sendError(event.room_id, "Matrix", type, e, event) + console.error("hit event-dispatcher's error handler with this exception:") + console.error(e) // TODO: also log errors into a file or into the database, maybe use a library for this? or just wing it? + console.error(`while handling this ${type} gateway event:`) + console.dir(event, {depth: null}) + + if (Date.now() - lastReportedEvent < 5000) return + lastReportedEvent = Date.now() + + let stackLines = e.stack.split("\n") + api.sendEvent(event.room_id, "m.room.message", { + msgtype: "m.text", + body: "\u26a0 Matrix event not delivered to Discord. See formatted content for full details.", + format: "org.matrix.custom.html", + formatted_body: "\u26a0 <strong>Matrix event not delivered to Discord</strong>" + + `<br>Event type: ${type}` + + `<br>${e.toString()}` + + `<br><details><summary>Error trace</summary>` + + `<pre>${stackLines.join("\n")}</pre></details>` + + `<details><summary>Original payload</summary>` + + `<pre>${util.inspect(event, false, 4, false)}</pre></details>`, + "moe.cadence.ooye.error": { + source: "matrix", + payload: event + }, + "m.mentions": { + user_ids: ["@cadence:cadence.moe"] + } + }) } } } -const errorRetrySema = new Semaphore() - /** * @param {Ty.Event.Outer<Ty.Event.M_Reaction>} reactionEvent */ async function onRetryReactionAdd(reactionEvent) { const roomID = reactionEvent.room_id - await errorRetrySema.request(async () => { - const event = await api.getEvent(roomID, reactionEvent.content["m.relates_to"]?.event_id) + const event = await api.getEvent(roomID, reactionEvent.content["m.relates_to"]?.event_id) - // Check that it's a real error from OOYE - const error = event.content["moe.cadence.ooye.error"] - if (event.sender !== `@${reg.sender_localpart}:${reg.ooye.server_name}` || !error) return + // Check that it's a real error from OOYE + const error = event.content["moe.cadence.ooye.error"] + if (event.sender !== `@${reg.sender_localpart}:${reg.ooye.server_name}` || !error) return - // To stop people injecting misleading messages, the reaction needs to come from either the original sender or a room moderator - if (reactionEvent.sender !== error.payload.sender) { - // Check if it's a room moderator - const {powers: {[reactionEvent.sender]: senderPower}, powerLevels} = await utils.getEffectivePower(roomID, [reactionEvent.sender], api) - if (senderPower < (powerLevels.state_default ?? 50)) return - } + // To stop people injecting misleading messages, the reaction needs to come from either the original sender or a room moderator + if (reactionEvent.sender !== event.sender) { + // Check if it's a room moderator + const powerLevelsStateContent = await api.getStateEvent(roomID, "m.room.power_levels", "") + const powerLevel = powerLevelsStateContent.users?.[reactionEvent.sender] || 0 + if (powerLevel < 50) return + } - // Retry - if (error.source === "matrix") { - as.emit(`type:${error.payload.type}`, error.payload) - } else if (error.source === "discord") { - discord.cloud.emit("event", error.payload) - } + // Retry + if (error.source === "matrix") { + as.emit(`type:${error.payload.type}`, error.payload) + } else if (error.source === "discord") { + discord.cloud.emit("event", error.payload) + } - // Redact the error to stop people from executing multiple retries - await api.redactEvent(roomID, event.event_id) - }, roomID) + // Redact the error to stop people from executing multiple retries + api.redactEvent(roomID, event.event_id) } sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message", @@ -206,13 +98,10 @@ sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message", async event => { if (utils.eventSenderIsFromDiscord(event.sender)) return const messageResponses = await sendEvent.sendEvent(event) - if (!messageResponses.length) return if (event.type === "m.room.message" && event.content.msgtype === "m.text") { // @ts-ignore await matrixCommandHandler.execute(event) } - retrigger.messageFinishedBridging(event.event_id) - await api.ackEvent(event) })) sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker", @@ -222,56 +111,6 @@ sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker", async event => { if (utils.eventSenderIsFromDiscord(event.sender)) return const messageResponses = await sendEvent.sendEvent(event) - retrigger.messageFinishedBridging(event.event_id) - await api.ackEvent(event) -})) - -sync.addTemporaryListener(as, "type:org.matrix.msc3381.poll.start", guard("org.matrix.msc3381.poll.start", -/** - * @param {Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Start} event it is a org.matrix.msc3381.poll.start because that's what this listener is filtering for - */ -async event => { - if (utils.eventSenderIsFromDiscord(event.sender)) return - const messageResponses = await sendEvent.sendEvent(event) - await api.ackEvent(event) -})) - -sync.addTemporaryListener(as, "type:org.matrix.msc3381.poll.response", guard("org.matrix.msc3381.poll.response", -/** - * @param {Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Response} event it is a org.matrix.msc3381.poll.response because that's what this listener is filtering for - */ -async event => { - if (utils.eventSenderIsFromDiscord(event.sender)) return - await vote.updateVote(event) // Matrix votes can't be bridged, so all we do is store it in the database. - await api.ackEvent(event) -})) - -sync.addTemporaryListener(as, "type:org.matrix.msc3381.poll.end", guard("org.matrix.msc3381.poll.end", -/** - * @param {Ty.Event.Outer_Org_Matrix_Msc3381_Poll_End} event it is a org.matrix.msc3381.poll.end because that's what this listener is filtering for - */ -async event => { - if (utils.eventSenderIsFromDiscord(event.sender)) return - const pollEventID = event.content["m.relates_to"]?.event_id - if (!pollEventID) return // Validity check - const messageID = select("event_message", "message_id", {event_id: pollEventID, event_type: "org.matrix.msc3381.poll.start", source: 0}).pluck().get() - if (!messageID) return // Nothing can be done if the parent message was never bridged. Also, Discord-native polls cannot be ended by others, so this only works for polls started on Matrix. - try { - var pollEvent = await api.getEvent(event.room_id, pollEventID) // Poll start event must exist for this to be valid - } catch (e) { - return - } - - // According to the rules, the poll end is only allowed if it was sent by the poll starter, or by someone with redact powers. - if (pollEvent.sender !== event.sender) { - const {powerLevels, powers: {[event.sender]: enderPower}} = await utils.getEffectivePower(event.room_id, [event.sender], api) - if (enderPower < (powerLevels.redact ?? 50)) { - return // Not allowed - } - } - - const messageResponses = await sendEvent.sendEvent(event) - await api.ackEvent(event) })) sync.addTemporaryListener(as, "type:m.reaction", guard("m.reaction", @@ -296,7 +135,6 @@ sync.addTemporaryListener(as, "type:m.room.redaction", guard("m.room.redaction", async event => { if (utils.eventSenderIsFromDiscord(event.sender)) return await redact.handle(event) - await api.ackEvent(event) })) sync.addTemporaryListener(as, "type:m.room.avatar", guard("m.room.avatar", @@ -321,131 +159,25 @@ async event => { db.prepare("UPDATE channel_room SET nick = ? WHERE room_id = ?").run(name, event.room_id) })) -sync.addTemporaryListener(as, "type:m.room.topic", guard("m.room.topic", -/** - * @param {Ty.Event.StateOuter<Ty.Event.M_Room_Topic>} event - */ -async event => { - if (event.state_key !== "") return - if (utils.eventSenderIsFromDiscord(event.sender)) return - const customTopic = +!!event.content.topic - const row = select("channel_room", ["channel_id", "custom_topic"], {room_id: event.room_id}).get() - if (!row) return - if (customTopic !== row.custom_topic) db.prepare("UPDATE channel_room SET custom_topic = ? WHERE channel_id = ?").run(customTopic, row.channel_id) - if (!customTopic) await createRoom.syncRoom(row.channel_id) // if it's cleared we should reset it to whatever's on discord -})) - -sync.addTemporaryListener(as, "type:m.room.pinned_events", guard("m.room.pinned_events", -/** - * @param {Ty.Event.StateOuter<Ty.Event.M_Room_PinnedEvents>} event - */ -async event => { - if (event.state_key !== "") return - if (utils.eventSenderIsFromDiscord(event.sender)) return - const pins = event.content.pinned - if (!Array.isArray(pins)) return - let prev = event.unsigned?.prev_content?.pinned - if (!Array.isArray(prev)) { - if (pins.length === 1) { - /* - In edge cases, prev_content isn't guaranteed to be provided by the server. - If prev_content is missing, we can't diff. Better safe than sorry: we'd like to ignore the change rather than wiping the whole channel's pins on Discord. - However, that would mean if the first ever pin came from Matrix-side, it would be ignored, because there would be no prev_content (it's the first pinned event!) - So to handle that edge case, we assume that if there's exactly 1 entry in `pinned`, this is the first ever pin and it should go through. - */ - prev = [] - } else { - return - } - } - - await updatePins.updatePins(pins, prev) - await api.ackEvent(event) -})) - - - -sync.addTemporaryListener(as, "type:m.space.child", guard("m.space.child", -/** - * @param {Ty.Event.StateOuter<Ty.Event.M_Space_Child>} event - */ -async event => { - if (Array.isArray(event.content.via) && event.content.via.length) { // space child is being added - try { - // try to join if able, it's okay if it doesn't want, bot will still respond to invites - await api.joinRoom(event.state_key) - // if autojoined a child space, store it in invite (otherwise the child space will be impossible to use with self-service in the future) - const hierarchy = await api.getHierarchy(event.state_key, {limit: 1}) - const roomProperties = hierarchy.rooms?.[0] - if (roomProperties?.room_id === event.state_key && roomProperties.room_type === "m.space" && roomProperties.name) { - db.prepare("INSERT OR IGNORE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)") - .run(event.sender, event.state_key, roomProperties.room_type, roomProperties.name, roomProperties.topic, roomProperties.avatar_url) - await updateMemberCachePowerLevels(event.state_key) // store privileged users in member_cache so they are also allowed to perform self-service - } - } catch (e) {} - } -})) - sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member", /** * @param {Ty.Event.StateOuter<Ty.Event.M_Room_Member>} event */ async event => { if (event.state_key[0] !== "@") return - - if (event.state_key === utils.bot) { - const upgraded = await roomUpgrade.onBotMembership(event, api, createRoom) - if (upgraded) return - } - - if (event.content.membership === "invite" && event.state_key === utils.bot) { - // Supposed to be here already? - const guildID = select("guild_space", "guild_id", {space_id: event.room_id}).pluck().get() - if (guildID) { - await api.joinRoom(event.room_id) - return - } - - // We were invited to a room. We should join, and register the invite details for future reference in web. - try { - var inviteRoomState = await api.getInviteState(event.room_id, event) - } catch (e) { - console.error(e) - return await api.leaveRoomWithReason(event.room_id, `I wasn't able to find out what this room is. Please report this as a bug. Check console for more details. (${e.toString()})`) - } - if (!inviteRoomState?.name) return await api.leaveRoomWithReason(event.room_id, `Please only invite me to rooms that have a name/avatar set. Update the room details and reinvite.`) - await api.joinRoom(event.room_id) - db.prepare("REPLACE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, inviteRoomState.type, inviteRoomState.name, inviteRoomState.topic, inviteRoomState.avatar) - if (inviteRoomState.avatar) utils.getPublicUrlForMxc(inviteRoomState.avatar) // make sure it's available in the media_proxy allowed URLs - await updateMemberCachePowerLevels(event.room_id) // store privileged users in member_cache so they are also allowed to perform self-service - } - + if (utils.eventSenderIsFromDiscord(event.state_key)) return if (event.content.membership === "leave" || event.content.membership === "ban") { // Member is gone db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key) - - // Unregister room's use as a direct chat and/or an invite target if the bot itself left - if (event.state_key === utils.bot) { - db.prepare("DELETE FROM direct WHERE room_id = ?").run(event.room_id) - db.prepare("DELETE FROM invite WHERE room_id = ?").run(event.room_id) - } + } else { + // Member is here + db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?") + .run( + event.room_id, event.state_key, + event.content.displayname || null, event.content.avatar_url || null, + event.content.displayname || null, event.content.avatar_url || null + ) } - - if (utils.eventSenderIsFromDiscord(event.state_key)) return - - const exists = select("channel_room", "room_id", {room_id: event.room_id}) ?? select("guild_space", "space_id", {space_id: event.room_id}) - if (!exists) return // don't cache members in unbridged rooms - - // Member is here - let {powers: {[event.state_key]: memberPower}, tombstone} = await utils.getEffectivePower(event.room_id, [event.state_key], api) - if (memberPower === Infinity) memberPower = tombstone // database storage compatibility - const displayname = event.content.displayname || null - const avatar_url = event.content.avatar_url - db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url, power_level) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?, power_level = ?, missing_profile = NULL").run( - event.room_id, event.state_key, - displayname, avatar_url, memberPower, - displayname, avatar_url, memberPower - ) })) sync.addTemporaryListener(as, "type:m.room.power_levels", guard("m.room.power_levels", @@ -454,35 +186,9 @@ sync.addTemporaryListener(as, "type:m.room.power_levels", guard("m.room.power_le */ async event => { if (event.state_key !== "") return - await updateMemberCachePowerLevels(event.room_id) -})) - -/** - * @param {string} roomID - */ -async function updateMemberCachePowerLevels(roomID) { - const existingPower = select("member_cache", "mxid", {room_id: roomID}).pluck().all() - const {powerLevels, allCreators, tombstone} = await utils.getEffectivePower(roomID, [], api) - const newPower = powerLevels.users || {} - const newPowerUsers = Object.keys(newPower) - const relevantUsers = existingPower.concat(newPowerUsers).concat(allCreators) - for (const mxid of [...new Set(relevantUsers)]) { - const level = allCreators.includes(mxid) ? tombstone : newPower[mxid] ?? powerLevels.users_default ?? 0 - db.prepare("INSERT INTO member_cache (room_id, mxid, power_level, missing_profile) VALUES (?, ?, ?, 1) ON CONFLICT DO UPDATE SET power_level = ?") - .run(roomID, mxid, level, level) + const existingPower = select("member_cache", "mxid", {room_id: event.room_id}).pluck().all() + const newPower = event.content.users || {} + for (const mxid of existingPower) { + db.prepare("UPDATE member_cache SET power_level = ? WHERE room_id = ? AND mxid = ?").run(newPower[mxid] || 0, event.room_id, mxid) } -} - -sync.addTemporaryListener(as, "type:m.room.tombstone", guard("m.room.tombstone", -/** - * @param {Ty.Event.StateOuter<Ty.Event.M_Room_Tombstone>} event - */ -async event => { - if (event.state_key !== "") return - if (!event.content.replacement_room) return - await roomUpgrade.onTombstone(event, api) })) - -module.exports.stringifyErrorStack = stringifyErrorStack -module.exports.sendError = sendError -module.exports.printError = printError diff --git a/src/m2d/event-dispatcher.test.js b/src/m2d/event-dispatcher.test.js deleted file mode 100644 index de754da..0000000 --- a/src/m2d/event-dispatcher.test.js +++ /dev/null @@ -1,23 +0,0 @@ -// @ts-check - -const {test} = require("supertape") -const {stringifyErrorStack} = require("./event-dispatcher") - -test("stringify error stack: works", t => { - function a() { - const e = new Error("message", {cause: new Error("inner")}) - // @ts-ignore - e.prop = 2.1 - throw e - } - try { - a() - t.fail("shouldn't get here") - } catch (e) { - const str = stringifyErrorStack(e) - t.match(str, /^Error: message$/m) - t.match(str, /^ at a \(.*event-dispatcher\.test\.js/m) - t.match(str, /^ \[cause\]: Error: inner$/m) - t.match(str, /^ \[prop\]: 2.1$/m) - } -}) diff --git a/src/matrix/api.js b/src/matrix/api.js index 87bbf0c..4866495 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -2,10 +2,11 @@ const Ty = require("../types") const assert = require("assert").strict -const streamWeb = require("stream/web") + +const fetch = require("node-fetch").default const passthrough = require("../passthrough") -const {sync, db, select} = passthrough +const { discord, sync, db } = passthrough /** @type {import("./mreq")} */ const mreq = sync.require("./mreq") /** @type {import("./txnid")} */ @@ -22,11 +23,7 @@ function path(p, mxid, otherParams = {}) { const u = new URL(p, "http://localhost") if (mxid) u.searchParams.set("user_id", mxid) for (const entry of Object.entries(otherParams)) { - if (Array.isArray(entry[1])) { - for (const element of entry[1]) { - u.searchParams.append(entry[0], element) - } - } else if (entry[1] != undefined) { + if (entry[1] != undefined) { u.searchParams.set(entry[0], entry[1]) } } @@ -38,22 +35,14 @@ function path(p, mxid, otherParams = {}) { /** * @param {string} username + * @returns {Promise<Ty.R.Registered>} */ -async function register(username) { +function register(username) { console.log(`[api] register: ${username}`) - try { - await mreq.mreq("POST", "/client/v3/register", { - type: "m.login.application_service", - inhibit_login: true, // https://github.com/element-hq/matrix-bot-sdk/pull/70/changes https://github.com/matrix-org/matrix-spec-proposals/blob/quenting/as-device-management/proposals/4190-as-device-management.md - username - }) - } catch (e) { - if (e.errcode === "M_USER_IN_USE" || e.data?.error === "Internal server error") { - // "Internal server error" is the only OK error because older versions of Synapse say this if you try to register the same username twice. - } else { - throw e - } - } + return mreq.mreq("POST", "/client/v3/register", { + type: "m.login.application_service", + username + }) } /** @@ -67,29 +56,18 @@ async function createRoom(content) { } /** - * @param {string} roomIDOrAlias - * @param {string?} [mxid] - * @param {string[]?} [via] * @returns {Promise<string>} room ID */ -async function joinRoom(roomIDOrAlias, mxid, via) { +async function joinRoom(roomIDOrAlias, mxid) { /** @type {Ty.R.RoomJoined} */ - const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid, {via}), {}) + const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid)) return root.room_id } async function inviteToRoom(roomID, mxidToInvite, mxid) { - try { - await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/invite`, mxid), { - user_id: mxidToInvite - }) - } catch (e) { - if (e.message.includes("is already in the room.") || e.message.includes("cannot invite user that is joined")) { - // Sweet! - } else { - throw e - } - } + await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/invite`, mxid), { + user_id: mxidToInvite + }) } async function leaveRoom(roomID, mxid) { @@ -97,16 +75,6 @@ async function leaveRoom(roomID, mxid) { await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, mxid), {}) } -/** - * @param {string} roomID - * @param {string} reason - * @param {string} [mxid] - */ -async function leaveRoomWithReason(roomID, reason, mxid) { - console.log(`[api] leave: ${roomID}: ${mxid}, because ${reason}`) - await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, mxid), {reason}) -} - /** * @param {string} roomID * @param {string} eventID @@ -130,20 +98,7 @@ async function getEventForTimestamp(roomID, ts) { /** * @param {string} roomID - * @param {"b" | "f"} dir - * @param {{from?: string, limit?: any}} [pagination] - * @param {any} [filter] - */ -async function getEvents(roomID, dir, pagination = {}, filter) { - filter = filter && JSON.stringify(filter) - /** @type {Ty.MessagesPagination<Ty.Event.Outer<any>>} */ - const root = await mreq.mreq("GET", path(`/client/v3/rooms/${roomID}/messages`, null, {...pagination, dir, filter})) - return root -} - -/** - * @param {string} roomID - * @returns {Promise<Ty.Event.StateOuter<any>[]>} + * @returns {Promise<Ty.Event.BaseStateEvent[]>} */ function getAllState(roomID) { return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state`) @@ -159,97 +114,6 @@ function getStateEvent(roomID, type, key) { return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state/${type}/${key}`) } -/** - * @param {string} roomID - * @param {string} type - * @param {string} key - * @returns {Promise<Ty.Event.StateOuter<any>>} the entire state event - */ -function getStateEventOuter(roomID, type, key) { - return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state/${type}/${key}?format=event`) -} - -/** - * @param {string} roomID - * @param {{unsigned?: {invite_room_state?: Ty.Event.InviteStrippedState[]}}} [event] - * @returns {Promise<{name: string?, topic: string?, avatar: string?, type: string?}>} - */ -async function getInviteState(roomID, event) { - function getFromInviteRoomState(strippedState, nskey, key) { - if (!Array.isArray(strippedState)) return null - for (const event of strippedState) { - if (event.type === nskey && event.state_key === "") { - return event.content[key] - } - } - return null - } - - // Try extracting from event (if passed) - if (Array.isArray(event?.unsigned?.invite_room_state) && event.unsigned.invite_room_state.length) { - return { - name: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.name", "name"), - topic: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.topic", "topic"), - avatar: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.avatar", "url"), - type: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.create", "type") - } - } - - // Try calling sliding sync API and extracting from stripped state - let root - try { - /** @type {Ty.R.SSS} */ - root = await mreq.mreq("POST", path("/client/unstable/org.matrix.simplified_msc3575/sync", `@${reg.sender_localpart}:${reg.ooye.server_name}`, {timeout: "0"}), { - lists: { - a: { - ranges: [[0, 999]], - timeline_limit: 0, - required_state: [], - filters: { - is_invite: true - } - } - } - }) - - // Extract from sliding sync response if valid (seems to be okay on Synapse, Tuwunel and Continuwuity at time of writing) - if ("lists" in root) { - if (!root.rooms?.[roomID]) { - const e = new Error("Room data unavailable via SSS") - e["data_sss"] = root - throw e - } - - const roomResponse = root.rooms[roomID] - const strippedState = "stripped_state" in roomResponse ? roomResponse.stripped_state : roomResponse.invite_state - - return { - name: getFromInviteRoomState(strippedState, "m.room.name", "name"), - topic: getFromInviteRoomState(strippedState, "m.room.topic", "topic"), - avatar: getFromInviteRoomState(strippedState, "m.room.avatar", "url"), - type: getFromInviteRoomState(strippedState, "m.room.create", "type") - } - } - } catch (e) {} - - // Invalid sliding sync response, try alternative (required for Conduit at time of writing) - const hierarchy = await getHierarchy(roomID, {limit: 1}) - if (hierarchy?.rooms?.[0]?.room_id === roomID) { - const room = hierarchy?.rooms?.[0] - return { - name: room.name ?? null, - topic: room.topic ?? null, - avatar: room.avatar_url ?? null, - type: room.room_type ?? null - } - } - - const e = new Error("Room data unavailable via SSS/hierarchy") - e["data_sss"] = root - e["data_hierarchy"] = hierarchy - throw e -} - /** * "Any of the AS's users must be in the room. This API is primarily for Application Services and should be faster to respond than /members as it can be implemented more efficiently on the server." * @param {string} roomID @@ -259,17 +123,6 @@ function getJoinedMembers(roomID) { return mreq.mreq("GET", `/client/v3/rooms/${roomID}/joined_members`) } -/** - * "Get the list of members for this room." This includes joined, invited, knocked, left, and banned members unless a filter is provided. - * The endpoint also supports `at` and `not_membership` URL parameters, but they are not exposed in this wrapper yet. - * @param {string} roomID - * @param {"join" | "invite" | "knock" | "leave" | "ban"} [membership] The kind of membership to filter for. Only one choice allowed. - * @returns {Promise<{chunk: Ty.Event.Outer<Ty.Event.M_Room_Member>[]}>} - */ -function getMembers(roomID, membership) { - return mreq.mreq("GET", `/client/v3/rooms/${roomID}/members`, undefined, {membership}) -} - /** * @param {string} roomID * @param {{from?: string, limit?: any}} pagination @@ -301,23 +154,6 @@ async function getFullHierarchy(roomID) { return rooms } -/** - * Like `getFullHierarchy` but reveals a page at a time through an async iterator. - * @param {string} roomID - */ -async function* generateFullHierarchy(roomID) { - /** @type {string | undefined} */ - let nextBatch = undefined - do { - /** @type {Ty.HierarchyPagination<Ty.R.Hierarchy>} */ - const res = await getHierarchy(roomID, {from: nextBatch}) - for (const room of res.rooms) { - yield room - } - nextBatch = res.next_batch - } while (nextBatch) -} - /** * @param {string} roomID * @param {string} eventID @@ -412,33 +248,51 @@ async function sendTyping(roomID, isTyping, mxid, duration) { }) } -/** - * @param {string} mxid - * @param {string} displayname - * @param {boolean} [inhibitPropagate] - */ -async function profileSetDisplayname(mxid, displayname, inhibitPropagate) { - const params = {} - if (inhibitPropagate) params["org.matrix.msc4069.propagate"] = false - await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/displayname`, mxid, params), { +async function profileSetDisplayname(mxid, displayname) { + await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/displayname`, mxid), { displayname }) } +async function profileSetAvatarUrl(mxid, avatar_url) { + await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/avatar_url`, mxid), { + avatar_url + }) +} + /** + * Set a user's power level within a room. + * @param {string} roomID * @param {string} mxid - * @param {string | null | undefined} avatar_url - * @param {boolean} [inhibitPropagate] + * @param {number} power */ -async function profileSetAvatarUrl(mxid, avatar_url, inhibitPropagate) { - const params = {} - if (inhibitPropagate) params["org.matrix.msc4069.propagate"] = false - if (avatar_url) { - await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/avatar_url`, mxid, params), { - avatar_url - }) +async function setUserPower(roomID, mxid, power) { + assert(roomID[0] === "!") + assert(mxid[0] === "@") + // Yes there's no shortcut https://github.com/matrix-org/matrix-appservice-bridge/blob/2334b0bae28a285a767fe7244dad59f5a5963037/src/components/intent.ts#L352 + const powerLevels = await getStateEvent(roomID, "m.room.power_levels", "") + powerLevels.users = powerLevels.users || {} + if (power != null) { + powerLevels.users[mxid] = power } else { - await mreq.mreq("DELETE", path(`/client/v3/profile/${mxid}/avatar_url`, mxid, params)) + delete powerLevels.users[mxid] + } + await sendState(roomID, "m.room.power_levels", "", powerLevels) + return powerLevels +} + +/** + * Set a user's power level for a whole room hierarchy. + * @param {string} roomID + * @param {string} mxid + * @param {number} power + */ +async function setUserPowerCascade(roomID, mxid, power) { + assert(roomID[0] === "!") + assert(mxid[0] === "@") + const rooms = await getFullHierarchy(roomID) + for (const room of rooms) { + await setUserPower(room.room_id, mxid, power) } } @@ -460,132 +314,18 @@ async function ping() { } /** - * Given an mxc:// URL, and an optional height for thumbnailing, get the file from the content repository. Returns res. * @param {string} mxc - * @param {RequestInit & {height?: number | string}} [init] - * @return {Promise<Response & {body: streamWeb.ReadableStream<Uint8Array>}>} + * @param {fetch.RequestInit} [init] */ -async function getMedia(mxc, init = {}) { +function getMedia(mxc, init = {}) { const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/) assert(mediaParts) - const downloadOrThumbnail = init.height ? "thumbnail" : "download" - let url = `${mreq.baseUrl}/client/v1/media/${downloadOrThumbnail}/${mediaParts[1]}/${mediaParts[2]}` - if (init.height) url += "?" + new URLSearchParams({height: String(init.height), width: String(init.height)}) - const res = await fetch(url, { + return fetch(`${mreq.baseUrl}/client/v1/media/download/${mediaParts[1]}/${mediaParts[2]}`, { headers: { Authorization: `Bearer ${reg.as_token}` }, ...init }) - if (res.status !== 200) { - throw await mreq.makeMatrixServerError(res, {...init, url}) - } - if (init.method !== "HEAD") { - assert(res.body) - } - // @ts-ignore - return res -} - -/** - * Updates the m.read receipt in roomID to point to eventID. - * This doesn't modify m.fully_read, which matches [the behaviour of matrix-bot-sdk.](https://github.com/element-hq/matrix-bot-sdk/blob/e72a4c498e00c6c339a791630c45d00a351f56a8/src/MatrixClient.ts#L1227) - * @param {string} roomID - * @param {string} eventID - * @param {string?} [mxid] - */ -async function sendReadReceipt(roomID, eventID, mxid) { - await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/receipt/m.read/${eventID}`, mxid), {}) -} - -/** - * Acknowledge an event as read by calling api.sendReadReceipt on it. - * @param {Ty.Event.Outer<any>} event - * @param {string?} [mxid] - */ -async function ackEvent(event, mxid) { - await sendReadReceipt(event.room_id, event.event_id, mxid) -} - -/** - * Resolve a room alias to a room ID. - * @param {string} alias - */ -async function getAlias(alias) { - /** @type {Ty.R.ResolvedRoom} */ - const root = await mreq.mreq("GET", `/client/v3/directory/room/${encodeURIComponent(alias)}`) - return root.room_id -} - -/** - * @param {string} type namespaced event type, e.g. m.direct - * @param {string} [mxid] you - * @returns the *content* of the account data "event" - */ -async function getAccountData(type, mxid) { - if (!mxid) mxid = `@${reg.sender_localpart}:${reg.ooye.server_name}` - const root = await mreq.mreq("GET", `/client/v3/user/${mxid}/account_data/${type}`) - return root -} - -/** - * @param {string} type namespaced event type, e.g. m.direct - * @param {any} content whatever you want - * @param {string} [mxid] you - */ -async function setAccountData(type, content, mxid) { - if (!mxid) mxid = `@${reg.sender_localpart}:${reg.ooye.server_name}` - await mreq.mreq("PUT", `/client/v3/user/${mxid}/account_data/${type}`, content) -} - -/** - * @param {{presence: "online" | "offline" | "unavailable", status_msg?: string}} data - * @param {string} mxid - */ -async function setPresence(data, mxid) { - await mreq.mreq("PUT", path(`/client/v3/presence/${mxid}/status`, mxid), data) -} - -/** - * @param {string} mxid - * @returns {Promise<{displayname?: string, avatar_url?: string}>} - */ -function getProfile(mxid) { - return mreq.mreq("GET", `/client/v3/profile/${mxid}`) -} - -function versions() { - return mreq.mreq("GET", "/client/versions") -} - -/** - * @param {string} mxid - */ -async function usePrivateChat(mxid) { - // Check if we have an existing DM - let roomID = select("direct", "room_id", {mxid}).pluck().get() - if (roomID) { - // Check that the person is/still in the room - try { - var member = await getStateEvent(roomID, "m.room.member", mxid) - } catch (e) {} - - // Invite them back to the room if needed - if (!member || member.membership === "leave") { - await inviteToRoom(roomID, mxid) - } - return roomID - } - - // No existing DM, create a new room and invite - roomID = await createRoom({ - invite: [mxid], - is_direct: true, - preset: "trusted_private_chat" - }) - // Store the newly created room in the database (not using account data due to awkward bugs with misaligned state) - db.prepare("REPLACE INTO direct (mxid, room_id) VALUES (?, ?)").run(mxid, roomID) - return roomID } module.exports.path = path @@ -594,19 +334,13 @@ module.exports.createRoom = createRoom module.exports.joinRoom = joinRoom module.exports.inviteToRoom = inviteToRoom module.exports.leaveRoom = leaveRoom -module.exports.leaveRoomWithReason = leaveRoomWithReason module.exports.getEvent = getEvent module.exports.getEventForTimestamp = getEventForTimestamp -module.exports.getEvents = getEvents module.exports.getAllState = getAllState module.exports.getStateEvent = getStateEvent -module.exports.getStateEventOuter = getStateEventOuter -module.exports.getInviteState = getInviteState module.exports.getJoinedMembers = getJoinedMembers -module.exports.getMembers = getMembers module.exports.getHierarchy = getHierarchy module.exports.getFullHierarchy = getFullHierarchy -module.exports.generateFullHierarchy = generateFullHierarchy module.exports.getRelations = getRelations module.exports.getFullRelations = getFullRelations module.exports.sendState = sendState @@ -615,14 +349,7 @@ module.exports.redactEvent = redactEvent module.exports.sendTyping = sendTyping module.exports.profileSetDisplayname = profileSetDisplayname module.exports.profileSetAvatarUrl = profileSetAvatarUrl +module.exports.setUserPower = setUserPower +module.exports.setUserPowerCascade = setUserPowerCascade module.exports.ping = ping module.exports.getMedia = getMedia -module.exports.sendReadReceipt = sendReadReceipt -module.exports.ackEvent = ackEvent -module.exports.getAlias = getAlias -module.exports.getAccountData = getAccountData -module.exports.setAccountData = setAccountData -module.exports.setPresence = setPresence -module.exports.getProfile = getProfile -module.exports.versions = versions -module.exports.usePrivateChat = usePrivateChat diff --git a/src/matrix/api.test.js b/src/matrix/api.test.js index da92385..82565eb 100644 --- a/src/matrix/api.test.js +++ b/src/matrix/api.test.js @@ -24,7 +24,3 @@ test("api path: real world mxid", t => { test("api path: extras number works", t => { t.equal(path(`/client/v3/rooms/!example/timestamp_to_event`, null, {ts: 1687324651120}), "/client/v3/rooms/!example/timestamp_to_event?ts=1687324651120") }) - -test("api path: multiple via params", t => { - t.equal(path(`/client/v3/rooms/!example/join`, null, {via: ["cadence.moe", "matrix.org"], ts: 1687324651120}), "/client/v3/rooms/!example/join?via=cadence.moe&via=matrix.org&ts=1687324651120") -}) diff --git a/src/matrix/appservice.js b/src/matrix/appservice.js index 8f85a51..67f16ee 100644 --- a/src/matrix/appservice.js +++ b/src/matrix/appservice.js @@ -3,5 +3,6 @@ const {reg} = require("../matrix/read-registration") const {AppService} = require("@cloudrac3r/in-your-element") const as = new AppService(reg) +as.listen() module.exports.as = as diff --git a/src/matrix/file.js b/src/matrix/file.js index 7bc1fec..f0ee29a 100644 --- a/src/matrix/file.js +++ b/src/matrix/file.js @@ -1,9 +1,8 @@ // @ts-check -const passthrough = require("../passthrough") -const {reg, writeRegistration} = require("./read-registration.js") -const Ty = require("../types") +const fetch = require("node-fetch").default +const passthrough = require("../passthrough") const {sync, db, select} = passthrough /** @type {import("./mreq")} */ const mreq = sync.require("./mreq") @@ -47,8 +46,11 @@ async function uploadDiscordFileToMxc(path) { return existingFromDb } - // Download from Discord and upload to Matrix - const promise = module.exports._actuallyUploadDiscordFileToMxc(url).then(root => { + // Download from Discord + const promise = fetch(url, {}).then(/** @param {import("node-fetch").Response} res */ async res => { + // Upload to Matrix + const root = await module.exports._actuallyUploadDiscordFileToMxc(urlNoExpiry, res) + // Store relationship in database db.prepare("INSERT INTO file (discord_url, mxc_url) VALUES (?, ?)").run(urlNoExpiry, root.content_uri) inflight.delete(urlNoExpiry) @@ -60,33 +62,15 @@ async function uploadDiscordFileToMxc(path) { return promise } -/** - * @param {string} url - * @returns {Promise<Ty.R.FileUploaded>} - */ -async function _actuallyUploadDiscordFileToMxc(url) { - const res = await fetch(url, {}) - try { - /** @type {Ty.R.FileUploaded} */ - const root = await mreq.mreq("POST", "/media/v3/upload", res.body, { - headers: { - "Content-Type": res.headers.get("content-type") - } - }) - return root - } catch (e) { - if (e instanceof mreq.MatrixServerError && e.data.error?.includes("Content-Length") && !reg.ooye.content_length_workaround) { - reg.ooye.content_length_workaround = true - const root = await _actuallyUploadDiscordFileToMxc(url) - console.error("OOYE cannot stream uploads to Synapse. The `content_length_workaround` option" - + "\nhas been activated in registration.yaml, which works around the problem, but" - + "\nhalves the speed of bridging d->m files. A better way to resolve this problem" - + "\nis to run an nginx reverse proxy to Synapse and re-run OOYE setup.") - writeRegistration(reg) - return root +async function _actuallyUploadDiscordFileToMxc(url, res) { + const body = res.body + /** @type {import("../types").R.FileUploaded} */ + const root = await mreq.mreq("POST", "/media/v3/upload", body, { + headers: { + "Content-Type": res.headers.get("content-type") } - throw e - } + }) + return root } function guildIcon(guild) { @@ -98,28 +82,29 @@ function userAvatar(user) { } function memberAvatar(guildID, user, member) { - if (!member?.avatar) return userAvatar(user) - return `/guilds/${guildID}/users/${user.id}/avatars/${member?.avatar}.png?size=${IMAGE_SIZE}` + if (!member.avatar) return userAvatar(user) + return `/guilds/${guildID}/users/${user.id}/avatars/${member.avatar}.png?size=${IMAGE_SIZE}` } function emoji(emojiID, animated) { - const base = `/emojis/${emojiID}.webp` - if (animated) return base + "?animated=true" - else return base + const base = `/emojis/${emojiID}` + if (animated) return base + ".gif" + else return base + ".png" } const stickerFormat = new Map([ - [1, {label: "PNG", ext: "png", mime: "image/png", endpoint: "/stickers/"}], - [2, {label: "APNG", ext: "png", mime: "image/apng", endpoint: "/stickers/"}], - [3, {label: "LOTTIE", ext: "json", mime: "lottie", endpoint: "/stickers/"}], - [4, {label: "GIF", ext: "gif", mime: "image/gif", endpoint: "https://media.discordapp.net/stickers/"}] + [1, {label: "PNG", ext: "png", mime: "image/png"}], + [2, {label: "APNG", ext: "png", mime: "image/apng"}], + [3, {label: "LOTTIE", ext: "json", mime: "lottie"}], + [4, {label: "GIF", ext: "gif", mime: "image/gif"}] ]) /** @param {{id: string, format_type: number}} sticker */ function sticker(sticker) { const format = stickerFormat.get(sticker.format_type) if (!format) throw new Error(`No such format ${sticker.format_type} for sticker ${JSON.stringify(sticker)}`) - return `${format.endpoint}${sticker.id}.${format.ext}` + const ext = format.ext + return `/stickers/${sticker.id}.${ext}` } module.exports.DISCORD_IMAGES_BASE = DISCORD_IMAGES_BASE diff --git a/src/matrix/kstate.js b/src/matrix/kstate.js index 3648f2d..67bb063 100644 --- a/src/matrix/kstate.js +++ b/src/matrix/kstate.js @@ -8,10 +8,6 @@ const passthrough = require("../passthrough") const {sync} = passthrough /** @type {import("./file")} */ const file = sync.require("./file") -/** @type {import("./api")} */ -const api = sync.require("./api") -/** @type {import("./utils")} */ -const utils = sync.require("./utils") /** Mutates the input. Not recursive - can only include or exclude entire state events. */ function kstateStripConditionals(kstate) { @@ -47,7 +43,7 @@ async function kstateUploadMxc(obj) { return obj } -/** Automatically strips conditionals and uploads URLs to mxc. m.room.create is removed. */ +/** Automatically strips conditionals and uploads URLs to mxc. */ async function kstateToState(kstate) { const events = [] kstateStripConditionals(kstate) @@ -57,30 +53,19 @@ async function kstateToState(kstate) { assert(slashIndex > 0) const type = k.slice(0, slashIndex) const state_key = k.slice(slashIndex + 1) - if (type === "m.room.create") continue events.push({type, state_key, content}) } return events } -/** Extracts m.room.create for use in room creation_content. */ -function kstateToCreationContent(kstate) { - return kstate["m.room.create/"] || {} -} - /** - * @param {import("../types").Event.StateOuter<any>[]} events + * @param {import("../types").Event.BaseStateEvent[]} events * @returns {any} */ function stateToKState(events) { const kstate = {} for (const event of events) { kstate[event.type + "/" + event.state_key] = event.content - - // need to remember m.room.create sender for later... - if (event.type === "m.room.create" && event.state_key === "") { - kstate["m.room.create/outer"] = event - } } return kstate } @@ -94,28 +79,10 @@ function diffKState(actual, target) { if (key === "m.room.power_levels/") { // Special handling for power levels, we want to deep merge the actual and target into the final state. if (!(key in actual)) throw new Error(`want to apply a power levels diff, but original power level data is missing\nstarted with: ${JSON.stringify(actual)}\nwant to apply: ${JSON.stringify(target)}`) - // if the diff includes users, it needs to be cleaned wrt room version 12 - const cleanedTarget = mixin({}, target[key]) - if (target[key].users && Object.keys(target[key].users).length > 0) { - assert("m.room.create/" in actual, `want to apply a power levels diff, but original m.room.create/ is missing\nstarted with: ${JSON.stringify(actual)}\nwant to apply: ${JSON.stringify(target)}`) - assert("m.room.create/outer" in actual, `want to apply a power levels diff, but original m.room.create/outer is missing\nstarted with: ${JSON.stringify(actual)}\nwant to apply: ${JSON.stringify(target)}`) - utils.removeCreatorsFromPowerLevels(actual["m.room.create/outer"], cleanedTarget) - } - const mixedTarget = mixin({}, actual[key], cleanedTarget) - if (!isDeepStrictEqual(actual[key], mixedTarget)) { + const temp = mixin({}, actual[key], target[key]) + if (!isDeepStrictEqual(actual[key], temp)) { // they differ. use the newly prepared object as the diff. - diff[key] = mixedTarget - } - - } else if (key === "m.room.create/") { - // can't be modified - only for kstateToCreationContent - - } else if (key === "m.room.topic/") { - // synapse generates different m.room.topic events on original creation - // https://github.com/element-hq/synapse/blob/0f2b29511fd88d1dc2278f41fd6e4e2f2989fcb7/synapse/handlers/room.py#L1729 - // diff the `topic` to determine change - if (!(key in actual) || actual[key].topic !== target[key].topic) { - diff[key] = target[key] + diff[key] = temp } } else if (key in actual) { @@ -135,45 +102,8 @@ function diffKState(actual, target) { return diff } -/* c8 ignore start */ - -/** - * Async because it gets all room state from the homeserver. - * @param {string} roomID - * @param {[type: string, key: string][]} [limitToEvents] - */ -async function roomToKState(roomID, limitToEvents) { - if (!limitToEvents) { - const root = await api.getAllState(roomID) - return stateToKState(root) - } else { - const root = [] - await Promise.all(limitToEvents.map(async ([type, key]) => { - try { - const outer = await api.getStateEventOuter(roomID, type, key) - root.push(outer) - } catch (e) {} - })) - return stateToKState(root) - } -} - -/** - * @param {string} roomID - * @param {any} kstate - */ -async function applyKStateDiffToRoom(roomID, kstate) { - const events = await kstateToState(kstate) - return Promise.all(events.map(({type, state_key, content}) => - api.sendState(roomID, type, state_key, content) - )) -} - module.exports.kstateStripConditionals = kstateStripConditionals module.exports.kstateUploadMxc = kstateUploadMxc module.exports.kstateToState = kstateToState -module.exports.kstateToCreationContent = kstateToCreationContent module.exports.stateToKState = stateToKState module.exports.diffKState = diffKState -module.exports.roomToKState = roomToKState -module.exports.applyKStateDiffToRoom = applyKStateDiffToRoom diff --git a/src/matrix/kstate.test.js b/src/matrix/kstate.test.js index b67a725..0538450 100644 --- a/src/matrix/kstate.test.js +++ b/src/matrix/kstate.test.js @@ -1,5 +1,5 @@ const assert = require("assert") -const {kstateToState, stateToKState, diffKState, kstateStripConditionals, kstateUploadMxc, kstateToCreationContent} = require("./kstate") +const {kstateToState, stateToKState, diffKState, kstateStripConditionals, kstateUploadMxc} = require("./kstate") const {test} = require("supertape") test("kstate strip: strips false conditions", t => { @@ -68,8 +68,6 @@ test("kstateUploadMxc and strip: work together", async t => { test("kstate2state: general", async t => { t.deepEqual(await kstateToState({ - "m.room.create/": {bogus: true}, - "m.room.create/outer": {bogus: true}, "m.room.name/": {name: "test name"}, "m.room.member/@cadence:cadence.moe": {membership: "join"}, "uk.half-shot.bridge/org.matrix.appservice-irc://irc/epicord.net/#general": {creator: "@cadence:cadence.moe"} @@ -100,14 +98,6 @@ test("kstate2state: general", async t => { test("state2kstate: general", t => { t.deepEqual(stateToKState([ - { - type: "m.room.create", - state_key: "", - sender: "@example:matrix.org", - content: { - room_version: "12" - } - }, { type: "m.room.name", state_key: "", @@ -132,9 +122,7 @@ test("state2kstate: general", t => { ]), { "m.room.name/": {name: "test name"}, "m.room.member/@cadence:cadence.moe": {membership: "join"}, - "uk.half-shot.bridge/org.matrix.appservice-irc://irc/epicord.net/#general": {creator: "@cadence:cadence.moe"}, - "m.room.create/": {room_version: "12"}, - "m.room.create/outer": {type: "m.room.create", state_key: "", sender: "@example:matrix.org", content: {room_version: "12"}} + "uk.half-shot.bridge/org.matrix.appservice-irc://irc/epicord.net/#general": {creator: "@cadence:cadence.moe"} }) }) @@ -169,17 +157,6 @@ test("diffKState: detects new properties", t => { test("diffKState: power levels are mixed together", t => { const original = { - "m.room.create/outer": { - type: "m.room.create", - state_key: "", - sender: "@example:matrix.org", - content: { - room_version: "11" - } - }, - "m.room.create/": { - room_version: "11" - }, "m.room.power_levels/": { "ban": 50, "events": { @@ -204,9 +181,6 @@ test("diffKState: power levels are mixed together", t => { "m.room.power_levels/": { "events": { "m.room.avatar": 0 - }, - users: { - "@example:matrix.org": 100 } } }) @@ -227,8 +201,7 @@ test("diffKState: power levels are mixed together", t => { "redact": 50, "state_default": 50, "users": { - "@example:localhost": 100, - "@example:matrix.org": 100 + "@example:localhost": 100 }, "users_default": 0 } @@ -261,190 +234,3 @@ test("diffKState: kstate keys must contain a slash separator", t => { , /does not contain a slash separator/) t.pass() }) - -test("diffKState: topic does not change if the topic key has not changed", t => { - t.deepEqual(diffKState({ - "m.room.topic/": { - topic: "hello", - "m.topic": { - "m.text": "hello" - } - } - }, { - "m.room.topic/": { - topic: "hello" - } - }), - {}) -}) - -test("diffKState: topic changes if the topic key has changed", t => { - t.deepEqual(diffKState({ - "m.room.topic/": { - topic: "hello", - "m.topic": { - "m.text": "hello" - } - } - }, { - "m.room.topic/": { - topic: "hello you" - } - }), - { - "m.room.topic/": { - topic: "hello you" - } - }) -}) - -test("diffKState: room v12 creators cannot be introduced into power levels", t => { - const original = { - "m.room.create/outer": { - type: "m.room.create", - state_key: "", - sender: "@example1:matrix.org", - content: { - additional_creators: ["@example2:matrix.org"], - room_version: "12" - } - }, - "m.room.create/": { - room_version: "12" - }, - "m.room.power_levels/": { - "ban": 50, - "events": { - "m.room.name": 100, - "m.room.power_levels": 100 - }, - "events_default": 0, - "invite": 50, - "kick": 50, - "notifications": { - "room": 20 - }, - "redact": 50, - "state_default": 50, - "users": { - "@example:localhost": 100 - }, - "users_default": 0 - } - } - const result = diffKState(original, { - "m.room.create/": { - bogus: true - }, - "m.room.power_levels/": { - events: { - "m.room.avatar": 0 - }, - users: { - "@example1:matrix.org": 100, - "@example2:matrix.org": 100, - "@example3:matrix.org": 100 - } - } - }) - t.deepEqual(result, { - "m.room.power_levels/": { - "ban": 50, - "events": { - "m.room.name": 100, - "m.room.power_levels": 100, - "m.room.avatar": 0 - }, - "events_default": 0, - "invite": 50, - "kick": 50, - "notifications": { - "room": 20 - }, - "redact": 50, - "state_default": 50, - "users": { - "@example:localhost": 100, - "@example3:matrix.org": 100 - }, - "users_default": 0 - } - }) - t.notDeepEqual(original, result) -}) - -test("diffKState: room v12 creators cannot be introduced into power levels - no diff if no changes", t => { - const original = { - "m.room.create/outer": { - type: "m.room.create", - state_key: "", - sender: "@example1:matrix.org", - content: { - additional_creators: ["@example2:matrix.org"], - room_version: "12" - } - }, - "m.room.create/": { - additional_creators: ["@example2:matrix.org"], - room_version: "12" - }, - "m.room.power_levels/": { - "ban": 50, - "events": { - "m.room.name": 100, - "m.room.power_levels": 100 - }, - "events_default": 0, - "invite": 50, - "kick": 50, - "notifications": { - "room": 20 - }, - "redact": 50, - "state_default": 50, - "users": { - "@example:localhost": 100 - }, - "users_default": 0 - } - } - const result = diffKState(original, { - "m.room.power_levels/": { - users: { - "@example1:matrix.org": 100, - "@example2:matrix.org": 100 - } - } - }) - t.deepEqual(result, {}) - t.notDeepEqual(original, result) -}) - -test("kstateToCreationContent: works", t => { - const original = { - "m.room.create/outer": { - type: "m.room.create", - state_key: "", - sender: "@example1:matrix.org", - content: { - additional_creators: ["@example2:matrix.org"], - room_version: "12", - type: "m.space" - } - }, - "m.room.create/": { - additional_creators: ["@example2:matrix.org"], - room_version: "12", - type: "m.space" - } - } - t.deepEqual(kstateToCreationContent(original), { - additional_creators: ["@example2:matrix.org"], - room_version: "12", - type: "m.space" - }) -}) - -test("kstateToCreationContent: works if empty", t => { - t.deepEqual(kstateToCreationContent({}), {}) -}) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index e382a32..7a35e12 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -8,8 +8,8 @@ const sharp = require("sharp") const {discord, sync, db, select} = require("../passthrough") /** @type {import("./api")}) */ const api = sync.require("./api") -/** @type {import("./utils")} */ -const mxUtils = sync.require("./utils") +/** @type {import("../m2d/converters/utils")} */ +const mxUtils = sync.require("../m2d/converters/utils") /** @type {import("../discord/utils")} */ const dUtils = sync.require("../discord/utils") /** @type {import("./kstate")} */ @@ -58,7 +58,7 @@ async function addButton(roomID, eventID, key, mxid) { setInterval(() => { const now = Date.now() buttons = buttons.filter(b => now - b.created < 2*60*60*1000) -}, 10*60*1000).unref() +}, 10*60*1000) /** @param {Ty.Event.Outer<Ty.Event.M_Reaction>} event */ function onReactionAdd(event) { @@ -114,7 +114,7 @@ const commands = [{ const guild = discord.guilds.get(guildID) assert(guild) const slots = getSlotCount(guild.premium_tier) - const permissions = dUtils.getPermissions(guild.id, [], guild.roles) + const permissions = dUtils.getPermissions([], guild.roles) if (guild.emojis.length >= slots) { matrixOnlyReason = "CAPACITY" } else if (!(permissions & 0x40000000n)) { // MANAGE_GUILD_EXPRESSIONS (apparently CREATE_GUILD_EXPRESSIONS isn't good enough...) @@ -123,9 +123,12 @@ const commands = [{ } if (matrixOnlyReason) { // If uploading to Matrix, check if we have permission - const {powerLevels, powers: {[mxUtils.bot]: botPower}} = await mxUtils.getEffectivePower(event.room_id, [mxUtils.bot], api) - const requiredPower = powerLevels.events?.["im.ponies.room_emotes"] ?? powerLevels.state_default ?? 50 - if (botPower < requiredPower) { + const state = await api.getAllState(event.room_id) + const kstate = ks.stateToKState(state) + const powerLevels = kstate["m.room.power_levels/"] + const required = powerLevels.events["im.ponies.room_emotes"] ?? powerLevels.state_default ?? 50 + const have = powerLevels.users[`@${reg.sender_localpart}:${reg.ooye.server_name}`] ?? powerLevels.users_default ?? 0 + if (have < required) { return api.sendEvent(event.room_id, "m.room.message", { ...ctx, msgtype: "m.text", @@ -174,7 +177,7 @@ const commands = [{ .addLine(`Ⓜ️ *If you were a Discord user, you wouldn't have permission to create emojis. ${matrixOnlyConclusion}`, `Ⓜ️ <em>If you were a Discord user, you wouldn't have permission to create emojis. ${matrixOnlyConclusion}</em>`, matrixOnlyReason === "CAPACITY") .addLine("[Preview not available in plain text.]", "Preview:") for (const e of toUpload) { - b.add("", `:${e.name}: <img data-mx-emoticon height="48" src="${e.url}" title=":${e.name}:" alt=":${e.name}:">`) + b.add("", `<img data-mx-emoticon height="48" src="${e.url}" title=":${e.name}:" alt=":${e.name}:">`) } b.addLine("Hit ✅ to add it.") const sent = await api.sendEvent(event.room_id, "m.room.message", { @@ -221,7 +224,7 @@ const commands = [{ .png() .toBuffer({resolveWithObject: true}) console.log(`uploading emoji ${resizeOutput.data.length} bytes to :${e.name}:`) - await discord.snow.assets.createGuildEmoji(guildID, {name: e.name, image: "data:image/png;base64," + resizeOutput.data.toString("base64")}) + const emoji = await discord.snow.guildAssets.createEmoji(guildID, {name: e.name, image: "data:image/png;base64," + resizeOutput.data.toString("base64")}) } api.sendEvent(event.room_id, "m.room.message", { ...ctx, @@ -250,7 +253,7 @@ const commands = [{ const guild = discord.guilds.get(guildID) assert(guild) - const permissions = dUtils.getPermissions(guild.id, [], guild.roles) + const permissions = dUtils.getPermissions([], guild.roles) if (!(permissions & 0x800000000n)) { // CREATE_PUBLIC_THREADS return api.sendEvent(event.room_id, "m.room.message", { ...ctx, diff --git a/src/matrix/mreq.js b/src/matrix/mreq.js index bb59506..4707ae6 100644 --- a/src/matrix/mreq.js +++ b/src/matrix/mreq.js @@ -1,11 +1,12 @@ // @ts-check -const stream = require("stream") -const streamWeb = require("stream/web") -const {buffer} = require("stream/consumers") +const fetch = require("node-fetch").default const mixin = require("@cloudrac3r/mixin-deep") +const stream = require("stream") +const getStream = require("get-stream") const {reg} = require("./read-registration.js") + const baseUrl = `${reg.ooye.server_origin}/_matrix` class MatrixServerError extends Error { @@ -18,71 +19,41 @@ class MatrixServerError extends Error { } } -/** - * @param {undefined | string | object | streamWeb.ReadableStream | stream.Readable} body - * @returns {Promise<string | streamWeb.ReadableStream | stream.Readable | Buffer>} - */ -async function _convertBody(body) { - if (body == undefined || Object.is(body.constructor, Object)) { - return JSON.stringify(body) // almost every POST request is going to follow this one - } else if (body instanceof stream.Readable && reg.ooye.content_length_workaround) { - return await buffer(body) // content length workaround is set, so convert to buffer. the buffer consumer accepts node streams. - } else if (body instanceof stream.Readable) { - return stream.Readable.toWeb(body) // native fetch can only consume web streams - } else if (body instanceof streamWeb.ReadableStream && reg.ooye.content_length_workaround) { - return await buffer(body) // content lenght workaround is set, so convert to buffer. the buffer consumer accepts async iterables, which web streams are. - } - return body -} - -/* c8 ignore start */ - -/** - * @param {Response} res - * @param {object} opts - */ -async function makeMatrixServerError(res, opts = {}) { - delete opts.headers?.["Authorization"] - if (res.headers.get("content-type") === "application/json") { - return new MatrixServerError(await res.json(), opts) - } else if (res.headers.get("content-type")?.startsWith("text/")) { - return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, message: await res.text()}, opts) - } else { - return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, content_type: res.headers.get("content-type")}, opts) - } -} - /** * @param {string} method * @param {string} url - * @param {string | object | streamWeb.ReadableStream | stream.Readable} [bodyIn] + * @param {any} [body] * @param {any} [extra] */ -async function mreq(method, url, bodyIn, extra = {}) { - const body = await _convertBody(bodyIn) +async function mreq(method, url, body, extra = {}) { + if (body == undefined || Object.is(body.constructor, Object)) { + body = JSON.stringify(body) + } else if (body instanceof stream.Readable && reg.ooye.content_length_workaround) { + body = await getStream.buffer(body) + } - /** @type {RequestInit} */ const opts = mixin({ method, body, headers: { Authorization: `Bearer ${reg.as_token}` - }, - ...(body && {duplex: "half"}), // https://github.com/octokit/request.js/pull/571/files + } }, extra) + // console.log(baseUrl + url, opts) const res = await fetch(baseUrl + url, opts) - const text = await res.text() - try { - /** @type {any} */ - var root = JSON.parse(text) - } catch (e) { - delete opts.headers?.["Authorization"] - throw new MatrixServerError(text, {baseUrl, url, ...opts}) - } + const root = await res.json() if (!res.ok || root.errcode) { - delete opts.headers?.["Authorization"] + if (root.error?.includes("Content-Length")) { + console.error(`OOYE cannot stream uploads to Synapse. Please choose one of these workarounds:` + + `\n * Run an nginx reverse proxy to Synapse, and point registration.yaml's` + + `\n \`server_origin\` to nginx` + + `\n * Set \`content_length_workaround: true\` in registration.yaml (this will` + + `\n halve the speed of bridging d->m files)`) + throw new Error("Synapse is not accepting stream uploads, see the message above.") + } + delete opts.headers.Authorization throw new MatrixServerError(root, {baseUrl, url, ...opts}) } return root @@ -107,8 +78,6 @@ async function withAccessToken(token, callback) { } module.exports.MatrixServerError = MatrixServerError -module.exports.makeMatrixServerError = makeMatrixServerError module.exports.baseUrl = baseUrl module.exports.mreq = mreq module.exports.withAccessToken = withAccessToken -module.exports._convertBody = _convertBody diff --git a/src/matrix/mreq.test.js b/src/matrix/mreq.test.js deleted file mode 100644 index 7ac343e..0000000 --- a/src/matrix/mreq.test.js +++ /dev/null @@ -1,47 +0,0 @@ -// @ts-check - -const assert = require("assert") -const stream = require("stream") -const streamWeb = require("stream/web") -const {buffer} = require("stream/consumers") -const {test} = require("supertape") -const {_convertBody} = require("./mreq") -const {reg} = require("./read-registration") - -async function *generator() { - yield "a" - yield "b" -} - -reg.ooye.content_length_workaround = false - -test("convert body: converts object to string", async t => { - t.equal(await _convertBody({a: "1"}), `{"a":"1"}`) -}) - -test("convert body: leaves undefined as undefined", async t => { - t.equal(await _convertBody(undefined), undefined) -}) - -test("convert body: leaves web readable as web readable", async t => { - const webReadable = stream.Readable.toWeb(stream.Readable.from(generator())) - t.equal(await _convertBody(webReadable), webReadable) -}) - -test("convert body: converts node readable to web readable (for native fetch upload)", async t => { - const readable = stream.Readable.from(generator()) - const webReadable = await _convertBody(readable) - assert(webReadable instanceof streamWeb.ReadableStream) - t.deepEqual(await buffer(webReadable), Buffer.from("ab")) -}) - -test("convert body: converts node readable to buffer", async t => { - reg.ooye.content_length_workaround = true - const readable = stream.Readable.from(generator()) - t.deepEqual(await _convertBody(readable), Buffer.from("ab")) -}) - -test("convert body: converts web readable to buffer", async t => { - const webReadable = stream.Readable.toWeb(stream.Readable.from(generator())) - t.deepEqual(await _convertBody(webReadable), Buffer.from("ab")) -}) diff --git a/src/matrix/power.js b/src/matrix/power.js index d323d17..3e613dd 100644 --- a/src/matrix/power.js +++ b/src/matrix/power.js @@ -3,6 +3,7 @@ const {db, from} = require("../passthrough") const {reg} = require("./read-registration") const ks = require("./kstate") +const {applyKStateDiffToRoom, roomToKState} = require("../d2m/actions/create-room") /** Apply global power level requests across ALL rooms where the member cache entry exists but the power level has not been applied yet. */ function _getAffectedRooms() { @@ -22,9 +23,9 @@ async function applyPower() { const rows = _getAffectedRooms() for (const row of rows) { - const kstate = await ks.roomToKState(row.room_id) + const kstate = await roomToKState(row.room_id) const diff = ks.diffKState(kstate, {"m.room.power_levels/": {users: {[row.mxid]: row.power_level}}}) - await ks.applyKStateDiffToRoom(row.room_id, diff) + await applyKStateDiffToRoom(row.room_id, diff) // There is a listener on m.room.power_levels to do this same update, // but we update it here anyway since the homeserver does not always deliver the event round-trip. db.prepare("UPDATE member_cache SET power_level = ? WHERE room_id = ? AND mxid = ?").run(row.power_level, row.room_id, row.mxid) diff --git a/src/matrix/power.test.js b/src/matrix/power.test.js new file mode 100644 index 0000000..5423c4f --- /dev/null +++ b/src/matrix/power.test.js @@ -0,0 +1,12 @@ +// @ts-check + +const {test} = require("supertape") +const power = require("./power") + +test("power: get affected rooms", t => { + t.deepEqual(power._getAffectedRooms(), [{ + mxid: "@test_auto_invite:example.org", + power_level: 100, + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", + }]) +}) diff --git a/src/matrix/read-registration.js b/src/matrix/read-registration.js index 114bf75..9fb0535 100644 --- a/src/matrix/read-registration.js +++ b/src/matrix/read-registration.js @@ -9,9 +9,9 @@ const registrationFilePath = path.join(process.cwd(), "registration.yaml") /** @param {import("../types").AppServiceRegistrationConfig} reg */ function checkRegistration(reg) { - reg["ooye"].invite = reg.ooye.invite.filter(mxid => mxid.endsWith(`:${reg.ooye.server_name}`)) // one day I will understand why typescript disagrees with dot notation on this line + reg["ooye"].invite = (reg.ooye.invite || []).filter(mxid => mxid.endsWith(`:${reg.ooye.server_name}`)) // one day I will understand why typescript disagrees with dot notation on this line assert(reg.ooye?.max_file_size) - assert(reg.ooye?.namespace_prefix != null) + assert(reg.ooye?.namespace_prefix) assert(reg.ooye?.server_name) assert(reg.sender_localpart?.startsWith(reg.ooye.namespace_prefix), "appservice's localpart must be in the namespace it controls") assert(reg.ooye?.server_origin.match(/^https?:\/\//), "server origin must start with http or https") @@ -19,10 +19,9 @@ function checkRegistration(reg) { assert.match(reg.url, /^https?:/, "url must start with http:// or https://") } -/* c8 ignore next 4 */ /** @param {import("../types").AppServiceRegistrationConfig} reg */ function writeRegistration(reg) { - fs.writeFileSync(registrationFilePath, JSON.stringify(reg, null, 2) + "\n") + fs.writeFileSync(registrationFilePath, JSON.stringify(reg, null, 2)) } /** @@ -53,12 +52,10 @@ function getTemplateRegistration(serverName) { socket: 6693, ooye: { namespace_prefix, - server_name: serverName, max_file_size: 5000000, content_length_workaround: false, include_user_id_in_mxid: false, - invite: [], - receive_presences: true + invite: [] } } } @@ -69,8 +66,6 @@ function readRegistration() { try { const content = fs.readFileSync(registrationFilePath, "utf8") result = JSON.parse(content) - result.ooye.invite ||= [] - /* c8 ignore next */ } catch (e) {} return result } diff --git a/src/matrix/read-registration.test.js b/src/matrix/read-registration.test.js index 5fb3b55..80ac09f 100644 --- a/src/matrix/read-registration.test.js +++ b/src/matrix/read-registration.test.js @@ -1,8 +1,5 @@ -// @ts-check - -const tryToCatch = require("try-to-catch") const {test} = require("supertape") -const {reg, checkRegistration, getTemplateRegistration} = require("./read-registration") +const {reg} = require("./read-registration") test("reg: has necessary parameters", t => { const propertiesToCheck = ["sender_localpart", "id", "as_token", "ooye"] @@ -11,19 +8,3 @@ test("reg: has necessary parameters", t => { propertiesToCheck ) }) - -test("check: passes on sample", t => { - checkRegistration(reg) - t.pass("all assertions passed") -}) - -test("check: fails on template as template is missing some required values that are gathered during setup", t => { - let err - try { - // @ts-ignore - checkRegistration(getTemplateRegistration("cadence.moe")) - } catch (e) { - err = e - } - t.ok(err, "one of the assertions failed as expected") -}) diff --git a/src/matrix/room-upgrade.js b/src/matrix/room-upgrade.js deleted file mode 100644 index 5a2606e..0000000 --- a/src/matrix/room-upgrade.js +++ /dev/null @@ -1,96 +0,0 @@ -// @ts-check - -const assert = require("assert/strict") -const Ty = require("../types") -const {Semaphore} = require("@chriscdn/promise-semaphore") -const {tag} = require("@cloudrac3r/html-template-tag") -const {db, sync, select, from} = require("../passthrough") - -/** @type {import("./utils")}) */ -const utils = sync.require("./utils") - -const roomUpgradeSema = new Semaphore() - -/** - * @param {Ty.Event.StateOuter<Ty.Event.M_Room_Tombstone>} event - * @param {import("./api")} api - */ -async function onTombstone(event, api) { - // Preconditions (checked by event-dispatcher, enforced here) - assert.equal(event.state_key, "") - assert.ok(event.content.replacement_room) - - // Set up - const oldRoomID = event.room_id - const newRoomID = event.content.replacement_room - const channel = select("channel_room", ["name", "channel_id"], {room_id: oldRoomID}).get() - if (!channel) return - db.prepare("REPLACE INTO room_upgrade_pending (new_room_id, old_room_id) VALUES (?, ?)").run(newRoomID, oldRoomID) - - // Try joining - try { - await api.joinRoom(newRoomID) - } catch (e) { - const message = new utils.MatrixStringBuilder() - message.add( - `You upgraded the bridged room ${channel.name}. To keep bridging, I need you to invite me to the new room: https://matrix.to/#/${newRoomID}`, - tag`You upgraded the bridged room <strong>${channel.name}</strong>. To keep bridging, I need you to invite me to the new room: <a href="https://matrix.to/#/${newRoomID}">https://matrix.to/#/${newRoomID}</a>` - ) - const privateRoomID = await api.usePrivateChat(event.sender) - await api.sendEvent(privateRoomID, "m.room.message", message.get()) - } - - // Now wait to be invited to/join the room that has the upgrade pending... -} - -/** - * @param {Ty.Event.StateOuter<Ty.Event.M_Room_Member>} event - * @param {import("./api")} api - * @param {import("../d2m/actions/create-room")} createRoom - * @returns {Promise<boolean>} whether to cancel other membership actions - */ -async function onBotMembership(event, api, createRoom) { - // Preconditions (checked by event-dispatcher, enforced here) - assert.equal(event.type, "m.room.member") - assert.equal(event.state_key, utils.bot) - - // Check if an upgrade is pending for this room - const newRoomID = event.room_id - const oldRoomID = select("room_upgrade_pending", "old_room_id", {new_room_id: newRoomID}).pluck().get() - if (!oldRoomID) return false - const channelRow = from("channel_room").join("guild_space", "guild_id").where({room_id: oldRoomID}).select("space_id", "guild_id", "channel_id").get() - assert(channelRow) // this could only fail if the channel was unbridged or something between upgrade and joining - - // Check if is join/invite - if (event.content.membership !== "invite" && event.content.membership !== "join") return false - - return await roomUpgradeSema.request(async () => { - // If invited, join - if (event.content.membership === "invite") { - await api.joinRoom(newRoomID) - } - - // Remove old room from space - await api.sendState(channelRow.space_id, "m.space.child", oldRoomID, {}) - // await api.sendState(oldRoomID, "m.space.parent", spaceID, {}) // keep this - the room isn't advertised but should still be grouped if opened - - // Remove declaration that old room is bridged (if able) - try { - await api.sendState(oldRoomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${channelRow.guild_id}/${channelRow.channel_id}`, {}) - } catch (e) { /* c8 ignore next */ } - - // Update database - db.transaction(() => { - db.prepare("DELETE FROM room_upgrade_pending WHERE new_room_id = ?").run(newRoomID) - db.prepare("UPDATE channel_room SET room_id = ? WHERE channel_id = ?").run(newRoomID, channelRow.channel_id) - db.prepare("INSERT INTO historical_channel_room (room_id, reference_channel_id, upgraded_timestamp) VALUES (?, ?, ?)").run(newRoomID, channelRow.channel_id, Date.now()) - })() - - // Sync - await createRoom.syncRoom(channelRow.channel_id) - return true - }, event.room_id) -} - -module.exports.onTombstone = onTombstone -module.exports.onBotMembership = onBotMembership diff --git a/src/matrix/room-upgrade.test.js b/src/matrix/room-upgrade.test.js deleted file mode 100644 index 3de1a8f..0000000 --- a/src/matrix/room-upgrade.test.js +++ /dev/null @@ -1,169 +0,0 @@ -const {test} = require("supertape") -const {select} = require("../passthrough") -const {onTombstone, onBotMembership} = require("./room-upgrade") - -test("join upgraded room: only cares about upgrades in progress", async t => { - let called = 0 - await onBotMembership({ - type: "m.room.member", - state_key: "@_ooye_bot:cadence.moe", - room_id: "!JBxeGYnzQwLnaooOLD:cadence.moe", - content: { - membership: "invite" - } - }, { - /* c8 ignore next 4 */ - async joinRoom(roomID) { - called++ - throw new Error("should not join this room") - } - }) - t.equal(called, 0) -}) - -test("tombstone: only cares about bridged rooms", async t => { - let called = 0 - await onTombstone({ - event_id: "$tombstone", - type: "m.room.tombstone", - state_key: "", - sender: "@cadence:cadence.moe", - origin_server_ts: 0, - room_id: "!imaginary:cadence.moe", - content: { - body: "This room has been replaced", - replacement_room: "!JBxeGYnzQwLnaooNEW:cadence.moe" - } - }, { - /* c8 ignore next 4 */ - async joinRoom(roomID) { - called++ - throw new Error("should not join this room") - } - }) - t.equal(called, 0) -}) - -test("tombstone: joins new room and stores upgrade in database", async t => { - let called = 0 - await onTombstone({ - event_id: "$tombstone", - type: "m.room.tombstone", - state_key: "", - sender: "@cadence:cadence.moe", - origin_server_ts: 0, - room_id: "!JBxeGYnzQwLnaooOLD:cadence.moe", - content: { - body: "This room has been replaced", - replacement_room: "!JBxeGYnzQwLnaooNEW:cadence.moe" - } - }, { - async joinRoom(roomID) { - called++ - t.equal(roomID, "!JBxeGYnzQwLnaooNEW:cadence.moe") - return roomID - } - }) - t.equal(called, 1) - t.ok(select("room_upgrade_pending", ["old_room_id", "new_room_id"], {new_room_id: "!JBxeGYnzQwLnaooNEW:cadence.moe", old_room_id: "!JBxeGYnzQwLnaooOLD:cadence.moe"}).get()) -}) - -test("tombstone: requests invite from upgrader if can't join room", async t => { - let called = 0 - await onTombstone({ - event_id: "$tombstone", - type: "m.room.tombstone", - state_key: "", - sender: "@cadence:cadence.moe", - origin_server_ts: 0, - room_id: "!JBxeGYnzQwLnaooOLD:cadence.moe", - content: { - body: "This room has been replaced", - replacement_room: "!JBxeGYnzQwLnaooNEW:cadence.moe" - } - }, { - async joinRoom(roomID) { - called++ - t.equal(roomID, "!JBxeGYnzQwLnaooNEW:cadence.moe") - throw new Error("access denied or something") - }, - async usePrivateChat(sender) { - called++ - t.equal(sender, "@cadence:cadence.moe") - return "!private" - }, - async sendEvent(roomID, type, content) { - called++ - t.equal(roomID, "!private") - t.equal(type, "m.room.message") - t.deepEqual(content, { - msgtype: "m.text", - body: "You upgraded the bridged room winners. To keep bridging, I need you to invite me to the new room: https://matrix.to/#/!JBxeGYnzQwLnaooNEW:cadence.moe", - format: "org.matrix.custom.html", - formatted_body: `You upgraded the bridged room <strong>winners</strong>. To keep bridging, I need you to invite me to the new room: <a href="https://matrix.to/#/!JBxeGYnzQwLnaooNEW:cadence.moe">https://matrix.to/#/!JBxeGYnzQwLnaooNEW:cadence.moe</a>` - }) - } - }) - t.equal(called, 3) -}) - -test("join upgraded room: only cares about invites/joins", async t => { - let called = 0 - await onBotMembership({ - type: "m.room.member", - state_key: "@_ooye_bot:cadence.moe", - room_id: "!JBxeGYnzQwLnaooNEW:cadence.moe", - content: { - membership: "leave" - } - }, { - /* c8 ignore next 4 */ - async joinRoom(roomID) { - called++ - throw new Error("should not join this room") - } - }) - t.equal(called, 0) -}) - -test("join upgraded room: joins invited room, updates database", async t => { - let called = 0 - await onBotMembership({ - type: "m.room.member", - state_key: "@_ooye_bot:cadence.moe", - room_id: "!JBxeGYnzQwLnaooNEW:cadence.moe", - content: { - membership: "invite" - } - }, { - async joinRoom(roomID) { - called++ - t.equal(roomID, "!JBxeGYnzQwLnaooNEW:cadence.moe") - return roomID - }, - async sendState(roomID, type, key, content) { - called++ - if (type === "m.space.child") { - t.equal(roomID, "!CvQMeeqXIkgedUpkzv:cadence.moe") // space - t.equal(key, "!JBxeGYnzQwLnaooOLD:cadence.moe") - t.deepEqual(content, {}) - return "$child" - } else if (type === "uk.half-shot.bridge") { - t.equal(roomID, "!JBxeGYnzQwLnaooOLD:cadence.moe") - t.equal(key, "moe.cadence.ooye://discord/1345641201902288987/598707048112193536") - t.deepEqual(content, {}) - return "$bridge" - } - /* c8 ignore next */ - throw new Error(`unexpected sendState: ${roomID} - ${type}/${key}`) - } - }, { - async syncRoom(channelID) { - called++ - t.equal(channelID, "598707048112193536") - } - }) - t.equal(called, 4) - t.equal(select("channel_room", "room_id", {channel_id: "598707048112193536"}).pluck().get(), "!JBxeGYnzQwLnaooNEW:cadence.moe") - t.equal(select("historical_channel_room", "historical_room_index", {reference_channel_id: "598707048112193536"}).pluck().all().length, 2) -}) diff --git a/src/matrix/utils.js b/src/matrix/utils.js deleted file mode 100644 index 9f5cb0f..0000000 --- a/src/matrix/utils.js +++ /dev/null @@ -1,415 +0,0 @@ -// @ts-check - -const assert = require("assert").strict -const Ty = require("../types") -const {tag} = require("@cloudrac3r/html-template-tag") -const passthrough = require("../passthrough") -const {db} = passthrough - -const {reg} = require("./read-registration") -const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) - -/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore -let hasher = null -// @ts-ignore -require("xxhash-wasm")().then(h => hasher = h) - -const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}` - -const BLOCK_ELEMENTS = [ - "ADDRESS", "ARTICLE", "ASIDE", "AUDIO", "BLOCKQUOTE", "BODY", "CANVAS", - "CENTER", "DD", "DETAILS", "DIR", "DIV", "DL", "DT", "FIELDSET", "FIGCAPTION", "FIGURE", - "FOOTER", "FORM", "FRAMESET", "H1", "H2", "H3", "H4", "H5", "H6", "HEADER", - "HGROUP", "HR", "HTML", "ISINDEX", "LI", "MAIN", "MENU", "NAV", "NOFRAMES", - "NOSCRIPT", "OL", "OUTPUT", "P", "PRE", "SECTION", "SUMMARY", "TABLE", "TBODY", "TD", - "TFOOT", "TH", "THEAD", "TR", "UL" -] -const NEWLINE_ELEMENTS = BLOCK_ELEMENTS.concat(["BR"]) - -/** - * Determine whether an event is the bridged representation of a discord message. - * Such messages shouldn't be bridged again. - * @param {string} sender - */ -function eventSenderIsFromDiscord(sender) { - // If it's from a user in the bridge's namespace, then it originated from discord - // This could include messages sent by the appservice's bot user, because that is what's used for webhooks - if (userRegex.some(x => sender.match(x))) { - return true - } - - return false -} - -/** - * Event IDs are really big and have more entropy than we need. - * If we want to store the event ID in the database, we can store a more compact version by hashing it with this. - * I choose a 64-bit non-cryptographic hash as only a 32-bit hash will see birthday collisions unreasonably frequently: https://en.wikipedia.org/wiki/Birthday_attack#Mathematics - * xxhash outputs an unsigned 64-bit integer. - * Converting to a signed 64-bit integer with no bit loss so that it can be stored in an SQLite integer field as-is: https://www.sqlite.org/fileformat2.html#record_format - * This should give very efficient storage with sufficient entropy. - * @param {string} eventID - */ -function getEventIDHash(eventID) { - assert(hasher, "xxhash is not ready yet") - if (eventID[0] === "$" && eventID.length >= 13) { - eventID = eventID.slice(1) // increase entropy per character to potentially help xxhash - } - const unsignedHash = hasher.h64(eventID) - const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range - return signedHash -} - -class MatrixStringBuilderStack { - constructor() { - this.stack = [new MatrixStringBuilder()] - } - - get msb() { - return this.stack[0] - } - - bump() { - this.stack.unshift(new MatrixStringBuilder()) - } - - shift() { - const msb = this.stack.shift() - assert(msb) - return msb - } -} - -class MatrixStringBuilder { - constructor() { - this.body = "" - this.formattedBody = "" - } - - /** - * @param {string} body - * @param {string} [formattedBody] - * @param {any} [condition] - */ - add(body, formattedBody, condition = true) { - if (condition) { - if (formattedBody == undefined) formattedBody = tag`${body}` - this.body += body - this.formattedBody += formattedBody - } - return this - } - - /** - * @param {string} body - * @param {string} [formattedBody] - * @param {any} [condition] - */ - addLine(body, formattedBody, condition = true) { - if (condition) { - if (formattedBody == undefined) formattedBody = tag`${body}` - if (this.body.length && this.body.slice(-1) !== "\n") this.body += "\n" - this.body += body - const match = this.formattedBody.match(/<\/?([a-zA-Z]+[a-zA-Z0-9]*)[^>]*>\s*$/) - if (this.formattedBody.length && (!match || !NEWLINE_ELEMENTS.includes(match[1].toUpperCase()))) this.formattedBody += "<br>" - this.formattedBody += formattedBody - } - return this - } - - /** - * @param {string} body - * @param {string} [formattedBody] - * @param {any} [condition] - */ - addParagraph(body, formattedBody, condition = true) { - if (condition) { - if (formattedBody == undefined) formattedBody = tag`${body}` - if (this.body.length && this.body.slice(-1) !== "\n") this.body += "\n\n" - this.body += body - const match = formattedBody.match(/^<([a-zA-Z]+[a-zA-Z0-9]*)/) - if (!match || !BLOCK_ELEMENTS.includes(match[1].toUpperCase())) formattedBody = `<p>${formattedBody}</p>` - this.formattedBody += formattedBody - } - return this - } - - get() { - return { - msgtype: "m.text", - body: this.body, - format: "org.matrix.custom.html", - formatted_body: this.formattedBody - } - } -} - -/** - * Context: Room IDs are not routable on their own. Room permalinks need a list of servers to try. The client is responsible for coming up with a list of servers. - * ASSUMPTION 1: The bridge bot is a member of the target room and can therefore access its power levels and member list for calculation. - * ASSUMPTION 2: Because the bridge bot is a member of the target room, the target room is bridged. - * https://spec.matrix.org/v1.9/appendices/#routing - * https://gitdab.com/cadence/out-of-your-element/issues/11 - * @param {string} roomID - * @param {{[K in "getStateEvent" | "getStateEventOuter" | "getJoinedMembers"]: import("./api")[K]} | {getEffectivePower: (roomID: string, mxids: string[], api: any) => Promise<{powers: Record<string, number>, allCreators: string[], tombstone: number, roomCreate: Ty.Event.StateOuter<Ty.Event.M_Room_Create>, powerLevels: Ty.Event.M_Power_Levels}>, getJoinedMembers: import("./api")["getJoinedMembers"]}} api - */ -async function getViaServers(roomID, api) { - const candidates = [] - const {joined} = await api.getJoinedMembers(roomID) - // Candidate 0: The bot's own server name - candidates.push(reg.ooye.server_name) - // Candidate 1: Highest joined non-sim non-bot power level user in the room - // https://github.com/matrix-org/matrix-react-sdk/blob/552c65db98b59406fb49562e537a2721c8505517/src/utils/permalinks/Permalinks.ts#L172 - /* c8 ignore next */ - const call = "getEffectivePower" in api ? api.getEffectivePower(roomID, [bot], api) : getEffectivePower(roomID, [bot], api) - const {allCreators, powerLevels} = await call - powerLevels.users ??= {} - const sorted = allCreators.concat(Object.entries(powerLevels.users).sort((a, b) => b[1] - a[1]).map(([mxid]) => mxid)) // Highest... - for (const mxid of sorted) { - if (!(mxid in joined)) continue // joined... - if (userRegex.some(r => mxid.match(r))) continue // non-sim non-bot... - const match = mxid.match(/:(.*)/) - assert(match) - /* c8 ignore next - should be already covered by the userRegex test, but let's be explicit */ - if (candidates.includes(match[1])) continue // from a different server - candidates.push(match[1]) - break - } - // Candidates 2-3: Most popular servers in the room - /** @type {Map<string, number>} */ - const servers = new Map() - // We can get the most popular servers if we know the members, so let's process those... - Object.keys(joined) - .filter(mxid => !mxid.startsWith("@_")) // Quick check - .filter(mxid => !userRegex.some(r => mxid.match(r))) // Full check - .slice(0, 1000) // Just sample the first thousand real members - .map(mxid => { - const match = mxid.match(/:(.*)/) - assert(match) - return match[1] - }) - .filter(server => !server.match(/([a-f0-9:]+:+)+[a-f0-9]+/)) // No IPv6 servers - .filter(server => !server.match(/[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/)) // No IPv4 servers - // I don't care enough to check ACLs - .forEach(server => { - const existing = servers.get(server) - if (!existing) servers.set(server, 1) - else servers.set(server, existing + 1) - }) - const serverList = [...servers.entries()].sort((a, b) => b[1] - a[1]) - for (const server of serverList) { - if (!candidates.includes(server[0])) { - candidates.push(server[0]) - if (candidates.length >= 4) break // Can have at most 4 candidate via servers - } - } - return candidates -} - -/** - * Context: Room IDs are not routable on their own. Room permalinks need a list of servers to try. The client is responsible for coming up with a list of servers. - * ASSUMPTION 1: The bridge bot is a member of the target room and can therefore access its power levels and member list for calculation. - * ASSUMPTION 2: Because the bridge bot is a member of the target room, the target room is bridged. - * https://spec.matrix.org/v1.9/appendices/#routing - * https://gitdab.com/cadence/out-of-your-element/issues/11 - * @param {string} roomID - * @param {{[K in "getStateEvent" | "getStateEventOuter" | "getJoinedMembers"]: import("./api")[K]}} api - * @returns {Promise<URLSearchParams>} - */ -async function getViaServersQuery(roomID, api) { - const list = await getViaServers(roomID, api) - const qs = new URLSearchParams() - for (const server of list) { - qs.append("via", server) - } - return qs -} - -function generatePermittedMediaHash(mxc) { - assert(hasher, "xxhash is not ready yet") - const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/) - if (!mediaParts) return undefined - - const serverAndMediaID = `${mediaParts[1]}/${mediaParts[2]}` - const unsignedHash = hasher.h64(serverAndMediaID) - const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range - db.prepare("INSERT OR IGNORE INTO media_proxy (permitted_hash) VALUES (?)").run(signedHash) - - return serverAndMediaID -} - -/** - * Since the introduction of authenticated media, this can no longer just be the /_matrix/media/r0/download URL - * because Discord and Discord users cannot use those URLs. Media now has to be proxied through the bridge. - * To avoid the bridge acting as a proxy for *any* media, there is a list of permitted media stored in the database. - * (The other approach would be signing the URLs with a MAC (or similar) and adding the signature, but I'm not a - * cryptographer, so I don't want to.) To reduce database disk space usage, instead of storing each permitted URL, - * we just store its xxhash as a signed (as in +/-, not signature) 64-bit integer, which fits in an SQLite integer field. - * @see https://matrix.org/blog/2024/06/26/sunsetting-unauthenticated-media/ background - * @see https://matrix.org/blog/2024/06/20/matrix-v1.11-release/ implementation details - * @see https://www.sqlite.org/fileformat2.html#record_format SQLite integer field size - * @param {string | null | undefined} mxc - * @returns {string | undefined} - */ -function getPublicUrlForMxc(mxc) { - const serverAndMediaID = makeMxcPublic(mxc) - if(!serverAndMediaID) return undefined - return `${reg.ooye.bridge_origin}/download/matrix/${serverAndMediaID}` -} - -/** - * @param {string | null | undefined} mxc - * @returns {string | undefined} mxc URL with protocol stripped, e.g. "cadence.moe/abcdef1234" - */ -function makeMxcPublic(mxc) { - assert(hasher, "xxhash is not ready yet") - const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/) - if (!mediaParts) return undefined - - const serverAndMediaID = `${mediaParts[1]}/${mediaParts[2]}` - const unsignedHash = hasher.h64(serverAndMediaID) - const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range - db.prepare("INSERT OR IGNORE INTO media_proxy (permitted_hash) VALUES (?)").run(signedHash) - - return serverAndMediaID -} - -/** - * @param {string} roomVersionString - * @param {number} desiredVersion - */ -function roomHasAtLeastVersion(roomVersionString, desiredVersion) { - /* - I hate this. - The spec instructs me to compare room versions ordinally, for example, "In room versions 12 and higher..." - So if the real room version is 13, this should pass the check. - However, the spec also says "room versions are not intended to be parsed and should be treated as opaque identifiers", "due to versions not being ordered or hierarchical". - So versions are unordered and opaque and you can't parse them, but you're still expected to parse them to a number and compare them to another number to measure if it's "12 or higher"? - Theoretically MSC3244 would clean this up, but that isn't happening since Element removed support for MSC3244: https://github.com/element-hq/element-web/commit/644b8415912afb9c5eed54859a444a2ee7224117 - Element replaced it with the following function: - */ - - // Assumption: all unstable room versions don't support the feature. Calling code can check for unstable - // room versions explicitly if it wants to. The spec reserves [0-9] and `.` for its room versions. - if (!roomVersionString.match(/^[\d.]+$/)) { - return false; - } - - // Element dev note: While the spec says room versions are not linear, we can make reasonable assumptions - // until the room versions prove themselves to be non-linear in the spec. We should see this coming - // from a mile away and can course-correct this function if needed. - return Number(roomVersionString) >= Number(desiredVersion); -} - -/** - * Starting in room version 12, creators may not be specified in power levels users. - * Modifies the input power levels. - * @param {Ty.Event.StateOuter<Ty.Event.M_Room_Create>} roomCreateOuter - * @param {Ty.Event.M_Power_Levels} powerLevels - */ -function removeCreatorsFromPowerLevels(roomCreateOuter, powerLevels) { - assert(roomCreateOuter.sender) - if (roomHasAtLeastVersion(roomCreateOuter.content.room_version, 12) && powerLevels.users) { - for (const creator of (roomCreateOuter.content.additional_creators ?? []).concat(roomCreateOuter.sender)) { - delete powerLevels.users[creator] - } - } - return powerLevels -} - -/** - * @template {string} T - * @param {string} roomID - * @param {T[]} mxids - * @param {{[K in "getStateEvent" | "getStateEventOuter"]: import("./api")[K]}} api - * @returns {Promise<{powers: Record<T, number>, allCreators: string[], tombstone: number, roomCreate: Ty.Event.StateOuter<Ty.Event.M_Room_Create>, powerLevels: Ty.Event.M_Power_Levels}>} - */ -async function getEffectivePower(roomID, mxids, api) { - /** @type {[Ty.Event.StateOuter<Ty.Event.M_Room_Create>, Ty.Event.M_Power_Levels]} */ - const [roomCreate, powerLevels] = await Promise.all([ - api.getStateEventOuter(roomID, "m.room.create", ""), - api.getStateEvent(roomID, "m.room.power_levels", "") - ]) - const allCreators = - ( roomHasAtLeastVersion(roomCreate.content.room_version, 12) ? (roomCreate.content.additional_creators ?? []).concat(roomCreate.sender) - : []) - const tombstone = - ( roomHasAtLeastVersion(roomCreate.content.room_version, 12) ? powerLevels.events?.["m.room.tombstone"] ?? 150 - : powerLevels.events?.["m.room.tombstone"] ?? powerLevels.state_default ?? 50) - /** @type {Record<T, number>} */ // @ts-ignore - const powers = {} - for (const mxid of mxids) { - powers[mxid] = - ( roomHasAtLeastVersion(roomCreate.content.room_version, 12) && allCreators.includes(mxid) ? Infinity - : powerLevels.users?.[mxid] - ?? powerLevels.users_default - ?? 0) - } - return {powers, allCreators, tombstone, roomCreate, powerLevels} -} - -/** - * Set a user's power level within a room. - * @param {string} roomID - * @param {string} mxid - * @param {number} newPower - * @param {{[K in "getStateEvent" | "getStateEventOuter" | "sendState"]: import("./api")[K]}} api - */ -async function setUserPower(roomID, mxid, newPower, api) { - assert(roomID[0] === "!") - assert(mxid[0] === "@") - // Yes there's no shortcut https://github.com/matrix-org/matrix-appservice-bridge/blob/2334b0bae28a285a767fe7244dad59f5a5963037/src/components/intent.ts#L352 - const {powerLevels, powers: {[mxid]: oldPowerLevel, [bot]: botPowerLevel}} = await getEffectivePower(roomID, [mxid, bot], api) - - // Check if it has really changed to avoid sending a useless state event - if (oldPowerLevel === newPower) return - - // Bridge bot can't demote equal power users, so need to decide which user will send the event - const eventSender = oldPowerLevel >= botPowerLevel ? mxid : undefined - - // Update the event content - powerLevels.users ??= {} - if (newPower == null || newPower === (powerLevels.users_default ?? 0)) { - delete powerLevels.users[mxid] - } else { - powerLevels.users[mxid] = newPower - } - - await api.sendState(roomID, "m.room.power_levels", "", powerLevels, eventSender) -} - -/** - * Set a user's power level for a whole room hierarchy. - * @param {string} spaceID - * @param {string} mxid - * @param {number} power - * @param {{[K in "getStateEvent" | "getStateEventOuter" | "sendState" | "generateFullHierarchy"]: import("./api")[K]}} api - */ -async function setUserPowerCascade(spaceID, mxid, power, api) { - assert(spaceID[0] === "!") - assert(mxid[0] === "@") - let seenSpace = false - for await (const room of api.generateFullHierarchy(spaceID)) { - if (room.room_id === spaceID) seenSpace = true - await setUserPower(room.room_id, mxid, power, api) - } - if (!seenSpace) { - await setUserPower(spaceID, mxid, power, api) - } -} - -module.exports.bot = bot -module.exports.BLOCK_ELEMENTS = BLOCK_ELEMENTS -module.exports.eventSenderIsFromDiscord = eventSenderIsFromDiscord -module.exports.makeMxcPublic = makeMxcPublic -module.exports.getPublicUrlForMxc = getPublicUrlForMxc -module.exports.getEventIDHash = getEventIDHash -module.exports.MatrixStringBuilder = MatrixStringBuilder -module.exports.MatrixStringBuilderStack = MatrixStringBuilderStack -module.exports.getViaServers = getViaServers -module.exports.getViaServersQuery = getViaServersQuery -module.exports.roomHasAtLeastVersion = roomHasAtLeastVersion -module.exports.removeCreatorsFromPowerLevels = removeCreatorsFromPowerLevels -module.exports.getEffectivePower = getEffectivePower -module.exports.setUserPower = setUserPower -module.exports.setUserPowerCascade = setUserPowerCascade diff --git a/src/matrix/utils.test.js b/src/matrix/utils.test.js deleted file mode 100644 index 842c513..0000000 --- a/src/matrix/utils.test.js +++ /dev/null @@ -1,420 +0,0 @@ -// @ts-check - -const {select} = require("../passthrough") -const {test} = require("supertape") -const {eventSenderIsFromDiscord, getEventIDHash, MatrixStringBuilder, getViaServers, roomHasAtLeastVersion, removeCreatorsFromPowerLevels, setUserPower} = require("./utils") -const util = require("util") - -/** @param {string[]} mxids */ -function joinedList(mxids) { - /** @type {{[mxid: string]: {display_name: null, avatar_url: null}}} */ - const joined = {} - for (const mxid of mxids) { - joined[mxid] = { - display_name: null, - avatar_url: null - } - } - return {joined} -} - -test("sender type: matrix user", t => { - t.notOk(eventSenderIsFromDiscord("@cadence:cadence.moe")) -}) - -test("sender type: ooye bot", t => { - t.ok(eventSenderIsFromDiscord("@_ooye_bot:cadence.moe")) -}) - -test("sender type: ooye puppet", t => { - t.ok(eventSenderIsFromDiscord("@_ooye_sheep:cadence.moe")) -}) - -test("event hash: hash is the same each time", t => { - const eventID = "$example" - t.equal(getEventIDHash(eventID), getEventIDHash(eventID)) -}) - -test("event hash: hash is different for different inputs", t => { - t.notEqual(getEventIDHash("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe1"), getEventIDHash("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe2")) -}) - -test("MatrixStringBuilder: add, addLine, add same text", t => { - const e = { - stack: "Error: Custom error\n at ./example.test.js:3:11)", - toString() { - return "Error: Custom error" - } - } - const gatewayMessage = {t: "MY_MESSAGE", d: {display: "Custom message data"}} - let stackLines = e.stack.split("\n") - - const builder = new MatrixStringBuilder() - builder.addLine("\u26a0 Bridged event from Discord not delivered", "\u26a0 <strong>Bridged event from Discord not delivered</strong>") - builder.addLine(`Gateway event: ${gatewayMessage.t}`) - builder.addLine(e.toString()) - if (stackLines) { - stackLines = stackLines.slice(0, 2) - stackLines[1] = stackLines[1].replace(/\\/g, "/").replace(/(\s*at ).*(\/m2d\/)/, "$1.$2") - builder.addLine(`Error trace:`, `<details><summary>Error trace</summary>`) - builder.add(`\n${stackLines.join("\n")}`, `<pre>${stackLines.join("\n")}</pre></details>`) - } - builder.addLine("", `<details><summary>Original payload</summary><pre>${util.inspect(gatewayMessage.d, false, 4, false)}</pre></details>`) - - t.deepEqual(builder.get(), { - msgtype: "m.text", - body: "\u26a0 Bridged event from Discord not delivered" - + "\nGateway event: MY_MESSAGE" - + "\nError: Custom error" - + "\nError trace:" - + "\nError: Custom error" - + "\n at ./example.test.js:3:11)\n", - format: "org.matrix.custom.html", - formatted_body: "\u26a0 <strong>Bridged event from Discord not delivered</strong>" - + "<br>Gateway event: MY_MESSAGE" - + "<br>Error: Custom error" - + "<br><details><summary>Error trace</summary><pre>Error: Custom error\n at ./example.test.js:3:11)</pre></details>" - + `<details><summary>Original payload</summary><pre>{ display: 'Custom message data' }</pre></details>` - }) -}) - -test("MatrixStringBuilder: complete code coverage", t => { - const builder = new MatrixStringBuilder() - builder.add("Line 1") - builder.addParagraph("Line 2") - builder.add("Line 3") - builder.addParagraph("Line 4") - - t.deepEqual(builder.get(), { - msgtype: "m.text", - body: "Line 1\n\nLine 2Line 3\n\nLine 4", - format: "org.matrix.custom.html", - formatted_body: "Line 1<p>Line 2</p>Line 3<p>Line 4</p>" - }) -}) - -/** - * @param {string[]} [creators] - * @param {{[x: string]: number}} [users] - * @param {string} [roomVersion] - */ -function mockGetEffectivePower(creators = ["@_ooye_bot:cadence.moe"], users = {}, roomVersion = "12") { - return async function getEffectivePower(roomID, mxids) { - return { - allCreators: creators, - powerLevels: {users}, - powers: mxids.reduce((a, mxid) => { - if (creators.includes(mxid) && roomHasAtLeastVersion(roomVersion, 12)) a[mxid] = Infinity - else if (mxid in users) a[mxid] = users[mxid] - else a[mxid] = 0 - return a - }, {}), - roomCreate: { - type: "m.room.create", - state_key: "", - sender: creators[0], - content: { - additional_creators: creators.slice(1), - room_version: roomVersion - }, - room_id: roomID, - origin_server_ts: 0, - event_id: "$create" - }, - tombstone: roomVersion === "12" ? 150 : 100, - } - } -} - -test("getViaServers: returns the server name if the room only has sim users", async t => { - const result = await getViaServers("!baby", { - getEffectivePower: mockGetEffectivePower(), - getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe"]) - }) - t.deepEqual(result, ["cadence.moe"]) -}) - -test("getViaServers: also returns the most popular servers in order", async t => { - const result = await getViaServers("!baby", { - getEffectivePower: mockGetEffectivePower(), - getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid"]) - }) - t.deepEqual(result, ["cadence.moe", "thecollective.invalid", "selfhosted.invalid"]) -}) - -test("getViaServers: does not return IP address servers", async t => { - const result = await getViaServers("!baby", { - getEffectivePower: mockGetEffectivePower(), - getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:45.77.232.172:8443", "@cadence:[::1]:8443", "@cadence:123example.456example.invalid"]) - }) - t.deepEqual(result, ["cadence.moe", "123example.456example.invalid"]) -}) - -test("getViaServers: also returns the highest power level user (v12 creator)", async t => { - const result = await getViaServers("!baby", { - getEffectivePower: mockGetEffectivePower(["@_ooye_bot:cadence.moe", "@singleuser:selfhosted.invalid"], { - "@moderator:tractor.invalid": 50 - }), - getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid", "@moderator:tractor.invalid"]) - }) - t.deepEqual(result, ["cadence.moe", "selfhosted.invalid", "thecollective.invalid", "tractor.invalid"]) -}) - -test("getViaServers: also returns the highest power level user (100)", async t => { - const result = await getViaServers("!baby", { - getEffectivePower: mockGetEffectivePower(["@_ooye_bot:cadence.moe"], { - "@moderator:tractor.invalid": 50, - "@singleuser:selfhosted.invalid": 100 - }), - getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid", "@moderator:tractor.invalid"]) - }) - t.deepEqual(result, ["cadence.moe", "selfhosted.invalid", "thecollective.invalid", "tractor.invalid"]) -}) - -test("getViaServers: also returns the highest power level user (50)", async t => { - const result = await getViaServers("!baby", { - getEffectivePower: mockGetEffectivePower(["@_ooye_bot:cadence.moe"], { - "@moderator:tractor.invalid": 50 - }), - getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid", "@singleuser:selfhosted.invalid"]) - }) - t.deepEqual(result, ["cadence.moe", "tractor.invalid", "thecollective.invalid", "selfhosted.invalid"]) -}) - -test("getViaServers: returns at most 4 results", async t => { - const result = await getViaServers("!baby", { - getEffectivePower: mockGetEffectivePower(["@_ooye_bot:cadence.moe"], { - "@moderator:tractor.invalid": 50, - "@singleuser:selfhosted.invalid": 100 - }), - getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@cadence:123example.456example.invalid"]) - }) - t.deepEqual(result.length, 4) -}) - -test("getViaServers: only considers power levels of currently joined members", async t => { - const result = await getViaServers("!baby", { - getEffectivePower: mockGetEffectivePower(["@_ooye_bot:cadence.moe", "@former_moderator:missing.invalid"], { - "@moderator:tractor.invalid": 50 - }), - getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid", "@singleuser:selfhosted.invalid"]) - }) - t.deepEqual(result, ["cadence.moe", "tractor.invalid", "thecollective.invalid", "selfhosted.invalid"]) -}) - -test("roomHasAtLeastVersion: v9 < v11", t => { - t.equal(roomHasAtLeastVersion("9", 11), false) -}) - -test("roomHasAtLeastVersion: v12 >= v11", t => { - t.equal(roomHasAtLeastVersion("12", 11), true) -}) - -test("roomHasAtLeastVersion: v12 >= v12", t => { - t.equal(roomHasAtLeastVersion("12", 12), true) -}) - -test("roomHasAtLeastVersion: custom versions never match", t => { - t.equal(roomHasAtLeastVersion("moe.cadence.silly", 11), false) -}) - -test("removeCreatorsFromPowerLevels: removes the creator from a v12 room", t => { - t.deepEqual(removeCreatorsFromPowerLevels({ - type: "m.room.create", - state_key: "", - sender: "@_ooye_bot:cadence.moe", - room_id: "!example", - event_id: "$create", - origin_server_ts: 0, - content: { - room_version: "12" - } - }, { - users: { - "@_ooye_bot:cadence.moe": 100 - } - }), { - users: { - } - }) -}) - -test("removeCreatorsFromPowerLevels: removes all creators from a v12 room", t => { - t.deepEqual(removeCreatorsFromPowerLevels({ - type: "m.room.create", - state_key: "", - sender: "@_ooye_bot:cadence.moe", - room_id: "!example", - event_id: "$create", - origin_server_ts: 0, - content: { - additional_creators: ["@cadence:cadence.moe"], - room_version: "12" - } - }, { - users: { - "@_ooye_bot:cadence.moe": 100, - "@cadence:cadence.moe": 100 - } - }), { - users: { - } - }) -}) - -test("removeCreatorsFromPowerLevels: doesn't touch a v11 room", t => { - t.deepEqual(removeCreatorsFromPowerLevels({ - type: "m.room.create", - state_key: "", - sender: "@_ooye_bot:cadence.moe", - room_id: "!example", - event_id: "$create", - origin_server_ts: 0, - content: { - additional_creators: ["@cadence:cadence.moe"], - room_version: "11" - } - }, { - users: { - "@_ooye_bot:cadence.moe": 100, - "@cadence:cadence.moe": 100 - } - }), { - users: { - "@_ooye_bot:cadence.moe": 100, - "@cadence:cadence.moe": 100 - } - }) -}) - -test("set user power: no-op", async t => { - let called = 0 - await setUserPower("!room", "@cadence:cadence.moe", 0, { - async getStateEvent(roomID, type, key) { - called++ - t.equal(roomID, "!room") - t.equal(type, "m.room.power_levels") - t.equal(key, "") - return {} - }, - async getStateEventOuter(roomID, type, key) { - called++ - t.equal(roomID, "!room") - t.equal(type, "m.room.create") - t.equal(key, "") - return { - type: "m.room.create", - state_key: "", - sender: "@_ooye_bot:cadence.moe", - room_id: "!room", - origin_server_ts: 0, - event_id: "$create", - content: { - room_version: "11" - } - } - }, - /* c8 ignore next 4 */ - async sendState() { - called++ - throw new Error("should not try to send state") - } - }) - t.equal(called, 2) -}) - -test("set user power: bridge bot must promote unprivileged users", async t => { - let called = 0 - await setUserPower("!room", "@cadence:cadence.moe", 100, { - async getStateEvent(roomID, type, key) { - called++ - t.equal(roomID, "!room") - t.equal(type, "m.room.power_levels") - t.equal(key, "") - return { - users: {"@_ooye_bot:cadence.moe": 100} - } - }, - async getStateEventOuter(roomID, type, key) { - called++ - t.equal(roomID, "!room") - t.equal(type, "m.room.create") - t.equal(key, "") - return { - type: "m.room.create", - state_key: "", - sender: "@_ooye_bot:cadence.moe", - room_id: "!room", - origin_server_ts: 0, - event_id: "$create", - content: { - room_version: "11" - } - } - }, - async sendState(roomID, type, key, content, mxid) { - called++ - t.equal(roomID, "!room") - t.equal(type, "m.room.power_levels") - t.equal(key, "") - t.deepEqual(content, { - users: { - "@_ooye_bot:cadence.moe": 100, - "@cadence:cadence.moe": 100 - } - }) - t.equal(mxid, undefined) - return "$sent" - } - }) - t.equal(called, 3) -}) - -test("set user power: privileged users must demote themselves", async t => { - let called = 0 - await setUserPower("!room", "@cadence:cadence.moe", 0, { - async getStateEvent(roomID, type, key) { - called++ - t.equal(roomID, "!room") - t.equal(type, "m.room.power_levels") - t.equal(key, "") - return { - users: { - "@cadence:cadence.moe": 100, - "@_ooye_bot:cadence.moe": 100 - } - } - }, - async getStateEventOuter(roomID, type, key) { - called++ - t.equal(roomID, "!room") - t.equal(type, "m.room.create") - t.equal(key, "") - return { - type: "m.room.create", - state_key: "", - sender: "@_ooye_bot:cadence.moe", - room_id: "!room", - origin_server_ts: 0, - event_id: "$create", - content: { - room_version: "11" - } - } - }, - async sendState(roomID, type, key, content, mxid) { - called++ - t.equal(roomID, "!room") - t.equal(type, "m.room.power_levels") - t.equal(key, "") - t.deepEqual(content, { - users: {"@_ooye_bot:cadence.moe": 100} - }) - t.equal(mxid, "@cadence:cadence.moe") - return "$sent" - } - }) - t.equal(called, 3) -}) - -module.exports.mockGetEffectivePower = mockGetEffectivePower diff --git a/src/passthrough.js b/src/passthrough.js index 8eedfc4..378f516 100644 --- a/src/passthrough.js +++ b/src/passthrough.js @@ -4,7 +4,7 @@ * @typedef {Object} Passthrough * @property {import("repl").REPLServer} repl * @property {import("./d2m/discord-client")} discord - * @property {import("heatsync")} sync + * @property {import("heatsync").default} sync * @property {import("better-sqlite3/lib/database")} db * @property {import("@cloudrac3r/in-your-element").AppService} as * @property {import("./db/orm").from} from diff --git a/src/stdin.js b/src/stdin.js index fea5fad..9051395 100644 --- a/src/stdin.js +++ b/src/stdin.js @@ -5,7 +5,7 @@ const util = require("util") const {addbot} = require("../addbot") const passthrough = require("./passthrough") -const {discord, sync, db, select, from, as} = passthrough +const {discord, sync, db} = passthrough const data = sync.require("../test/data") const createSpace = sync.require("./d2m/actions/create-space") @@ -19,18 +19,19 @@ const eventDispatcher = sync.require("./d2m/event-dispatcher") const updatePins = sync.require("./d2m/actions/update-pins") const speedbump = sync.require("./d2m/actions/speedbump") const ks = sync.require("./matrix/kstate") -const setPresence = sync.require("./d2m/actions/set-presence") -const channelWebhook = sync.require("./m2d/actions/channel-webhook") const guildID = "112760669178241024" +const extraContext = {} + if (process.stdin.isTTY) { - setImmediate(() => { + setImmediate(() => { // assign after since old extraContext data will get removed if (!passthrough.repl) { const cli = repl.start({ prompt: "", eval: customEval, writer: s => s }) - Object.assign(cli.context, passthrough) + Object.assign(cli.context, extraContext, passthrough) passthrough.repl = cli + } else { + Object.assign(passthrough.repl.context, extraContext) } - // @ts-ignore sync.addTemporaryListener(passthrough.repl, "exit", () => process.exit()) }) } @@ -59,3 +60,9 @@ async function customEval(input, _context, _filename, callback) { return callback(null, util.inspect(e, false, 100, true)) } } + +sync.events.once(__filename, () => { + for (const key in extraContext) { + delete passthrough.repl.context[key] + } +}) diff --git a/src/types.d.ts b/src/types.d.ts index a85907d..62d9b30 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,5 +1,3 @@ -import * as DiscordTypes from "discord-api-types/v10" - export type AppServiceRegistrationConfig = { id: string as_token: string @@ -30,11 +28,6 @@ export type AppServiceRegistrationConfig = { content_length_workaround: boolean include_user_id_in_mxid: boolean invite: string[] - discord_origin?: string - discord_cdn_origin?: string, - web_password: string - time_zone?: string - receive_presences: boolean } old_bridge?: { as_token: string @@ -62,12 +55,10 @@ export type InitialAppServiceRegistrationConfig = { socket?: string | number, ooye: { namespace_prefix: string - server_name: string - max_file_size: number - content_length_workaround: boolean - invite: string[] + max_file_size: number, + content_length_workaround: boolean, + invite: string[], include_user_id_in_mxid: boolean - receive_presences: boolean } } @@ -76,13 +67,6 @@ export type WebhookCreds = { token: string } -/** Discord API message->author. A webhook as an author. */ -export type WebhookAuthor = { - username: string - avatar: string | null - id: string -} - export type PkSystem = { id: string uuid: string @@ -129,7 +113,7 @@ export namespace Event { sender: string content: T origin_server_ts: number - unsigned?: any + unsigned: any event_id: string } @@ -145,43 +129,19 @@ export namespace Event { } } - export type StrippedChildStateEvent = { + export type BaseStateEvent = { type: string - state_key: string + room_id: string sender: string - origin_server_ts: number content: any - } - - export type InviteStrippedState = { - type: string state_key: string - sender: string - content: Event.M_Room_Create | Event.M_Room_Name | Event.M_Room_Avatar | Event.M_Room_Topic | Event.M_Room_JoinRules | Event.M_Room_CanonicalAlias - } - - export type M_Room_Create = { - additional_creators?: string[] - "m.federate"?: boolean - room_version: string - type?: string - predecessor?: { - room_id: string - event_id?: string - } - } - - export type M_Room_JoinRules = { - join_rule: "public" | "knock" | "invite" | "private" | "restricted" | "knock_restricted" - allow?: { - type: string - room_id: string - }[] - } - - export type M_Room_CanonicalAlias = { - alias?: string - alt_aliases?: string[] + origin_server_ts: number + unsigned: any + event_id: string + user_id: string + age: number + replaces_state: string + prev_content?: any } export type M_Room_Message = { @@ -203,12 +163,9 @@ export namespace Event { export type M_Room_Message_File = { msgtype: "m.file" | "m.image" | "m.video" | "m.audio" body: string - format?: "org.matrix.custom.html" - formatted_body?: string filename?: string url: string info?: any - "page.codeberg.everypizza.msc4193.spoiler"?: boolean "m.relates_to"?: { "m.in_reply_to": { event_id: string @@ -223,10 +180,7 @@ export namespace Event { export type M_Room_Message_Encrypted_File = { msgtype: "m.file" | "m.image" | "m.video" | "m.audio" body: string - format?: "org.matrix.custom.html" - formatted_body?: string filename?: string - "page.codeberg.everypizza.msc4193.spoiler"?: boolean file: { url: string iv: string @@ -271,49 +225,6 @@ export namespace Event { export type Outer_M_Sticker = Outer<M_Sticker> & {type: "m.sticker"} - export type Org_Matrix_Msc3381_Poll_Start = { - "org.matrix.msc3381.poll.start": { - question: { - "org.matrix.msc1767.text": string - body: string - msgtype: string - }, - kind: string - max_selections: number - answers: { - id: string - "org.matrix.msc1767.text": string - }[] - "org.matrix.msc1767.text": string - } - } - - export type Outer_Org_Matrix_Msc3381_Poll_Start = Outer<Org_Matrix_Msc3381_Poll_Start> & {type: "org.matrix.msc3381.poll.start"} - - export type Org_Matrix_Msc3381_Poll_Response = { - "org.matrix.msc3381.poll.response": { - answers: string[] - } - "m.relates_to": { - rel_type: string - event_id: string - } - } - - export type Outer_Org_Matrix_Msc3381_Poll_Response = Outer<Org_Matrix_Msc3381_Poll_Response> & {type: "org.matrix.msc3381.poll.response"} - - export type Org_Matrix_Msc3381_Poll_End = { - "org.matrix.msc3381.poll.end": {}, - "org.matrix.msc1767.text": string, - body: string, - "m.relates_to": { - rel_type: string - event_id: string - } - } - - export type Outer_Org_Matrix_Msc3381_Poll_End = Outer<Org_Matrix_Msc3381_Poll_End> & {type: "org.matrix.msc3381.poll.end"} - export type M_Room_Member = { membership: string displayname?: string @@ -321,6 +232,7 @@ export namespace Event { } export type M_Room_Avatar = { + discord_path?: string url?: string } @@ -328,14 +240,6 @@ export namespace Event { name?: string } - export type M_Room_Topic = { - topic?: string - } - - export type M_Room_PinnedEvents = { - pinned: string[] - } - export type M_Power_Levels = { /** The level required to ban a user. Defaults to 50 if unspecified. */ ban?: number, @@ -366,11 +270,6 @@ export namespace Event { users_default?: number } - export type M_Space_Child = { - via?: string[] - suggested?: boolean - } - export type M_Reaction = { "m.relates_to": { rel_type: "m.annotation" @@ -385,11 +284,6 @@ export namespace Event { }> & { redacts: string } - - export type M_Room_Tombstone = { - body: string - replacement_room: string - } } export namespace R { @@ -429,82 +323,20 @@ export namespace R { export type Hierarchy = { avatar_url?: string canonical_alias?: string - children_state: Event.StrippedChildStateEvent[] + children_state: {} guest_can_join: boolean join_rule?: string name?: string - topic?: string num_joined_members: number room_id: string room_type?: string } - - export type ResolvedRoom = { - room_id: string - servers: string[] - } - - export type SSS = { - pos: string - lists: { - [list_key: string]: { - count: number - } - } - rooms: { - [room_id: string]: { - bump_stamp: number - /** Omitted if user not in room (peeking) */ - membership?: Membership - /** Names of lists that match this room */ - lists: string[] - } - // If user has been in the room - at least, that's what the spec says. Synapse returns some of these, such as `name` and `avatar`, for invites as well. Go nuts. - & { - name?: string - avatar?: string - heroes?: any[] - /** According to account data */ - is_dm?: boolean - /** If false, omitted fields are unchanged from their previous value. If true, omitted fields means the fields are not set. */ - initial?: boolean - expanded_timeline?: boolean - required_state?: Event.StateOuter<any>[] - timeline_events?: Event.Outer<any>[] - prev_batch?: string - limited?: boolean - num_live?: number - joined_count?: number - invited_count?: number - notification_count?: number - highlight_count?: number - } - // If user is invited or knocked - & ({ - /** @deprecated */ - invite_state: Event.InviteStrippedState[] - } | { - stripped_state: Event.InviteStrippedState[] - }) - } - extensions: { - [extension_key: string]: any - } - } } -export type Membership = "invite" | "knock" | "join" | "leave" | "ban" - export type Pagination<T> = { chunk: T[] next_batch?: string - prev_batch?: string -} - -export type MessagesPagination<T> = { - chunk: T[] - start: string - end?: string + prev_match?: string } export type HierarchyPagination<T> = { diff --git a/src/web/auth.js b/src/web/auth.js deleted file mode 100644 index c14dcd8..0000000 --- a/src/web/auth.js +++ /dev/null @@ -1,33 +0,0 @@ -// @ts-check - -const h3 = require("h3") -const {db} = require("../passthrough") -const {reg} = require("../matrix/read-registration") - -/** - * Combined guilds managed by Discord account + Matrix account. - * @param {h3.H3Event} event - * @returns {Promise<Set<string>>} guild IDs - */ -async function getManagedGuilds(event) { - const session = await useSession(event) - const managed = new Set(session.data.managedGuilds || []) - if (session.data.mxid) { - const matrixGuilds = db.prepare("SELECT guild_id FROM guild_space INNER JOIN member_cache ON space_id = room_id WHERE mxid = ? AND power_level >= 50").pluck().all(session.data.mxid) - for (const id of matrixGuilds) { - managed.add(id) - } - } - return managed -} - -/** - * @param {h3.H3Event} event - * @returns {ReturnType<typeof h3.useSession<{userID?: string, mxid?: string, managedGuilds?: string[], state?: string, selfService?: boolean, password?: string}>>} - */ -function useSession(event) { - return h3.useSession(event, {password: reg.as_token, maxAge: 365 * 24 * 60 * 60}) -} - -module.exports.getManagedGuilds = getManagedGuilds -module.exports.useSession = useSession diff --git a/src/web/pug-sync.js b/src/web/pug-sync.js index f87550d..32e7acc 100644 --- a/src/web/pug-sync.js +++ b/src/web/pug-sync.js @@ -3,15 +3,12 @@ const assert = require("assert/strict") const fs = require("fs") const {join} = require("path") -const getRelativePath = require("get-relative-path") const h3 = require("h3") -const {defineEventHandler, defaultContentType, setResponseStatus, getQuery} = h3 +const {defineEventHandler, defaultContentType, setResponseStatus, useSession, getQuery} = h3 const {compileFile} = require("@cloudrac3r/pug") -const pretty = process.argv.join(" ").includes("test") -const {sync} = require("../passthrough") -/** @type {import("./auth")} */ -const auth = sync.require("./auth") +const {as} = require("../passthrough") +const {reg} = require("../matrix/read-registration") // Pug @@ -31,38 +28,20 @@ function addGlobals(obj) { */ function render(event, filename, locals) { const path = join(__dirname, "pug", filename) - return renderPath(event, path, locals) -} -/** - * @param {import("h3").H3Event} event - * @param {string} path - * @param {Record<string, any>} locals - */ -function renderPath(event, path, locals) { function compile() { try { - const template = compileFile(path, {pretty}) + const template = compileFile(path, {}) pugCache.set(path, async (event, locals) => { defaultContentType(event, "text/html; charset=utf-8") - const session = await auth.useSession(event) - const managed = await auth.getManagedGuilds(event) - const rel = (to, paramsObject) => { - let result = getRelativePath(event.path, to) - if (paramsObject) { - const params = new URLSearchParams(paramsObject) - result += "?" + params.toString() - } - return result - } + const session = await useSession(event, {password: reg.as_token}) return template(Object.assign({}, getQuery(event), // Query parameters can be easily accessed on the top level but don't allow them to overwrite anything globals, // Globals locals, // Explicit locals overwrite globals in case we need to DI something - {session, event, rel, managed} // These are assigned last so they overwrite everything else. It would be catastrophically bad if they can't be trusted. + {session} // Session is always session because it has to be trusted )) }) - /* c8 ignore start */ } catch (e) { pugCache.set(path, async (event) => { setResponseStatus(event, 500, "Internal Template Error") @@ -70,7 +49,6 @@ function renderPath(event, path, locals) { return e.toString() }) } - /* c8 ignore stop */ } if (!pugCache.has(path)) { @@ -97,5 +75,4 @@ function createRoute(router, url, filename) { module.exports.addGlobals = addGlobals module.exports.render = render -module.exports.renderPath = renderPath module.exports.createRoute = createRoute diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index a9e770b..f92bf75 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -11,9 +11,7 @@ mixin badge-private | Private mixin discord(channel, radio=false) - //- Previously, we passed guild.roles as the second parameter, but this doesn't quite match Discord's behaviour. See issue #42 for why this was changed. - //- Basically we just want to assign badges based on the channel overwrites, without considering the guild's base permissions. /shrug - - let permissions = dUtils.getPermissions(guild_id, [], [{id: guild_id, name: "@everyone", permissions: 1<<10 | 1<<11}], null, channel.permission_overwrites) + - let permissions = dUtils.getPermissions([], discord.guilds.get(channel.guild_id).roles, "", channel.permission_overwrites) .s-user-card.s-user-card__small if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel) != icons.Icons.IconLock @@ -47,196 +45,115 @@ mixin matrix(row, radio=false, badge="") else .s-user-card--link.fs-body1 a(href=`https://matrix.to/#/${row.room_id}`)= row.nick || row.name - if row.join_rule === "invite" - +badge-private block body - .s-page-title.mb24 - h1.s-page-title--header= guild.name + if !guild_id && session.data.managedGuilds + .s-empty-state.wmx4.p48 + != icons.Spots.SpotEmptyXL + p Select a server from the top right corner to continue. + p If the server you're looking for isn't there, try #[a(href="/oauth?action=add") logging in again.] - .d-flex.g16(class="sm:fw-wrap") - .fl-grow1 - h2.fs-headline1 Invite a Matrix user + else if !session.data.managedGuilds + .s-empty-state.wmx4.p48 + != icons.Spots.SpotEmptyXL + p You need to log in to manage your servers. + a.s-btn.s-btn__icon.s-btn__filled(href="/oauth") + != icons.Icons.IconDiscord + = ` Log in with Discord` - form.d-grid.g-af-column.gy4.gx8.jc-start(method="post" action=rel("/api/invite") hx-post=rel("/api/invite") hx-trigger="submit" hx-swap="none" hx-on::after-request="if (event.detail.successful) this.reset()" hx-disabled-elt="input, button" hx-indicator="#invite-button") - label.s-label(for="mxid") Matrix ID - input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org" pattern="@([^:]+):([a-z0-9:\\-]+\\.[a-z0-9.:\\-]+)") - label.s-label(for="permissions") Permissions - .s-select - select#permissions(name="permissions") - option(value="default") Default - option(value="moderator") Moderator - option(value="admin") Admin - input(type="hidden" name="guild_id" value=guild_id) - .grid--row-start2 - button.s-btn.s-btn__filled#invite-button Invite - div - .s-card.d-flex.ai-center.jc-center(style="min-width: 132px; min-height: 132px;") - button.s-btn(class=space_id ? "s-btn__muted" : "s-btn__filled" hx-get=rel(`/qr?guild_id=${guild_id}`) hx-indicator="closest button" hx-swap="outerHTML" hx-disabled-elt="this") Show QR + else if !discord.guilds.has(guild_id) || !session.data.managedGuilds || !session.data.managedGuilds.includes(guild_id) + .s-empty-state.wmx4.p48 + != icons.Spots.SpotAlertXL + p Either the selected server doesn't exist, or you don't have the Manage Server permission on Discord. + p If you've checked your permissions, try #[a(href="/oauth") logging in again.] - if space_id - h2.mt48.fs-headline1 Server settings - h3.mt32.fs-category Privacy level - span#privacy-level-loading - .s-card - form(hx-post=rel("/api/privacy-level") hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="input") - input(type="hidden" name="guild_id" value=guild_id) + else + - let guild = discord.guilds.get(guild_id) - .s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental.d-grid.gx16.ai-center(style="grid-template-columns: auto 1fr") - input(type="radio" name="privacy_level" value="directory" id="privacy-level-directory" checked=(privacy_level === 2)) - label.d-flex.gx8.jc-center.grid--row-start3(for="privacy-level-directory") - != icons.Icons.IconPlusSm - != icons.Icons.IconInternationalSm - .fl-grow1 Directory + .s-page-title.mb24 + h1.s-page-title--header= guild.name - input(type="radio" name="privacy_level" value="link" id="privacy-level-link" checked=(privacy_level === 1)) - label.d-flex.gx8.jc-center.grid--row-start2(for="privacy-level-link") - != icons.Icons.IconPlusSm - != icons.Icons.IconLinkSm - .fl-grow1 Link + .d-flex.g16 + .fl-grow1 + h2.fs-headline1 Invite a Matrix user - input(type="radio" name="privacy_level" value="invite" id="privacy-level-invite" checked=(privacy_level === 0)) - label.d-flex.gx8.jc-center.grid--row-start1(for="privacy-level-invite") - svg.svg-icon(width="14" height="14" viewBox="0 0 14 14") - != icons.Icons.IconLockSm - .fl-grow1 Invite + form.d-grid.g-af-column.gy4.gx8.jc-start(method="post" action="/api/invite" style="grid-template-rows: repeat(2, auto)") + label.s-label(for="mxid") Matrix ID + input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org") + label.s-label(for="permissions") Permissions + .s-select + select#permissions(name="permissions") + option(value="default") Default + option(value="moderator") Moderator + input(type="hidden" name="guild_id" value=guild_id) + .grid--row-start2 + button.s-btn.s-btn__filled.htmx-indicator Invite + div + - + let size = 105 + let src = new URL(`https://api.qrserver.com/v1/create-qr-code/?qzone=1&format=svg&size=${size}x${size}`) + src.searchParams.set("data", `https://bridge.cadence.moe/invite?nonce=${nonce}`) + img(width=size height=size src=src.toString()) - p.s-description.m0 In-app direct invite from another user - p.s-description.m0 Shareable invite links, like Discord - p.s-description.m0 Publicly listed in directory, like Discord server discovery + h2.mt48.fs-headline1 Linked channels - h3.mt32.fs-category Features - .s-card.d-grid.px0.g16 - form.d-flex.ai-center.g16 - #url-preview-loading.p8 - - let value = !!select("guild_space", "url_preview", {guild_id}).pluck().get() - input(type="hidden" name="guild_id" value=guild_id) - input.s-toggle-switch#url-preview(name="url_preview" type="checkbox" hx-post=rel("/api/url-preview") hx-indicator="#url-preview-loading" hx-disabled-elt="this" checked=value autocomplete="off") - label.s-label.fl-grow1(for="url-preview") - | Show Discord's URL previews on Matrix - p.s-description Shows info about links posted to chat. Discord's previews are generally better quality than Synapse's, especially for social media and videos. + - + function getPosition(channel) { + let position = 0 + let looking = channel + while (looking.parent_id) { + looking = discord.channels.get(looking.parent_id) + position = looking.position * 1000 + } + if (channel.position) position += channel.position + return position + } + let channelIDs = discord.guildChannelMap.get(guild_id) - form.d-flex.ai-center.g16 - #presence-loading.p8 - - value = !!select("guild_space", "presence", {guild_id}).pluck().get() - input(type="hidden" name="guild_id" value=guild_id) - input.s-toggle-switch#presence(name="presence" type="checkbox" hx-post=rel("/api/presence") hx-indicator="#presence-loading" hx-disabled-elt="this" checked=value autocomplete="off") - label.s-label(for="presence") - | Show online statuses on Matrix - p.s-description This might cause lag on really big Discord servers. + let linkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {channel_id: channelIDs}).all() + let linkedChannelsWithDetails = linkedChannels.map(c => ({channel: discord.channels.get(c.channel_id), ...c})).filter(c => c.channel) + let linkedChannelIDs = linkedChannelsWithDetails.map(c => c.channel_id) + linkedChannelsWithDetails.sort((a, b) => getPosition(a.channel) - getPosition(b.channel)) - form.d-flex.ai-center.g16 - #webhook-profile-loading.p8 - - value = !!select("guild_space", "webhook_profile", {guild_id}).pluck().get() - input(type="hidden" name="guild_id" value=guild_id) - input.s-toggle-switch#webhook-profile(name="webhook_profile" type="checkbox" hx-post=rel("/api/webhook-profile") hx-indicator="#webhook-profile-loading" hx-disabled-elt="this" checked=value autocomplete="off") - label.s-label(for="webhook-profile") - | Create persistent Matrix sims for webhooks - p.s-description Useful when using other Discord bridges. Otherwise, not ideal, as sims will clutter the Matrix user list and will never be cleaned up. - - if space_id - h2.mt48.fs-headline1 Channel setup - - h3.mt32.fs-category Linked channels + let unlinkedChannelIDs = channelIDs.filter(c => !linkedChannelIDs.includes(c)) + let unlinkedChannels = unlinkedChannelIDs.map(c => discord.channels.get(c)).filter(c => [0, 5].includes(c.type)) + unlinkedChannels.sort((a, b) => getPosition(a) - getPosition(b)) .s-card.bs-sm.p0 - form.s-table-container(method="post" action=rel("/api/unlink")) - input(type="hidden" name="guild_id" value=guild_id) + .s-table-container table.s-table.s-table__bx-simple each row in linkedChannelsWithDetails tr td.w40: +discord(row.channel) - td.p2: button.s-btn.s-btn__muted.s-btn__xs(name="channel_id" cx-prevent-default hx-post=rel("/api/unlink") hx-confirm="Do you want to unlink these channels?\nIt may take a moment to clean up Matrix resources." value=row.channel.id hx-indicator="this" hx-disabled-elt="this")!= icons.Icons.IconLinkSm + td.p2: button.s-btn.s-btn__muted.s-btn__xs!= icons.Icons.IconLinkSm td: +matrix(row) else tr td(colspan="3") .s-empty-state No channels linked between Discord and Matrix yet... - h3.fs-category.mt32 Auto-create - .s-card.d-grid.px0 - form.d-flex.ai-center.g16 - #autocreate-loading.p8 - - let value = !!select("guild_active", "autocreate", {guild_id}).pluck().get() - input(type="hidden" name="guild_id" value=guild_id) - input.s-toggle-switch#autocreate(name="autocreate" type="checkbox" hx-post=rel("/api/autocreate") hx-indicator="#autocreate-loading" hx-disabled-elt="this" checked=value autocomplete="off") - label.s-label.fl-grow1(for="autocreate") - | Create new Matrix rooms automatically - p.s-description If you want, OOYE can automatically create new Matrix rooms and link them when an unlinked Discord channel is spoken in. + h3.mt32.fs-category Auto-create + .s-card + form.d-flex.ai-center.g8 + label.s-label.fl-grow1(for="autocreate") + | Create new Matrix rooms automatically + p.s-description If you want, OOYE can automatically create new Matrix rooms and link them when an unlinked Discord channel is spoken in. + - let value = select("guild_active", "autocreate", {guild_id}).pluck().get() + input(type="hidden" name="guild_id" value=guild_id) + input.s-toggle-switch.order-last#autocreate(name="autocreate" type="checkbox" hx-post="/api/autocreate" hx-indicator="#autocreate-loading" hx-disabled-elt="this" checked=value) + .is-loading#autocreate-loading - if space_id h3.mt32.fs-category Manually link channels - form.d-flex.g16.ai-start(hx-post=rel("/api/link") hx-trigger="submit" hx-disabled-elt="input, button" hx-indicator="#link-button") + form.d-flex.g16.ai-start(method="post" action="/api/link") .fl-grow2.s-btn-group.fd-column.w40 each channel in unlinkedChannels - input.s-btn--radio(type="radio" name="discord" required id=channel.id value=channel.id) + input.s-btn--radio(type="radio" name="discord" id=channel.id value=channel.id) label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id) +discord(channel, true, "Announcement") else .s-empty-state.p8 All Discord channels are linked. .fl-grow1.s-btn-group.fd-column.w30 - each room in unlinkedRooms - input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id) - label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id) - +matrix(room, true) - else - .s-empty-state.p8 All Matrix rooms are linked. - input(type="hidden" name="guild_id" value=guild_id) + .s-empty-state.p8 I don't know how to get the Matrix room list yet... div - button.s-btn.s-btn__icon.s-btn__filled#link-button - != icons.Icons.IconMerge - = ` Link` - - h3.mt32.fs-category Unlink server - form.s-card.d-flex.gx16.ai-center(method="post" action=rel("/api/unlink-space")) - input(type="hidden" name="guild_id" value=guild.id) - .fl-grow1.s-prose.s-prose__sm.lh-lg - p.fc-medium. - Not using this bridge, or just made a mistake? You can unlink the whole server and all its channels.#[br] - This may take a minute to process. Please be patient and wait until the page refreshes. - div - button.s-btn.s-btn__icon.s-btn__danger.s-btn__outlined(cx-prevent-default hx-post=rel("/api/unlink-space") hx-confirm="Do you want to unlink this server and all its channels?\nIt may take a minute to clean up Matrix resources." hx-indicator="this" hx-disabled-elt="this") - != icons.Icons.IconUnsync - span.ml4= ` Unlink` - - if space_id - details.mt48 - summary Debug room list - .d-grid.grid__2.gx24 - div - h3.mt24 Channels - p Channels are read from the channel_room table and then merged with the discord.channels memory cache to make the merged list. Anything in memory cache that's not in channel_room is considered unlinked. - div - h3.mt24 Rooms - p Rooms use the same merged list as channels, based on augmented channel_room data. Then, rooms are read from the space. Anything in the space that's not merged is considered unlinked. - div - h3.mt24 Unavailable channels: Deleted from Discord - .s-card.p0 - ul.my8.ml24 - each row in removedUncachedChannels - li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`)= row.nick || row.name - h3.mt24 Unavailable channels: Wrong type - .s-card.p0 - ul.my8.ml24 - each row in removedWrongTypeChannels - li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name} - h3.mt24 Unavailable channels: Discord bot can't access - .s-card.p0 - ul.my8.ml24 - each row in removedPrivateChannels - li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`)= row.name - div- // Rooms - h3.mt24 Unavailable rooms: Already linked - .s-card.p0 - ul.my8.ml24 - each row in removedLinkedRooms - li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name - h3.mt24 Unavailable rooms: Wrong type - .s-card.p0 - ul.my8.ml24 - each row in removedWrongTypeRooms - li: a(href=`https://matrix.to/#/${row.room_id}`) (#{row.room_type}) #{row.name} - h3.mt24 Unavailable rooms: Archived thread - .s-card.p0 - ul.my8.ml24 - each row in removedArchivedThreadRooms - li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name + button.s-btn.s-btn__icon.s-btn__filled + != icons.Icons.IconLink + = ` Connect` diff --git a/src/web/pug/guild_access_denied.pug b/src/web/pug/guild_access_denied.pug deleted file mode 100644 index 42fea7b..0000000 --- a/src/web/pug/guild_access_denied.pug +++ /dev/null @@ -1,36 +0,0 @@ -extends includes/template.pug - -block body - if !session.data.userID - .s-empty-state.wmx4.p48 - != icons.Spots.SpotEmptyXL - p You need to log in to manage your servers. - .d-flex.jc-center.g8 - a.s-btn.s-btn__icon.s-btn__featured.s-btn__filled(href=rel("/oauth")) - != icons.Icons.IconDiscord - = ` Log in with Discord` - a.s-btn.s-btn__icon.s-btn__matrix.s-btn__filled(href=rel("/log-in-with-matrix")) - != icons.Icons.IconSpeechBubble - = ` Log in with Matrix` - - else if !guild_id - .s-empty-state.wmx4.p48 - != icons.Spots.SpotEmptyXL - p Select a server from the top right corner to continue. - p If the server you're looking for isn't there, try #[a(href=rel("/oauth?action=add")) logging in again.] - - else if !discord.guilds.has(guild_id) || !managed.has(guild_id) - .s-empty-state.wmx4.p48 - != icons.Spots.SpotAlertXL - p Either the selected server doesn't exist, or you don't have the Manage Server permission on Discord. - p If you've checked your permissions, try #[a(href=rel("/oauth")) logging in again.] - - else if !row - .s-empty-state.wmx4.p48 - != icons.Spots.SpotAlertXL - p Please add the bot to your server using the buttons on the home page. - - else - .s-empty-state.wmx4.p48 - != icons.Spots.SpotAlertXL - p Access denied. diff --git a/src/web/pug/guild_not_linked.pug b/src/web/pug/guild_not_linked.pug deleted file mode 100644 index 04d2dae..0000000 --- a/src/web/pug/guild_not_linked.pug +++ /dev/null @@ -1,64 +0,0 @@ -extends includes/template.pug - -mixin space(space) - .s-user-card.flex__1 - span.s-avatar.s-avatar__32.s-user-card--avatar - if space.avatar - img.s-avatar--image(src=mUtils.getPublicUrlForMxc(space.avatar) alt="") - else - .s-avatar--letter.bg-silver-400.bar-md(aria-hidden="true")= space.name[0] - .s-user-card--info.ai-start - strong= space.name - if space.topic - ul.s-user-card--awards - li= space.topic - -block body - .s-notice.s-notice__info.d-flex.g16 - div - != icons.Icons.IconInfo - div - - const self = `@${reg.sender_localpart}:${reg.ooye.server_name}` - strong You picked self-service mode - .mt4 To complete setup, you need to manually choose a Matrix space to link with #[strong= guild.name]. - .mt4 On Matrix, invite #[code.s-code-block: a.fc-black.s-link(href=`https://matrix.to/#/${self}` target="_blank")= self] to a space. Then you can pick the space on this page. - - h3.mt32.fs-category Choose a space - - form.s-card.bs-sm.p0.s-table-container.bar-md(method="post" action=rel("/api/link-space")) - input(type="hidden" name="guild_id" value=guild_id) - table.s-table.s-table__bx-simple - each space in spaces - tr - td.p0: +space(space) - td: button.s-btn(name="space_id" value=space.room_id hx-post=rel("/api/link-space") hx-trigger="click" hx-disabled-elt="this") Link with this space - else - if session.data.mxid - tr - td.p16 Invite the bridge to a space, and the space will show up here. - else - tr - td.d-flex.ai-center.pl16.g16 - | You need to log in with Matrix first. - a.s-btn.s-btn__matrix.s-btn__outlined(href=rel(`/log-in-with-matrix`, {next: `./guild?guild_id=${guild_id}`})) Log in with Matrix - - h3.mt48.fs-category Other choices - .s-card.d-grid.g16 - form.d-flex.ai-center.g8(method="post" action=rel("/api/autocreate") hx-post=rel("/api/autocreate") hx-indicator="#easy-mode-button") - input(type="hidden" name="guild_id" value=guild_id) - input(type="hidden" name="autocreate" value="true") - label.s-label.fl-grow1 - | Do it automatically - p.s-description If you want, OOYE can create and manage the Matrix space so you don't have to. - button.s-btn.s-btn__icon.s-btn__outlined#easy-mode-button - != icons.Icons.IconWand - span.ml4= ` Use easy mode` - - form.d-flex.gx16.ai-center(method="post" action=rel("/api/unlink-space")) - input(type="hidden" name="guild_id" value=guild.id) - label.s-label.fl-grow1 - | Cancel - p.s-description Don't want to link this server after all? Here's the button for you. - button.s-btn.s-btn__icon.s-btn__muted.s-btn__outlined(cx-prevent-default hx-post=rel("/api/unlink-space") hx-indicator="this" hx-disabled-elt="this") - != icons.Icons.IconUnsync - span.ml4= ` Unlink` diff --git a/src/web/pug/home.pug b/src/web/pug/home.pug index 8b86533..1ad787d 100644 --- a/src/web/pug/home.pug +++ b/src/web/pug/home.pug @@ -1,58 +1,24 @@ extends includes/template.pug block body - - let locked = reg.ooye.web_password && reg.ooye.web_password !== session.data.password + .s-page-title.mb24 + h1.s-page-title--header Bridge a Discord server - if locked - aside.s-notice.s-notice__warning.p8 - .d-flex.flex__center.jc-space-between.s-banner--container.g8(class="md:fw-wrap") - .d-flex.ai-center.g8 - .flex--item!= icons.Icons.IconLock - p.m0 <strong>Private instance.</strong> You need the password to use this instance of Out Of Your Element. - form(method="post" action=rel("/api/password")) - input.s-input(placeholder="Enter password" name="password") - - .h32 - - .s-page-title.mb24 - h1.s-page-title--header Out Of Your Element - - else - .s-page-title.mb24 - h1.s-page-title--header Bridge a Discord server - - .d-grid.g24.grid__2.mb24(class="sm:grid__1") - .s-card.bs-md.d-flex.fd-column - h2 Easy mode - p Add the bot to your Discord server. - p It will automatically create new Matrix rooms for you. - .fl-grow1 - a.s-btn.s-btn__filled.s-btn__icon(href=rel("/oauth?action=add")) - != icons.Icons.IconPlus - = ` Add to server` - .s-card.bs-md.d-flex.fd-column - h2 Self-service - p OOYE will link an existing Discord server and Matrix space together. - p Choose this option if you already have a community set up on Matrix. - p Or, choose this if you're migrating from a different bridge. - .fl-grow1 - a.s-btn.s-btn__outlined.s-btn__icon(href=rel("/oauth?action=add-self-service")) - != icons.Icons.IconUnorderedList - = ` Set up self-service` - - .s-prose - block bridge-info - h2 What is this? - p #[a(href="https://gitdab.com/cadence/out-of-your-element") Out Of Your Element] is a bridge between the Discord and Matrix chat apps. It lets people on both platforms chat with each other without needing to get everyone on the same app. - p Just chat like usual, and the bridge will forward messages back and forth between the two platforms, so everyone sees the whole conversation. - p All kinds of content are supported, including pictures, threads, emojis, and @mentions. - p It's really easy to set up, even if you only have Discord. Just add the bot to your server, and it'll make everything available on Matrix automatically. - - if locked - block locked-info - h2 This is a private instance - p Anybody can run their own instance of the Out Of Your Element software. The person running this instance has made it private, so you can't add it to your server just yet. If you know who's in charge of #{reg.ooye.server_name}, ask them for the password. - - h2 Run your own instance - p You can still use Out Of Your Element by running your own copy of the software, but this requires some technical skill. - p To get started, #[a(href="https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/get-started.md") check the installation instructions.] + .d-grid.grid__2.g24 + .s-card.bs-md.d-flex.fd-column + h2 Easy mode + p Add the bot to your Discord server. + p It will automatically create new Matrix rooms for you. + .fl-grow1 + a.s-btn.s-btn__filled.s-btn__icon(href="/oauth?action=add") + != icons.Icons.IconPlus + = ` Add to server` + .s-card.bs-md.d-flex.fd-column + h2 Self-service + p OOYE will link an existing Discord server and Matrix space together. + p Choose this option if you already have a community set up on Matrix. + p Or, choose this if you're migrating from a different bridge. + .fl-grow1 + a.s-btn.s-btn__outlined.s-btn__icon(href="/oauth?action=add-self-service") + != icons.Icons.IconUnorderedList + = ` Set up self-service` diff --git a/src/web/pug/includes/hash.svg b/src/web/pug/includes/hash.svg index 461f2dc..0f6fdd7 100644 --- a/src/web/pug/includes/hash.svg +++ b/src/web/pug/includes/hash.svg @@ -1 +1,46 @@ -<svg fill="none" viewBox="0 0 16 16" height="16" width="16"><path stroke="currentcolor" stroke-width="2" d="m6.75 1-2.5 14m7.5-14-2.5 14M14 10.25H1m14-4.5H2"></path></svg> +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + aria-hidden="true" + class="svg-icon iconItalic" + width="18" + height="18" + viewBox="0 0 18 18" + version="1.1" + id="svg1" + sodipodi:docname="hash.svg" + inkscape:version="1.3.2 (091e20ef0f, 2023-11-25)" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg"> + <defs + id="defs1" /> + <path + style="opacity:1;stroke-width:24.2222;stroke-linecap:square;paint-order:stroke fill markers" + d="m 13.949463,2.0417087 c 0,0 0.664304,0.00704 0.854464,0.00134 0.19016,-0.0057 0.924873,0.2384962 0.57664,0.9863413 -0.288846,0.6203095 -5.045042,11.035358 -5.4783833,11.984378 -0.4333415,0.949021 -0.7881247,0.945761 -1.3553087,0.945761 -0.567184,0 -0.3175392,0 -0.734375,0 -0.4168358,0 -0.7985231,-0.467356 -0.5770328,-0.951217 C 7.4569576,14.524452 12.479729,3.5512928 12.725807,3.0070042 13.022379,2.3510304 13.336114,2.0361844 13.949463,2.0417087 Z" + id="path4" + sodipodi:nodetypes="czszzzzsc" /> + <rect + style="opacity:1;stroke-width:27.7591;stroke-linecap:square;paint-order:stroke fill markers" + id="rect4" + width="11.987322" + height="2" + x="2.002677" + y="11.007812" + rx="1" + ry="1" /> + <rect + style="opacity:1;stroke-width:27.7591;stroke-linecap:square;paint-order:stroke fill markers" + id="rect5" + width="11.987322" + height="2" + x="4.0100012" + y="5.007813" + rx="1" + ry="1" /> + <path + style="opacity:1;stroke-width:24.2222;stroke-linecap:square;paint-order:stroke fill markers" + d="m 9.1764922,2.0417087 c 0,0 0.664304,0.00704 0.8544638,0.00134 0.19016,-0.0057 0.924873,0.2384962 0.57664,0.9863413 -0.288846,0.6203095 -5.0450418,11.035358 -5.4783831,11.984378 -0.4333415,0.949021 -0.7881247,0.945761 -1.3553087,0.945761 -0.567184,0 -0.3175392,0 -0.734375,0 -0.4168358,0 -0.7985231,-0.467356 -0.5770328,-0.951217 C 2.6839868,14.524452 7.7067582,3.5512928 7.9528362,3.0070042 8.2494082,2.3510304 8.5631432,2.0361844 9.1764922,2.0417087 Z" + id="path1" + sodipodi:nodetypes="czszzzzsc" /> +</svg> diff --git a/src/web/pug/includes/template.pug b/src/web/pug/includes/template.pug index 9fe80aa..c117056 100644 --- a/src/web/pug/includes/template.pug +++ b/src/web/pug/includes/template.pug @@ -1,173 +1,66 @@ -mixin guild-menuitem(guild) - - let bridgedRoomCount = from("channel_room").selectUnsafe("count(*) as count").where({guild_id: guild.id}).and("AND thread_parent IS NULL").get().count - li(role="menuitem") - a.s-topbar--item.s-user-card.d-flex.p4(href=rel(`/guild?guild_id=${guild.id}`) class={"bg-purple-200": bridgedRoomCount === 0, "h:bg-purple-300": bridgedRoomCount === 0}) - +guild(guild, bridgedRoomCount) - -mixin guild(guild, bridgedRoomCount) - span.s-avatar.s-avatar__32.s-user-card--avatar - if guild.icon - img.s-avatar--image(src=`https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=32` alt="") - else - .s-avatar--letter.bg-silver-400.bar-md(aria-hidden="true")= guild.name[0] - .s-user-card--info.ai-start - strong= guild.name - if bridgedRoomCount != null - ul.s-user-card--awards - if bridgedRoomCount - li #{bridgedRoomCount} bridged rooms - else - li.fc-purple Not yet linked - -mixin define-theme(name, h, s, l) - style. - :root { - --#{name}-h: #{h}; - --#{name}-s: #{s}; - --#{name}-l: #{l}; - --#{name}: var(--#{name}-400); - --#{name}-100: hsl(var(--#{name}-h), calc(var(--#{name}-s) + 0 * 1%), clamp(70%, calc(var(--#{name}-l) + 50 * 1%), 95%)); - --#{name}-200: hsl(var(--#{name}-h), calc(var(--#{name}-s) + 0 * 1%), clamp(55%, calc(var(--#{name}-l) + 35 * 1%), 90%)); - --#{name}-300: hsl(var(--#{name}-h), calc(var(--#{name}-s) + 0 * 1%), clamp(35%, calc(var(--#{name}-l) + 15 * 1%), 75%)); - --#{name}-400: hsl(var(--#{name}-h), calc(var(--#{name}-s) + 0 * 1%), clamp(20%, calc(var(--#{name}-l) + 0 * 1%), 60%)); - --#{name}-500: hsl(var(--#{name}-h), calc(var(--#{name}-s) + 0 * 1%), clamp(15%, calc(var(--#{name}-l) + -14 * 1%), 45%)); - --#{name}-600: hsl(var(--#{name}-h), calc(var(--#{name}-s) + 0 * 1%), clamp(5%, calc(var(--#{name}-l) + -26 * 1%), 30%)); - } - -mixin define-themed-button(name, theme) - style. - .s-btn.s-btn__#{name} { - --_bu-bg-active: var(--#{theme}-300); - --_bu-bg-hover: var(--#{theme}-200); - --_bu-bg-selected: var(--#{theme}-300); - --_bu-fc: var(--#{theme}-500); - --_bu-fc-active: var(--_bu-fc); - --_bu-fc-hover: var(--#{theme}-500); - --_bu-fc-selected: var(--#{theme}-600); - --_bu-filled-bc: transparent; - --_bu-filled-bc-selected: var(--_bu-filled-bc); - --_bu-filled-bg: var(--#{theme}-400); - --_bu-filled-bg-active: var(--#{theme}-500); - --_bu-filled-bg-hover: var(--#{theme}-500); - --_bu-filled-bg-selected: var(--#{theme}-600); - --_bu-filled-fc: var(--white); - --_bu-filled-fc-active: var(--_bu-filled-fc); - --_bu-filled-fc-hover: var(--_bu-filled-fc); - --_bu-filled-fc-selected: var(--_bu-filled-fc); - --_bu-outlined-bc: var(--#{theme}-400); - --_bu-outlined-bc-selected: var(--#{theme}-500); - --_bu-outlined-bg-selected: var(--_bu-bg-selected); - --_bu-outlined-fc-selected: var(--_bu-fc-selected); - --_bu-number-fc: var(--white); - --_bu-number-fc-filled: var(--#{theme}); - } - -doctype html -html(lang="en") - head - title Out Of Your Element - <meta name="viewport" content="width=device-width, initial-scale=1"> - link(rel="stylesheet" type="text/css" href=rel("/static/stacks.min.css")) - //- Please use responsibly!!!!! - link(rel="stylesheet" type="text/css" href=rel("/custom.css")) - <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 80%22><text y=%22.83em%22 font-size=%2283%22>💬</text></svg>"> - meta(name="htmx-config" content='{"requestClass":"is-loading"}') - style. - .s-prose a { - text-decoration: underline; - } - .themed { - --theme-base-primary-color-h: 266; - --theme-base-primary-color-s: 53%; - --theme-base-primary-color-l: 63%; - --theme-dark-primary-color-h: 266; - --theme-dark-primary-color-s: 53%; - --theme-dark-primary-color-l: 63%; - } - .s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental input[type="radio"]:checked ~ label:not(.s-toggle-switch--label-off) { - --_ts-multiple-bg: var(--green-400); - --_ts-multiple-fc: var(--white); - } - .s-btn__dropdown:has(+ :popover-open) { - background-color: var(--theme-topbar-item-background-hover, var(--black-200)) !important; - } - @media (prefers-color-scheme: dark) { - body.theme-system .s-popover { - --_po-bg: var(--black-100); - --_po-bc: var(--bc-light); - --_po-bs: var(--bs-lg); - --_po-arrow-fc: var(--black-100); - } - } - +define-themed-button("matrix", "black") - body.themed.theme-system - header.s-topbar - a.s-topbar--skip-link(href="#content") Skip to main content - .s-topbar--container.wmx9 - a.s-topbar--logo(href=rel("/")) - img.s-avatar.s-avatar__32(src=rel("/icon.png") alt="") - nav.s-topbar--navigation - ul.s-topbar--content - li.ps-relative.g8 - if !session.data.mxid - a.s-btn.s-btn__icon.s-btn__matrix.s-btn__outlined.as-center(href=rel("/log-in-with-matrix")) - != icons.Icons.IconSpeechBubble - = ` Log in` - span(class="sm:d-none")= ` with Matrix` - if !session.data.userID - a.s-btn.s-btn__icon.s-btn__featured.s-btn__outlined.as-center(href=rel("/oauth")) - != icons.Icons.IconDiscord - = ` Log in` - span(class="sm:d-none")= ` with Discord` - if guild_id && managed.has(guild_id) && discord.guilds.has(guild_id) - button.s-topbar--item.s-btn.s-btn__muted.s-btn__dropdown.pr32.bar0.s-user-card(popovertarget="guilds") - +guild(discord.guilds.get(guild_id)) - else if managed.size - button.s-topbar--item.s-btn.s-btn__muted.s-btn__dropdown.pr24.s-user-card.bar0.fc-black(popovertarget="guilds") - | Your servers - else if session.data.mxid || session.data.userID - .d-flex.ai-center - .s-badge.s-badge__bot.py6.px16.bar-md - | No servers available - #guilds(popover data-popper-placement="bottom" style="display: revert; width: revert;").s-popover.overflow-visible - .s-popover--arrow.s-popover--arrow__tc - .s-popover--content.overflow-y-auto.overflow-x-hidden - ul.s-menu(role="menu") - each guild in [...managed].map(id => discord.guilds.get(id)).filter(g => g).sort((a, b) => a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1) - +guild-menuitem(guild) - //- Body - .mx-auto.w100.wmx9.py24.px8.fs-body1#content - block body - //- Guild list popover - script. - document.querySelectorAll("[popovertarget]").forEach(e => { - e.addEventListener("click", () => { - const rect = e.getBoundingClientRect() - const t = `:popover-open { position: absolute; top: ${Math.floor(rect.bottom)}px; left: ${Math.floor(rect.left + rect.width / 2)}px; width: ${Math.floor(rect.width)}px; transform: translateX(-50%); box-sizing: content-box; margin: 0 }` - document.styleSheets[0].insertRule(t, document.styleSheets[0].cssRules.length) - }) - }) - //- Prevent default - script. - document.querySelectorAll("[cx-prevent-default]").forEach(e => { - e.addEventListener("click", event => { - event.preventDefault() - }) - }) - script(src=rel("/static/htmx.js")) - //- Error dialog - aside.s-modal#server-error(aria-hidden="true") - .s-modal--dialog - h1.s-modal--header Server error - pre.overflow-auto#server-error-content - button.s-modal--close.s-btn.s-btn__muted(aria-label="Close" type="button" onclick="hideError()")!= icons.Icons.IconClearSm - .s-modal--footer - button.s-btn.s-btn__outlined.s-btn__muted(type="button" onclick="hideError()") OK - script. - function hideError() { - document.getElementById("server-error").setAttribute("aria-hidden", "true") - } - document.body.addEventListener("htmx:responseError", event => { - document.getElementById("server-error").setAttribute("aria-hidden", "false") - document.getElementById("server-error-content").textContent = event.detail.xhr.responseText - }) +mixin guild(guild) + span.s-avatar.s-avatar__32.s-user-card--avatar + if guild.icon + img.s-avatar--image(src=`https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=32`) + else + .s-avatar--letter.bg-silver-400.bar-md(aria-hidden="true")= guild.name[0] + .s-user-card--info.ai-start + strong= guild.name + ul.s-user-card--awards + li #{discord.guildChannelMap.get(guild.id).filter(c => [0, 5, 15, 16].includes(discord.channels.get(c).type)).length} channels + +doctype html +html(lang="en") + head + title Out Of Your Element + link(rel="stylesheet" type="text/css" href="/static/stacks.min.css") + <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 80%22><text y=%22.83em%22 font-size=%2283%22>💬</text></svg>"> + meta(name="htmx-config" content='{"indicatorClass":"is-loading"}') + style. + .themed { + --theme-base-primary-color-h: 266; + --theme-base-primary-color-s: 53%; + --theme-base-primary-color-l: 63%; + --theme-dark-primary-color-h: 266; + --theme-dark-primary-color-s: 53%; + --theme-dark-primary-color-l: 63%; + } + body.themed.theme-system + header.s-topbar + .s-topbar--skip-link(href="#content") Skip to main content + .s-topbar--container.wmx9 + a.s-topbar--logo(href="/") + img.s-avatar.s-avatar__32(src="/icon.png") + nav.s-topbar--navigation + ul.s-topbar--content + li.ps-relative + if !session.data.managedGuilds || session.data.managedGuilds.length === 0 + a.s-btn.s-btn__icon.as-center(href="/oauth") + != icons.Icons.IconDiscord + = ` Log in` + else if guild_id && session.data.managedGuilds.includes(guild_id) && discord.guilds.has(guild_id) + button.s-topbar--item.s-btn.s-btn__muted.s-user-card(popovertarget="guilds") + +guild(discord.guilds.get(guild_id)) + else if session.data.managedGuilds + button.s-topbar--item.s-btn.s-btn__muted.s-btn__dropdown.pr24.s-user-card.s-label(popovertarget="guilds") + | Your servers + #guilds(popover data-popper-placement="bottom" style="display: revert; width: revert;").s-popover.overflow-visible + .s-popover--arrow.s-popover--arrow__tc + .s-popover--content.overflow-y-auto.overflow-x-hidden + ul.s-menu(role="menu") + each guild in (session.data.managedGuilds || []).map(id => discord.guilds.get(id)).filter(g => g) + li(role="menuitem") + a.s-topbar--item.s-user-card.d-flex.p4(href=`/guild?guild_id=${guild.id}`) + +guild(guild) + .mx-auto.w100.wmx9.py24#content + block body + script. + document.querySelectorAll("[popovertarget]").forEach(e => { + e.addEventListener("click", () => { + const rect = e.getBoundingClientRect() + const t = `:popover-open { position: absolute; top: ${Math.floor(rect.bottom)}px; left: ${Math.floor(rect.left + rect.width / 2)}px; width: ${Math.floor(rect.width)}px; transform: translateX(-50%); box-sizing: content-box; margin: 0 }` + // console.log(t) + document.styleSheets[0].insertRule(t) + }) + }) + script(src="/static/htmx.min.js") diff --git a/src/web/pug/invite.pug b/src/web/pug/invite.pug index 81a428d..e346880 100644 --- a/src/web/pug/invite.pug +++ b/src/web/pug/invite.pug @@ -3,7 +3,7 @@ extends includes/template.pug block body if !isValid .s-empty-state.wmx4.p48 - != icons.Spots.SpotExpireXL + != icons.Spots.SpotAlertXL p This QR code has expired. p Refresh the guild management page to generate a new one. @@ -13,11 +13,11 @@ block body .s-page-title.mb24 h1.s-page-title--header= guild.name - .d-flex.g16#form-container + .d-flex.g16 .fl-grow1 h2.fs-headline1 Invite a Matrix user - form.d-flex.gy16.fd-column(method="post" action=rel("/api/invite") hx-post=rel("/api/invite") hx-indicator="#invite-button" hx-select="#ok" hx-target="#form-container") + form.d-flex.gy16.fd-column(method="post" action="/api/invite" style="grid-template-rows: repeat(2, auto)") .d-flex.gy4.fd-column label.s-label(for="mxid") Matrix ID input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org") @@ -27,7 +27,6 @@ block body select#permissions(name="permissions") option(value="default") Default option(value="moderator") Moderator - option(value="admin") Admin input(type="hidden" name="nonce" value=nonce) div - button.s-btn.s-btn__filled#invite-button Invite + button.s-btn.s-btn__filled.htmx-indicator Invite diff --git a/src/web/pug/log-in-with-matrix.pug b/src/web/pug/log-in-with-matrix.pug deleted file mode 100644 index 3bb72e2..0000000 --- a/src/web/pug/log-in-with-matrix.pug +++ /dev/null @@ -1,16 +0,0 @@ -extends includes/template.pug - -block body - .s-page-title.mb24 - h1.s-page-title--header Log in with Matrix - - .d-flex.g16#form-container - .fl-grow1 - form.d-flex.gy16.fd-column(method="post" action=rel("/api/log-in-with-matrix") hx-post=rel("/api/log-in-with-matrix") hx-indicator="#log-in-button" hx-select="#ok" hx-target="#form-container") - if next - input(type="hidden" name="next" value=next) - .d-flex.gy4.fd-column - label.s-label(for="mxid") Your Matrix ID - input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org" pattern="@([^:]+):([a-z0-9:\\-]+\\.[a-z0-9.:\\-]+)") - div - button.s-btn.s-btn__github#log-in-button Continue with Matrix diff --git a/src/web/pug/ok.pug b/src/web/pug/ok.pug index bf32e8d..9aed737 100644 --- a/src/web/pug/ok.pug +++ b/src/web/pug/ok.pug @@ -1,6 +1,6 @@ extends includes/template.pug block body - .ta-center.wmx5.p48.mx-auto#ok - != spot ? icons.Spots[spot] : icons.Spots.SpotApproveXL + .ta-center.wmx5.p48.mx-auto + != icons.Spots.SpotApproveXL p.mt24.fs-body2= msg diff --git a/src/web/routes/download-discord.js b/src/web/routes/download-discord.js index 769fc9c..ee64074 100644 --- a/src/web/routes/download-discord.js +++ b/src/web/routes/download-discord.js @@ -1,7 +1,7 @@ // @ts-check const assert = require("assert/strict") -const {defineEventHandler, getValidatedRouterParams, sendRedirect, createError, H3Event} = require("h3") +const {defineEventHandler, getValidatedRouterParams, sendRedirect, createError} = require("h3") const {z} = require("zod") /** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore @@ -19,15 +19,6 @@ const schema = { }) } -/** - * @param {H3Event} event - * @returns {import("snowtransfer").SnowTransfer} - */ -function getSnow(event) { - /* c8 ignore next */ - return event.context.snow || discord.snow -} - /** @type {Map<string, Promise<string>>} */ const cache = new Map() @@ -38,6 +29,7 @@ function timeUntilExpiry(url) { assert(ex) // refreshed urls from the discord api always include this parameter const time = parseInt(ex, 16)*1000 - Date.now() if (time > 0) return time + return false } function defineMediaProxyHandler(domain) { @@ -64,13 +56,11 @@ function defineMediaProxyHandler(domain) { if (!timeUntilExpiry(refreshed)) promise = undefined } if (!promise) { - const snow = getSnow(event) - promise = snow.channel.refreshAttachmentURLs([url]).then(x => x.refreshed_urls[0].refreshed) + promise = discord.snow.channel.refreshAttachmentURLs([url]).then(x => x.refreshed_urls[0].refreshed) cache.set(url, promise) refreshed = await promise const time = timeUntilExpiry(refreshed) assert(time) // the just-refreshed URL will always be in the future - /* c8 ignore next 3 */ setTimeout(() => { cache.delete(url) }, time).unref() @@ -83,5 +73,3 @@ function defineMediaProxyHandler(domain) { as.router.get(`/download/discordcdn/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("cdn.discordapp.com")) as.router.get(`/download/discordmedia/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("media.discordapp.net")) - -module.exports._cache = cache diff --git a/src/web/routes/download-discord.test.js b/src/web/routes/download-discord.test.js deleted file mode 100644 index e4f4ab4..0000000 --- a/src/web/routes/download-discord.test.js +++ /dev/null @@ -1,97 +0,0 @@ -// @ts-check - -const assert = require("assert").strict -const tryToCatch = require("try-to-catch") -const {test} = require("supertape") -const {router} = require("../../../test/web") -const {_cache} = require("./download-discord") - -test("web download discord: access denied if not a known attachment", async t => { - const [error] = await tryToCatch(() => - router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name", { - params: { - channel_id: "1", - attachment_id: "2", - file_name: "image.png" - } - }) - ) - t.ok(error) -}) - -test("web download discord: works if a known attachment", async t => { - const event = {} - await router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name", { - params: { - channel_id: "655216173696286746", - attachment_id: "1314358913482621010", - file_name: "image.png" - }, - event, - snow: { - channel: { - async refreshAttachmentURLs(attachments) { - assert(Array.isArray(attachments)) - return { - refreshed_urls: attachments.map(a => ({ - original: a, - refreshed: a + `?ex=${Math.floor(Date.now() / 1000 + 3600).toString(16)}` - })) - } - } - } - } - }) - t.equal(event.node.res.statusCode, 302) - t.match(event.node.res.getHeader("location"), /https:\/\/cdn.discordapp.com\/attachments\/655216173696286746\/1314358913482621010\/image\.png\?ex=/) -}) - -test("web download discord: uses cache", async t => { - let notCalled = true - const event = {} - await router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name", { - params: { - channel_id: "655216173696286746", - attachment_id: "1314358913482621010", - file_name: "image.png" - }, - event, - snow: { - channel: { - /* c8 ignore next 4 */ - async refreshAttachmentURLs(attachments) { - notCalled = false - throw new Error("tried to refresh when it should be in cache") - } - } - } - }) - t.ok(notCalled) -}) - -test("web download discord: refreshes when cache has expired", async t => { - _cache.set(`https://cdn.discordapp.com/attachments/655216173696286746/1314358913482621010/image.png`, Promise.resolve(`https://cdn.discordapp.com/blah?ex=${Math.floor(new Date("2026-01-01").getTime() / 1000 + 3600).toString(16)}`)) - let called = 0 - await router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name", { - params: { - channel_id: "655216173696286746", - attachment_id: "1314358913482621010", - file_name: "image.png" - }, - snow: { - channel: { - async refreshAttachmentURLs(attachments) { - called++ - assert(Array.isArray(attachments)) - return { - refreshed_urls: attachments.map(a => ({ - original: a, - refreshed: a + `?ex=${Math.floor(Date.now() / 1000 + 3600).toString(16)}` - })) - } - } - } - } - }) - t.equal(called, 1) -}) diff --git a/src/web/routes/download-matrix.js b/src/web/routes/download-matrix.js index 82e2f7e..f996716 100644 --- a/src/web/routes/download-matrix.js +++ b/src/web/routes/download-matrix.js @@ -1,7 +1,7 @@ // @ts-check const assert = require("assert/strict") -const {defineEventHandler, getValidatedRouterParams, setResponseStatus, setResponseHeader, createError, H3Event, getValidatedQuery} = require("h3") +const {defineEventHandler, getValidatedRouterParams, setResponseStatus, setResponseHeader, sendStream, createError} = require("h3") const {z} = require("zod") /** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore @@ -11,47 +11,20 @@ require("xxhash-wasm")().then(h => hasher = h) const {sync, as, select} = require("../../passthrough") -/** @type {import("../../m2d/actions/emoji-sheet")} */ -const emojiSheet = sync.require("../../m2d/actions/emoji-sheet") -/** @type {import("../../m2d/converters/emoji-sheet")} */ -const emojiSheetConverter = sync.require("../../m2d/converters/emoji-sheet") - -/** @type {import("../../m2d/actions/sticker")} */ -const sticker = sync.require("../../m2d/actions/sticker") +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") const schema = { params: z.object({ server_name: z.string(), media_id: z.string() - }), - sheet: z.object({ - e: z.array(z.string()).or(z.string()) - }), - sticker: z.object({ - server_name: z.string().regex(/^[^/]+$/), - media_id: z.string().regex(/^[A-Za-z0-9_-]+$/) }) } -/** - * @param {H3Event} event - * @returns {import("../../matrix/api")} - */ -function getAPI(event) { - /* c8 ignore next */ - return event.context.api || sync.require("../../matrix/api") -} +as.router.get(`/download/matrix/:server_name/:media_id`, defineEventHandler(async event => { + const params = await getValidatedRouterParams(event, schema.params.parse) -/** - * @param {H3Event} event - * @returns {typeof emojiSheet["getAndConvertEmoji"]} - */ -function getMxcDownloader(event) { - /* c8 ignore next */ - return event.context.mxcDownloader || emojiSheet.getAndConvertEmoji -} - -function verifyMediaHash(serverAndMediaID) { + const serverAndMediaID = `${params.server_name}/${params.media_id}` const unsignedHash = hasher.h64(serverAndMediaID) const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range @@ -62,13 +35,7 @@ function verifyMediaHash(serverAndMediaID) { data: `The file you requested isn't permitted by this media proxy.` }) } -} -as.router.get(`/download/matrix/:server_name/:media_id`, defineEventHandler(async event => { - const params = await getValidatedRouterParams(event, schema.params.parse) - - verifyMediaHash(`${params.server_name}/${params.media_id}`) - const api = getAPI(event) const res = await api.getMedia(`mxc://${params.server_name}/${params.media_id}`) const contentType = res.headers.get("content-type") @@ -79,32 +46,3 @@ as.router.get(`/download/matrix/:server_name/:media_id`, defineEventHandler(asyn setResponseHeader(event, "Transfer-Encoding", "chunked") return res.body })) - -as.router.get(`/download/sheet`, defineEventHandler(async event => { - const query = await getValidatedQuery(event, schema.sheet.parse) - - /** remember that these have no mxc:// protocol in the string for space reasons */ - let mxcs = query.e - if (!Array.isArray(mxcs)) { - mxcs = [mxcs] - } - - for (const serverAndMediaID of mxcs) { - verifyMediaHash(serverAndMediaID) - } - - const buffer = await emojiSheetConverter.compositeMatrixEmojis(mxcs.map(s => `mxc://${s}`), getMxcDownloader(event)) - setResponseHeader(event, "Content-Type", "image/png") - return buffer -})) - -as.router.get(`/download/sticker/:server_name/:media_id/_.webp`, defineEventHandler(async event => { - const {server_name, media_id} = await getValidatedRouterParams(event, schema.sticker.parse) - /** remember that this has no mxc:// protocol in the string */ - const mxc = server_name + "/" + media_id - verifyMediaHash(mxc) - - const stream = await sticker.getAndResizeSticker(`mxc://${mxc}`) - setResponseHeader(event, "Content-Type", "image/webp") - return stream -})) diff --git a/src/web/routes/download-matrix.test.js b/src/web/routes/download-matrix.test.js deleted file mode 100644 index ccbcfdd..0000000 --- a/src/web/routes/download-matrix.test.js +++ /dev/null @@ -1,88 +0,0 @@ -// @ts-check - -const fs = require("fs") -const {convertImageStream} = require("../../m2d/converters/emoji-sheet") -const tryToCatch = require("try-to-catch") -const {test} = require("supertape") -const {router} = require("../../../test/web") -const streamWeb = require("stream/web") - -test("web download matrix: access denied if not a known attachment", async t => { - const [error] = await tryToCatch(() => - router.test("get", "/download/matrix/:server_name/:media_id", { - params: { - server_name: "cadence.moe", - media_id: "1" - } - }) - ) - t.ok(error) -}) - -test("web download matrix: works if a known attachment", async t => { - const event = {} - await router.test("get", "/download/matrix/:server_name/:media_id", { - params: { - server_name: "cadence.moe", - media_id: "KrwlqopRyMxnEBcWDgpJZPxh", - }, - event, - api: { - // @ts-ignore - async getMedia(mxc, init) { - return new Response("", {status: 200, headers: {"content-type": "image/png"}}) - } - } - }) - t.equal(event.node.res.statusCode, 200) - t.equal(event.node.res.getHeader("content-type"), "image/png") -}) - -/** - * MOCK: Gets the emoji from the filesystem and converts to uncompressed PNG data. - * @param {string} mxc a single mxc:// URL - * @returns {Promise<Buffer | undefined>} uncompressed PNG data, or undefined if the downloaded emoji is not valid -*/ -async function mockGetAndConvertEmoji(mxc) { - const id = mxc.match(/\/([^./]*)$/)?.[1] - let s - if (fs.existsSync(`test/res/${id}.png`)) { - s = fs.createReadStream(`test/res/${id}.png`) - } else { - s = fs.createReadStream(`test/res/${id}.gif`) - } - return convertImageStream(s, () => { - s.pause() - s.emit("end") - }) -} - -test("web sheet: single emoji", async t => { - const event = {} - const sheet = await router.test("get", "/download/sheet?e=cadence.moe%2FRLMgJGfgTPjIQtvvWZsYjhjy", { - event, - mxcDownloader: mockGetAndConvertEmoji - }) - t.equal(event.node.res.statusCode, 200) - t.equal(sheet.subarray(0, 90).toString("base64"), "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAPoAAAD6AG1e1JrAAALoklEQVR4nM1ZaVBU2RU+LZSIGnAvFUtcRkSk6abpbkDH") -}) - -test("web sheet: multiple sources", async t => { - const event = {} - const sheet = await router.test("get", "/download/sheet?e=cadence.moe%2FWbYqNlACRuicynBfdnPYtmvc&e=cadence.moe%2FHYcztccFIPgevDvoaWNsEtGJ", { - event, - mxcDownloader: mockGetAndConvertEmoji - }) - t.equal(event.node.res.statusCode, 200) - t.equal(sheet.subarray(0, 90).toString("base64"), "iVBORw0KGgoAAAANSUhEUgAAAGAAAAAwCAYAAADuFn/PAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAT/klEQVR4nOVcC3CVRZbuS2KAIMpDQt5PQkIScm/uvYRX") -}) - -test("web sheet: big sheet", async t => { - const event = {} - const sheet = await router.test("get", "/download/sheet?e=cadence.moe%2FlHfmJpzgoNyNtYHdAmBHxXix&e=cadence.moe%2FMtRdXixoKjKKOyHJGWLsWLNU&e=cadence.moe%2FHXfFuougamkURPPMflTJRxGc&e=cadence.moe%2FikYKbkhGhMERAuPPbsnQzZiX&e=cadence.moe%2FAYPpqXzVJvZdzMQJGjioIQBZ&e=cadence.moe%2FUVuzvpVUhqjiueMxYXJiFEAj&e=cadence.moe%2FlHfmJpzgoNyNtYHdAmBHxXix&e=cadence.moe%2FMtRdXixoKjKKOyHJGWLsWLNU&e=cadence.moe%2FHXfFuougamkURPPMflTJRxGc&e=cadence.moe%2FikYKbkhGhMERAuPPbsnQzZiX&e=cadence.moe%2FAYPpqXzVJvZdzMQJGjioIQBZ&e=cadence.moe%2FUVuzvpVUhqjiueMxYXJiFEAj", { - event, - mxcDownloader: mockGetAndConvertEmoji - }) - t.equal(event.node.res.statusCode, 200) - t.equal(sheet.subarray(0, 90).toString("base64"), "iVBORw0KGgoAAAANSUhEUgAAAYAAAABgCAYAAAAU9KWJAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAgAElEQVR4nOx9B3hUVdr/KIpKL2nT0pPpLRNQkdXddV1c") -}) diff --git a/src/web/routes/guild-settings.js b/src/web/routes/guild-settings.js index 63dd3ec..7940853 100644 --- a/src/web/routes/guild-settings.js +++ b/src/web/routes/guild-settings.js @@ -1,96 +1,23 @@ // @ts-check -const assert = require("assert/strict") const {z} = require("zod") -const {defineEventHandler, createError, readValidatedBody, getRequestHeader, setResponseHeader, sendRedirect, H3Event} = require("h3") +const {defineEventHandler, sendRedirect, useSession, createError, readValidatedBody} = require("h3") -const {as, db, sync, select} = require("../../passthrough") +const {as, db} = require("../../passthrough") +const {reg} = require("../../matrix/read-registration") -/** @type {import("../auth")} */ -const auth = sync.require("../auth") -/** @type {import("../../d2m/actions/set-presence")} */ -const setPresence = sync.require("../../d2m/actions/set-presence") - -/** - * @param {H3Event} event - * @returns {import("../../d2m/actions/create-space")} - */ -function getCreateSpace(event) { - /* c8 ignore next */ - return event.context.createSpace || sync.require("../../d2m/actions/create-space") -} - -/** - * @typedef Options - * @prop {(value: string?) => number} transform - * @prop {(event: H3Event, guildID: string) => any} [after] - * @prop {keyof import("../../db/orm-defs").Models} table - */ - -/** - * @template {string} T - * @param {T} key - * @param {Partial<Options>} [inputOptions] - */ -function defineToggle(key, inputOptions) { - /** @type {Options} */ - const options = { - transform: x => +!!x, // convert toggle to 0 or 1 - table: "guild_space" - } - Object.assign(options, inputOptions) - return defineEventHandler(async event => { - const bodySchema = z.object({ - guild_id: z.string(), - [key]: z.string().optional() - }) - /** @type {Record<T, string?> & Record<"guild_id", string> & Record<string, unknown>} */ // @ts-ignore - const parsedBody = await readValidatedBody(event, bodySchema.parse) - const managed = await auth.getManagedGuilds(event) - if (!managed.has(parsedBody.guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"}) - - const value = options.transform(parsedBody[key]) - assert(typeof value === "number") - db.prepare(`UPDATE ${options.table} SET ${key} = ? WHERE guild_id = ?`).run(value, parsedBody.guild_id) - - return (options.after && await options.after(event, parsedBody.guild_id)) || null +const schema = { + autocreate: z.object({ + guild_id: z.string(), + autocreate: z.string().optional() }) } -as.router.post("/api/autocreate", defineToggle("autocreate", { - table: "guild_active", - after(event, guild_id) { - // If showing a partial page due to incomplete setup, need to refresh the whole page to show the alternate version - const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get() - if (!spaceID) { - if (getRequestHeader(event, "HX-Request")) { - setResponseHeader(event, "HX-Refresh", "true") - } else { - return sendRedirect(event, "", 302) - } - } - } -})) - -as.router.post("/api/url-preview", defineToggle("url_preview")) - -as.router.post("/api/webhook-profile", defineToggle("webhook_profile")) - -as.router.post("/api/presence", defineToggle("presence", { - after() { - setPresence.guildPresenceSetting.update() - } -})) - -as.router.post("/api/privacy-level", defineToggle("privacy_level", { - transform(value) { - assert(value) - const i = ["invite", "link", "directory"].indexOf(value) - assert.notEqual(i, -1) - return i - }, - async after(event, guildID) { - const createSpace = getCreateSpace(event) - await createSpace.syncSpaceFully(guildID) // this is inefficient but OK to call infrequently on user request - } +as.router.post("/api/autocreate", defineEventHandler(async event => { + const parsedBody = await readValidatedBody(event, schema.autocreate.parse) + const session = await useSession(event, {password: reg.as_token}) + if (!(session.data.managedGuilds || []).includes(parsedBody.guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"}) + + db.prepare("UPDATE guild_active SET autocreate = ? WHERE guild_id = ?").run(+!!parsedBody.autocreate, parsedBody.guild_id) + return sendRedirect(event, `/guild?guild_id=${parsedBody.guild_id}`, 302) })) diff --git a/src/web/routes/guild-settings.test.js b/src/web/routes/guild-settings.test.js deleted file mode 100644 index fccc266..0000000 --- a/src/web/routes/guild-settings.test.js +++ /dev/null @@ -1,96 +0,0 @@ -// @ts-check - -const tryToCatch = require("try-to-catch") -const {router, test} = require("../../../test/web") -const {select} = require("../../passthrough") -const {MatrixServerError} = require("../../matrix/mreq") - -test("web autocreate: checks permissions", async t => { - const [error] = await tryToCatch(() => router.test("post", "/api/autocreate", { - body: { - guild_id: "66192955777486848" - } - })) - t.equal(error.data, "Can't change settings for a guild you don't have Manage Server permissions in") -}) - - -test("web autocreate: turns off autocreate and does htmx page refresh when guild not linked", async t => { - const event = {} - await router.test("post", "/api/autocreate", { - sessionData: { - managedGuilds: ["66192955777486848"] - }, - body: { - guild_id: "66192955777486848", - // autocreate is false - }, - headers: { - "hx-request": "true" - }, - event - }) - t.equal(event.node.res.getHeader("hx-refresh"), "true") - t.equal(select("guild_active", "autocreate", {guild_id: "66192955777486848"}).pluck().get(), 0) -}) - -test("web autocreate: turns on autocreate and issues 302 when not using htmx", async t => { - const event = {} - await router.test("post", "/api/autocreate", { - sessionData: { - managedGuilds: ["66192955777486848"] - }, - body: { - guild_id: "66192955777486848", - autocreate: "yes" - }, - event - }) - t.equal(event.node.res.getHeader("location"), "") - t.equal(select("guild_active", "autocreate", {guild_id: "66192955777486848"}).pluck().get(), 1) -}) - -test("web privacy level: checks permissions", async t => { - const [error] = await tryToCatch(() => router.test("post", "/api/privacy-level", { - body: { - guild_id: "112760669178241024", - privacy_level: "directory" - } - })) - t.equal(error.data, "Can't change settings for a guild you don't have Manage Server permissions in") -}) - -test("web privacy level: updates privacy level", async t => { - let called = 0 - await router.test("post", "/api/privacy-level", { - sessionData: { - managedGuilds: ["112760669178241024"] - }, - body: { - guild_id: "112760669178241024", - privacy_level: "directory" - }, - createSpace: { - async syncSpaceFully(guildID) { - called++ - t.equal(guildID, "112760669178241024") - return "" - } - } - }) - t.equal(called, 1) - t.equal(select("guild_space", "privacy_level", {guild_id: "112760669178241024"}).pluck().get(), 2) // directory = 2 -}) - -test("web presence: updates presence", async t => { - await router.test("post", "/api/presence", { - sessionData: { - managedGuilds: ["112760669178241024"] - }, - body: { - guild_id: "112760669178241024" - // presence is on by default - turn it off - } - }) - t.equal(select("guild_space", "presence", {guild_id: "112760669178241024"}).pluck().get(), 0) -}) diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js deleted file mode 100644 index a5508c4..0000000 --- a/src/web/routes/guild.js +++ /dev/null @@ -1,268 +0,0 @@ -// @ts-check - -const DiscordTypes = require("discord-api-types/v10") -const assert = require("assert/strict") -const {z} = require("zod") -const {H3Event, defineEventHandler, sendRedirect, createError, getValidatedQuery, readValidatedBody, setResponseHeader} = require("h3") -const {randomUUID} = require("crypto") -const {LRUCache} = require("lru-cache") -const Ty = require("../../types") -const uqr = require("uqr") - -const {id: botID} = require("../../../addbot") -const {discord, as, sync, select, from, db} = require("../../passthrough") -/** @type {import("../pug-sync")} */ -const pugSync = sync.require("../pug-sync") -/** @type {import("../../d2m/actions/create-space")} */ -const createSpace = sync.require("../../d2m/actions/create-space") -/** @type {import("../auth")} */ -const auth = require("../auth") -/** @type {import("../../discord/utils")} */ -const dUtils = sync.require("../../discord/utils") -/** @type {import("../../matrix/utils")} */ -const mxUtils = sync.require("../../matrix/utils") -const {reg} = require("../../matrix/read-registration") - -const schema = { - guild: z.object({ - guild_id: z.string().optional() - }), - qr: z.object({ - guild_id: z.string().optional() - }), - invite: z.object({ - mxid: z.string().regex(/@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)/), - permissions: z.enum(["default", "moderator", "admin"]), - guild_id: z.string().optional(), - nonce: z.string().optional() - }), - inviteNonce: z.object({ - nonce: z.string() - }) -} - -/** - * @param {H3Event} event - * @returns {import("../../matrix/api")} - */ -function getAPI(event) { - /* c8 ignore next */ - return event.context.api || sync.require("../../matrix/api") -} - -/** @type {LRUCache<string, string>} nonce to guild id */ -const validNonce = new LRUCache({max: 200}) - -/** - * @param {{type: number, parent_id?: string | null, position?: number}} channel - * @param {Map<string, {type: number, parent_id?: string | null, position?: number}>} channels - */ -function getPosition(channel, channels) { - let position = 0 - - // Categories always appear below un-categorised channels. Their contents can be ordered. - // So categories, and things in them, will have their position multiplied by a big number. The category's big number. The regular position small number sorts within the category. - // Categories are size 2000. - let foundCategory = channel - while (foundCategory.parent_id) { - const f = channels.get(foundCategory.parent_id) - assert(f) - foundCategory = f - } - if (foundCategory.type === DiscordTypes.ChannelType.GuildCategory) position = ((foundCategory.position || 0) + 1) * 2000 - - // Categories always appear above what they contain. - if (channel.type === DiscordTypes.ChannelType.GuildCategory) position -= 0.5 - - // Within a category, voice channels are always sorted to the bottom. The text/voice split is size 1000 each. - if ([DiscordTypes.ChannelType.GuildVoice, DiscordTypes.ChannelType.GuildStageVoice].includes(channel.type)) position += 1000 - - // Channels are manually ordered within the text/voice split. - if (typeof channel.position === "number") position += channel.position - - // Threads appear below their channel. - if ([DiscordTypes.ChannelType.PublicThread, DiscordTypes.ChannelType.PrivateThread, DiscordTypes.ChannelType.AnnouncementThread].includes(channel.type)) { - position += 0.5 - let parent = channels.get(channel.parent_id || "") - if (parent && parent["position"]) position += parent["position"] - } - - return position -} - -/** - * @param {DiscordTypes.APIGuild} guild - * @param {Ty.R.Hierarchy[]} rooms - * @param {string[]} roles - */ -function getChannelRoomsLinks(guild, rooms, roles) { - let channelIDs = discord.guildChannelMap.get(guild.id) - assert(channelIDs) - - let linkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {channel_id: channelIDs}).all() - let linkedChannelsWithDetails = linkedChannels.map(c => ({ - // @ts-ignore - /** @type {DiscordTypes.APIGuildChannel} */ channel: discord.channels.get(c.channel_id), - ...c - })) - let removedUncachedChannels = dUtils.filterTo(linkedChannelsWithDetails, c => c.channel) - let linkedChannelIDs = linkedChannelsWithDetails.map(c => c.channel_id) - linkedChannelsWithDetails.sort((a, b) => getPosition(a.channel, discord.channels) - getPosition(b.channel, discord.channels)) - - let unlinkedChannelIDs = channelIDs.filter(c => !linkedChannelIDs.includes(c)) - /** @type {DiscordTypes.APIGuildChannel[]} */ // @ts-ignore - let unlinkedChannels = unlinkedChannelIDs.map(c => discord.channels.get(c)) - let removedWrongTypeChannels = dUtils.filterTo(unlinkedChannels, c => c && [0, 5].includes(c.type)) - let removedPrivateChannels = dUtils.filterTo(unlinkedChannels, c => { - const permissions = dUtils.getPermissions(guild.id, roles, guild.roles, botID, c["permission_overwrites"]) - return dUtils.hasSomePermissions(permissions, ["Administrator", "ViewChannel"]) - }) - unlinkedChannels.sort((a, b) => getPosition(a, discord.channels) - getPosition(b, discord.channels)) - - let linkedRoomIDs = linkedChannels.map(c => c.room_id) - let unlinkedRooms = [...rooms] - let removedLinkedRooms = dUtils.filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id)) - let removedWrongTypeRooms = dUtils.filterTo(unlinkedRooms, r => !r.room_type) - // https://discord.com/developers/docs/topics/threads#active-archived-threads - // need to filter out linked archived threads from unlinkedRooms, will just do that by comparing against the name - let removedArchivedThreadRooms = dUtils.filterTo(unlinkedRooms, r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/)) - - return { - linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms, - removedUncachedChannels, removedWrongTypeChannels, removedPrivateChannels, removedLinkedRooms, removedWrongTypeRooms, removedArchivedThreadRooms - } -} - -/** - * @param {string} mxid - */ -function getInviteTargetSpaces(mxid) { - /** @type {{room_id: string, mxid: string, type: string, name: string, topic: string?, avatar: string?}[]} */ - const spaces = - // invited spaces - db.prepare("SELECT room_id, invite.mxid, type, name, topic, avatar FROM invite LEFT JOIN guild_space ON invite.room_id = guild_space.space_id WHERE mxid = ? AND space_id IS NULL AND type = 'm.space'").all(mxid) - // moderated spaces - .concat(db.prepare("SELECT room_id, invite.mxid, type, name, topic, avatar FROM invite LEFT JOIN guild_space ON invite.room_id = guild_space.space_id INNER JOIN member_cache USING (room_id) WHERE member_cache.mxid = ? AND power_level >= 50 AND space_id IS NULL AND type = 'm.space'").all(mxid)) - const seen = new Set(spaces.map(s => s.room_id)) - return spaces.filter(s => seen.delete(s.room_id)) -} - -as.router.get("/guild", defineEventHandler(async event => { - const {guild_id} = await getValidatedQuery(event, schema.guild.parse) - const session = await auth.useSession(event) - const managed = await auth.getManagedGuilds(event) - const row = from("guild_active").join("guild_space", "guild_id", "left").select("space_id", "privacy_level", "autocreate").where({guild_id}).get() - // @ts-ignore - const guild = discord.guilds.get(guild_id) - - // Permission problems - if (!guild_id || !guild || !managed.has(guild_id) || !row) { - return pugSync.render(event, "guild_access_denied.pug", {guild_id, row}) - } - - // Self-service guild that hasn't been linked yet - needs a special page encouraging the link flow - if (!row.space_id && row.autocreate === 0) { - const spaces = session.data.mxid ? getInviteTargetSpaces(session.data.mxid) : [] - return pugSync.render(event, "guild_not_linked.pug", {guild, guild_id, spaces}) - } - - const roles = guild.members?.find(m => m.user.id === botID)?.roles || [] - - // Easy mode guild that hasn't been linked yet - need to remove elements that would require an existing space - if (!row.space_id) { - const links = getChannelRoomsLinks(guild, [], roles) - return pugSync.render(event, "guild.pug", {guild, guild_id, ...links, ...row}) - } - - // Linked guild - const api = getAPI(event) - const rooms = await api.getFullHierarchy(row.space_id) - const links = getChannelRoomsLinks(guild, rooms, roles) - return pugSync.render(event, "guild.pug", {guild, guild_id, ...links, ...row}) -})) - -as.router.get("/qr", defineEventHandler(async event => { - const {guild_id} = await getValidatedQuery(event, schema.qr.parse) - const managed = await auth.getManagedGuilds(event) - const row = from("guild_active").join("guild_space", "guild_id", "left").select("space_id", "privacy_level", "autocreate").where({guild_id}).get() - // @ts-ignore - const guild = discord.guilds.get(guild_id) - - // Permission problems - if (!guild_id || !guild || !managed.has(guild_id) || !row) { - return pugSync.render(event, "guild_access_denied.pug", {guild_id, row}) - } - - const nonce = randomUUID() - validNonce.set(nonce, guild_id) - - const data = `${reg.ooye.bridge_origin}/invite?nonce=${nonce}` - // necessary to scale the svg pixel-perfectly on the page - // https://github.com/unjs/uqr/blob/244952a8ba2d417f938071b61e11fb1ff95d6e75/src/svg.ts#L24 - const generatedSvg = uqr.renderSVG(data, {pixelSize: 3}) - const svg = generatedSvg.replace(/viewBox="0 0 ([0-9]+) ([0-9]+)"/, `data-nonce="${nonce}" width="$1" height="$2" $&`) - assert.notEqual(svg, generatedSvg) - - return svg -})) - -as.router.get("/invite", defineEventHandler(async event => { - const {nonce} = await getValidatedQuery(event, schema.inviteNonce.parse) - const isValid = validNonce.has(nonce) - const guild_id = validNonce.get(nonce) - const guild = discord.guilds.get(guild_id || "") - return pugSync.render(event, "invite.pug", {isValid, nonce, guild_id, guild}) -})) - -as.router.post("/api/invite", defineEventHandler(async event => { - const parsedBody = await readValidatedBody(event, schema.invite.parse) - const managed = await auth.getManagedGuilds(event) - const api = getAPI(event) - - // Check guild ID or nonce - if (parsedBody.guild_id) { - var guild_id = parsedBody.guild_id - if (!managed.has(guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't invite users to a guild you don't have Manage Server permissions in"}) - } else if (parsedBody.nonce) { - if (!validNonce.has(parsedBody.nonce)) throw createError({status: 403, message: "Nonce expired", data: "Nonce means number-used-once, and, well, you tried to use it twice..."}) - let ok = validNonce.get(parsedBody.nonce) - assert(ok) - var guild_id = ok - validNonce.delete(parsedBody.nonce) - } else { - throw createError({status: 400, message: "Missing guild ID", data: "Passing a guild ID or a nonce is required."}) - } - - // Check guild is bridged - const guild = discord.guilds.get(guild_id) - assert(guild) - const spaceID = await createSpace.ensureSpace(guild) - - // Check for existing invite to the space - let spaceMember - try { - spaceMember = await api.getStateEvent(spaceID, "m.room.member", parsedBody.mxid) - } catch (e) {} - - if (!spaceMember || !["invite", "join"].includes(spaceMember.membership)) { - // Invite - await api.inviteToRoom(spaceID, parsedBody.mxid) - } - - // Permissions - const powerLevel = - ( parsedBody.permissions === "admin" ? 100 - : parsedBody.permissions === "moderator" ? 50 - : 0) - if (powerLevel) await mxUtils.setUserPowerCascade(spaceID, parsedBody.mxid, powerLevel, api) - - if (parsedBody.guild_id) { - setResponseHeader(event, "HX-Refresh", true) - return sendRedirect(event, `/guild?guild_id=${guild_id}`, 302) - } else { - return sendRedirect(event, "/ok?msg=User has been invited.", 302) - } -})) - -module.exports._getPosition = getPosition -module.exports.getInviteTargetSpaces = getInviteTargetSpaces diff --git a/src/web/routes/guild.test.js b/src/web/routes/guild.test.js deleted file mode 100644 index aa17548..0000000 --- a/src/web/routes/guild.test.js +++ /dev/null @@ -1,396 +0,0 @@ -// @ts-check - -const DiscordTypes = require("discord-api-types/v10") -const tryToCatch = require("try-to-catch") -const {router, test} = require("../../../test/web") -const {MatrixServerError} = require("../../matrix/mreq") -const {_getPosition} = require("./guild") - -let nonce - -test("web guild: access denied when not logged in", async t => { - const html = await router.test("get", "/guild?guild_id=112760669178241024", { - sessionData: { - }, - }) - t.has(html, "You need to log in to manage your servers.") -}) - -test("web guild: asks to select guild if not selected", async t => { - const html = await router.test("get", "/guild", { - sessionData: { - userID: "1", - managedGuilds: [] - }, - }) - t.has(html, "Select a server from the top right corner to continue.") -}) - -test("web guild: access denied when guild id messed up", async t => { - const html = await router.test("get", "/guild?guild_id=1", { - sessionData: { - userID: "1", - managedGuilds: [] - }, - }) - t.has(html, "the selected server doesn't exist") -}) - -test("web qr: access denied when guild id messed up", async t => { - const html = await router.test("get", "/qr?guild_id=1", { - sessionData: { - userID: "1", - managedGuilds: [] - }, - }) - t.has(html, "the selected server doesn't exist") -}) - -test("web invite: access denied with invalid nonce", async t => { - const html = await router.test("get", "/invite?nonce=1") - t.match(html, /This QR code has expired./) -}) - - - -test("web guild: can view unbridged guild", async t => { - const html = await router.test("get", "/guild?guild_id=66192955777486848", { - sessionData: { - managedGuilds: ["66192955777486848"] - } - }) - t.has(html, `<h1 class="s-page-title--header">Function & Arg</h1>`) -}) - -test("web guild: unbridged self-service guild prompts log in to matrix", async t => { - const html = await router.test("get", "/guild?guild_id=665289423482519565", { - sessionData: { - managedGuilds: ["665289423482519565"] - } - }) - t.has(html, `You picked self-service mode`) - t.has(html, `You need to log in with Matrix first`) -}) - -test("web guild: unbridged self-service guild asks to be invited", async t => { - const html = await router.test("get", "/guild?guild_id=665289423482519565", { - sessionData: { - mxid: "@user:example.org", - managedGuilds: ["665289423482519565"] - } - }) - t.has(html, `On Matrix, invite <`) -}) - -test("web guild: unbridged self-service guild shows available spaces", async t => { - const html = await router.test("get", "/guild?guild_id=665289423482519565", { - sessionData: { - mxid: "@cadence:cadence.moe", - managedGuilds: ["665289423482519565"] - } - }) - t.has(html, `<strong>Data Horde</strong>`) - t.has(html, `<li>here is the space topic</li>`) - t.has(html, `<img class="s-avatar--image" src="https://bridge.example.org/download/matrix/cadence.moe/TLqQOsTSrZkVKwBSWYTZNTrw" alt="">`) - t.notMatch(html, /<strong>some room<\/strong>/) - t.notMatch(html, /<strong>somebody else's space<\/strong>/) -}) - - -test("web guild: can view bridged guild when logged in with discord", async t => { - const html = await router.test("get", "/guild?guild_id=112760669178241024", { - sessionData: { - managedGuilds: ["112760669178241024"] - }, - api: { - async getFullHierarchy(roomID) { - return [] - } - } - }) - t.has(html, `<h1 class="s-page-title--header">Psychonauts 3</h1>`) -}) - -test("web guild: can view bridged guild when logged in with matrix", async t => { - const html = await router.test("get", "/guild?guild_id=112760669178241024", { - sessionData: { - mxid: "@cadence:cadence.moe" - }, - api: { - async getFullHierarchy(roomID) { - return [] - } - } - }) - t.has(html, `<h1 class="s-page-title--header">Psychonauts 3</h1>`) -}) - -test("web qr: generates nonce", async t => { - const html = await router.test("get", "/qr?guild_id=112760669178241024", { - sessionData: { - managedGuilds: ["112760669178241024"] - } - }) - nonce = html.match(/data-nonce="([a-f0-9-]+)"/)?.[1] - t.ok(nonce) -}) - -test("web invite: page loads with valid nonce", async t => { - const html = await router.test("get", `/invite?nonce=${nonce}`) - t.has(html, "Invite a Matrix user") -}) - - - - -test("api invite: access denied with nothing", async t => { - const [error] = await tryToCatch(() => - router.test("post", `/api/invite`, { - body: { - mxid: "@cadence:cadence.moe", - permissions: "moderator" - } - }) - ) - t.equal(error.message, "Missing guild ID") -}) - -test("api invite: access denied when not in guild", async t => { - const [error] = await tryToCatch(() => - router.test("post", `/api/invite`, { - body: { - mxid: "@cadence:cadence.moe", - permissions: "moderator", - guild_id: "112760669178241024" - } - }) - ) - t.equal(error.message, "Forbidden") -}) - -test("api invite: can invite with valid nonce", async t => { - let called = 0 - const [error] = await tryToCatch(() => - router.test("post", `/api/invite`, { - body: { - mxid: "@cadence:cadence.moe", - permissions: "moderator", - nonce - }, - api: { - async getStateEvent(roomID, type, key) { - called++ - if (type === "m.room.member" && key === "@cadence:cadence.moe") { - throw new Error("event not found") - } else if (type === "m.room.power_levels" && key === "") { - return {} - } - /* c8 ignore next */ - t.fail(`unexpected getStateEvent call. roomID: ${roomID}, type: ${type}, key: ${key}`) - }, - async getStateEventOuter(roomID, type, key) { - called++ - return { - type: "m.room.create", - state_key: "", - sender: "@_ooye_bot:cadence.moe", - event_id: "$create", - origin_server_ts: 0, - room_id: roomID, - content: { - room_version: "11" - } - } - }, - async inviteToRoom(roomID, mxidToInvite, mxid) { - called++ - t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") - }, - async *generateFullHierarchy(spaceID) { - called++ - yield { - room_id: "!hierarchy", - children_state: [], - guest_can_join: false, - num_joined_members: 2, - } - }, - async sendState(roomID, type, key, content) { - called++ - t.ok(["!hierarchy", "!jjmvBegULiLucuWEHU:cadence.moe"].includes(roomID), `expected room ID to be in hierarchy, but was ${roomID}`) - t.equal(type, "m.room.power_levels") - t.equal(key, "") - t.deepEqual(content, { - users: {"@cadence:cadence.moe": 50} - }) - return "$updated" - } - } - }) - ) - t.notOk(error) - /* - 1. get membership - 2. invite to room - set power: - 3. generate hierarchy - 4-5. calculate powers - 6. send state - 7-8. calculate powers - 9. send state - */ - t.equal(called, 9) // get membership + -}) - -test("api invite: access denied when nonce has been used", async t => { - const [error] = await tryToCatch(() => - router.test("post", `/api/invite`, { - body: { - mxid: "@cadence:cadence.moe", - permissions: "moderator", - nonce - } - }) - ) - t.equal(error.message, "Nonce expired") -}) - -test("api invite: can invite to a moderated guild", async t => { - let called = 0 - const [error] = await tryToCatch(() => - router.test("post", `/api/invite`, { - body: { - mxid: "@cadence:cadence.moe", - permissions: "admin", - guild_id: "112760669178241024" - }, - sessionData: { - managedGuilds: ["112760669178241024"] - }, - api: { - async getStateEvent(roomID, type, key) { - called++ - if (type === "m.room.member" && key === "@cadence:cadence.moe") { - return {membership: "leave"} - } else if (type === "m.room.power_levels" && key === "") { - return {} - } - /* c8 ignore next */ - t.fail(`unexpected getStateEvent call. roomID: ${roomID}, type: ${type}, key: ${key}`) - }, - async getStateEventOuter(roomID, type, key) { - called++ - return { - type: "m.room.create", - state_key: "", - sender: "@_ooye_bot:cadence.moe", - event_id: "$create", - origin_server_ts: 0, - room_id: roomID, - content: { - room_version: "11" - } - } - }, - async inviteToRoom(roomID, mxidToInvite, mxid) { - called++ - t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") - }, - async *generateFullHierarchy(spaceID) { - called++ - yield { - room_id: "!hierarchy", - children_state: [], - guest_can_join: false, - num_joined_members: 2, - } - yield { - room_id: spaceID, - children_state: [], - guest_can_join: false, - num_joined_members: 2, - room_type: "m.space" - } - }, - async sendState(roomID, type, key, content) { - called++ - t.ok(["!hierarchy", "!jjmvBegULiLucuWEHU:cadence.moe"].includes(roomID), `expected room ID to be in hierarchy, but was ${roomID}`) - t.equal(type, "m.room.power_levels") - t.equal(key, "") - t.deepEqual(content, { - users: {"@cadence:cadence.moe": 100} - }) - return "$updated" - } - } - }) - ) - t.notOk(error) - t.equal(called, 9) -}) - -test("api invite: does not reinvite joined users", async t => { - let called = 0 - const [error] = await tryToCatch(() => - router.test("post", `/api/invite`, { - body: { - mxid: "@cadence:cadence.moe", - permissions: "default", - guild_id: "112760669178241024" - }, - sessionData: { - managedGuilds: ["112760669178241024"] - }, - api: { - async getStateEvent(roomID, type, key) { - called++ - return {membership: "join"} - } - } - }) - ) - t.notOk(error) - t.equal(called, 1) -}) - - -test("position sorting: sorts like discord does", t => { - const channelsList = [{ - type: DiscordTypes.ChannelType.GuildText, - id: "first", - position: 0 - }, { - type: DiscordTypes.ChannelType.PublicThread, - id: "thread", - parent_id: "first", - }, { - type: DiscordTypes.ChannelType.GuildText, - id: "second", - position: 1 - }, { - type: DiscordTypes.ChannelType.GuildVoice, - id: "voice", - position: 0 - }, { - type: DiscordTypes.ChannelType.GuildCategory, - id: "category", - position: 0 - }, { - type: DiscordTypes.ChannelType.GuildText, - id: "category-first", - parent_id: "category", - position: 0 - }, { - type: DiscordTypes.ChannelType.GuildText, - id: "category-second", - parent_id: "category", - position: 1 - }, { - type: DiscordTypes.ChannelType.PublicThread, - id: "category-second-thread", - parent_id: "category-second", - }].reverse() - const channels = new Map(channelsList.map(c => [c.id, c])) - const sortedChannelIDs = [...channels.values()].sort((a, b) => _getPosition(a, channels) - _getPosition(b, channels)).map(c => c.id) - t.deepEqual(sortedChannelIDs, ["first", "thread", "second", "voice", "category", "category-first", "category-second", "category-second-thread"]) -}) diff --git a/src/web/routes/info.js b/src/web/routes/info.js deleted file mode 100644 index e83bf89..0000000 --- a/src/web/routes/info.js +++ /dev/null @@ -1,76 +0,0 @@ -// @ts-check - -const {z} = require("zod") -const {defineEventHandler, getValidatedQuery, H3Event} = require("h3") -const {as, from, sync, select} = require("../../passthrough") - -/** @type {import("../../matrix/utils")} */ -const mUtils = sync.require("../../matrix/utils") - -/** - * @param {H3Event} event - * @returns {import("../../matrix/api")} - */ -function getAPI(event) { - /* c8 ignore next */ - return event.context.api || sync.require("../../matrix/api") -} - -const schema = { - message: z.object({ - message_id: z.string().regex(/^[0-9]+$/) - }) -} - -as.router.get("/api/message", defineEventHandler(async event => { - const api = getAPI(event) - - const {message_id} = await getValidatedQuery(event, schema.message.parse) - const metadatas = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index").where({message_id}) - .select("event_id", "event_type", "event_subtype", "part", "reaction_part", "reference_channel_id", "room_id", "source").and("ORDER BY part ASC, reaction_part DESC").all() - - if (metadatas.length === 0) { - return new Response("Message not found", {status: 404, statusText: "Not Found"}) - } - - const current_room_id = select("channel_room", "room_id", {channel_id: metadatas[0].reference_channel_id}).pluck().get() - const events = await Promise.all(metadatas.map(metadata => - api.getEvent(metadata.room_id, metadata.event_id).then(raw => ({ - metadata: { - event_id: metadata.event_id, - event_type: metadata.event_type, - event_subtype: metadata.event_subtype, - part: metadata.part, - reaction_part: metadata.reaction_part, - channel_id: metadata.reference_channel_id, - room_id: metadata.room_id, - source: metadata.source, - sender: raw.sender, - current_room_id: current_room_id - }, - raw - })) - )) - - /* c8 ignore next */ - const primary = events.find(e => e.metadata.part === 0) || events[0] - const mxid = primary.metadata.sender - const source = primary.metadata.source === 0 ? "matrix" : "discord" - - let matrix_author = undefined - if (source === "matrix") { - matrix_author = select("member_cache", ["displayname", "avatar_url", "mxid"], {room_id: primary.metadata.room_id, mxid}).get() - if (!matrix_author) { - try { - matrix_author = await api.getProfile(mxid) - } catch (e) { - matrix_author = {} - } - } - if (!matrix_author.displayname) matrix_author.displayname = mxid - matrix_author.avatar_url = mUtils.getPublicUrlForMxc(matrix_author.avatar_url) || null - matrix_author["mxid"] = mxid - } - - return {source, matrix_author, events} -})) diff --git a/src/web/routes/info.test.js b/src/web/routes/info.test.js deleted file mode 100644 index 39b2c00..0000000 --- a/src/web/routes/info.test.js +++ /dev/null @@ -1,227 +0,0 @@ -// @ts-check - -const assert = require("assert/strict") -const {router, test} = require("../../../test/web") - -test("web info: returns 404 when message doesn't exist", async t => { - const res = await router.test("get", "/api/message?message_id=1") - assert(res instanceof Response) - t.equal(res.status, 404) -}) - -test("web info: returns data for a matrix message and profile", async t => { - let called = 0 - const raw = { - type: "m.room.message", - room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: "testing :heart_pink: :heart_pink: ", - format: "org.matrix.custom.html", - formatted_body: "testing <img data-mx-emoticon=\"\" src=\"mxc://cadence.moe/AyAhnRNjWyFhJYTRibYwQpvf\" alt=\":heart_pink:\" title=\":heart_pink:\" height=\"32\" vertical-align=\"middle\" /> <img data-mx-emoticon=\"\" src=\"mxc://cadence.moe/AyAhnRNjWyFhJYTRibYwQpvf\" alt=\":heart_pink:\" title=\":heart_pink:\" height=\"32\" vertical-align=\"middle\" />" - }, - origin_server_ts: 1739312945302, - unsigned: { - membership: "join", - age: 10063702303 - }, - event_id: "$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk", - user_id: "@cadence:cadence.moe", - age: 10063702303 - } - const res = await router.test("get", "/api/message?message_id=1339000288144658482", { - api: { - // @ts-ignore - returning static data when method could be called with a different typescript generic - async getEvent(roomID, eventID) { - called++ - t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") - t.equal(eventID, "$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk") - return raw - }, - async getProfile(mxid) { - called++ - t.equal(mxid, "@cadence:cadence.moe") - return { - displayname: "okay 🤍 yay 🤍" - } - } - } - }) - t.deepEqual(res, { - source: "matrix", - matrix_author: { - displayname: "okay 🤍 yay 🤍", - avatar_url: null, - mxid: "@cadence:cadence.moe" - }, - events: [{ - metadata: { - event_id: "$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk", - event_subtype: "m.text", - event_type: "m.room.message", - part: 0, - reaction_part: 0, - room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe", - channel_id: "176333891320283136", - current_room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe", - sender: "@cadence:cadence.moe", - source: 0 - }, - raw - }] - }) - t.equal(called, 2) -}) - -test("web info: returns data for a matrix message without profile", async t => { - let called = 0 - const raw = { - type: "m.room.message", - room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: "testing :heart_pink: :heart_pink: ", - format: "org.matrix.custom.html", - formatted_body: "testing <img data-mx-emoticon=\"\" src=\"mxc://cadence.moe/AyAhnRNjWyFhJYTRibYwQpvf\" alt=\":heart_pink:\" title=\":heart_pink:\" height=\"32\" vertical-align=\"middle\" /> <img data-mx-emoticon=\"\" src=\"mxc://cadence.moe/AyAhnRNjWyFhJYTRibYwQpvf\" alt=\":heart_pink:\" title=\":heart_pink:\" height=\"32\" vertical-align=\"middle\" />" - }, - origin_server_ts: 1739312945302, - unsigned: { - membership: "join", - age: 10063702303 - }, - event_id: "$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk", - user_id: "@cadence:cadence.moe", - age: 10063702303 - } - const res = await router.test("get", "/api/message?message_id=1339000288144658482", { - api: { - // @ts-ignore - returning static data when method could be called with a different typescript generic - async getEvent(roomID, eventID) { - called++ - t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") - t.equal(eventID, "$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk") - return raw - } - } - }) - t.deepEqual(res, { - source: "matrix", - matrix_author: { - displayname: "@cadence:cadence.moe", - avatar_url: null, - mxid: "@cadence:cadence.moe" - }, - events: [{ - metadata: { - event_id: "$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk", - event_subtype: "m.text", - event_type: "m.room.message", - part: 0, - reaction_part: 0, - room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe", - channel_id: "176333891320283136", - current_room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe", - sender: "@cadence:cadence.moe", - source: 0 - }, - raw - }] - }) - t.equal(called, 1) -}) - -test("web info: returns data for a discord message", async t => { - let called = 0 - const raw1 = { - type: "m.room.message", - sender: "@_ooye_accavish:cadence.moe", - content: { - "m.mentions": {}, - msgtype: "m.text", - body: "brony music mentioned on wikipedia's did you know and also unrelated cat pic" - }, - origin_server_ts: 1749377203735, - unsigned: { - membership: "join", - age: 119 - }, - event_id: "$AfrB8hzXkDMvuoWjSZkDdFYomjInWH7jMBPkwQMN8AI", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" - } - const raw2 = { - type: "m.room.message", - sender: "@_ooye_accavish:cadence.moe", - content: { - "m.mentions": {}, - msgtype: "m.image", - url: "mxc://cadence.moe/ABOMymxHcpVeecHvmSIYmYXx", - external_url: "https://bridge.cadence.moe/download/discordcdn/112760669178241024/1381212840710504448/image.png", - body: "image.png", - filename: "image.png", - info: { - mimetype: "image/png", - w: 966, - h: 368, - size: 166060 - } - }, - origin_server_ts: 1749377203789, - unsigned: { - membership: "join", - age: 65 - }, - event_id: "$43baKEhJfD-RlsFQi0LB16Zxd8yMqp0HSVL00TDQOqM", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" - } - const res = await router.test("get", "/api/message?message_id=1381212840957972480", { - api: { - // @ts-ignore - returning static data when method could be called with a different typescript generic - async getEvent(roomID, eventID) { - called++ - t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") - if (eventID === raw1.event_id) { - return raw1 - } else { - assert(eventID === raw2.event_id) - return raw2 - } - } - } - }) - t.deepEqual(res, { - source: "discord", - matrix_author: undefined, - events: [{ - metadata: { - event_id: "$AfrB8hzXkDMvuoWjSZkDdFYomjInWH7jMBPkwQMN8AI", - event_subtype: "m.text", - event_type: "m.room.message", - part: 0, - reaction_part: 1, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - channel_id: "112760669178241024", - current_room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@_ooye_accavish:cadence.moe", - source: 1 - }, - raw: raw1 - }, { - metadata: { - event_id: "$43baKEhJfD-RlsFQi0LB16Zxd8yMqp0HSVL00TDQOqM", - event_subtype: "m.image", - event_type: "m.room.message", - part: 1, - reaction_part: 0, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - channel_id: "112760669178241024", - current_room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@_ooye_accavish:cadence.moe", - source: 1 - }, - raw: raw2 - }] - }) - t.equal(called, 2) -}) diff --git a/src/web/routes/invite.js b/src/web/routes/invite.js new file mode 100644 index 0000000..eec7a3c --- /dev/null +++ b/src/web/routes/invite.js @@ -0,0 +1,99 @@ +// @ts-check + +const assert = require("assert/strict") +const {z} = require("zod") +const {defineEventHandler, sendRedirect, useSession, createError, getValidatedQuery, readValidatedBody} = require("h3") +const {randomUUID} = require("crypto") +const {LRUCache} = require("lru-cache") + +const {discord, as, sync, select} = require("../../passthrough") +/** @type {import("../pug-sync")} */ +const pugSync = sync.require("../pug-sync") +const {reg} = require("../../matrix/read-registration") + +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") + +const schema = { + guild: z.object({ + guild_id: z.string().optional() + }), + invite: z.object({ + mxid: z.string().regex(/@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)/), + permissions: z.enum(["default", "moderator"]), + guild_id: z.string().optional(), + nonce: z.string().optional() + }), + inviteNonce: z.object({ + nonce: z.string() + }) +} + +/** @type {LRUCache<string, string>} nonce to guild id */ +const validNonce = new LRUCache({max: 200}) + +as.router.get("/guild", defineEventHandler(async event => { + const {guild_id} = await getValidatedQuery(event, schema.guild.parse) + const nonce = randomUUID() + if (guild_id) { + // Security note: the nonce alone is valid for updating the guild + // We have not verified the user has sufficient permissions in the guild at generation time + // These permissions are checked later during page rendering and the generated nonce is only revealed if the permissions are sufficient + validNonce.set(nonce, guild_id) + } + return pugSync.render(event, "guild.pug", {nonce}) +})) + +as.router.get("/invite", defineEventHandler(async event => { + const {nonce} = await getValidatedQuery(event, schema.inviteNonce.parse) + const isValid = validNonce.has(nonce) + const guild_id = validNonce.get(nonce) + const guild = discord.guilds.get(guild_id || "") + return pugSync.render(event, "invite.pug", {isValid, nonce, guild_id, guild}) +})) + +as.router.post("/api/invite", defineEventHandler(async event => { + const parsedBody = await readValidatedBody(event, schema.invite.parse) + const session = await useSession(event, {password: reg.as_token}) + + // Check guild ID or nonce + if (parsedBody.guild_id) { + var guild_id = parsedBody.guild_id + if (!(session.data.managedGuilds || []).includes(guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't invite users to a guild you don't have Manage Server permissions in"}) + } else if (parsedBody.nonce) { + if (!validNonce.has(parsedBody.nonce)) throw createError({status: 403, message: "Nonce expired", data: "Nonce means number-used-once, and, well, you tried to use it twice..."}) + let ok = validNonce.get(parsedBody.nonce) + assert(ok) + var guild_id = ok + validNonce.delete(parsedBody.nonce) + } else { + throw createError({status: 400, message: "Missing guild ID", data: "Passing a guild ID or a nonce is required."}) + } + + // Check guild is bridged + const spaceID = select("guild_space", "space_id", {guild_id: guild_id}).pluck().get() + if (!spaceID) throw createError({status: 428, message: "Server not bridged", data: "You can only invite Matrix users to servers that are bridged to Matrix."}) + + // Check for existing invite to the space + let spaceMember + try { + spaceMember = await api.getStateEvent(spaceID, "m.room.member", parsedBody.mxid) + } catch (e) {} + if (spaceMember && (spaceMember.membership === "invite" || spaceMember.membership === "join")) { + return sendRedirect(event, `/guild?guild_id=${guild_id}`, 302) + } + + // Invite + await api.inviteToRoom(spaceID, parsedBody.mxid) + + // Permissions + if (parsedBody.permissions === "moderator") { + await api.setUserPowerCascade(spaceID, parsedBody.mxid, 50) + } + + if (parsedBody.guild_id) { + return sendRedirect(event, `/guild?guild_id=${guild_id}`, 302) + } else { + return sendRedirect(event, "/ok?msg=User has been invited.", 302) + } +})) diff --git a/src/web/routes/link.js b/src/web/routes/link.js deleted file mode 100644 index 43995fc..0000000 --- a/src/web/routes/link.js +++ /dev/null @@ -1,298 +0,0 @@ -// @ts-check - -const assert = require("assert").strict -const {z} = require("zod") -const {defineEventHandler, createError, readValidatedBody, setResponseHeader, H3Event} = require("h3") -const Ty = require("../../types") -const DiscordTypes = require("discord-api-types/v10") - -const {discord, db, as, sync, select, from} = require("../../passthrough") -/** @type {import("../auth")} */ -const auth = sync.require("../auth") -/** @type {import("../../matrix/utils")}*/ -const utils = sync.require("../../matrix/utils") -/** @type {import("./guild")}*/ -const guildRoute = sync.require("./guild") - -/** - * @param {H3Event} event - * @returns {import("../../matrix/api")} - */ -function getAPI(event) { - /* c8 ignore next */ - return event.context.api || sync.require("../../matrix/api") -} - -/** - * @param {H3Event} event - * @returns {import("../../d2m/actions/create-room")} - */ -function getCreateRoom(event) { - /* c8 ignore next */ - return event.context.createRoom || sync.require("../../d2m/actions/create-room") -} - -/** - * @param {H3Event} event - * @returns {import("../../d2m/actions/create-space")} - */ -function getCreateSpace(event) { - /* c8 ignore next */ - return event.context.createSpace || sync.require("../../d2m/actions/create-space") -} - -/** - * @param {H3Event} event - * @returns {import("snowtransfer").SnowTransfer} - */ -function getSnow(event) { - /* c8 ignore next */ - return event.context.snow || discord.snow -} - -const schema = { - linkSpace: z.object({ - guild_id: z.string(), - space_id: z.string() - }), - link: z.object({ - guild_id: z.string(), - matrix: z.string(), - discord: z.string() - }), - unlink: z.object({ - guild_id: z.string(), - channel_id: z.string() - }), - unlinkSpace: z.object({ - guild_id: z.string(), - }), -} - -/** - * @param {H3Event} event - * @param {string} channel_id - * @param {string} guild_id - */ -async function validateAndUnbridgeChannel(event, channel_id, guild_id) { - const createRoom = getCreateRoom(event) - - // Check channel is currently bridged - const row = select("channel_room", "channel_id", {channel_id: channel_id}).get() - if (!row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel_id} is not currently bridged`}) - - // Check that the channel (if it exists) is part of this guild - /** @type {any} */ - let channel = discord.channels.get(channel_id) - if (channel) { - if (!("guild_id" in channel) || channel.guild_id !== guild_id) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel_id} is not part of guild ${guild_id}`}) - } else { - // Otherwise, if the channel isn't cached, it must have been deleted. - // There's no other authentication here - it's okay for anyone to unlink a deleted channel just by knowing its ID. - channel = {id: channel_id} - } - - // Do it - await createRoom.unbridgeChannel(channel, guild_id) -} - -as.router.post("/api/link-space", defineEventHandler(async event => { - const parsedBody = await readValidatedBody(event, schema.linkSpace.parse) - const session = await auth.useSession(event) - const managed = await auth.getManagedGuilds(event) - const api = getAPI(event) - - // Check guild ID - const guildID = parsedBody.guild_id - if (!managed.has(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"}) - - // Check space ID - if (!session.data.mxid) throw createError({status: 403, message: "Forbidden", data: "Can't link with your Matrix space if you aren't logged in to Matrix"}) - const spaceID = parsedBody.space_id - - // Check they are not already bridged - const existing = select("guild_space", "guild_id", {}, "WHERE guild_id = ? OR space_id = ?").get(guildID, spaceID) - if (existing) throw createError({status: 400, message: "Bad Request", data: `Guild ID ${guildID} or space ID ${spaceID} are already bridged and cannot be reused`}) - - // Check space ID is a valid invite target - const inviteRow = guildRoute.getInviteTargetSpaces(session.data.mxid).find(s => s.room_id === spaceID) - if (!inviteRow) throw createError({status: 403, message: "Forbidden", data: "You personally must invite OOYE to that space on Matrix"}) - - const inviteServer = inviteRow.mxid.match(/:(.*)/)?.[1] - assert(inviteServer) - const via = [inviteServer] - - // Check space exists and bridge is joined - try { - await api.joinRoom(parsedBody.space_id, null, via) - } catch (e) { - throw createError({status: 400, message: "Unable To Join", data: `Unable to join the requested Matrix space. Please invite the bridge to the space and try again. (Server said: ${e.errcode} - ${e.message})`}) - } - - // Check bridge has PL 100 - const {powerLevels, powers: {[utils.bot]: selfPowerLevel, [session.data.mxid]: invitingPowerLevel}} = await utils.getEffectivePower(spaceID, [utils.bot, session.data.mxid], api) - if (selfPowerLevel < (powerLevels?.state_default ?? 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix space"}) - - // Check inviting user is a moderator in the space - if (invitingPowerLevel < (powerLevels?.state_default ?? 50)) throw createError({status: 403, message: "Forbidden", data: `You need to be at least power level 50 (moderator) in the target Matrix space to set up OOYE, but you are currently power level ${invitingPowerLevel}.`}) - - // Insert database entry - db.transaction(() => { - db.prepare("INSERT INTO guild_space (guild_id, space_id) VALUES (?, ?)").run(guildID, spaceID) - db.prepare("DELETE FROM invite WHERE room_id = ?").run(spaceID) - })() - - setResponseHeader(event, "HX-Refresh", "true") - return null // 204 -})) - -as.router.post("/api/link", defineEventHandler(async event => { - const parsedBody = await readValidatedBody(event, schema.link.parse) - const managed = await auth.getManagedGuilds(event) - const api = getAPI(event) - const createRoom = getCreateRoom(event) - const createSpace = getCreateSpace(event) - - // Check guild ID or nonce - const guildID = parsedBody.guild_id - if (!managed.has(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"}) - - // Check guild is bridged - const guild = discord.guilds.get(guildID) - if (!guild) throw createError({status: 400, message: "Bad Request", data: "Discord guild does not exist or bot has not joined it"}) - const spaceID = await createSpace.ensureSpace(guild) - - // Check channel exists - const channel = discord.channels.get(parsedBody.discord) - if (!channel) throw createError({status: 400, message: "Bad Request", data: "Discord channel does not exist"}) - - // Check channel is part of the guild - if (!("guild_id" in channel) || channel.guild_id !== guildID) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel.id} is not part of guild ${guildID}`}) - - // Check channel and room are not already bridged - const row = from("channel_room").select("channel_id", "room_id").and("WHERE channel_id = ? OR room_id = ?").get(channel.id, parsedBody.matrix) - if (row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${row.channel_id} or room ID ${parsedBody.matrix} are already bridged and cannot be reused`}) - - // Check room is part of the guild's space - let foundRoom = false - /** @type {string[]?} */ - let foundVia = null - for await (const room of api.generateFullHierarchy(spaceID)) { - // When finding a space during iteration, look at space's children state, because we need a `via` to join the room (when we find it later) - for (const state of room.children_state) { - if (state.type === "m.space.child" && state.state_key === parsedBody.matrix) { - foundVia = state.content.via - } - } - - // When finding a room during iteration, see if it was the requested room (to confirm that the room is in the space) - if (room.room_id === parsedBody.matrix && !room.room_type) { - foundRoom = true - } - - if (foundRoom && foundVia) break - } - if (!foundRoom) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged space"}) - - // Check room exists and bridge is joined - try { - await api.joinRoom(parsedBody.matrix, null, foundVia) - } catch (e) { - if (!foundVia) { - throw createError({status: 400, message: "Unable To Join", data: `Unable to join the requested Matrix room. Please invite the bridge to the room and try again. (Server said: ${e.errcode} - ${e.message})`}) - } - throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`}) - } - - // Check bridge has PL 100 - const {powerLevels, powers: {[utils.bot]: selfPowerLevel}} = await utils.getEffectivePower(parsedBody.matrix, [utils.bot], api) - if (selfPowerLevel < (powerLevels?.state_default ?? 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix room"}) - - // Insert database entry, but keep the room's existing properties if they are set - const nick = await api.getStateEvent(parsedBody.matrix, "m.room.name", "").then(content => content.name || null).catch(() => null) - const avatar = await api.getStateEvent(parsedBody.matrix, "m.room.avatar", "").then(content => content.url || null).catch(() => null) - const topic = await api.getStateEvent(parsedBody.matrix, "m.room.topic", "").then(content => content.topic || null).catch(() => null) - db.transaction(() => { - db.prepare("INSERT INTO channel_room (channel_id, room_id, name, guild_id, nick, custom_avatar, custom_topic) VALUES (?, ?, ?, ?, ?, ?, ?)").run(channel.id, parsedBody.matrix, channel.name, guildID, nick, avatar, topic) - db.prepare("INSERT INTO historical_channel_room (reference_channel_id, room_id, upgraded_timestamp) VALUES (?, ?, 0)").run(channel.id, parsedBody.matrix) - })() - - // Sync room data and space child - await createRoom.syncRoom(parsedBody.discord) - - // Send a notification in the room - if (channel.type === DiscordTypes.ChannelType.GuildText) { - await api.sendEvent(parsedBody.matrix, "m.room.message", { - msgtype: "m.notice", - body: "👋 This room is now bridged with Discord. Say hi!" - }) - } - - setResponseHeader(event, "HX-Refresh", "true") - return null // 204 -})) - -as.router.post("/api/unlink", defineEventHandler(async event => { - const {channel_id, guild_id} = await readValidatedBody(event, schema.unlink.parse) - const managed = await auth.getManagedGuilds(event) - - // Check guild ID or nonce - if (!managed.has(guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"}) - - // Check guild exists - const guild = discord.guilds.get(guild_id) - if (!guild) throw createError({status: 400, message: "Bad Request", data: "Discord guild does not exist or bot has not joined it"}) - - await validateAndUnbridgeChannel(event, channel_id, guild_id) - - setResponseHeader(event, "HX-Refresh", "true") - return null // 204 -})) - -as.router.post("/api/unlink-space", defineEventHandler(async event => { - const {guild_id} = await readValidatedBody(event, schema.unlinkSpace.parse) - const managed = await auth.getManagedGuilds(event) - const api = getAPI(event) - const snow = getSnow(event) - - // Check guild ID or nonce - if (!managed.has(guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"}) - - // Check guild exists - const guild = discord.guilds.get(guild_id) - if (!guild) throw createError({status: 400, message: "Bad Request", data: "Discord guild does not exist or bot has not joined it"}) - - const active = select("guild_active", "guild_id", {guild_id: guild_id}).get() - if (!active) { - throw createError({status: 400, message: "Bad Request", data: "Discord guild has not been considered for bridging"}) - } - - // Check if there are Matrix resources - const spaceID = select("guild_space", "space_id", {guild_id: guild_id}).pluck().get() - if (spaceID) { - // Unlink all rooms - const linkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {guild_id: guild_id}).all() - for (const channel of linkedChannels) { - await validateAndUnbridgeChannel(event, channel.channel_id, guild_id) - } - - // Verify all rooms were unlinked - const remainingLinkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {guild_id: guild_id}).all() - if (remainingLinkedChannels.length) { - throw createError({status: 500, message: "Internal Server Error", data: "Failed to unlink some rooms. Please try doing it manually, or report a bug. The space will not be unlinked until all rooms are."}) - } - - // Unlink space - await utils.setUserPower(spaceID, utils.bot, 0, api) - await api.leaveRoom(spaceID) - db.prepare("DELETE FROM guild_space WHERE guild_id = ? AND space_id = ?").run(guild_id, spaceID) - db.prepare("DELETE FROM invite WHERE room_id = ?").run(spaceID) - } - - // Mark as not considered for bridging - db.prepare("DELETE FROM guild_active WHERE guild_id = ?").run(guild_id) - await snow.user.leaveGuild(guild_id) - - setResponseHeader(event, "HX-Redirect", "/") - return null -})) diff --git a/src/web/routes/link.test.js b/src/web/routes/link.test.js deleted file mode 100644 index e8473f8..0000000 --- a/src/web/routes/link.test.js +++ /dev/null @@ -1,845 +0,0 @@ -// @ts-check - -const tryToCatch = require("try-to-catch") -const {router, test} = require("../../../test/web") -const {MatrixServerError} = require("../../matrix/mreq") -const {select, db} = require("../../passthrough") -const assert = require("assert").strict - -test("web link space: access denied when not logged in to Discord", async t => { - const [error] = await tryToCatch(() => router.test("post", "/api/link-space", { - sessionData: { - }, - body: { - space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe", - guild_id: "665289423482519565" - } - })) - t.equal(error.data, "Can't edit a guild you don't have Manage Server permissions in") -}) - -test("web link space: access denied when not logged in to Matrix", async t => { - const [error] = await tryToCatch(() => router.test("post", "/api/link-space", { - sessionData: { - managedGuilds: ["665289423482519565"] - }, - body: { - space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe", - guild_id: "665289423482519565" - } - })) - t.equal(error.data, "Can't link with your Matrix space if you aren't logged in to Matrix") -}) - -test("web link space: access denied when bot was invited by different user", async t => { - const [error] = await tryToCatch(() => router.test("post", "/api/link-space", { - sessionData: { - managedGuilds: ["665289423482519565"], - mxid: "@user:example.org" - }, - body: { - space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe", - guild_id: "665289423482519565" - } - })) - t.equal(error.data, "You personally must invite OOYE to that space on Matrix") -}) - -test("web link space: access denied when guild is already in use", async t => { - const [error] = await tryToCatch(() => router.test("post", "/api/link-space", { - sessionData: { - managedGuilds: ["112760669178241024"], - mxid: "@cadence:cadence.moe" - }, - body: { - space_id: "!jjmvBegULiLucuWEHU:cadence.moe", - guild_id: "112760669178241024" - } - })) - t.equal(error.data, "Guild ID 112760669178241024 or space ID !jjmvBegULiLucuWEHU:cadence.moe are already bridged and cannot be reused") -}) - -test("web link space: check that OOYE is joined", async t => { - let called = 0 - const [error] = await tryToCatch(() => router.test("post", "/api/link-space", { - sessionData: { - managedGuilds: ["665289423482519565"], - mxid: "@cadence:cadence.moe" - }, - body: { - space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe", - guild_id: "665289423482519565" - }, - api: { - async joinRoom(roomID) { - called++ - throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}) - } - } - })) - t.equal(error.data, "Unable to join the requested Matrix space. Please invite the bridge to the space and try again. (Server said: M_FORBIDDEN - not allowed to join I guess)") - t.equal(called, 1) -}) - -test("web link space: check that OOYE has PL 100 (not 50)", async t => { - let called = 0 - const [error] = await tryToCatch(() => router.test("post", "/api/link-space", { - sessionData: { - managedGuilds: ["665289423482519565"], - mxid: "@cadence:cadence.moe" - }, - body: { - space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe", - guild_id: "665289423482519565" - }, - api: { - async joinRoom(roomID) { - called++ - return roomID - }, - async getStateEvent(roomID, type, key) { - called++ - t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - t.equal(type, "m.room.power_levels") - t.equal(key, "") - return {users: {"@_ooye_bot:cadence.moe": 50}} - }, - async getStateEventOuter(roomID, type, key) { - called++ - t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - t.equal(type, "m.room.create") - t.equal(key, "") - return { - type: "m.room.create", - state_key: "", - sender: "@creator:cadence.moe", - room_id: "!zTMspHVUBhFLLSdmnS:cadence.moe", - event_id: "$create", - origin_server_ts: 0, - content: { - room_version: "11" - } - } - } - } - })) - t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix space") - t.equal(called, 3) -}) - -test("web link space: check that inviting user has PL 50", async t => { - let called = 0 - const [error] = await tryToCatch(() => router.test("post", "/api/link-space", { - sessionData: { - managedGuilds: ["665289423482519565"], - mxid: "@cadence:cadence.moe" - }, - body: { - space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe", - guild_id: "665289423482519565" - }, - api: { - async joinRoom(roomID) { - called++ - return roomID - }, - async getStateEvent(roomID, type, key) { - called++ - t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - t.equal(type, "m.room.power_levels") - t.equal(key, "") - return {users: {"@_ooye_bot:cadence.moe": 100}, events: {"m.room.tombstone": 150}} - }, - async getStateEventOuter(roomID, type, key) { - called++ - t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - t.equal(type, "m.room.create") - t.equal(key, "") - return { - type: "m.room.create", - state_key: "", - sender: "@creator:cadence.moe", - room_id: "!zTMspHVUBhFLLSdmnS:cadence.moe", - event_id: "$create", - origin_server_ts: 0, - content: { - room_version: "12" - } - } - } - } - })) - t.equal(error.data, "You need to be at least power level 50 (moderator) in the target Matrix space to set up OOYE, but you are currently power level 0.") - t.equal(called, 3) -}) - -test("web link space: successfully adds entry to database and loads page", async t => { - let called = 0 - await router.test("post", "/api/link-space", { - sessionData: { - managedGuilds: ["665289423482519565"], - mxid: "@cadence:cadence.moe" - }, - body: { - space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe", - guild_id: "665289423482519565" - }, - api: { - async joinRoom(roomID) { - called++ - return roomID - }, - async getStateEvent(roomID, type, key) { - called++ - t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - t.equal(type, "m.room.power_levels") - t.equal(key, "") - return {users: {"@cadence:cadence.moe": 50}} - }, - async getStateEventOuter(roomID, type, key) { - called++ - t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - t.equal(type, "m.room.create") - t.equal(key, "") - return { - type: "m.room.create", - state_key: "", - sender: "@_ooye_bot:cadence.moe", - room_id: "!zTMspHVUBhFLLSdmnS:cadence.moe", - event_id: "$create", - origin_server_ts: 0, - content: { - room_version: "12" - } - } - } - } - }) - t.equal(called, 3) - - // check that the entry was added to the database - t.equal(select("guild_space", "privacy_level", {guild_id: "665289423482519565", space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe"}).pluck().get(), 0) - - // check that the guild info page now loads - const html = await router.test("get", "/guild?guild_id=665289423482519565", { - sessionData: { - managedGuilds: ["665289423482519565"], - mxid: "@cadence:cadence.moe" - }, - api: { - async getFullHierarchy(spaceID) { - return [] - } - } - }) - t.has(html, `<h1 class="s-page-title--header">Data Horde</h1>`) -}) - -// ***** - -test("web link room: access denied when not logged in to Discord", async t => { - const [error] = await tryToCatch(() => router.test("post", "/api/link", { - sessionData: { - }, - body: { - discord: "665310973967597573", - matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - guild_id: "665289423482519565" - } - })) - t.equal(error.data, "Can't edit a guild you don't have Manage Server permissions in") -}) - -test("web link room: check that guild exists", async t => { - const [error] = await tryToCatch(() => router.test("post", "/api/link", { - sessionData: { - managedGuilds: ["1"] - }, - body: { - discord: "665310973967597573", - matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - guild_id: "1" - } - })) - t.equal(error.data, "Discord guild does not exist or bot has not joined it") -}) - -test("web link room: check that channel exists", async t => { - const [error] = await tryToCatch(() => router.test("post", "/api/link", { - sessionData: { - managedGuilds: ["665289423482519565"] - }, - body: { - discord: "1", - matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - guild_id: "665289423482519565" - } - })) - t.equal(error.data, "Discord channel does not exist") -}) - -test("web link room: check that channel is part of guild", async t => { - const [error] = await tryToCatch(() => router.test("post", "/api/link", { - sessionData: { - managedGuilds: ["665289423482519565"] - }, - body: { - discord: "112760669178241024", - matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - guild_id: "665289423482519565" - } - })) - t.equal(error.data, "Channel ID 112760669178241024 is not part of guild 665289423482519565") -}) - -test("web link room: check that channel is not already linked", async t => { - const [error] = await tryToCatch(() => router.test("post", "/api/link", { - sessionData: { - managedGuilds: ["112760669178241024"] - }, - body: { - discord: "112760669178241024", - matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - guild_id: "112760669178241024" - } - })) - t.equal(error.data, "Channel ID 112760669178241024 or room ID !NDbIqNpJyPvfKRnNcr:cadence.moe are already bridged and cannot be reused") -}) - -test("web link room: checks the autocreate setting if the space doesn't exist yet", async t => { - let called = 0 - const [error] = await tryToCatch(() => router.test("post", "/api/link", { - sessionData: { - managedGuilds: ["665289423482519565"] - }, - body: { - discord: "665310973967597573", - matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - guild_id: "665289423482519565" - }, - createSpace: { - async ensureSpace(guild) { - called++ - t.equal(guild.id, "665289423482519565") - // simulate what ensureSpace is intended to check - const autocreate = 0 - assert.equal(autocreate, 1, "refusing to implicitly create a space for guild 665289423482519565. set the guild_active data first before calling ensureSpace/syncSpace.") - return "" - } - } - })) - t.match(error.message, /refusing to implicitly create a space/) - t.equal(called, 1) -}) - -test("web link room: check that room is part of space (not in hierarchy)", async t => { - let called = 0 - const [error] = await tryToCatch(() => router.test("post", "/api/link", { - sessionData: { - managedGuilds: ["665289423482519565"] - }, - body: { - discord: "665310973967597573", - matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - guild_id: "665289423482519565" - }, - api: { - async *generateFullHierarchy(spaceID) { - called++ - t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - } - } - })) - t.equal(error.data, "Matrix room needs to be part of the bridged space") - t.equal(called, 1) -}) - -test("web link room: check that bridge can join room (notices lack of via and asks for invite instead)", async t => { - let called = 0 - const [error] = await tryToCatch(() => router.test("post", "/api/link", { - sessionData: { - managedGuilds: ["665289423482519565"] - }, - body: { - discord: "665310973967597573", - matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - guild_id: "665289423482519565" - }, - api: { - async joinRoom(roomID) { - called++ - throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}) - }, - async *generateFullHierarchy(spaceID) { - called++ - t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - yield { - room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - children_state: [], - guest_can_join: false, - num_joined_members: 2 - } - /* c8 ignore next */ - } - } - })) - t.equal(error.data, "Unable to join the requested Matrix room. Please invite the bridge to the room and try again. (Server said: M_FORBIDDEN - not allowed to join I guess)") - t.equal(called, 2) -}) - -test("web link room: check that bridge can join room (uses via for join attempt)", async t => { - let called = 0 - const [error] = await tryToCatch(() => router.test("post", "/api/link", { - sessionData: { - managedGuilds: ["665289423482519565"] - }, - body: { - discord: "665310973967597573", - matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - guild_id: "665289423482519565" - }, - api: { - async joinRoom(roomID, _, via) { - called++ - t.deepEqual(via, ["cadence.moe", "hashi.re"]) - throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}) - }, - async *generateFullHierarchy(spaceID) { - called++ - t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - yield { - room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - children_state: [], - guest_can_join: false, - num_joined_members: 2 - } - yield { - room_id: "!zTMspHVUBhFLLSdmnS:cadence.moe", - children_state: [{ - type: "m.space.child", - state_key: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - sender: "@elliu:hashi.re", - content: { - via: ["cadence.moe", "hashi.re"] - }, - origin_server_ts: 0 - }], - guest_can_join: false, - num_joined_members: 2 - } - /* c8 ignore next */ - } - } - })) - t.equal(error.data, "M_FORBIDDEN - not allowed to join I guess") - t.equal(called, 2) -}) - -test("web link room: check that bridge has PL 100 in target room", async t => { - let called = 0 - const [error] = await tryToCatch(() => router.test("post", "/api/link", { - sessionData: { - managedGuilds: ["665289423482519565"] - }, - body: { - discord: "665310973967597573", - matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - guild_id: "665289423482519565" - }, - api: { - async joinRoom(roomID) { - called++ - return roomID - }, - async *generateFullHierarchy(spaceID) { - called++ - t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - yield { - room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - children_state: [], - guest_can_join: false, - num_joined_members: 2 - } - /* c8 ignore next */ - }, - async getStateEvent(roomID, type, key) { - called++ - t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") - t.equal(type, "m.room.power_levels") - t.equal(key, "") - return {users_default: 50} - }, - async getStateEventOuter(roomID, type, key) { - called++ - t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") - t.equal(type, "m.room.create") - t.equal(key, "") - return { - type: "m.room.create", - state_key: "", - sender: "@creator:cadence.moe", - room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - event_id: "$create", - origin_server_ts: 0, - content: { - room_version: "11" - } - } - } - } - })) - t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix room") - t.equal(called, 4) -}) - -test("web link room: successfully calls createRoom", async t => { - let called = 0 - await router.test("post", "/api/link", { - sessionData: { - managedGuilds: ["665289423482519565"] - }, - body: { - discord: "665310973967597573", - matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - guild_id: "665289423482519565" - }, - api: { - async joinRoom(roomID) { - called++ - return roomID - }, - async *generateFullHierarchy(spaceID) { - called++ - t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - yield { - room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - children_state: [], - guest_can_join: false, - num_joined_members: 2 - } - /* c8 ignore next */ - }, - async getStateEvent(roomID, type, key) { - if (type === "m.room.power_levels") { - called++ - t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") - t.equal(key, "") - return {users: {"@_ooye_bot:cadence.moe": 100}} - } else if (type === "m.room.name") { - called++ - t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") - return {} - } else if (type === "m.room.avatar") { - called++ - t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") - return {} - } else if (type === "m.room.topic") { - called++ - t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") - return {} - } - }, - async getStateEventOuter(roomID, type, key) { - called++ - t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") - t.equal(type, "m.room.create") - t.equal(key, "") - return { - type: "m.room.create", - state_key: "", - sender: "@creator:cadence.moe", - room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - event_id: "$create", - origin_server_ts: 0, - content: { - room_version: "11" - } - } - }, - async sendEvent(roomID, type, content) { - called++ - t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") - t.equal(type, "m.room.message") - t.match(content.body, /👋/) - return "" - } - }, - createRoom: { - async syncRoom(channelID) { - called++ - t.equal(channelID, "665310973967597573") - return "!NDbIqNpJyPvfKRnNcr:cadence.moe" - } - } - }) - t.equal(called, 9) -}) - -// ***** - -test("web unlink room: access denied if not logged in to Discord", async t => { - const [error] = await tryToCatch(() => router.test("post", "/api/unlink", { - body: { - channel_id: "665310973967597573", - guild_id: "665289423482519565" - } - })) - t.equal(error.data, "Can't edit a guild you don't have Manage Server permissions in") -}) - -test("web unlink room: checks that guild exists", async t => { - const [error] = await tryToCatch(() => router.test("post", "/api/unlink", { - sessionData: { - managedGuilds: ["2"] - }, - body: { - channel_id: "665310973967597573", - guild_id: "2" - } - })) - t.equal(error.data, "Discord guild does not exist or bot has not joined it") -}) - -test("web unlink room: checks that the channel is part of the guild", async t => { - const [error] = await tryToCatch(() => router.test("post", "/api/unlink", { - sessionData: { - managedGuilds: ["665289423482519565"] - }, - body: { - channel_id: "112760669178241024", - guild_id: "665289423482519565" - } - })) - t.equal(error.data, "Channel ID 112760669178241024 is not part of guild 665289423482519565") -}) - -test("web unlink room: successfully calls unbridgeChannel when the channel does exist", async t => { - let called = 0 - await router.test("post", "/api/unlink", { - sessionData: { - managedGuilds: ["665289423482519565"] - }, - body: { - channel_id: "665310973967597573", - guild_id: "665289423482519565" - }, - createRoom: { - async unbridgeChannel(channel) { - called++ - t.equal(channel.id, "665310973967597573") - } - } - }) - t.equal(called, 1) -}) - -test("web unlink room: successfully calls unbridgeChannel when the channel does not exist", async t => { - let called = 0 - await router.test("post", "/api/unlink", { - sessionData: { - managedGuilds: ["112760669178241024"] - }, - body: { - channel_id: "489237891895768942", - guild_id: "112760669178241024" - }, - createRoom: { - async unbridgeChannel(channel) { - called++ - t.equal(channel.id, "489237891895768942") - } - } - }) - t.equal(called, 1) -}) - -test("web unlink room: checks that the channel is bridged", async t => { - const row = db.prepare("SELECT * FROM channel_room WHERE channel_id = '665310973967597573'").get() - db.prepare("DELETE FROM channel_room WHERE channel_id = '665310973967597573'").run() - - const [error] = await tryToCatch(() => router.test("post", "/api/unlink", { - sessionData: { - managedGuilds: ["665289423482519565"] - }, - body: { - channel_id: "665310973967597573", - guild_id: "665289423482519565" - } - })) - t.equal(error.data, "Channel ID 665310973967597573 is not currently bridged") - - db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar, last_bridged_pin_timestamp, speedbump_id, speedbump_checked, speedbump_webhook_id, guild_id, custom_topic) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(row.channel_id, row.room_id, row.name, row.nick, row.thread_parent, row.custom_avatar, row.last_bridged_pin_timestamp, row.speedbump_id, row.speedbump_checked, row.speedbump_webhook_id, row.guild_id, row.custom_topic) - const new_row = db.prepare("SELECT * FROM channel_room WHERE channel_id = '665310973967597573'").get() - t.deepEqual(row, new_row) -}) - -// ***** - -test("web unlink space: access denied if not logged in to Discord", async t => { - const [error] = await tryToCatch(() => router.test("post", "/api/unlink-space", { - body: { - guild_id: "665289423482519565" - } - })) - t.equal(error.data, "Can't edit a guild you don't have Manage Server permissions in") -}) - -test("web unlink space: checks that guild exists", async t => { - const [error] = await tryToCatch(() => router.test("post", "/api/unlink-space", { - sessionData: { - managedGuilds: ["2"] - }, - body: { - guild_id: "2" - } - })) - t.equal(error.data, "Discord guild does not exist or bot has not joined it") -}) - -test("web unlink space: checks that a space is linked to the guild before trying to unlink the space", async t => { - db.exec("BEGIN TRANSACTION") - db.prepare("DELETE FROM guild_active WHERE guild_id = '665289423482519565'").run() - - const [error] = await tryToCatch(() => router.test("post", "/api/unlink-space", { - sessionData: { - managedGuilds: ["665289423482519565"] - }, - body: { - guild_id: "665289423482519565" - } - })) - t.equal(error.data, "Discord guild has not been considered for bridging") - - db.exec("ROLLBACK") // ぬ -}) - -test("web unlink space: correctly abort unlinking if some linked channels remain after trying to unlink them all", async t => { - let unbridgedChannel = false - - const [error] = await tryToCatch(() => router.test("post", "/api/unlink-space", { - sessionData: { - managedGuilds: ["665289423482519565"] - }, - body: { - guild_id: "665289423482519565", - }, - createRoom: { - async unbridgeChannel(channel, guildID) { - unbridgedChannel = true - t.ok(["1438284564815548418", "665310973967597573"].includes(channel.id)) - t.equal(guildID, "665289423482519565") - // Do not actually delete the link from DB, should trigger error later in check - } - }, - api: { - async *generateFullHierarchy(spaceID) { - t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - yield { - room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - children_state: [], - guest_can_join: false, - num_joined_members: 2 - } - /* c8 ignore next */ - }, - } - })) - - t.equal(error.data, "Failed to unlink some rooms. Please try doing it manually, or report a bug. The space will not be unlinked until all rooms are.") - t.equal(unbridgedChannel, true) -}) - -test("web unlink space: successfully calls unbridgeChannel on linked channels in space, self-downgrade power level, leave space, and delete link from DB", async t => { - const {reg} = require("../../matrix/read-registration") - const me = `@${reg.sender_localpart}:${reg.ooye.server_name}` - - const getLinkRowQuery = "SELECT * FROM guild_space WHERE guild_id = '665289423482519565'" - - const row = db.prepare(getLinkRowQuery).get() - t.equal(row.space_id, "!zTMspHVUBhFLLSdmnS:cadence.moe") - - let unbridgedChannel = false - let downgradedPowerLevel = false - let leftRoom = false - await router.test("post", "/api/unlink-space", { - sessionData: { - managedGuilds: ["665289423482519565"] - }, - body: { - guild_id: "665289423482519565", - }, - createRoom: { - async unbridgeChannel(channel, guildID) { - unbridgedChannel = true - t.ok(["1438284564815548418", "665310973967597573"].includes(channel.id)) - t.equal(guildID, "665289423482519565") - - // In order to not simulate channel deletion and not trigger the post unlink channels, pre-unlink space check - db.prepare("DELETE FROM channel_room WHERE channel_id = ?").run(channel.id) - } - }, - snow: { - user: { - // @ts-ignore - snowtransfer or discord-api-types broken, 204 No Content should be mapped to void but is actually mapped to never - async leaveGuild(guildID) { - t.equal(guildID, "665289423482519565") - } - } - }, - api: { - async *generateFullHierarchy(spaceID) { - t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - yield { - room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - children_state: [], - guest_can_join: false, - num_joined_members: 2 - } - /* c8 ignore next */ - }, - - async getStateEvent(roomID, type, key) { // getting power levels from space to apply to room - t.equal(type, "m.room.power_levels") - t.equal(key, "") - return {users: {"@_ooye_bot:cadence.moe": 100, "@example:matrix.org": 50}, events: {"m.room.tombstone": 100}} - }, - - async getStateEventOuter(roomID, type, key) { - t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - t.equal(type, "m.room.create") - t.equal(key, "") - return { - type: "m.room.create", - state_key: "", - sender: "@_ooye_bot:cadence.moe", - room_id: "!zTMspHVUBhFLLSdmnS:cadence.moe", - event_id: "$create", - origin_server_ts: 0, - content: { - room_version: "11" - } - } - }, - - async sendState(roomID, type, key, content) { - downgradedPowerLevel = true - t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - t.equal(type, "m.room.power_levels") - t.notOk(me in content.users, `got ${JSON.stringify(content)} but expected bot user to not be present`) - return "" - }, - - async leaveRoom(spaceID) { - leftRoom = true - t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - }, - } - }) - - t.equal(unbridgedChannel, true) - t.equal(downgradedPowerLevel, true) - t.equal(leftRoom, true) - - const missed_row = db.prepare(getLinkRowQuery).get() - t.equal(missed_row, undefined) -}) diff --git a/src/web/routes/log-in-with-matrix.js b/src/web/routes/log-in-with-matrix.js deleted file mode 100644 index d36d8fa..0000000 --- a/src/web/routes/log-in-with-matrix.js +++ /dev/null @@ -1,100 +0,0 @@ -// @ts-check - -const {z} = require("zod") -const {randomUUID} = require("crypto") -const {defineEventHandler, getValidatedQuery, sendRedirect, readValidatedBody, createError, getRequestHeader, H3Event} = require("h3") -const {LRUCache} = require("lru-cache") - -const {as, db, select} = require("../../passthrough") -const {reg} = require("../../matrix/read-registration") - -const {sync} = require("../../passthrough") -const assert = require("assert").strict -/** @type {import("../pug-sync")} */ -const pugSync = sync.require("../pug-sync") -/** @type {import("../auth")} */ -const auth = sync.require("../auth") - -const schema = { - form: z.object({ - mxid: z.string().regex(/^@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)$/), - next: z.string().optional() - }), - token: z.object({ - token: z.string().optional(), - next: z.string().optional() - }) -} - -/** - * @param {H3Event} event - * @returns {import("../../matrix/api")} - */ -function getAPI(event) { - /* c8 ignore next */ - return event.context.api || sync.require("../../matrix/api") -} - -/** @type {LRUCache<string, string>} token to mxid */ -const validToken = new LRUCache({max: 200}) - -/* - 1st request, GET, they clicked the button, need to input their mxid - 2nd request, POST, they input their mxid and we need to send a link - 3rd request, GET, they clicked the link and we need to set the session data (just their mxid) -*/ - -as.router.get("/log-in-with-matrix", defineEventHandler(async event => { - let {token, next} = await getValidatedQuery(event, schema.token.parse) - - if (!token) { - // We are in the first request and need to tell them to input their mxid - return pugSync.render(event, "log-in-with-matrix.pug", {next}) - } - - const userAgent = getRequestHeader(event, "User-Agent") - if (userAgent?.match(/bot|matrix/)) throw createError({status: 400, data: "Sorry URL previewer, you can't have this URL."}) - - if (!validToken.has(token)) return sendRedirect(event, `${reg.ooye.bridge_origin}/log-in-with-matrix`, 302) - - const session = await auth.useSession(event) - const mxid = validToken.get(token) - assert(mxid) - validToken.delete(token) - - await session.update({mxid}) - - if (!next) next = "./" // open to homepage where they can see they're logged in - return sendRedirect(event, next, 302) -})) - -as.router.post("/api/log-in-with-matrix", defineEventHandler(async event => { - const api = getAPI(event) - const {mxid, next} = await readValidatedBody(event, schema.form.parse) - - // Don't extend a duplicate invite for the same user - for (const alreadyInvited of validToken.values()) { - if (mxid === alreadyInvited) { - return sendRedirect(event, "../ok?msg=We already sent you a link on Matrix. Please click it!", 302) - } - } - - const roomID = await api.usePrivateChat(mxid) - - const token = randomUUID() - - console.log(`web log in requested for ${mxid}`) - const paramsObject = {token} - if (next) paramsObject.next = next - const params = new URLSearchParams(paramsObject) - let link = `${reg.ooye.bridge_origin}/log-in-with-matrix?${params.toString()}` - const body = `Hi, this is Out Of Your Element! You just clicked the "log in" button on the website.\nOpen this link to finish: ${link}\nThe link can be used once.` - await api.sendEvent(roomID, "m.room.message", { - msgtype: "m.text", - body - }) - - validToken.set(token, mxid) - - return sendRedirect(event, "../ok?msg=Please check your inbox on Matrix!&spot=SpotMailXL", 302) -})) diff --git a/src/web/routes/log-in-with-matrix.test.js b/src/web/routes/log-in-with-matrix.test.js deleted file mode 100644 index 830556e..0000000 --- a/src/web/routes/log-in-with-matrix.test.js +++ /dev/null @@ -1,112 +0,0 @@ -// @ts-check - -const tryToCatch = require("try-to-catch") -const {router, test} = require("../../../test/web") -const {MatrixServerError} = require("../../matrix/mreq") - -// ***** first request ***** - -test("log in with matrix: shows web page with form on first request", async t => { - const html = await router.test("get", "/log-in-with-matrix", { - }) - t.has(html, `hx-post="api/log-in-with-matrix"`) -}) - -// ***** second request ***** - -let token - -test("log in with matrix: checks if mxid format looks valid", async t => { - const [error] = await tryToCatch(() => router.test("post", "/api/log-in-with-matrix", { - body: { - mxid: "x@cadence:cadence.moe" - } - })) - t.match(error.data.fieldErrors.mxid, /must match pattern/) -}) - -test("log in with matrix: checks if mxid domain format looks valid", async t => { - const [error] = await tryToCatch(() => router.test("post", "/api/log-in-with-matrix", { - body: { - mxid: "@cadence:cadence." - } - })) - t.match(error.data.fieldErrors.mxid, /must match pattern/) -}) - -test("log in with matrix: sends message to log in", async t => { - const event = {} - let called = 0 - await router.test("post", "/api/log-in-with-matrix", { - body: { - mxid: "@cadence:cadence.moe", - next: "https://bridge.cadence.moe/guild?guild_id=123" - }, - api: { - async usePrivateChat(mxid) { - called++ - t.equal(mxid, "@cadence:cadence.moe") - return "!created:cadence.moe" - }, - async sendEvent(roomID, type, content) { - called++ - t.equal(roomID, "!created:cadence.moe") - t.equal(type, "m.room.message") - token = content.body.match(/log-in-with-matrix\?token=([a-f0-9-]+)&next=/)[1] - t.ok(token, "log in token not issued") - return "" - } - }, - event - }) - t.match(event.node.res.getHeader("location"), /Please check your inbox on Matrix/) - t.equal(called, 2) -}) - -test("log in with matrix: does not send another message when a log in is in progress", async t => { - const event = {} - await router.test("post", "/api/log-in-with-matrix", { - body: { - mxid: "@cadence:cadence.moe" - }, - event - }) - t.match(event.node.res.getHeader("location"), /We already sent you a link on Matrix/) -}) - -// ***** third request ***** - - -test("log in with matrix: does not use up token when requested by Synapse URL previewer", async t => { - const event = {} - const [error] = await tryToCatch(() => router.test("get", `/log-in-with-matrix?token=${token}`, { - headers: { - "user-agent": "Synapse (bot; +https://github.com/matrix-org/synapse)" - }, - event - })) - t.equal(error.data, "Sorry URL previewer, you can't have this URL.") -}) - -test("log in with matrix: does not use up token when requested by Discord URL previewer", async t => { - const event = {} - const [error] = await tryToCatch(() => router.test("get", `/log-in-with-matrix?token=${token}`, { - headers: { - "user-agent": "Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)" - }, - event - })) - t.equal(error.data, "Sorry URL previewer, you can't have this URL.") -}) - -test("log in with matrix: successful request when using valid token", async t => { - const event = {} - await router.test("get", `/log-in-with-matrix?token=${token}`, {event}) - t.equal(event.node.res.getHeader("location"), "./") -}) - -test("log in with matrix: won't log in again if token has been used", async t => { - const event = {} - await router.test("get", `/log-in-with-matrix?token=${token}`, {event}) - t.equal(event.node.res.getHeader("location"), "https://bridge.example.org/log-in-with-matrix") -}) diff --git a/src/web/routes/oauth.js b/src/web/routes/oauth.js index f4bb61f..f3078ad 100644 --- a/src/web/routes/oauth.js +++ b/src/web/routes/oauth.js @@ -2,15 +2,13 @@ const {z} = require("zod") const {randomUUID} = require("crypto") -const {defineEventHandler, getValidatedQuery, sendRedirect, createError, H3Event} = require("h3") -const {SnowTransfer, tokenless} = require("snowtransfer") +const {defineEventHandler, getValidatedQuery, sendRedirect, getQuery, useSession, createError} = require("h3") +const {SnowTransfer} = require("snowtransfer") const DiscordTypes = require("discord-api-types/v10") -const getRelativePath = require("get-relative-path") +const fetch = require("node-fetch") -const {as, db, sync} = require("../../passthrough") -const {id, permissions} = require("../../../addbot") -/** @type {import("../auth")} */ -const auth = sync.require("../auth") +const {as} = require("../../passthrough") +const {id} = require("../../../addbot") const {reg} = require("../../matrix/read-registration") const redirect_uri = `${reg.ooye.bridge_origin}/oauth` @@ -27,49 +25,29 @@ const schema = { token: z.object({ token_type: z.string(), access_token: z.string(), - expires_in: z.coerce.number(), + expires_in: z.number({coerce: true}), refresh_token: z.string(), scope: z.string() }) } -/** - * @param {H3Event} event - * @returns {(string) => {user: {getGuilds: () => Promise<DiscordTypes.RESTGetAPICurrentUserGuildsResult>}}} - */ -function getClient(event) { - /* c8 ignore next */ - return event.context.getClient || (accessToken => new SnowTransfer(`Bearer ${accessToken}`)) -} - -/** - * @param {H3Event} event - * @returns {typeof tokenless.getOauth2Token} - */ -function getOauth2Token(event) { - /* c8 ignore next */ - return event.context.getOauth2Token || tokenless.getOauth2Token -} - as.router.get("/oauth", defineEventHandler(async event => { - const session = await auth.useSession(event) + const session = await useSession(event, {password: reg.as_token}) let scope = "guilds" - if (!reg.ooye.web_password || reg.ooye.web_password === session.data.password) { - const parsedFirstQuery = await getValidatedQuery(event, schema.first.safeParse) - if (parsedFirstQuery.data?.action === "add") { - scope = "bot+guilds" - await session.update({selfService: false}) - } else if (parsedFirstQuery.data?.action === "add-self-service") { - scope = "bot+guilds" - await session.update({selfService: true}) - } + const parsedFirstQuery = await getValidatedQuery(event, schema.first.safeParse) + if (parsedFirstQuery.data?.action === "add") { + scope = "bot+guilds" + await session.update({selfService: false}) + } else if (parsedFirstQuery.data?.action === "add-self-service") { + scope = "bot+guilds" + await session.update({selfService: true}) } async function tryAgain() { const newState = randomUUID() await session.update({state: newState}) - return sendRedirect(event, `https://discord.com/oauth2/authorize?client_id=${id}&scope=${scope}&permissions=${permissions}&response_type=code&redirect_uri=${redirect_uri}&state=${newState}`) + return sendRedirect(event, `https://discord.com/oauth2/authorize?client_id=${id}&scope=${scope}&permissions=1610883072&response_type=code&redirect_uri=${redirect_uri}&state=${newState}`) } const parsedQuery = await getValidatedQuery(event, schema.code.safeParse) @@ -79,26 +57,36 @@ as.router.get("/oauth", defineEventHandler(async event => { if (!savedState) throw createError({status: 400, message: "Missing state", data: "Missing saved state parameter. Please try again, and make sure you have cookies enabled."}) if (savedState != parsedQuery.data.state) return tryAgain() - const oauthResult = await getOauth2Token(event)(id, redirect_uri, reg.ooye.discord_client_secret, parsedQuery.data.code) - const parsedToken = schema.token.parse(oauthResult) + const res = await fetch("https://discord.com/api/oauth2/token", { + method: "post", + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: id, + client_secret: reg.ooye.discord_client_secret, + redirect_uri, + code: parsedQuery.data.code + }) + }) + const root = await res.json() - const userID = Buffer.from(parsedToken.access_token.split(".")[0], "base64").toString() - const client = getClient(event)(parsedToken.access_token) + const parsedToken = schema.token.safeParse(root) + if (!res.ok || !parsedToken.success) { + throw createError({status: 502, message: "Invalid token response", data: `Discord completed OAuth, but returned this instead of an OAuth access token: ${JSON.stringify(root)}`}) + } - const guilds = await client.user.getGuilds() - var managedGuilds = guilds.filter(g => BigInt(g.permissions) & DiscordTypes.PermissionFlagsBits.ManageGuild).map(g => g.id) - await session.update({managedGuilds, userID, state: undefined}) - - // Set auto-create for the guild - // @ts-ignore - if (managedGuilds.includes(parsedQuery.data.guild_id)) { - const autocreateInteger = +!session.data.selfService - db.prepare("INSERT INTO guild_active (guild_id, autocreate) VALUES (?, ?) ON CONFLICT DO UPDATE SET autocreate = ?").run(parsedQuery.data.guild_id, autocreateInteger, autocreateInteger) + const client = new SnowTransfer(`Bearer ${parsedToken.data.access_token}`) + try { + const guilds = await client.user.getGuilds() + const managedGuilds = guilds.filter(g => BigInt(g.permissions) & DiscordTypes.PermissionFlagsBits.ManageGuild).map(g => g.id) + await session.update({managedGuilds}) + } catch (e) { + throw createError({status: 502, message: "API call failed", data: e.message}) } if (parsedQuery.data.guild_id) { - return sendRedirect(event, getRelativePath(event.path, `/guild?guild_id=${parsedQuery.data.guild_id}`), 302) + // TODO: we probably need to create a matrix space and database entry immediately here so that self-service settings apply and so matrix users can be invited + return sendRedirect(event, `/guild?guild_id=${parsedQuery.data.guild_id}`, 302) } - return sendRedirect(event, getRelativePath(event.path, "/"), 302) + return sendRedirect(event, "/", 302) })) diff --git a/src/web/routes/oauth.test.js b/src/web/routes/oauth.test.js deleted file mode 100644 index 2f3a791..0000000 --- a/src/web/routes/oauth.test.js +++ /dev/null @@ -1,121 +0,0 @@ -// @ts-check - -const DiscordTypes = require("discord-api-types/v10") -const tryToCatch = require("try-to-catch") -const assert = require("assert/strict") -const {router, test} = require("../../../test/web") - -test("web oauth: redirects to Discord on first visit (add easy)", async t => { - let event = {} - await router.test("get", "/oauth?action=add", { - event, - sessionData: { - password: "password123" - } - }) - t.equal(event.node.res.statusCode, 302) - t.match(event.node.res.getHeader("location"), /^https:\/\/discord.com\/oauth2\/authorize\?client_id=684280192553844747&scope=bot\+guilds&permissions=2251801424568320&response_type=code&redirect_uri=https:\/\/bridge\.example\.org\/oauth&state=/) -}) - -test("web oauth: redirects to Discord on first visit (add self service)", async t => { - let event = {} - await router.test("get", "/oauth?action=add-self-service", { - event, - sessionData: { - password: "password123" - } - }) - t.equal(event.node.res.statusCode, 302) - t.match(event.node.res.getHeader("location"), /^https:\/\/discord.com\/oauth2\/authorize\?client_id=684280192553844747&scope=bot\+guilds&permissions=2251801424568320&response_type=code&redirect_uri=https:\/\/bridge\.example\.org\/oauth&state=/) -}) - -test("web oauth: advises user about cookies if state is missing", async t => { - let event = {} - const [e] = await tryToCatch(() => router.test("get", "/oauth?state=693551d5-47c5-49e2-a433-3600abe3c15c&code=DISCORD_CODE&guild_id=9", { - event - })) - t.equal(e.message, "Missing state") -}) - -test("web oauth: redirects to Discord again if state doesn't match", async t => { - let event = {} - await router.test("get", "/oauth?state=693551d5-47c5-49e2-a433-3600abe3c15c&code=DISCORD_CODE", { - event, - sessionData: { - state: "438aa253-1311-4483-9aa2-c251e29e72c9", - password: "password123" - } - }) - t.equal(event.node.res.statusCode, 302) - t.match(event.node.res.getHeader("location"), /^https:\/\/discord\.com\/oauth2\/authorize/) -}) - -test("web oauth: uses returned state, logs in", async t => { - let event = {} - await router.test("get", "/oauth?state=693551d5-47c5-49e2-a433-3600abe3c15c&code=DISCORD_CODE", { - event, - sessionData: { - state: "693551d5-47c5-49e2-a433-3600abe3c15c", - selfService: false, - password: "password123" - }, - getOauth2Token() { - return { - token_type: "Bearer", - access_token: "6qrZcUqja7812RVdnEKjpzOL4CvHBFG", - expires_in: 604800, - refresh_token: "D43f5y0ahjqew82jZ4NViEr2YafMKhue", - scope: "bot+guilds" - } - }, - getClient(accessToken) { - return { - user: { - async getGuilds() { - return [{ - id: "9", - permissions: DiscordTypes.PermissionFlagsBits.ManageGuild - }] - } - } - } - } - }) - t.equal(event.node.res.statusCode, 302) - t.equal(event.node.res.getHeader("location"), "./") -}) - -test("web oauth: uses returned state, adds managed guild", async t => { - let event = {} - await router.test("get", "/oauth?state=693551d5-47c5-49e2-a433-3600abe3c15c&code=DISCORD_CODE&guild_id=9", { - event, - sessionData: { - state: "693551d5-47c5-49e2-a433-3600abe3c15c", - selfService: false, - password: "password123" - }, - getOauth2Token() { - return { - token_type: "Bearer", - access_token: "6qrZcUqja7812RVdnEKjpzOL4CvHBFG", - expires_in: 604800, - refresh_token: "D43f5y0ahjqew82jZ4NViEr2YafMKhue", - scope: "bot+guilds" - } - }, - getClient(accessToken) { - return { - user: { - async getGuilds() { - return [{ - id: "9", - permissions: DiscordTypes.PermissionFlagsBits.ManageGuild - }] - } - } - } - } - }) - t.equal(event.node.res.statusCode, 302) - t.equal(event.node.res.getHeader("location"), "guild?guild_id=9") -}) diff --git a/src/web/routes/password.js b/src/web/routes/password.js deleted file mode 100644 index e1dd299..0000000 --- a/src/web/routes/password.js +++ /dev/null @@ -1,21 +0,0 @@ -// @ts-check - -const {z} = require("zod") -const {defineEventHandler, readValidatedBody, sendRedirect} = require("h3") -const {as, sync} = require("../../passthrough") - -/** @type {import("../auth")} */ -const auth = sync.require("../auth") - -const schema = { - password: z.object({ - password: z.string() - }) -} - -as.router.post("/api/password", defineEventHandler(async event => { - const {password} = await readValidatedBody(event, schema.password.parse) - const session = await auth.useSession(event) - await session.update({password}) - return sendRedirect(event, "../") -})) diff --git a/src/web/routes/password.test.js b/src/web/routes/password.test.js deleted file mode 100644 index aa60bd3..0000000 --- a/src/web/routes/password.test.js +++ /dev/null @@ -1,16 +0,0 @@ -// @ts-check - -const tryToCatch = require("try-to-catch") -const {test} = require("supertape") -const {router} = require("../../../test/web") - -test("web password: stores password", async t => { - const event = {} - await router.test("post", "/api/password", { - body: { - password: "password123" - }, - event - }) - t.equal(event.node.res.statusCode, 302) -}) diff --git a/src/web/server.js b/src/web/server.js index dc13cf0..387439f 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -1,35 +1,39 @@ // @ts-check -const assert = require("assert") const fs = require("fs") const {join} = require("path") const h3 = require("h3") -const mimeTypes = require("mime-types") -const {defineEventHandler, defaultContentType, getRequestHeader, setResponseHeader, handleCacheHeaders, serveStatic} = h3 +const {defineEventHandler, defaultContentType, getRequestHeader, setResponseHeader, setResponseStatus, useSession, getQuery, handleCacheHeaders} = h3 const icons = require("@stackoverflow/stacks-icons") const DiscordTypes = require("discord-api-types/v10") const dUtils = require("../discord/utils") -const reg = require("../matrix/read-registration") -const {sync, discord, as, select, from} = require("../passthrough") +const {sync, discord, as, select} = require("../passthrough") /** @type {import("./pug-sync")} */ const pugSync = sync.require("./pug-sync") -/** @type {import("../matrix/utils")} */ -const mUtils = sync.require("../matrix/utils") const {id} = require("../../addbot") // Pug -pugSync.addGlobals({id, h3, discord, select, from, DiscordTypes, dUtils, mUtils, icons, reg: reg.reg}) +pugSync.addGlobals({id, h3, discord, select, DiscordTypes, dUtils, icons}) +pugSync.createRoute(as.router, "/", "home.pug") +pugSync.createRoute(as.router, "/ok", "ok.pug") + +// Routes + +sync.require("./routes/download-matrix") +sync.require("./routes/download-discord") +sync.require("./routes/invite") +sync.require("./routes/guild-settings") +sync.require("./routes/oauth") // Files function compressResponse(event, response) { if (!getRequestHeader(event, "accept-encoding")?.includes("gzip")) return - /* c8 ignore next */ if (typeof response.body !== "string") return + /** @type {ReadableStream} */ // @ts-ignore const stream = new Response(response.body).body - assert(stream) setResponseHeader(event, "content-encoding", "gzip") response.body = stream.pipeThrough(new CompressionStream("gzip")) } @@ -43,88 +47,16 @@ as.router.get("/static/stacks.min.css", defineEventHandler({ } })) -as.router.get("/static/htmx.js", defineEventHandler({ +as.router.get("/static/htmx.min.js", defineEventHandler({ onBeforeResponse: compressResponse, handler: async event => { handleCacheHeaders(event, {maxAge: 86400}) defaultContentType(event, "text/javascript") - return fs.promises.readFile(require.resolve("htmx.org/dist/htmx.js"), "utf-8") + return fs.promises.readFile(join(__dirname, "static", "htmx.min.js"), "utf-8") } })) -as.router.get("/download/file/poll-star-avatar.png", defineEventHandler(event => { - handleCacheHeaders(event, {maxAge: 86400}) - return fs.promises.readFile(join(__dirname, "../../docs/img/poll-star-avatar.png")) -})) - -// Custom files - -const publicDir = "custom-webroot" - -/** - * @param {h3.H3Event} event - * @param {boolean} fallthrough - */ -function tryStatic(event, fallthrough) { - return serveStatic(event, { - indexNames: ["/index.html", "/index.pug"], - fallthrough, - getMeta: async id => { - // Check - const stats = await fs.promises.stat(join(publicDir, id)).catch(() => {}); - if (!stats || !stats.isFile()) { - return - } - // Pug - if (id.match(/\.pug$/)) { - defaultContentType(event, "text/html; charset=utf-8") - return {} - } - // Everything else - else { - const mime = mimeTypes.lookup(id) - if (typeof mime === "string") defaultContentType(event, mime) - return { - size: stats.size - } - } - }, - getContents: id => { - if (id.match(/\.pug$/)) { - const path = join(publicDir, id) - return pugSync.renderPath(event, path, {}) - } else { - return fs.promises.readFile(join(publicDir, id)) - } - } - }) -} - -as.router.get("/**", defineEventHandler(event => { - return tryStatic(event, false) -})) - -as.router.get("/", defineEventHandler(async event => { - return (await tryStatic(event, true)) || pugSync.render(event, "home.pug", {}) -})) - -as.router.get("/icon.png", defineEventHandler(async event => { - const s = await tryStatic(event, true) - if (s) return s +as.router.get("/icon.png", defineEventHandler(event => { handleCacheHeaders(event, {maxAge: 86400}) return fs.promises.readFile(join(__dirname, "../../docs/img/icon.png")) })) - -// Routes - -pugSync.createRoute(as.router, "/ok", "ok.pug") - -sync.require("./routes/download-matrix") -sync.require("./routes/download-discord") -sync.require("./routes/guild-settings") -sync.require("./routes/guild") -sync.require("./routes/info") -sync.require("./routes/link") -sync.require("./routes/log-in-with-matrix") -sync.require("./routes/oauth") -sync.require("./routes/password") diff --git a/src/web/server.test.js b/src/web/server.test.js deleted file mode 100644 index 6ed3535..0000000 --- a/src/web/server.test.js +++ /dev/null @@ -1,37 +0,0 @@ -// @ts-check - -const streamWeb = require("stream/web") -const {test} = require("../../test/web") -const {router} = require("../../test/web") -const assert = require("assert").strict - -require("./server") - -test("web server: can get home", async t => { - t.has(await router.test("get", "/", {}), /a bridge between the Discord and Matrix chat apps/) -}) - -test("web server: can get htmx", async t => { - t.match(await router.test("get", "/static/htmx.js", {}), /htmx =/) -}) - -test("web server: can get css", async t => { - t.match(await router.test("get", "/static/stacks.min.css", {}), /--stacks-/) -}) - -test("web server: can get icon", async t => { - const content = await router.test("get", "/icon.png", {}) - t.ok(content instanceof Buffer) -}) - -test("web server: compresses static resources", async t => { - const content = await router.test("get", "/static/stacks.min.css", { - headers: { - "accept-encoding": "gzip" - } - }) - assert(content instanceof streamWeb.ReadableStream) - const firstChunk = await content.getReader().read() - t.ok(firstChunk.value instanceof Uint8Array, "can get data") - t.deepEqual(firstChunk.value.slice(0, 3), Uint8Array.from([31, 139, 8]), "has compressed gzip header") -}) diff --git a/src/web/static/htmx.min.js b/src/web/static/htmx.min.js new file mode 100644 index 0000000..c11fbbd --- /dev/null +++ b/src/web/static/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.2"};Q.onLoad=$;Q.process=Dt;Q.on=be;Q.off=we;Q.trigger=de;Q.ajax=Hn;Q.find=r;Q.findAll=p;Q.closest=g;Q.remove=K;Q.addClass=Y;Q.removeClass=o;Q.toggleClass=W;Q.takeClass=ge;Q.swap=ze;Q.defineExtension=Bn;Q.removeExtension=Un;Q.logAll=z;Q.logNone=J;Q.parseInterval=h;Q._=_;const n={addTriggerHandler:Et,bodyContains:le,canAccessLocalStorage:j,findThisElement:Ee,filterValues:hn,swap:ze,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:T,getExpressionVars:Cn,getHeaders:dn,getInputValues:cn,getInternalData:ie,getSwapSpecification:pn,getTriggerSpecs:lt,getTarget:Ce,makeFragment:D,mergeObjects:ue,makeSettleInfo:xn,oobSwap:Te,querySelectorExt:ae,settleImmediately:Gt,shouldCancel:ht,triggerEvent:de,triggerErrorEvent:fe,withExtensions:Bt};const v=["get","post","put","delete","patch"];const O=v.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");const R=e("head");function e(e,t=false){return new RegExp(`<${e}(\\s[^>]*>|>)([\\s\\S]*?)<\\/${e}>`,t?"gim":"im")}function h(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function H(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function T(e,t){while(e&&!t(e)){e=u(e)}return e||null}function q(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;T(t,function(e){return!!(r=q(t,ce(e),n))});if(r!=="unset"){return r}}function f(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function L(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function N(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function A(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function I(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function P(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function k(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(P(e)){const t=I(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){w(e)}finally{e.remove()}}})}function D(e){const t=e.replace(R,"");const n=L(t);let r;if(n==="html"){r=new DocumentFragment;const i=N(e);A(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=N(t);A(r,i.body);r.title=i.title}else{const i=N('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){k(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function M(e){return typeof e==="function"}function X(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function F(t){const n=[];if(t){for(let e=0;e<t.length;e++){n.push(t[e])}}return n}function se(t,n){if(t){for(let e=0;e<t.length;e++){n(t[e])}}}function B(e){const t=e.getBoundingClientRect();const n=t.top;const r=t.bottom;return n<window.innerHeight&&r>=0}function le(e){const t=e.getRootNode&&e.getRootNode();if(t&&t instanceof window.ShadowRoot){return ne().body.contains(t.host)}else{return ne().body.contains(e)}}function U(e){return e.trim().split(/\s+/)}function ue(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){w(e);return null}}function j(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function V(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function _(e){return vn(ne().body,function(){return eval(e)})}function $(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function z(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function J(){Q.logger=null}function r(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return r(ne(),e)}}function p(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return p(ne(),e)}}function E(){return window}function K(e,t){e=y(e);if(t){E().setTimeout(function(){K(e);e=null},t)}else{u(e).removeChild(e)}}function ce(e){return e instanceof Element?e:null}function G(e){return e instanceof HTMLElement?e:null}function Z(e){return typeof e==="string"?e:null}function d(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function Y(e,t,n){e=ce(y(e));if(!e){return}if(n){E().setTimeout(function(){Y(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function o(e,t,n){let r=ce(y(e));if(!r){return}if(n){E().setTimeout(function(){o(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function ge(e,t){e=y(e);se(e.parentElement.children,function(e){o(e,t)});Y(ce(e),t)}function g(e,t){e=ce(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||f(e,t)){return e}}while(e=e&&ce(u(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function pe(e,t){return e.substring(e.length-t.length)===t}function i(e){const t=e.trim();if(l(t,"<")&&pe(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function m(e,t,n){e=y(e);if(t.indexOf("closest ")===0){return[g(ce(e),i(t.substr(8)))]}else if(t.indexOf("find ")===0){return[r(d(e),i(t.substr(5)))]}else if(t==="next"){return[ce(e).nextElementSibling]}else if(t.indexOf("next ")===0){return[me(e,i(t.substr(5)),!!n)]}else if(t==="previous"){return[ce(e).previousElementSibling]}else if(t.indexOf("previous ")===0){return[ye(e,i(t.substr(9)),!!n)]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else if(t==="body"){return[document.body]}else if(t==="root"){return[H(e,!!n)]}else if(t.indexOf("global ")===0){return m(e,t.slice(7),true)}else{return F(d(H(e,!!n)).querySelectorAll(i(t)))}}var me=function(t,e,n){const r=d(H(t,n)).querySelectorAll(e);for(let e=0;e<r.length;e++){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_PRECEDING){return o}}};var ye=function(t,e,n){const r=d(H(t,n)).querySelectorAll(e);for(let e=r.length-1;e>=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return m(e,t)[0]}else{return m(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return r(d(t)||document,e)}else{return e}}function xe(e,t,n){if(M(t)){return{target:ne().body,event:Z(e),listener:t}}else{return{target:y(e),event:Z(t),listener:n}}}function be(t,n,r){_n(function(){const e=xe(t,n,r);e.target.addEventListener(e.event,e.listener)});const e=M(n);return e?n:r}function we(t,n,r){_n(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return M(n)?n:r}const ve=ne().createElement("output");function Se(e,t){const n=re(e,t);if(n){if(n==="this"){return[Ee(e,t)]}else{const r=m(e,n);if(r.length===0){w('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Ee(e,t){return ce(T(e,function(e){return te(ce(e),t)!=null}))}function Ce(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Ee(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Oe(t){const n=Q.config.attributesToSettle;for(let e=0;e<n.length;e++){if(t===n[e]){return true}}return false}function Re(t,n){se(t.attributes,function(e){if(!n.hasAttribute(e.name)&&Oe(e.name)){t.removeAttribute(e.name)}});se(n.attributes,function(e){if(Oe(e.name)){t.setAttribute(e.name,e.value)}})}function He(t,e){const n=jn(e);for(let e=0;e<n.length;e++){const r=n[e];try{if(r.isInlineSwap(t)){return true}}catch(e){w(e)}}return t==="outerHTML"}function Te(e,o,i){let t="#"+ee(o,"id");let s="outerHTML";if(e==="true"){}else if(e.indexOf(":")>0){s=e.substr(0,e.indexOf(":"));t=e.substr(e.indexOf(":")+1,e.length)}else{s=e}const n=ne().querySelectorAll(t);if(n){se(n,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!He(s,e)){t=d(n)}const r={shouldSwap:true,target:e,fragment:t};if(!de(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){_e(s,e,e,t,i)}se(i.elts,function(e){de(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function qe(e){se(p(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){e.parentNode.replaceChild(n,e)}})}function Le(l,e,u){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=d(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Re(t,i);u.tasks.push(function(){Re(t,s)})}}})}function Ne(e){return function(){o(e,Q.config.addedClass);Dt(ce(e));Ae(d(e));de(e,"htmx:load")}}function Ae(e){const t="[autofocus]";const n=G(f(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function c(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;Y(ce(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ne(o))}}}function Ie(e,t){let n=0;while(n<e.length){t=(t<<5)-t+e.charCodeAt(n++)|0}return t}function Pe(t){let n=0;if(t.attributes){for(let e=0;e<t.attributes.length;e++){const r=t.attributes[e];if(r.value){n=Ie(r.name,n);n=Ie(r.value,n)}}}return n}function ke(t){const n=ie(t);if(n.onHandlers){for(let e=0;e<n.onHandlers.length;e++){const r=n.onHandlers[e];we(t,r.event,r.listener)}delete n.onHandlers}}function De(e){const t=ie(e);if(t.timeout){clearTimeout(t.timeout)}if(t.listenerInfos){se(t.listenerInfos,function(e){if(e.on){we(e.on,e.trigger,e.listener)}})}ke(e);se(Object.keys(t),function(e){delete t[e]})}function a(e){de(e,"htmx:beforeCleanupElement");De(e);if(e.children){se(e.children,function(e){a(e)})}}function Me(t,e,n){if(t instanceof Element&&t.tagName==="BODY"){return Ve(t,e,n)}let r;const o=t.previousSibling;c(u(t),t,e,n);if(o==null){r=u(t).firstChild}else{r=o.nextSibling}n.elts=n.elts.filter(function(e){return e!==t});while(r&&r!==t){if(r instanceof Element){n.elts.push(r)}r=r.nextSibling}a(t);if(t instanceof Element){t.remove()}else{t.parentNode.removeChild(t)}}function Xe(e,t,n){return c(e,e.firstChild,t,n)}function Fe(e,t,n){return c(u(e),e,t,n)}function Be(e,t,n){return c(e,null,t,n)}function Ue(e,t,n){return c(u(e),e.nextSibling,t,n)}function je(e){a(e);return u(e).removeChild(e)}function Ve(e,t,n){const r=e.firstChild;c(e,r,t,n);if(r){while(r.nextSibling){a(r.nextSibling);e.removeChild(r.nextSibling)}a(r);e.removeChild(r)}}function _e(t,e,n,r,o){switch(t){case"none":return;case"outerHTML":Me(n,r,o);return;case"afterbegin":Xe(n,r,o);return;case"beforebegin":Fe(n,r,o);return;case"beforeend":Be(n,r,o);return;case"afterend":Ue(n,r,o);return;case"delete":je(n);return;default:var i=jn(e);for(let e=0;e<i.length;e++){const s=i[e];try{const l=s.handleSwap(t,n,r,o);if(l){if(Array.isArray(l)){for(let e=0;e<l.length;e++){const u=l[e];if(u.nodeType!==Node.TEXT_NODE&&u.nodeType!==Node.COMMENT_NODE){o.tasks.push(Ne(u))}}}return}}catch(e){w(e)}}if(t==="innerHTML"){Ve(n,r,o)}else{_e(Q.config.defaultSwapStyle,e,n,r,o)}}}function $e(e,n){var t=p(e,"[hx-swap-oob], [data-hx-swap-oob]");se(t,function(e){if(Q.config.allowNestedOobSwaps||e.parentElement===null){const t=te(e,"hx-swap-oob");if(t!=null){Te(t,e,n)}}else{e.removeAttribute("hx-swap-oob");e.removeAttribute("data-hx-swap-oob")}});return t.length>0}function ze(e,t,r,o){if(!o){o={}}e=y(e);const n=document.activeElement;let i={};try{i={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const s=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=D(t);s.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t<u.length;t++){const c=u[t].split(":",2);let e=c[0].trim();if(e.indexOf("#")===0){e=e.substring(1)}const a=c[1]||"true";const f=n.querySelector("#"+e);if(f){Te(a,f,s)}}}$e(n,s);se(p(n,"template"),function(e){if($e(e.content,s)){e.remove()}});if(o.select){const d=ne().createDocumentFragment();se(n.querySelectorAll(o.select),function(e){d.appendChild(e)});n=d}qe(n);_e(r.swapStyle,o.contextElement,e,n,s)}if(i.elt&&!le(i.elt)&&ee(i.elt,"id")){const h=document.getElementById(ee(i.elt,"id"));const g={preventScroll:r.focusScroll!==undefined?!r.focusScroll:!Q.config.defaultFocusScroll};if(h){if(i.start&&h.setSelectionRange){try{h.setSelectionRange(i.start,i.end)}catch(e){}}h.focus(g)}}e.classList.remove(Q.config.swappingClass);se(s.elts,function(e){if(e.classList){e.classList.add(Q.config.settlingClass)}de(e,"htmx:afterSwap",o.eventInfo)});if(o.afterSwapCallback){o.afterSwapCallback()}if(!r.ignoreTitle){Dn(s.title)}const l=function(){se(s.tasks,function(e){e.call()});se(s.elts,function(e){if(e.classList){e.classList.remove(Q.config.settlingClass)}de(e,"htmx:afterSettle",o.eventInfo)});if(o.anchor){const e=ce(y("#"+o.anchor));if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}bn(s.elts,r);if(o.afterSettleCallback){o.afterSettleCallback()}};if(r.settleDelay>0){E().setTimeout(l,r.settleDelay)}else{l()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(X(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}de(n,i,e)}}}else{const s=r.split(",");for(let e=0;e<s.length;e++){de(n,s[e].trim(),[])}}}const Ke=/\s/;const x=/[\s,]/;const Ge=/[_$a-zA-Z]/;const Ze=/[_$a-zA-Z0-9]/;const Ye=['"',"'","/"];const We=/[^\s]/;const Qe=/[{(]/;const et=/[})]/;function tt(e){const t=[];let n=0;while(n<e.length){if(Ge.exec(e.charAt(n))){var r=n;while(Ze.exec(e.charAt(n+1))){n++}t.push(e.substr(r,n-r+1))}else if(Ye.indexOf(e.charAt(n))!==-1){const o=e.charAt(n);var r=n;n++;while(n<e.length&&e.charAt(n)!==o){if(e.charAt(n)==="\\"){n++}n++}t.push(e.substr(r,n-r+1))}else{const i=e.charAt(n);t.push(i)}n++}return t}function nt(e,t,n){return Ge.exec(e.charAt(0))&&e!=="true"&&e!=="false"&&e!=="this"&&e!==n&&t!=="."}function rt(r,o,i){if(o[0]==="["){o.shift();let e=1;let t=" return (function("+i+"){ return (";let n=null;while(o.length>0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(nt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function b(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function ot(e){let t;if(e.length>0&&Qe.test(e[0])){e.shift();t=b(e,et).trim();e.shift()}else{t=b(e,x)}return t}const it="input, textarea, select";function st(e,t,n){const r=[];const o=tt(t);do{b(o,We);const l=o.length;const u=b(o,/[,\[\s]/);if(u!==""){if(u==="every"){const c={trigger:"every"};b(o,We);c.pollInterval=h(b(o,/[,\[\s]/));b(o,We);var i=rt(e,o,"event");if(i){c.eventFilter=i}r.push(c)}else{const a={trigger:u};var i=rt(e,o,"event");if(i){a.eventFilter=i}while(o.length>0&&o[0]!==","){b(o,We);const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=h(b(o,x))}else if(f==="from"&&o[0]===":"){o.shift();if(Qe.test(o[0])){var s=ot(o)}else{var s=b(o,x);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const d=ot(o);if(d.length>0){s+=" "+d}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=ot(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=h(b(o,x))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=b(o,x)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=ot(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=b(o,x)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}b(o,We)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function lt(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||st(e,t,r)}if(n.length>0){return n}else if(f(e,"form")){return[{trigger:"submit"}]}else if(f(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(f(e,it)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function ut(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!pt(n,e,Xt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function at(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function ft(e){return g(e,Q.config.disableSelector)}function dt(t,n,e){if(t instanceof HTMLAnchorElement&&at(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";if(r==="get"){}o=ee(t,"action")}e.forEach(function(e){mt(t,function(e,t){const n=ce(e);if(ft(n)){a(n);return}he(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ce(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(f(n,'input[type="submit"], button')&&g(n,"form")!==null){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function gt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function pt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function mt(s,l,e,u,c){const a=ie(s);let t;if(u.from){t=m(s,u.from)}else{t=[s]}if(u.changed){t.forEach(function(e){const t=ie(e);t.lastValue=e.value})}se(t,function(o){const i=function(e){if(!le(s)){o.removeEventListener(u.trigger,i);return}if(gt(s,e)){return}if(c||ht(e,s)){e.preventDefault()}if(pt(u,s,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(s)<0){t.handledFor.push(s);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!f(ce(e.target),u.target)){return}}if(u.once){if(a.triggeredOnce){return}else{a.triggeredOnce=true}}if(u.changed){const n=ie(o);const r=o.value;if(n.lastValue===r){return}n.lastValue=r}if(a.delayed){clearTimeout(a.delayed)}if(a.throttle){return}if(u.throttle>0){if(!a.throttle){de(s,"htmx:trigger");l(s,e);a.throttle=E().setTimeout(function(){a.throttle=null},u.throttle)}}else if(u.delay>0){a.delayed=E().setTimeout(function(){de(s,"htmx:trigger");l(s,e)},u.delay)}else{de(s,"htmx:trigger");l(s,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:i,on:o});o.addEventListener(u.trigger,i)})}let yt=false;let xt=null;function bt(){if(!xt){xt=function(){yt=true};window.addEventListener("scroll",xt);setInterval(function(){if(yt){yt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){wt(e)})}},200)}}function wt(e){if(!s(e,"data-hx-revealed")&&B(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){de(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){de(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function St(t,n,e){let i=false;se(v,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){Et(t,e,n,function(e,t){const n=ce(e);if(g(n,Q.config.disableSelector)){a(n);return}he(r,o,n,t)})})}});return i}function Et(r,e,t,n){if(e.trigger==="revealed"){bt();mt(r,n,t,e);wt(ce(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e<t.length;e++){const n=t[e];if(n.isIntersecting){de(r,"intersect");break}}},o);i.observe(ce(r));mt(ce(r),n,t,e)}else if(e.trigger==="load"){if(!pt(e,r,Xt("load",{elt:r}))){vt(ce(r),n,t,e.delay)}}else if(e.pollInterval>0){t.polling=true;ct(ce(r),n,e)}else{mt(r,n,t,e)}}function Ct(e){const t=ce(e);if(!t){return false}const n=t.attributes;for(let e=0;e<n.length;e++){const r=n[e].name;if(l(r,"hx-on:")||l(r,"data-hx-on:")||l(r,"hx-on-")||l(r,"data-hx-on-")){return true}}return false}const Ot=(new XPathEvaluator).createExpression('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or'+' starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]');function Rt(e,t){if(Ct(e)){t.push(ce(e))}const n=Ot.evaluate(e);let r=null;while(r=n.iterateNext())t.push(ce(r))}function Ht(e){const t=[];if(e instanceof DocumentFragment){for(const n of e.childNodes){Rt(n,t)}}else{Rt(e,t)}return t}function Tt(e){if(e.querySelectorAll){const n=", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]";const r=[];for(const i in Xn){const s=Xn[i];if(s.getSelectors){var t=s.getSelectors();if(t){r.push(t)}}}const o=e.querySelectorAll(O+n+", form, [type='submit'],"+" [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger]"+r.flat().map(e=>", "+e).join(""));return o}else{return[]}}function qt(e){const t=g(ce(e.target),"button, input[type='submit']");const n=Nt(e);if(n){n.lastButtonClicked=t}}function Lt(e){const t=Nt(e);if(t){t.lastButtonClicked=null}}function Nt(e){const t=g(ce(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",qt);e.addEventListener("focusin",qt);e.addEventListener("focusout",Lt)}function It(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(ft(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function Pt(t){ke(t);for(let e=0;e<t.attributes.length;e++){const n=t.attributes[e].name;const r=t.attributes[e].value;if(l(n,"hx-on")||l(n,"data-hx-on")){const o=n.indexOf("-on")+3;const i=n.slice(o,o+1);if(i==="-"||i===":"){let e=n.slice(o+1);if(l(e,":")){e="htmx"+e}else if(l(e,"-")){e="htmx:"+e.slice(1)}else if(l(e,"htmx-")){e="htmx:"+e.slice(5)}It(t,e,r)}}}}function kt(t){if(g(t,Q.config.disableSelector)){a(t);return}const n=ie(t);if(n.initHash!==Pe(t)){De(t);n.initHash=Pe(t);de(t,"htmx:beforeProcessNode");if(t.value){n.lastValue=t.value}const e=lt(t);const r=St(t,n,e);if(!r){if(re(t,"hx-boost")==="true"){dt(t,n,e)}else if(s(t,"hx-trigger")){e.forEach(function(e){Et(t,e,n,function(){})})}}if(t.tagName==="FORM"||ee(t,"type")==="submit"&&s(t,"form")){At(t)}de(t,"htmx:afterProcessNode")}}function Dt(e){e=y(e);if(g(e,Q.config.disableSelector)){a(e);return}kt(e);se(Tt(e),function(e){kt(e)});se(Ht(e),Pt)}function Mt(e){return e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase()}function Xt(e,t){let n;if(window.CustomEvent&&typeof window.CustomEvent==="function"){n=new CustomEvent(e,{bubbles:true,cancelable:true,composed:true,detail:t})}else{n=ne().createEvent("CustomEvent");n.initCustomEvent(e,true,true,t)}return n}function fe(e,t,n){de(e,t,ue({error:t},n))}function Ft(e){return e==="htmx:afterProcessNode"}function Bt(e,t){se(jn(e),function(e){try{t(e)}catch(e){w(e)}})}function w(e){if(console.error){console.error(e)}else if(console.log){console.log("ERROR: ",e)}}function de(e,t,n){e=y(e);if(n==null){n={}}n.elt=e;const r=Xt(t,n);if(Q.logger&&!Ft(t)){Q.logger(e,t,n)}if(n.error){w(n.error);de(e,"htmx:error",{errorInfo:n})}let o=e.dispatchEvent(r);const i=Mt(t);if(o&&i!==t){const s=Xt(i,r.detail);o=o&&e.dispatchEvent(s)}Bt(ce(e),function(e){o=o&&(e.onEvent(t,r)!==false&&!r.defaultPrevented)});return o}let Ut=location.pathname+location.search;function jt(){const e=ne().querySelector("[hx-history-elt],[data-hx-history-elt]");return e||ne().body}function Vt(t,e){if(!j()){return}const n=$t(e);const r=ne().title;const o=window.scrollY;if(Q.config.historyCacheSize<=0){localStorage.removeItem("htmx-history-cache");return}t=V(t);const i=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<i.length;e++){if(i[e].url===t){i.splice(e,1);break}}const s={url:t,content:n,title:r,scroll:o};de(ne().body,"htmx:historyItemCreated",{item:s,cache:i});i.push(s);while(i.length>Q.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function _t(t){if(!j()){return null}t=V(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<n.length;e++){if(n[e].url===t){return n[e]}}return null}function $t(e){const t=Q.config.requestClass;const n=e.cloneNode(true);se(p(n,"."+t),function(e){o(e,t)});se(p(n,"[data-disabled-by-htmx]"),function(e){e.removeAttribute("disabled")});return n.innerHTML}function zt(){const e=jt();const t=Ut||location.pathname+location.search;let n;try{n=ne().querySelector('[hx-history="false" i],[data-hx-history="false" i]')}catch(e){n=ne().querySelector('[hx-history="false"],[data-hx-history="false"]')}if(!n){de(ne().body,"htmx:beforeHistorySave",{path:t,historyElt:e});Vt(t,e)}if(Q.config.historyEnabled)history.replaceState({htmx:true},ne().title,window.location.href)}function Jt(e){if(Q.config.getCacheBusterParam){e=e.replace(/org\.htmx\.cache-buster=[^&]*&?/,"");if(pe(e,"&")||pe(e,"?")){e=e.slice(0,-1)}}if(Q.config.historyEnabled){history.pushState({htmx:true},"",e)}Ut=e}function Kt(e){if(Q.config.historyEnabled)history.replaceState({htmx:true},"",e);Ut=e}function Gt(e){se(e,function(e){e.call(undefined)})}function Zt(o){const e=new XMLHttpRequest;const i={path:o,xhr:e};de(ne().body,"htmx:historyCacheMiss",i);e.open("GET",o,true);e.setRequestHeader("HX-Request","true");e.setRequestHeader("HX-History-Restore-Request","true");e.setRequestHeader("HX-Current-URL",ne().location.href);e.onload=function(){if(this.status>=200&&this.status<400){de(ne().body,"htmx:historyCacheMissLoad",i);const e=D(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=jt();const r=xn(n);Dn(e.title);Ve(n,t,r);Gt(r.tasks);Ut=o;de(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Yt(e){zt();e=e||location.pathname+location.search;const t=_t(e);if(t){const n=D(t.content);const r=jt();const o=xn(r);Dn(n.title);Ve(r,n,o);Gt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Ut=e;de(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Zt(e)}}}function Wt(e){let t=Se(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Qt(e){let t=Se(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function en(e,t){se(e,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function tn(t,n){for(let e=0;e<t.length;e++){const r=t[e];if(r.isSameNode(n)){return true}}return false}function nn(e){const t=e;if(t.name===""||t.name==null||t.disabled||g(t,"fieldset[disabled]")){return false}if(t.type==="button"||t.type==="submit"||t.tagName==="image"||t.tagName==="reset"||t.tagName==="file"){return false}if(t.type==="checkbox"||t.type==="radio"){return t.checked}return true}function rn(t,e,n){if(t!=null&&e!=null){if(Array.isArray(e)){e.forEach(function(e){n.append(t,e)})}else{n.append(t,e)}}}function on(t,n,r){if(t!=null&&n!=null){let e=r.getAll(t);if(Array.isArray(n)){e=e.filter(e=>n.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function sn(t,n,r,o,i){if(o==null||tn(t,o)){return}else{t.push(o)}if(nn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=F(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=F(o.files)}rn(s,e,n);if(i){ln(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){on(e.name,e.value,n)}else{t.push(e)}if(i){ln(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}rn(t,e,n)})}}function ln(e,t){const n=e;if(n.willValidate){de(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});de(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function un(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){sn(n,o,i,g(e,"form"),l)}sn(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const c=s.lastButtonClicked||e;const a=ee(c,"name");rn(a,c.value,o)}const u=Se(e,"hx-include");se(u,function(e){sn(n,r,i,ce(e),l);if(!f(e,"form")){se(d(e).querySelectorAll(it),function(e){sn(n,r,i,e,l)})}});un(r,o);return{errors:i,formData:r,values:An(r)}}function an(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function fn(e){e=Ln(e);let n="";e.forEach(function(e,t){n=an(n,t,e)});return n}function dn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};wn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.substr(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function gn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function pn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!gn(e)){r.show="top"}if(n){const s=U(n);if(s.length>0){for(let e=0;e<s.length;e++){const l=s[e];if(l.indexOf("swap:")===0){r.swapDelay=h(l.substr(5))}else if(l.indexOf("settle:")===0){r.settleDelay=h(l.substr(7))}else if(l.indexOf("transition:")===0){r.transition=l.substr(11)==="true"}else if(l.indexOf("ignoreTitle:")===0){r.ignoreTitle=l.substr(12)==="true"}else if(l.indexOf("scroll:")===0){const u=l.substr(7);var o=u.split(":");const c=o.pop();var i=o.length>0?o.join(":"):null;r.scroll=c;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.substr(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const d=l.substr("focus-scroll:".length);r.focusScroll=d=="true"}else if(e==0){r.swapStyle=l}else{w("Unknown modifier in hx-swap: "+l)}}}}return r}function mn(e){return re(e,"hx-encoding")==="multipart/form-data"||f(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function yn(t,n,r){let o=null;Bt(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(mn(n)){return un(new FormData,Ln(r))}else{return fn(r)}}}function xn(e){return{tasks:[],elts:[e]}}function bn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ce(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ce(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function wn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.substr(11);t=true}else if(e.indexOf("js:")===0){e=e.substr(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return wn(ce(u(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function Sn(e,t){return wn(e,"hx-vars",true,t)}function En(e,t){return wn(e,"hx-vals",false,t)}function Cn(e){return ue(Sn(e),En(e))}function On(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function Rn(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function C(e,t){return t.test(e.getAllResponseHeaders())}function Hn(e,t,n){e=e.toLowerCase();if(n){if(n instanceof Element||typeof n==="string"){return he(e,t,null,null,{targetOverride:y(n),returnPromise:true})}else{return he(e,t,y(n.source),n.event,{handler:n.handler,headers:n.headers,values:n.values,targetOverride:y(n.target),swapOverride:n.swap,select:n.select,returnPromise:true})}}else{return he(e,t,null,null,{returnPromise:true})}}function Tn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function qn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return de(e,"htmx:validateUrl",ue({url:o,sameHost:r},n))}function Ln(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Nn(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(r){return new Proxy(r,{get:function(e,t){if(typeof t==="symbol"){return Reflect.get(e,t)}if(t==="toJSON"){return()=>Object.fromEntries(r)}if(t in e){if(typeof e[t]==="function"){return function(){return r[t].apply(r,arguments)}}else{return e[t]}}const n=r.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Nn(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function he(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Mn;const X=i.select||null;if(!le(r)){oe(s);return e}const u=i.targetOverride||ce(Ce(r));if(u==null||u==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let c=ie(r);const a=c.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const N=ee(a,"formmethod");if(N!=null){if(N.toLowerCase()!=="dialog"){t=N}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return he(t,n,r,o,i,!!e)};const G={target:u,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(de(r,"htmx:confirm",G)===false){oe(s);return e}}let d=r;let h=re(r,"hx-sync");let g=null;let F=false;if(h){const A=h.split(":");const I=A[0].trim();if(I==="this"){d=Ee(r,"hx-sync")}else{d=ce(ae(r,I))}h=(A[1]||"drop").trim();c=ie(d);if(h==="drop"&&c.xhr&&c.abortable!==true){oe(s);return e}else if(h==="abort"){if(c.xhr){oe(s);return e}else{F=true}}else if(h==="replace"){de(d,"htmx:abort")}else if(h.indexOf("queue")===0){const Z=h.split(" ");g=(Z[1]||"last").trim()}}if(c.xhr){if(c.abortable){de(d,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(c.queuedRequests==null){c.queuedRequests=[]}if(g==="first"&&c.queuedRequests.length===0){c.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(g==="all"){c.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(g==="last"){c.queuedRequests=[];c.queuedRequests.push(function(){he(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;c.xhr=p;c.abortable=F;const m=function(){c.xhr=null;c.abortable=false;if(c.queuedRequests!=null&&c.queuedRequests.length>0){const e=c.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var y=prompt(B);if(y===null||!de(r,"htmx:prompt",{prompt:y,target:u})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let x=dn(r,u,y);if(t!=="get"&&!mn(r)){x["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){x=ue(x,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){un(j,Ln(i.values))}const V=Ln(Cn(r));const w=un(j,V);let v=hn(w,r);if(Q.config.getCacheBusterParam&&t==="get"){v.set("org.htmx.cache-buster",ee(u,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=wn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:v,parameters:An(v),unfilteredFormData:w,unfilteredParameters:An(w),headers:x,target:u,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!de(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;x=C.headers;v=Ln(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){de(r,"htmx:validation:halted",C);oe(s);m();return e}const $=n.split("#");const z=$[0];const O=$[1];let R=n;if(E){R=z;const Y=!v.keys().next().done;if(Y){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=fn(v);if(O){R+="#"+O}}}if(!qn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in x){if(x.hasOwnProperty(k)){const W=x[k];On(p,k,W)}}}const H={xhr:p,target:u,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Tn(r);H.pathInfo.responsePath=Rn(p);M(r,H);if(H.keepIndicators!==true){en(T,q)}de(r,"htmx:afterRequest",H);de(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){de(e,"htmx:afterRequest",H);de(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ue({error:e},H));throw e}};p.onerror=function(){en(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){en(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){en(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!de(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Wt(r);var q=Qt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){de(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});de(r,"htmx:beforeSend",H);const J=E?null:yn(p,r,v);p.send(J);return e}function In(e,t){const n=t.xhr;let r=null;let o=null;if(C(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(C(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(C(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const u=re(e,"hx-replace-url");const c=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(u){a="replace";f=u}else if(c){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function Pn(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function kn(e){for(var t=0;t<Q.config.responseHandling.length;t++){var n=Q.config.responseHandling[t];if(Pn(n,e.status)){return n}}return{swap:false}}function Dn(e){if(e){const t=r("title");if(t){t.innerHTML=e}else{window.document.title=e}}}function Mn(o,i){const s=i.xhr;let l=i.target;const e=i.etc;const u=i.select;if(!de(o,"htmx:beforeOnLoad",i))return;if(C(s,/HX-Trigger:/i)){Je(s,"HX-Trigger",o)}if(C(s,/HX-Location:/i)){zt();let e=s.getResponseHeader("HX-Location");var t;if(e.indexOf("{")===0){t=S(e);e=t.path;delete t.path}Hn("get",e,t).then(function(){Jt(e)});return}const n=C(s,/HX-Refresh:/i)&&s.getResponseHeader("HX-Refresh")==="true";if(C(s,/HX-Redirect:/i)){i.keepIndicators=true;location.href=s.getResponseHeader("HX-Redirect");n&&location.reload();return}if(n){i.keepIndicators=true;location.reload();return}if(C(s,/HX-Retarget:/i)){if(s.getResponseHeader("HX-Retarget")==="this"){i.target=o}else{i.target=ce(ae(o,s.getResponseHeader("HX-Retarget")))}}const c=In(o,i);const r=kn(s);const a=r.swap;let f=!!r.error;let d=Q.config.ignoreTitle||r.ignoreTitle;let h=r.select;if(r.target){i.target=ce(ae(o,r.target))}var g=e.swapOverride;if(g==null&&r.swapOverride){g=r.swapOverride}if(C(s,/HX-Retarget:/i)){if(s.getResponseHeader("HX-Retarget")==="this"){i.target=o}else{i.target=ce(ae(o,s.getResponseHeader("HX-Retarget")))}}if(C(s,/HX-Reswap:/i)){g=s.getResponseHeader("HX-Reswap")}var p=s.response;var m=ue({shouldSwap:a,serverResponse:p,isError:f,ignoreTitle:d,selectOverride:h},i);if(r.event&&!de(l,r.event,m))return;if(!de(l,"htmx:beforeSwap",m))return;l=m.target;p=m.serverResponse;f=m.isError;d=m.ignoreTitle;h=m.selectOverride;i.target=l;i.failed=f;i.successful=!f;if(m.shouldSwap){if(s.status===286){ut(o)}Bt(o,function(e){p=e.transformResponse(p,s,o)});if(c.type){zt()}if(C(s,/HX-Reswap:/i)){g=s.getResponseHeader("HX-Reswap")}var y=pn(o,g);if(!y.hasOwnProperty("ignoreTitle")){y.ignoreTitle=d}l.classList.add(Q.config.swappingClass);let n=null;let r=null;if(u){h=u}if(C(s,/HX-Reselect:/i)){h=s.getResponseHeader("HX-Reselect")}const x=re(o,"hx-select-oob");const b=re(o,"hx-select");let e=function(){try{if(c.type){de(ne().body,"htmx:beforeHistoryUpdate",ue({history:c},i));if(c.type==="push"){Jt(c.path);de(ne().body,"htmx:pushedIntoHistory",{path:c.path})}else{Kt(c.path);de(ne().body,"htmx:replacedInHistory",{path:c.path})}}ze(l,p,y,{select:h||b,selectOOB:x,eventInfo:i,anchor:i.pathInfo.anchor,contextElement:o,afterSwapCallback:function(){if(C(s,/HX-Trigger-After-Swap:/i)){let e=o;if(!le(o)){e=ne().body}Je(s,"HX-Trigger-After-Swap",e)}},afterSettleCallback:function(){if(C(s,/HX-Trigger-After-Settle:/i)){let e=o;if(!le(o)){e=ne().body}Je(s,"HX-Trigger-After-Settle",e)}oe(n)}})}catch(e){fe(o,"htmx:swapError",i);oe(r);throw e}};let t=Q.config.globalViewTransitions;if(y.hasOwnProperty("transition")){t=y.transition}if(t&&de(o,"htmx:beforeTransition",i)&&typeof Promise!=="undefined"&&document.startViewTransition){const w=new Promise(function(e,t){n=e;r=t});const v=e;e=function(){document.startViewTransition(function(){v();return w})}}if(y.swapDelay>0){E().setTimeout(e,y.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ue({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Xn={};function Fn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Bn(e,t){if(t.init){t.init(n)}Xn[e]=ue(Fn(),t)}function Un(e){delete Xn[e]}function jn(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Xn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return jn(ce(u(e)),n,r)}var Vn=false;ne().addEventListener("DOMContentLoaded",function(){Vn=true});function _n(e){if(Vn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function $n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend","<style"+e+"> ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} </style>")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function Jn(){const e=zn();if(e){Q.config=ue(Q.config,e)}}_n(function(){Jn();$n();let e=ne().body;Dt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Yt();se(t,function(e){de(e,"htmx:restored",{document:ne(),triggerEvent:de})})}else{if(n){n(e)}}};E().setTimeout(function(){de(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/start.js b/start.js index 39e8ea0..be434f0 100755 --- a/start.js +++ b/start.js @@ -1,7 +1,6 @@ #!/usr/bin/env node // @ts-check -const fs = require("fs") const sqlite = require("better-sqlite3") const migrate = require("./src/db/migrate") const HeatSync = require("heatsync") @@ -10,7 +9,8 @@ const {reg} = require("./src/matrix/read-registration") const passthrough = require("./src/passthrough") const db = new sqlite("ooye.db") -const sync = new HeatSync({watchFunction: fs.watchFile}) +/** @type {import("heatsync").default} */ // @ts-ignore +const sync = new HeatSync() Object.assign(passthrough, {sync, db}) @@ -36,9 +36,5 @@ sync.require("./src/m2d/event-dispatcher") sync.require("./src/web/server") await power.applyPower() - discord.cloud.once("ready", () => { - as.listen() - }) - require("./src/stdin") })() diff --git a/test/addbot.test.js b/test/addbot.test.js deleted file mode 100644 index 4130051..0000000 --- a/test/addbot.test.js +++ /dev/null @@ -1,8 +0,0 @@ -// @ts-check - -const {addbot} = require("../addbot") -const {test} = require("supertape") - -test("addbot: returns message and invite link", t => { - t.equal(addbot(), `Open this link to add the bot to a Discord server:\nhttps://discord.com/oauth2/authorize?client_id=684280192553844747&scope=bot&permissions=2251801424568320 `) -}) diff --git a/test/data.js b/test/data.js index 6a53cb0..c8217c2 100644 --- a/test/data.js +++ b/test/data.js @@ -18,95 +18,15 @@ module.exports = { id: "112760669178241024", default_thread_rate_limit_per_user: 0, guild_id: "112760669178241024" - }, - updates: { - type: 0, - topic: "Updates and release announcements for Out Of Your Element.", - rate_limit_per_user: 0, - position: 0, - permission_overwrites: [{ - type: 0, - id: "112760669178241024", - deny: "2048", - allow: "0" - }], - parent_id: null, - nsfw: false, - name: "updates", - last_message_id: "1329413270196715564", - id: "1161864271370666075", - guild_id: "112760669178241024" - }, - /** @type {DiscordTypes.APITextChannel} */ - saving_the_world: { - type: 0, - topic: "Anything and everything archiving/preservation related", - rate_limit_per_user: 0, - position: 0, - permission_overwrites: [ - { - id: "665289423482519565", - type: DiscordTypes.OverwriteType.Role, - allow: "0", - deny: String(DiscordTypes.PermissionFlagsBits.SendMessages) - }, - { - id: "684524730274807911", - type: DiscordTypes.OverwriteType.Role, - allow: String(DiscordTypes.PermissionFlagsBits.SendMessages), - deny: "0" - } - ], - parent_id: null, - name: "saving-the-world", - last_pin_timestamp: "2021-04-14T18:39:41+00:00", - last_message_id: "1335828749479837750", - id: "665310973967597573", - guild_id: "665289423482519565" - }, - character_art: { - version: 1749274266694, - type: 0, - topic: null, - rate_limit_per_user: 0, - position: 22, - permission_overwrites: [ - { - type: 0, - id: "1235396773510647810", - deny: "0", - allow: "3072" - }, - { - type: 0, - id: "1236581109391949875", - deny: "0", - allow: "0" - }, - { - type: 0, - id: "1234728422044074064", - deny: "3072", - allow: "309237645312" - } - ], - parent_id: "1234730744291528714", - nsfw: false, - name: "character-art", - last_message_id: "1384358176106872924", - id: "1235072132095021096", - flags: 0, - guild_id: "1234728422044074064" } }, room: { general: { - "m.room.create/": {additional_creators: ["@test_auto_invite:example.org"]}, "m.room.name/": {name: "main"}, "m.room.topic/": {topic: "#collective-unconscious | https://docs.google.com/document/d/blah/edit | I spread, pipe, and whip because it is my will. :headstone:\n\nChannel ID: 112760669178241024\nGuild ID: 112760669178241024"}, "m.room.guest_access/": {guest_access: "can_join"}, "m.room.history_visibility/": {history_visibility: "shared"}, - "m.space.parent/!jjmvBegULiLucuWEHU:cadence.moe": { + "m.space.parent/!jjWAGMeQdNrVZSSfvz:cadence.moe": { via: ["cadence.moe"], canonical: true }, @@ -114,25 +34,21 @@ module.exports = { join_rule: "restricted", allow: [{ type: "m.room_membership", - room_id: "!jjmvBegULiLucuWEHU:cadence.moe" + room_id: "!jjWAGMeQdNrVZSSfvz:cadence.moe" }] }, "m.room.avatar/": { url: {$url: "/icons/112760669178241024/a_f83622e09ead74f0c5c527fe241f8f8c.png?size=1024"} }, "m.room.power_levels/": { - events_default: 0, - events: { - "m.reaction": 0, - "m.room.redaction": 0 - }, users: { - "@test_auto_invite:example.org": 150 + "@test_auto_invite:example.org": 100 }, notifications: { room: 0 } }, + "chat.schildi.hide_ui/read_receipts": {hidden: true}, "uk.half-shot.bridge/moe.cadence.ooye://discord/112760669178241024/112760669178241024": { bridgebot: "@_ooye_bot:cadence.moe", protocol: { @@ -142,7 +58,7 @@ module.exports = { network: { id: "112760669178241024", displayname: "Psychonauts 3", - avatar_url: {$url: "/icons/112760669178241024/a_f83622e09ead74f0c5c527fe241f8f8c.png?size=1024"} + avatar_url: "mxc://cadence.moe/zKXGZhmImMHuGQZWJEFKJbsF" }, channel: { id: "112760669178241024", @@ -153,7 +69,6 @@ module.exports = { } }, guild: { - /** @type {DiscordTypes.APIGuild} */ // @ts-ignore general: { owner_id: "112760500130975744", premium_tier: 3, @@ -180,39 +95,6 @@ module.exports = { afk_timeout: 300, id: "112760669178241024", icon: "a_f83622e09ead74f0c5c527fe241f8f8c", - /** @type {DiscordTypes.APIGuildMember[]} */ // @ts-ignore - members: [{ - user: { - username: 'Matrix Bridge', - public_flags: 0, - primary_guild: null, - id: '684280192553844747', - global_name: null, - display_name_styles: null, - display_name: null, - discriminator: '5728', - collectibles: null, - bot: true, - avatar_decoration_data: null, - avatar: '48ae3c24f2a6ec5c60c41bdabd904018' - }, - roles: [ - '703457691342995528', - '289671295359254529', - '1040735082610167858', - '114526764860047367' - ], - premium_since: null, - pending: false, - nick: 'Mother', - mute: false, - joined_at: '2020-04-25T04:09:43.253000+00:00', - flags: 0, - deaf: false, - communication_disabled_until: null, - banner: null, - avatar: null - }], emojis: [ { roles: [], @@ -239,7 +121,7 @@ module.exports = { unicode_emoji: null, tags: {}, position: 0, - permissions: '1122573558996672', + permissions: '559623605575360', name: '@everyone', mentionable: false, managed: false, @@ -288,25 +170,6 @@ module.exports = { hoist: true, flags: 0, color: 16745267 - }, { - version: 1743122443142, - unicode_emoji: null, - tags: {}, - position: 3, - permissions: "0", - name: "Realdditors", - mentionable: true, - managed: false, - id: "1182745800661540927", - icon: null, - hoist: false, - flags: 0, - colors: { - tertiary_color: null, - secondary_color: null, - primary_color: 16729344 - }, - color: 16729344 } ], discovery_splash: null, @@ -326,784 +189,6 @@ module.exports = { max_stage_video_channel_users: 300, system_channel_flags: 0|0, safety_alerts_channel_id: null - }, - fna: { - application_id: null, - roles: [], - activity_instances: [], - banner: null, - stickers: [], - joined_at: "2020-04-25T07:36:09.644000+00:00", - default_message_notifications: 1, - afk_timeout: 60, - clan: null, - hub_type: null, - afk_channel_id: "216367750216548362", - discovery_splash: null, - splash: null, - explicit_content_filter: 0, - max_members: 500000, - premium_subscription_count: 0, - voice_states: [], - id: "66192955777486848", - premium_tier: 0, - name: "Function & Arg", - premium_progress_bar_enabled: false, - icon: "8bfeb3237cd8697d1d1cd5c626ca8cea", - large: true, - verification_level: 0, - public_updates_channel_id: null, - stage_instances: [], - rules_channel_id: null, - emojis: [], - owner_id: "66186356581208064", - threads: [], - max_stage_video_channel_users: 50, - description: null, - unavailable: false, - features: [ - "CHANNEL_ICON_EMOJIS_GENERATED", - "NEW_THREAD_PERMISSIONS", - "THREADS_ENABLED", - "SOUNDBOARD" - ], - latest_onboarding_question_id: null, - max_video_channel_users: 25, - home_header: null, - mfa_level: 0, - system_channel_id: null, - guild_scheduled_events: [], - nsfw_level: 0, - vanity_url_code: null, - member_count: 966, - presences: [], - application_command_counts: {}, - system_channel_flags: 0, - preferred_locale: "en-US", - region: "deprecated", - inventory_settings: null, - soundboard_sounds: [], - version: 1711491959939, - incidents_data: null, - embedded_activities: [], - nsfw: false, - safety_alerts_channel_id: null, - lazy: true - }, - data_horde: { - preferred_locale: "en-US", - afk_channel_id: null, - profile: null, - owner_id: "222343226990788609", - soundboard_sounds: [], - hub_type: null, - mfa_level: 0, - activity_instances: [], - inventory_settings: null, - voice_states: [], - system_channel_id: "675397790204952636", - id: "665289423482519565", - member_count: 138, - clan: null, - default_message_notifications: 1, - name: "Data Horde", - banner: null, - premium_subscription_count: 0, - max_stage_video_channel_users: 50, - max_members: 500000, - incidents_data: null, - joined_at: "2020-05-10T02:00:10.646000+00:00", - unavailable: false, - discovery_splash: null, - threads: [], - system_channel_flags: 0, - safety_alerts_channel_id: null, - nsfw: false, - nsfw_level: 0, - stage_instances: [], - large: false, - icon: "d7c4bdb35c10f21e475a50fb205d5c32", - roles: [ - { - version: 1683238686112, - unicode_emoji: null, - tags: {}, - position: 0, - permissions: "968619318849", - name: "@everyone", - mentionable: false, - managed: false, - id: "665289423482519565", - icon: null, - hoist: false, - flags: 0, - color: 0 - }, - { - version: 1683791258594, - unicode_emoji: null, - tags: {}, - position: 22, - permissions: "7515668211", - name: "Founder", - mentionable: true, - managed: false, - id: "665290147377578005", - icon: null, - hoist: false, - flags: 0, - color: 1752220 - }, - { - version: 1683791258594, - unicode_emoji: null, - tags: {}, - position: 22, - permissions: "8194", - name: "Moderator", - mentionable: true, - managed: false, - id: "682789592390281245", - icon: null, - hoist: false, - flags: 0, - color: 1752220 - }, - { - version: 1683791258580, - unicode_emoji: null, - tags: {}, - position: 19, - permissions: "6546775617", - name: "Gaming Alexandria", - mentionable: false, - managed: false, - id: "684524730274807911", - icon: null, - hoist: false, - flags: 0, - color: 15844367 - } - ], - description: null, - afk_timeout: 300, - verification_level: 1, - latest_onboarding_question_id: null, - guild_scheduled_events: [], - rules_channel_id: null, - embedded_activities: [], - region: "deprecated", - vanity_url_code: null, - application_id: null, - premium_tier: 0, - explicit_content_filter: 0, - stickers: [], - public_updates_channel_id: null, - splash: null, - premium_progress_bar_enabled: false, - features: [], - lazy: true, - max_video_channel_users: 25, - application_command_counts: {}, - home_header: null, - version: 1717720047590, - emojis: [], - presences: [] - }, - pathfinder: { - activity_instances: [], - max_video_channel_users: 25, - mfa_level: 0, - owner_id: "182266888003256320", - stage_instances: [], - profile: null, - rules_channel_id: null, - splash: null, - inventory_settings: null, - max_members: 25000000, - icon: "ec42ae174a7c246568da98983b611f64", - safety_alerts_channel_id: null, - latest_onboarding_question_id: null, - id: "1234728422044074064", - name: "Hub Pathfinder", - embedded_activities: [], - banner: null, - hub_type: null, - threads: [], - lazy: true, - system_channel_id: "1234728422475829318", - member_count: 21, - region: "deprecated", - description: null, - premium_features: null, - verification_level: 0, - unavailable: false, - stickers: [], - application_command_counts: {}, - roles: [ - { - version: 1741255049095, - unicode_emoji: null, - tags: {}, - position: 0, - permissions: "2173706675146305", - name: "@everyone", - mentionable: false, - managed: false, - id: "1234728422044074064", - icon: null, - hoist: false, - flags: 0, - colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, - color: 0 - }, - { - version: 1749271325117, - unicode_emoji: null, - tags: { bot_id: "684280192553844747" }, - position: 8, - permissions: "1610883072", - name: "Matrix Bridge", - mentionable: false, - managed: true, - id: "1235117664326783049", - icon: null, - hoist: false, - flags: 0, - colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, - color: 0 - }, - { - version: 1749271325132, - unicode_emoji: null, - tags: {}, - position: 12, - permissions: "0", - name: "Tuesday", - mentionable: false, - managed: false, - id: "1235396773510647810", - icon: null, - hoist: false, - flags: 0, - colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, - color: 0 - }, - { - version: 1749271325129, - unicode_emoji: null, - tags: {}, - position: 11, - permissions: "0", - name: "Thursday", - mentionable: false, - managed: false, - id: "1235397020919926844", - icon: null, - hoist: false, - flags: 0, - colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, - color: 0 - }, - { - version: 1749271325174, - unicode_emoji: null, - tags: {}, - position: 20, - permissions: "0", - name: "Fighter", - mentionable: false, - managed: false, - id: "1236579627615518720", - icon: null, - hoist: false, - flags: 0, - colors: { - tertiary_color: null, - secondary_color: null, - primary_color: 12657443 - }, - color: 12657443 - }, - { - version: 1749271325189, - unicode_emoji: null, - tags: {}, - position: 24, - permissions: "0", - name: "Bard", - mentionable: false, - managed: false, - id: "1236579780544036904", - icon: null, - hoist: false, - flags: 0, - colors: { - tertiary_color: null, - secondary_color: null, - primary_color: 12468701 - }, - color: 12468701 - }, - { - version: 1749271325179, - unicode_emoji: null, - tags: {}, - position: 22, - permissions: "0", - name: "Cleric", - mentionable: false, - managed: false, - id: "1236579861997555763", - icon: null, - hoist: false, - flags: 0, - colors: { - tertiary_color: null, - secondary_color: null, - primary_color: 14186005 - }, - color: 14186005 - }, - { - version: 1749271325138, - unicode_emoji: null, - tags: {}, - position: 14, - permissions: "0", - name: "Wizard", - mentionable: false, - managed: false, - id: "1236579900731822110", - icon: null, - hoist: false, - flags: 0, - colors: { - tertiary_color: null, - secondary_color: null, - primary_color: 3106806 - }, - color: 3106806 - }, - { - version: 1749271325176, - unicode_emoji: null, - tags: {}, - position: 21, - permissions: "0", - name: "Druid", - mentionable: false, - managed: false, - id: "1236579988254232606", - icon: null, - hoist: false, - flags: 0, - colors: { - tertiary_color: null, - secondary_color: null, - primary_color: 8248698 - }, - color: 8248698 - }, - { - version: 1749271325147, - unicode_emoji: null, - tags: {}, - position: 15, - permissions: "0", - name: "Witch", - mentionable: false, - managed: false, - id: "1236580304232255581", - icon: null, - hoist: false, - flags: 0, - colors: { - tertiary_color: null, - secondary_color: null, - primary_color: 1737848 - }, - color: 1737848 - }, - { - version: 1749271325206, - unicode_emoji: null, - tags: {}, - position: 28, - permissions: "8", - name: "DM", - mentionable: false, - managed: false, - id: "1236581109391949875", - icon: null, - hoist: false, - flags: 0, - colors: { - tertiary_color: null, - secondary_color: null, - primary_color: 6507441 - }, - color: 6507441 - }, - { - version: 1749271325156, - unicode_emoji: null, - tags: {}, - position: 17, - permissions: "0", - name: "Ranger", - mentionable: false, - managed: false, - id: "1240571725914312825", - icon: null, - hoist: false, - flags: 0, - colors: { - tertiary_color: null, - secondary_color: null, - primary_color: 2067276 - }, - color: 2067276 - }, - { - version: 1749271325151, - unicode_emoji: null, - tags: {}, - position: 16, - permissions: "0", - name: "Rogue", - mentionable: false, - managed: false, - id: "1249165855632265267", - icon: null, - hoist: false, - flags: 0, - colors: { - tertiary_color: null, - secondary_color: null, - primary_color: 9936031 - }, - color: 9936031 - }, - { - version: 1749271325123, - unicode_emoji: null, - tags: {}, - position: 10, - permissions: "0", - name: "Questions Ping!", - mentionable: false, - managed: false, - id: "1249167820571541534", - icon: null, - hoist: false, - flags: 0, - colors: { - tertiary_color: null, - secondary_color: null, - primary_color: 13297400 - }, - color: 13297400 - }, - { - version: 1749271325198, - unicode_emoji: null, - tags: {}, - position: 25, - permissions: "0", - name: "Barbarian", - mentionable: false, - managed: false, - id: "1344484288241991730", - icon: null, - hoist: false, - flags: 0, - colors: { - tertiary_color: null, - secondary_color: null, - primary_color: 8145454 - }, - color: 8145454 - }, - { - version: 1749271325200, - unicode_emoji: null, - tags: {}, - position: 26, - permissions: "0", - name: "Alchemist", - mentionable: false, - managed: false, - id: "1352190431944900628", - icon: null, - hoist: false, - flags: 0, - colors: { - tertiary_color: null, - secondary_color: null, - primary_color: 15844367 - }, - color: 15844367 - }, - { - version: 1749271325168, - unicode_emoji: null, - tags: {}, - position: 19, - permissions: "0", - name: "Investigator", - mentionable: false, - managed: false, - id: "1353890353391866028", - icon: null, - hoist: false, - flags: 0, - colors: { - tertiary_color: null, - secondary_color: null, - primary_color: 10068223 - }, - color: 10068223 - }, - { - version: 1749271325134, - unicode_emoji: null, - tags: {}, - position: 13, - permissions: "0", - name: "Monday", - mentionable: false, - managed: false, - id: "1359752622130593802", - icon: null, - hoist: false, - flags: 0, - colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, - color: 0 - }, - { - version: 1749271325162, - unicode_emoji: null, - tags: {}, - position: 18, - permissions: "0", - name: "Monk", - mentionable: false, - managed: false, - id: "1359753361963880590", - icon: null, - hoist: false, - flags: 0, - colors: { - tertiary_color: null, - secondary_color: null, - primary_color: 3447003 - }, - color: 3447003 - }, - { - version: 1749271325183, - unicode_emoji: null, - tags: {}, - position: 23, - permissions: "0", - name: "Champion", - mentionable: false, - managed: false, - id: "1359753472186122320", - icon: null, - hoist: false, - flags: 0, - colors: { - tertiary_color: null, - secondary_color: null, - primary_color: 15277667 - }, - color: 15277667 - }, - { - version: 1749271325114, - unicode_emoji: null, - tags: { bot_id: "431544605209788416" }, - position: 7, - permissions: "275415166016", - name: "Tupperbox", - mentionable: false, - managed: true, - id: "1377128320814153862", - icon: null, - hoist: false, - flags: 0, - colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, - color: 0 - }, - { - version: 1749271325120, - unicode_emoji: null, - tags: {}, - position: 9, - permissions: "0", - name: "PbD ping", - mentionable: false, - managed: false, - id: "1377139953510907995", - icon: null, - hoist: false, - flags: 0, - colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, - color: 0 - }, - { - version: 1749271325109, - unicode_emoji: null, - tags: { bot_id: "644942473315090434" }, - position: 6, - permissions: "535529122897", - name: "RPG Sage", - mentionable: false, - managed: true, - id: "1377144599310503959", - icon: null, - hoist: false, - flags: 0, - colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, - color: 0 - }, - { - version: 1749271325106, - unicode_emoji: null, - tags: { bot_id: "572698679618568193" }, - position: 5, - permissions: "278528", - name: "Dicecord", - mentionable: false, - managed: true, - id: "1378726921990307974", - icon: null, - hoist: false, - flags: 0, - colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, - color: 0 - }, - { - version: 1749271325203, - unicode_emoji: null, - tags: { bot_id: "443545183997657120" }, - position: 27, - permissions: "2097540216", - name: "ChannelBot", - mentionable: false, - managed: true, - id: "1380744875108204658", - icon: null, - hoist: false, - flags: 0, - colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, - color: 0 - }, - { - version: 1749271325101, - unicode_emoji: null, - tags: {}, - position: 4, - permissions: "0", - name: "Play-by-Discord", - mentionable: false, - managed: false, - id: "1380748596537720872", - icon: null, - hoist: false, - flags: 0, - colors: { - tertiary_color: null, - secondary_color: null, - primary_color: 16377559 - }, - color: 16377559 - }, - { - version: 1749271325098, - unicode_emoji: null, - tags: {}, - position: 3, - permissions: "0", - name: "Boredom Busters", - mentionable: false, - managed: false, - id: "1380756348190462015", - icon: null, - hoist: false, - flags: 0, - colors: { - tertiary_color: null, - secondary_color: null, - primary_color: 14542591 - }, - color: 14542591 - }, - { - version: 1749271361998, - unicode_emoji: null, - tags: {}, - position: 1, - permissions: "0", - name: "Bots", - mentionable: false, - managed: false, - id: "1380767647578460311", - icon: null, - hoist: true, - flags: 0, - colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, - color: 0 - }, - { - version: 1749271362001, - unicode_emoji: null, - tags: {}, - position: 2, - permissions: "0", - name: "Players", - mentionable: false, - managed: false, - id: "1380768596929806356", - icon: null, - hoist: true, - flags: 0, - colors: { tertiary_color: null, secondary_color: null, primary_color: 0 }, - color: 0 - } - ], - vanity_url_code: null, - afk_timeout: 300, - premium_tier: 0, - joined_at: "2024-05-01T06:36:38.605000+00:00", - public_updates_channel_id: null, - premium_subscription_count: 0, - soundboard_sounds: [], - home_header: null, - discovery_splash: null, - guild_scheduled_events: [], - system_channel_flags: 0, - preferred_locale: "en-US", - large: false, - explicit_content_filter: 0, - moderator_reporting: null, - features: [ - "TIERLESS_BOOSTING_SYSTEM_MESSAGE", - "ACTIVITY_FEED_DISABLED_BY_USER" - ], - version: 1750145431881, - owner_configured_content_level: null, - voice_states: [], - default_message_notifications: 1, - application_id: null, - incidents_data: null, - nsfw_level: 0, - premium_progress_bar_enabled: false, - afk_channel_id: null, - max_stage_video_channel_users: 50, - nsfw: false } }, user: { @@ -1121,19 +206,6 @@ module.exports = { global_name: "Clyde", avatar_decoration_data: null, banner_color: null - }, - jerassicore: { - username: "ser_jurassicore", - public_flags: 0, - primary_guild: null, - id: "493801948345139202", - global_name: "Jurassicore", - display_name_styles: null, - discriminator: "0", - collectibles: null, - clan: null, - avatar_decoration_data: null, - avatar: "2a4fa0de3aaea30f457ed7bba64176aa" } }, member: { @@ -1256,14 +328,12 @@ module.exports = { } }, pins: { - faked: { - items: [ - {message: {id: "1126786462646550579"}}, - {message: {id: "1141501302736695316"}}, - {message: {id: "1106366167788044450"}}, - {message: {id: "1115688611186193400"}} - ] - } + faked: [ + {id: "1126786462646550579"}, + {id: "1141501302736695316"}, + {id: "1106366167788044450"}, + {id: "1115688611186193400"} + ] }, message: { // Display order is text content, attachments, then stickers @@ -1433,63 +503,6 @@ module.exports = { attachments: [], guild_id: "112760669178241024" }, - simple_room_link: { - type: 0, - tts: false, - timestamp: "2023-07-10T20:04:25.939000+00:00", - referenced_message: null, - pinned: false, - nonce: "1128054139385806848", - mentions: [], - mention_roles: [], - mention_everyone: false, - member: { - roles: [ - "112767366235959296", "118924814567211009", - "204427286542417920", "199995902742626304", - "222168467627835392", "238028326281805825", - "259806643414499328", "265239342648131584", - "271173313575780353", "287733611912757249", - "225744901915148298", "305775031223320577", - "318243902521868288", "348651574924541953", - "349185088157777920", "378402925128712193", - "392141548932038658", "393912152173576203", - "482860581670486028", "495384759074160642", - "638988388740890635", "373336013109461013", - "530220455085473813", "454567553738473472", - "790724320824655873", "1123518980456452097", - "1040735082610167858", "695946570482450442", - "1123460940935991296", "849737964090556488" - ], - premium_since: null, - pending: false, - nick: null, - mute: false, - joined_at: "2015-11-11T09:55:40.321000+00:00", - flags: 0, - deaf: false, - communication_disabled_until: null, - avatar: null - }, - id: "1128054143064494233", - flags: 0, - embeds: [], - edited_timestamp: null, - content: "https://discord.com/channels/112760669178241024/1100319550446252084", - components: [], - channel_id: "266767590641238027", - author: { - username: "kumaccino", - public_flags: 128, - id: "113340068197859328", - global_name: "kumaccino", - discriminator: "0", - avatar_decoration: null, - avatar: "b48302623a12bc7c59a71328f72ccb39" - }, - attachments: [], - guild_id: "112760669178241024" - }, nicked_room_mention: { type: 0, tts: false, @@ -1928,93 +941,6 @@ module.exports = { components: [] } }, - reply_to_unknown_message: { - type: 19, - content: "enigmatic", - mentions: [ - { - id: "1060361805152669766", - username: "occimyy", - avatar: "009d2bf557bca7d4f5a1d5b75a4e2eea", - discriminator: "0", - public_flags: 0, - flags: 0, - banner: null, - accent_color: null, - global_name: "Lily", - avatar_decoration_data: null, - banner_color: null, - clan: null, - primary_guild: null - } - ], - mention_roles: [], - attachments: [], - embeds: [], - timestamp: "2025-02-22T23:34:14.036000+00:00", - edited_timestamp: null, - flags: 0, - components: [], - id: "1343002945670746173", - channel_id: "392141322863116319", - author: { - id: "114147806469554185", - username: "extremity", - avatar: "0c73816563bf912ccebf1a0f1546cfe4", - discriminator: "0", - public_flags: 768, - flags: 768, - banner: null, - accent_color: null, - global_name: null, - avatar_decoration_data: null, - banner_color: null, - clan: null, - primary_guild: null - }, - pinned: false, - mention_everyone: false, - tts: false, - message_reference: { - type: 0, - channel_id: "392141322863116319", - message_id: "1342606571380674560", - guild_id: "112760669178241024" - }, - position: 0, - referenced_message: { - type: 0, - content: "BILLY BOB THE GREAT", - mentions: [], - mention_roles: [], - attachments: [], - embeds: [], - timestamp: "2025-02-21T21:19:11.041000+00:00", - edited_timestamp: null, - flags: 0, - components: [], - id: "1342606571380674560", - channel_id: "392141322863116319", - author: { - id: "1060361805152669766", - username: "occimyy", - avatar: "009d2bf557bca7d4f5a1d5b75a4e2eea", - discriminator: "0", - public_flags: 0, - flags: 0, - banner: null, - accent_color: null, - global_name: "Occimyy", - avatar_decoration_data: null, - banner_color: null, - clan: null, - primary_guild: null - }, - pinned: false, - mention_everyone: false, - tts: false - } - }, attachment_no_content: { id: "1124628646670389348", type: 0, @@ -2425,95 +1351,6 @@ module.exports = { attachments: [], guild_id: "112760669178241024" }, - reply_to_matrix_user_mention: { - type: 19, - content: "kys", - mentions: [], - mention_roles: [], - attachments: [], - embeds: [], - timestamp: "2025-08-04T05:31:26.506000+00:00", - edited_timestamp: null, - flags: 0, - components: [], - id: "1401799674192723998", - channel_id: "112760669178241024", - author: { - id: "114147806469554185", - username: "extremity", - avatar: "e0394d500407a8fa93774e1835b8b03a", - discriminator: "0", - public_flags: 0, - flags: 0, - banner: null, - accent_color: null, - global_name: "Extremity", - avatar_decoration_data: null, - collectibles: null, - display_name_styles: null, - banner_color: null, - clan: null, - primary_guild: null - }, - pinned: false, - mention_everyone: false, - tts: false, - message_reference: { - type: 0, - channel_id: "112760669178241024", - message_id: "1401760355339862066", - guild_id: "112760669178241024" - }, - referenced_message: { - type: 0, - content: "<@114147806469554185> you owe me $30", - mentions: [ - { - id: "114147806469554185", - username: "extremity", - avatar: "e0394d500407a8fa93774e1835b8b03a", - discriminator: "0", - public_flags: 0, - flags: 0, - banner: null, - accent_color: null, - global_name: "Extremity", - avatar_decoration_data: null, - collectibles: null, - display_name_styles: null, - banner_color: null, - clan: null, - primary_guild: null - } - ], - mention_roles: [], - attachments: [], - embeds: [], - timestamp: "2025-08-04T02:55:12.161000+00:00", - edited_timestamp: null, - flags: 0, - components: [], - id: "1401760355339862066", - channel_id: "112760669178241024", - author: { - id: "1152700216189911081", - username: "okay 🤍 yay 🤍", - avatar: "90bc1d6912252d4fa9f92a2f5f6d347b", - discriminator: "0000", - public_flags: 0, - flags: 0, - bot: true, - global_name: null, - clan: null, - primary_guild: null - }, - pinned: false, - mention_everyone: false, - tts: false, - application_id: "684280192553844747", - webhook_id: "1152700216189911081" - } - }, reply_with_video: { id: "1197621094983676007", type: 19, @@ -2692,47 +1529,6 @@ module.exports = { flags: 0, components: [] }, - large_file_from_matrix: { - type: 0, - content: "", - attachments: [ - { - id: "1439351589474140290", - filename: "image.png", - size: 5112701, - url: "https://cdn.discordapp.com/attachments/1438284564815548418/1439351589474140290/image.png?ex=691cd720&is=691b85a0&hm=671d32324ce17acb9708057f9a532a3184d02343747b32b2ad8d330a277d8f65&", - proxy_url: "https://media.discordapp.net/attachments/1438284564815548418/1439351589474140290/image.png?ex=691cd720&is=691b85a0&hm=671d32324ce17acb9708057f9a532a3184d02343747b32b2ad8d330a277d8f65&", - width: 1930, - height: 2522, - content_type: "image/png", - flags: 16, - content_scan_version: 2, - placeholder: "ZhgKDQJ6pIp2B5hmhndoZ0lgiwTJ", - placeholder_version: 1, - spoiler: false - } - ], - embeds: [], - timestamp: new Date().toISOString(), - edited_timestamp: null, - flags: 0, - components: [], - id: "1439351590262800565", - channel_id: "1438284564815548418", - author: { - id: "1438286167706701958", - username: "cibo", - discriminator: "0000", - avatar: "9c9e1d63ce093e76b9cdb99328c91201", - bot: true - }, - pinned: false, - mentions: [], - mention_roles: [], - mention_everyone: false, - tts: false, - webhook_id: "1438286167706701958" - }, simple_reply_to_reply_in_thread: { type: 19, tts: false, @@ -3219,37 +2015,6 @@ module.exports = { flags: 0, components: [] }, - emojihax: { - id: "1126733830494093453", - type: 0, - content: "I only violate the don't modify our console part of terms of service [troll~1](https://cdn.discordapp.com/emojis/1254940125948022915.webp?size=48&name=troll%7E1&lossless=true)", - channel_id: "112760669178241024", - author: { - id: "111604486476181504", - username: "kyuugryphon", - avatar: "e4ce31267ca524d19be80e684d4cafa1", - discriminator: "0", - public_flags: 0, - flags: 0, - banner: null, - accent_color: null, - global_name: "KyuuGryphon", - avatar_decoration: null, - display_name: "KyuuGryphon", - banner_color: null - }, - attachments: [], - embeds: [], - mentions: [], - mention_roles: [], - pinned: false, - mention_everyone: false, - tts: false, - timestamp: "2023-07-07T04:37:58.892000+00:00", - edited_timestamp: null, - flags: 0, - components: [] - }, emoji_triple_long_name: { id: "1156394116540805170", type: 0, @@ -3364,493 +2129,7 @@ module.exports = { mention_everyone: false, tts: false } - }, - forwarded_image: { type: 0, - content: "", - mentions: [], - mention_roles: [], - attachments: [], - embeds: [], - timestamp: "2024-10-16T22:25:01.973000+00:00", - edited_timestamp: null, - flags: 16384, - components: [], - id: "1296237495993892916", - channel_id: "112760669178241024", - author: { - id: "113340068197859328", - username: "kumaccino", - avatar: "a8829abe66866d7797b36f0bfac01086", - discriminator: "0", - public_flags: 128, - flags: 128, - banner: null, - accent_color: null, - global_name: "kumaccino", - avatar_decoration_data: null, - banner_color: null, - clan: null - }, - pinned: false, - mention_everyone: false, - tts: false, - message_reference: { - type: 1, - channel_id: "1019762340922663022", - message_id: "1019779830469894234" - }, - position: 0, - message_snapshots: [ - { - message: { - type: 0, - content: "", - mentions: [], - mention_roles: [], - attachments: [ - { - id: "1296237494987133070", - filename: "100km.gif", - size: 2965649, - url: "https://cdn.discordapp.com/attachments/112760669178241024/1296237494987133070/100km.gif?ex=67118ebd&is=67103d3d&hm=8ed76d424f92f11366989f2ebc713d4f8206706ef712571e934da45b59944f77&", proxy_url: "https://media.discordapp.net/attachments/112760669178241024/1296237494987133070/100km.gif?ex=67118ebd&is=67103d3d&hm=8ed76d424f92f11366989f2ebc713d4f8206706ef712571e934da45b59944f77&", width: 300, - height: 300, - content_type: "image/gif" - } - ], - embeds: [], - timestamp: "2022-09-15T01:20:58.177000+00:00", - edited_timestamp: null, - flags: 0, - components: [] - } - } - ] - }, - constructed_forwarded_message: { type: 0, - content: "", - mentions: [], - mention_roles: [], - attachments: [], - embeds: [], - timestamp: "2024-10-16T22:25:01.973000+00:00", - edited_timestamp: null, - flags: 16384, - components: [], - id: "1296237495993892916", - channel_id: "112760669178241024", - author: { - id: "113340068197859328", - username: "kumaccino", - avatar: "a8829abe66866d7797b36f0bfac01086", - discriminator: "0", - public_flags: 128, - flags: 128, - banner: null, - accent_color: null, - global_name: "kumaccino", - avatar_decoration_data: null, - banner_color: null, - clan: null - }, - pinned: false, - mention_everyone: false, - tts: false, - message_reference: { - type: 1, - channel_id: "176333891320283136", - message_id: "1191567971970191490" - }, - position: 0, - message_snapshots: [ - { - message: { - type: 0, - content: "What's cooking, good looking? <:hipposcope:393635038903926784>", - mentions: [], - mention_roles: [], - attachments: [ - { - id: "1296237494987133070", - filename: "100km.gif", - size: 2965649, - url: "https://cdn.discordapp.com/attachments/112760669178241024/1296237494987133070/100km.gif?ex=67118ebd&is=67103d3d&hm=8ed76d424f92f11366989f2ebc713d4f8206706ef712571e934da45b59944f77&", proxy_url: "https://media.discordapp.net/attachments/112760669178241024/1296237494987133070/100km.gif?ex=67118ebd&is=67103d3d&hm=8ed76d424f92f11366989f2ebc713d4f8206706ef712571e934da45b59944f77&", width: 300, - height: 300, - content_type: "image/gif" - } - ], - embeds: [{ - type: "rich", - title: "This man is 100 km away from your house", - author: { - name: "This man" - }, - fields: [{ - name: "Distance away", - value: "99 km" - }, { - name: "Distance away", - value: "98 km" - }] - }], - timestamp: "2022-09-15T01:20:58.177000+00:00", - edited_timestamp: null, - flags: 0, - components: [] - } - } - ] - }, - constructed_forwarded_text: { type: 0, - content: "What's cooking everybody ‼️", - mentions: [], - mention_roles: [], - attachments: [], - embeds: [], - timestamp: "2024-10-16T22:25:01.973000+00:00", - edited_timestamp: null, - flags: 16384, - components: [], - id: "1296237495993892916", - channel_id: "112760669178241024", - author: { - id: "113340068197859328", - username: "kumaccino", - avatar: "a8829abe66866d7797b36f0bfac01086", - discriminator: "0", - public_flags: 128, - flags: 128, - banner: null, - accent_color: null, - global_name: "kumaccino", - avatar_decoration_data: null, - banner_color: null, - clan: null - }, - pinned: false, - mention_everyone: false, - tts: false, - message_reference: { - type: 1, - channel_id: "497161350934560778", - message_id: "0" - }, - position: 0, - message_snapshots: [ - { - message: { - type: 0, - content: "What's cooking, good looking?", - mentions: [], - mention_roles: [], - attachments: [], - embeds: [], - timestamp: "2022-09-15T01:20:58.177000+00:00", - edited_timestamp: null, - flags: 0, - components: [] - } - } - ] - }, - forwarded_dont_scan_for_mentions: { - type: 0, - tts: false, - timestamp: "2025-02-08T09:07:45.547000+00:00", - position: 0, - pinned: false, - nonce: "1337711633497063424", - message_snapshots: [ - { - message: { - type: 0, - timestamp: "2025-02-08T09:00:07.662000+00:00", - mentions: [], - flags: 0, - embeds: [], - edited_timestamp: null, - content: "If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile https://social.luca.run/@luca/113950834185678114", - components: [], - attachments: [] - } - } - ], - message_reference: { - type: 1, - message_id: "1337709539516223539", - guild_id: "500415824616620032", - channel_id: "794935364182867968" - }, - mentions: [], - mention_roles: [], - mention_everyone: false, - member: { - roles: [ - "1152297516755337248", - "300045569441660938", - "365531770420199435", - "1035943385338482698", - "1205645591212990515", - "1084555882341339259" - ], - premium_since: null, - pending: false, - nick: null, - mute: false, - joined_at: "2023-12-27T13:02:41.614000+00:00", - flags: 0, - deaf: false, - communication_disabled_until: null, - banner: null, - avatar: null - }, - id: "1337711460024844350", - flags: 16384, - embeds: [], - edited_timestamp: null, - content: "", - components: [], - channel_type: 0, - channel_id: "286888431945252874", - author: { - username: "athenna2000", - public_flags: 0, - primary_guild: null, - id: "620341774984151063", - global_name: "Amelia 🍄", - discriminator: "0", - clan: null, - avatar_decoration_data: null, - avatar: "a30f5b1bf17b5a5f387f1bb49771a2f8" - }, - attachments: [], - guild_id: "286888431945252874" - }, - poll_single_choice: { - type: 0, - content: "", - mentions: [], - mention_roles: [], - attachments: [], - embeds: [], - timestamp: "2025-02-15T23:19:04.127000+00:00", - edited_timestamp: null, - flags: 0, - components: [], - id: "1340462414176718889", - channel_id: "1340048919589158986", - author: { - id: "307894326028140546", - username: "ellienyaa", - avatar: "f98417a0a0b4aecc7d7667bece353b7e", - discriminator: "0", - public_flags: 128, - flags: 128, - banner: null, - accent_color: null, - global_name: "unambiguously boring username", - avatar_decoration_data: null, - banner_color: null, - clan: null, - primary_guild: null - }, - pinned: false, - mention_everyone: false, - tts: false, - position: 0, - poll: { - question: { - text: "only one answer allowed!" - }, - answers: [ - { - answer_id: 1, - poll_media: { - text: "answer one", - emoji: { - id: null, - name: "\ud83d\udc4d" - } - } - }, - { - answer_id: 2, - poll_media: { - text: "answer two", - emoji: { - id: null, - name: "\ud83d\udc4e" - } - } - }, - { - answer_id: 3, - poll_media: { - text: "answer three" - } - } - ], - expiry: "2025-02-16T23:19:04.122364+00:00", - allow_multiselect: false, - layout_type: 1, - results: { - answer_counts: [], - is_finalized: false - } - } - }, - poll_multiple_choice: { - type: 0, - content: "", - mentions: [], - mention_roles: [], - attachments: [], - embeds: [], - timestamp: "2025-02-16T00:47:12.310000+00:00", - edited_timestamp: null, - flags: 0, - components: [], - id: "1340484594423562300", - channel_id: "1340048919589158986", - author: { - id: "307894326028140546", - username: "ellienyaa", - avatar: "f98417a0a0b4aecc7d7667bece353b7e", - discriminator: "0", - public_flags: 128, - flags: 128, - banner: null, - accent_color: null, - global_name: "unambiguously boring username", - avatar_decoration_data: null, - banner_color: null, - clan: null, - primary_guild: null - }, - pinned: false, - mention_everyone: false, - tts: false, - position: 0, - poll: { - question: { - text: "more than one answer allowed" - }, - answers: [ - { - answer_id: 1, - poll_media: { - text: "no", - emoji: { - id: null, - name: "😭" - } - } - }, - { - answer_id: 2, - poll_media: { - text: "oh no", - emoji: { - id: "891723675261366292", - name: "this" - } - } - }, - { - answer_id: 3, - poll_media: { - text: "oh noooooo", - emoji: { - id: "964520120682680350", - name: "disapprove" - } - } - } - ], - expiry: "2025-02-17T00:47:12.307985+00:00", - allow_multiselect: true, - layout_type: 1, - results: { - answer_counts: [], - is_finalized: false - } - } - }, - poll_close: { - type: 46, - content: "", - mentions: [ - { - id: "307894326028140546", - username: "ellienyaa", - avatar: "f98417a0a0b4aecc7d7667bece353b7e", - discriminator: "0", - public_flags: 128, - flags: 128, - banner: null, - accent_color: null, - global_name: "unambiguously boring username", - avatar_decoration_data: null, - banner_color: null, - clan: null, - primary_guild: null - } - ], - mention_roles: [], - attachments: [], - embeds: [ - { - type: "poll_result", - fields: [ - { - name: "poll_question_text", - value: "test poll that's being closed", - inline: false - }, - { - name: "victor_answer_votes", - value: "0", - inline: false - }, - { - name: "total_votes", - value: "0", - inline: false - } - ], - content_scan_version: 0 - } - ], - timestamp: "2025-02-20T23:07:12.178000+00:00", - edited_timestamp: null, - flags: 0, - components: [], - id: "1342271367374049351", - channel_id: "1340048919589158986", - author: { - id: "307894326028140546", - username: "ellienyaa", - avatar: "f98417a0a0b4aecc7d7667bece353b7e", - discriminator: "0", - public_flags: 128, - flags: 128, - banner: null, - accent_color: null, - global_name: "unambiguously boring username", - avatar_decoration_data: null, - banner_color: null, - clan: null, - primary_guild: null - }, - pinned: false, - mention_everyone: false, - tts: false, - message_reference: { - type: 0, - channel_id: "1340048919589158986", - message_id: "1342271353990021206" - }, - position: 0 - } + } }, pk_message: { pk_reply_to_matrix: { @@ -4194,7 +2473,6 @@ module.exports = { }, webhook_id: "1109360903096369153" }, - reply_with_only_embed: { type: 19, tts: false, @@ -4946,304 +3224,6 @@ module.exports = { edited_timestamp: null, flags: 0, components: [] - }, - klipy_gif: { - type: 0, - content: "https://klipy.com/gifs/cute-15", - mentions: [], - mention_roles: [], - attachments: [], - embeds: [ - { - type: "gifv", - url: "https://klipy.com/gifs/cute-15", - title: "Cute Corgi Waddle", - provider: { - name: "Klipy", - url: "https://klipy.com" - }, - thumbnail: { - url: "https://static.klipy.com/ii/d7aec6f6f171607374b2065c836f92f4/5b/5b/xHVF6sVV.webp", - proxy_url: "https://images-ext-1.discordapp.net/external/Z54QmlQflPPb6NoXikflBHGmttgRm3_jhzmcILXHhcA/https/static.klipy.com/ii/d7aec6f6f171607374b2065c836f92f4/5b/5b/xHVF6sVV.webp", - width: 277, - height: 498, - placeholder: "3gcGDAJV+WZYl3RpZ2gGeFBxBw==", - placeholder_version: 1, - flags: 0 - }, - video: { - url: "https://static.klipy.com/ii/d7aec6f6f171607374b2065c836f92f4/5b/5b/7ndEhcilPNKJ8O.mp4", - proxy_url: "https://images-ext-1.discordapp.net/external/xZspzkQPUKBa74pBhJDpBf3v2d3d0lC943xaB9_JnoM/https/static.klipy.com/ii/d7aec6f6f171607374b2065c836f92f4/5b/5b/7ndEhcilPNKJ8O.mp4", - width: 356, - height: 640, - placeholder: "3gcGDAJV+WZYl3RpZ2gGeFBxBw==", - placeholder_version: 1, - flags: 0 - }, - content_scan_version: 4 - } - ], - timestamp: "2026-02-03T11:11:50.070000+00:00", - edited_timestamp: null, - flags: 0, - components: [], - id: "1468202316233707613", - channel_id: "1370776315266859131", - author: { - id: "304655299631906816", - username: "witterson", - avatar: "47ec94a1b2b4cc41ce0329b3575e9b66", - discriminator: "0", - public_flags: 0, - flags: 0, - banner: null, - accent_color: null, - global_name: "wit", - avatar_decoration_data: null, - collectibles: null, - display_name_styles: null, - banner_color: null, - clan: null, - primary_guild: null - }, - pinned: false, - mention_everyone: false, - tts: false - }, - tenor_gif: { - type: 0, - content: "<@&1182745800661540927> get real https://tenor.com/view/get-real-gif-26176788", - mentions: [], - mention_roles: [ "1182745800661540927" ], - attachments: [], - embeds: [ - { - type: "gifv", - url: "https://tenor.com/view/get-real-gif-26176788", - provider: { name: "Tenor", url: "https://tenor.co" }, - thumbnail: { - url: "https://media.tenor.com/Bz5pfRIu81oAAAAe/get-real.png", - proxy_url: "https://images-ext-1.discordapp.net/external/I71Ngw9drAKZhL_lhQRnAD_A-DkRNgN3EeZ2njv3Vi4/https/media.tenor.com/Bz5pfRIu81oAAAAe/get-real.png", - width: 632, - height: 640, - placeholder: "IBgSHwSYaIePiHh/d7h3d4eEJvkchZsA", - placeholder_version: 1, - flags: 0 - }, - video: { - url: "https://media.tenor.com/Bz5pfRIu81oAAAPo/get-real.mp4", - proxy_url: "https://images-ext-1.discordapp.net/external/vNEtsZd1p_mWQh-nEIa0ZBndMEo2_oa1sAOMyXsgoWI/https/media.tenor.com/Bz5pfRIu81oAAAPo/get-real.mp4", - width: 632, - height: 640, - placeholder: "IBgSHwSYaIePiHh/d7h3d4eEJvkchZsA", - placeholder_version: 1, - flags: 0 - } - } - ], - timestamp: "2025-06-08T03:49:08.500000+00:00", - edited_timestamp: null, - flags: 0, - components: [], - id: "1381117821190279271", - channel_id: "1099031887500034088", - author: { - id: "771520384671416320", - username: "Bojack Horseman", - avatar: "d14f47194b6ebe4da2e18a56fc6dacfd", - discriminator: "9703", - public_flags: 0, - flags: 0, - bot: true, - banner: null, - accent_color: null, - global_name: null, - avatar_decoration_data: null, - collectibles: null, - banner_color: null, - clan: null, - primary_guild: null - }, - pinned: false, - mention_everyone: false, - tts: false - } - }, - message_with_components: { - pk_question_mark_response: { - type: 0, - content: '', - mentions: [], - mention_roles: [], - attachments: [], - embeds: [], - timestamp: '2026-01-30T01:20:07.488000+00:00', - edited_timestamp: null, - flags: 32768, - author: { - id: '466378653216014359', - username: 'PluralKit', - avatar: '466df0c98b1af1e1388f595b4c1ad1b9', - discriminator: '0', - public_flags: 0, - flags: 0, - bot: true, - banner: null, - accent_color: null, - global_name: 'PluralKit', - avatar_decoration_data: null, - collectibles: null, - display_name_styles: null, - banner_color: null - }, - components: [ - { - type: 17, - id: 1, - accent_color: 1042150, - components: [ - { - type: 9, - id: 2, - components: [ - { type: 10, id: 3, content: '### Lillith (INX)' }, - { - type: 10, - id: 4, - content: '**Display name:** Lillith (she/her)\n' + - '**Pronouns:** She/Her\n' + - '**Message count:** 3091' - } - ], - accessory: { - type: 11, - id: 5, - media: { - id: '1466603856149610687', - url: 'https://files.inx.moe/p/cdn/lillith.webp', - proxy_url: 'https://images-ext-1.discordapp.net/external/Kn5b32mM4o8AAQbq0k39KOzp9-fy6D1tWKvK_XI27LI/https/files.inx.moe/p/cdn/lillith.webp', - width: 256, - height: 256, - placeholder: 'KVoKJwSnt7lZl5ecj1mal5eGWjAHZXIA', - placeholder_version: 1, - content_scan_metadata: { version: 4, flags: 0 }, - content_type: 'image/webp', - loading_state: 2, - flags: 0 - }, - description: null, - spoiler: false - } - }, - { type: 14, id: 6, spacing: 1, divider: true }, - { - type: 10, - id: 7, - content: '**Proxy tags:**\n' + - '``l;text``\n' + - '``l:text``\n' + - '``l.text``\n' + - '``textl.``\n' + - '``textl;``\n' + - '``textl:``' - } - ], - spoiler: false - }, - { - type: 9, - id: 8, - components: [ - { - type: 10, - id: 9, - content: '-# System ID: `xffgnx` ∙ Member ID: `pphhoh`\n' + - '-# Created: 2025-12-31 03:16:45 UTC' - } - ], - accessory: { - type: 2, - id: 10, - style: 5, - label: 'View on dashboard', - url: 'https://dash.pluralkit.me/profile/m/pphhoh' - } - }, - { type: 14, id: 11, spacing: 1, divider: true }, - { - type: 17, - id: 12, - accent_color: null, - components: [ - { - type: 9, - id: 13, - components: [ - { - type: 10, - id: 14, - content: '**System:** INX (`xffgnx`)\n' + - '**Member:** Lillith (`pphhoh`)\n' + - '**Sent by:** infinidoge1337 (<@197126718400626689>)\n' + - '\n' + - '**Account Roles (7)**\n' + - '§b, !, ‼, Ears Port Ping, Ears Update Ping, Yttr Ping, unsup Ping' - } - ], - accessory: { - type: 11, - id: 15, - media: { - id: '1466603856149610689', - url: 'https://files.inx.moe/p/cdn/lillith.webp', - proxy_url: 'https://images-ext-1.discordapp.net/external/Kn5b32mM4o8AAQbq0k39KOzp9-fy6D1tWKvK_XI27LI/https/files.inx.moe/p/cdn/lillith.webp', - width: 256, - height: 256, - placeholder: 'KVoKJwSnt7lZl5ecj1mal5eGWjAHZXIA', - placeholder_version: 1, - content_scan_metadata: { version: 4, flags: 0 }, - content_type: 'image/webp', - loading_state: 2, - flags: 0 - }, - description: null, - spoiler: false - } - }, - { type: 14, id: 16, spacing: 2, divider: true }, - { type: 10, id: 17, content: 'Same hat' }, - { - type: 12, - id: 18, - items: [ - { - media: { - id: '1466603856149610690', - url: 'https://cdn.discordapp.com/attachments/934955898965729280/1466556006527012987/image.png?ex=697d2c37&is=697bdab7&hm=09c5028be61ce01ebbdda5c79c42e4dc10d053ce0c4b12c9d84135a0708e9db6&', - proxy_url: 'https://media.discordapp.net/attachments/934955898965729280/1466556006527012987/image.png?ex=697d2c37&is=697bdab7&hm=09c5028be61ce01ebbdda5c79c42e4dc10d053ce0c4b12c9d84135a0708e9db6&', - width: 285, - height: 126, - placeholder: '0PcBA4BqSIl9t/dnn9f0rm0=', - placeholder_version: 1, - content_scan_metadata: { version: 4, flags: 0 }, - content_type: 'image/png', - loading_state: 2, - flags: 0 - }, - description: null, - spoiler: false - } - ] - } - ], - spoiler: false - }, - { - type: 10, - id: 19, - content: '-# Original Message ID: 1466556003645657118 · <t:1769724599:f>' - } - ] } }, message_update: { @@ -5505,6 +3485,7 @@ module.exports = { mention_roles: [], mentions: [], pinned: false, + timestamp: "2023-08-16T22:38:38.641000+00:00", tts: false, type: 0 }, @@ -5578,6 +3559,7 @@ module.exports = { mention_roles: [], mentions: [], pinned: false, + timestamp: "2023-08-16T22:38:38.641000+00:00", tts: false, type: 0 }, @@ -5612,6 +3594,7 @@ module.exports = { pinned: false, mention_everyone: false, tts: false, + timestamp: "2023-05-11T23:44:09.690000+00:00", edited_timestamp: "2023-05-11T23:44:19.690000+00:00", flags: 0, components: [], @@ -5652,6 +3635,7 @@ module.exports = { pinned: false, mention_everyone: false, tts: false, + timestamp: "2023-05-11T23:44:09.690000+00:00", edited_timestamp: "2023-05-11T23:44:19.690000+00:00", flags: 0, components: [], @@ -5692,6 +3676,7 @@ module.exports = { pinned: false, mention_everyone: false, tts: false, + timestamp: "2023-05-11T23:44:09.690000+00:00", edited_timestamp: "2023-05-11T23:44:19.690000+00:00", flags: 0, components: [], @@ -5864,36 +3849,6 @@ module.exports = { guild_id: "112760669178241024", id: "1210387798297682020" }, - embed_generated_social_media_image_for_matrix_user: { - channel_id: "112760669178241024", - embeds: [ - { - color: 8594767, - description: "1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:\n\n * Both players draw eight cards\n * Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand\n * Both players present their best five-or-less-card pok...", - provider: { - name: "hthrflwrs on cohost" - }, - thumbnail: { - height: 1587, - placeholder: "GpoKP5BJZphshnhwmmmYlmh3l7+m+mwJ", - placeholder_version: 1, - proxy_url: "https://images-ext-2.discordapp.net/external/9vTXIzlXU4wyUZvWfmlmQkck8nGLUL-A090W4lWsZ48/https/staging.cohostcdn.org/avatar/292-6b64b03c-4ada-42f6-8452-109275bfe68d-profile.png", - url: "https://staging.cohostcdn.org/avatar/292-6b64b03c-4ada-42f6-8452-109275bfe68d-profile.png", - width: 1644 - }, - title: "This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO", - type: "link", - url: "https://cohost.org/jkap/post/4794219-empty" - } - ], - author: { - name: "Matrix Bridge", - id: "684280192553844747" - }, - guild_id: "112760669178241024", - id: "1128118177155526666", - timestamp: "2025-01-01T00:00:00Z" - }, embed_generated_on_reply: { attachments: [], author: { @@ -5960,50 +3915,6 @@ module.exports = { } }, special_message: { - emoji_added: { - type: 63, - content: '<:cx_marvelous:1437322787994992650>', - mentions: [], - mention_roles: [], - attachments: [], - embeds: [], - timestamp: '2025-11-10T06:07:36.930000+00:00', - edited_timestamp: null, - flags: 0, - components: [], - id: '1437322788439457794', - channel_id: '1100319550446252084', - author: { - id: '772659086046658620', - username: 'cadence.worm', - avatar: '466df0c98b1af1e1388f595b4c1ad1b9', - discriminator: '0', - public_flags: 0, - flags: 0, - banner: null, - accent_color: null, - global_name: 'cadence', - avatar_decoration_data: null, - collectibles: null, - display_name_styles: null, - banner_color: null, - clan: { - identity_guild_id: '532245108070809601', - identity_enabled: true, - tag: 'doll', - badge: 'dba08126b4e810a0e096cc7cd5bc37f0' - }, - primary_guild: { - identity_guild_id: '532245108070809601', - identity_enabled: true, - tag: 'doll', - badge: 'dba08126b4e810a0e096cc7cd5bc37f0' - } - }, - pinned: false, - mention_everyone: false, - tts: false - }, thread_name_change: { id: "1142391602799710298", type: 4, @@ -6217,54 +4128,7 @@ module.exports = { guild_id: "112760669178241024" }, position: 0 - }, - ephemeral_message: { - webhook_id: "684280192553844747", - type: 20, - tts: false, - timestamp: "2024-09-29T11:22:04.865000+00:00", - position: 0, - pinned: false, - nonce: "1289910062243905536", - mentions: [], - mention_roles: [], - mention_everyone: false, - interaction_metadata: { - user: {baby: true}, - type: 2, - name: "invite", - id: "1289910063691206717", - command_type: 1, - authorizing_integration_owners: {baby: true} - }, - interaction: { - user: {baby: true}, - type: 2, - name: "invite", - id: "1289910063691206717" - }, - id: "1289910064995504182", - flags: 64, - embeds: [], - edited_timestamp: null, - content: "`@cadence:cadence.moe` is already in this server and this channel.", - components: [], - channel_id: "1100319550446252084", - author: { - username: "Matrix Bridge", - public_flags: 0, - id: "684280192553844747", - global_name: null, - discriminator: "5728", - clan: null, - bot: true, - avatar_decoration_data: null, - avatar: "48ae3c24f2a6ec5c60c41bdabd904018" - }, - attachments: [], - application_id: "684280192553844747" - }, - shard_id: 0 + } }, interaction_message: { thinking_interaction_without_bot_user: { @@ -6393,250 +4257,5 @@ module.exports = { application_id: "1109360903096369153", guild_id: "497159726455455754" } - }, - invite: { - irl: { - type: 0, - code: 'placeholder', - inviter: { - id: '772659086046658620', - username: 'cadence.worm', - avatar: '466df0c98b1af1e1388f595b4c1ad1b9', - discriminator: '0', - public_flags: 0, - flags: 0, - banner: null, - accent_color: 4534897, - global_name: 'cadence', - avatar_decoration_data: null, - collectibles: null, - banner_color: '#453271', - clan: null, - primary_guild: null - }, - expires_at: '2025-06-15T08:39:43+00:00', - guild: { - id: '1338114140941586518', - name: 'self service', - splash: null, - banner: null, - description: null, - icon: null, - features: [], - verification_level: 0, - vanity_url_code: null, - nsfw_level: 0, - nsfw: false, - premium_subscription_count: 0, - premium_tier: 0 - }, - guild_id: '1338114140941586518', - channel: { id: '1338114141658939517', type: 0, name: 'general' }, - guild_scheduled_event: { - id: '1381190945646710824', - guild_id: '1338114140941586518', - name: 'forest exploration', - description: '', - channel_id: null, - creator_id: '772659086046658620', - image: null, - scheduled_start_time: '2025-06-08T10:00:00.161000+00:00', - scheduled_end_time: '2025-06-08T12:00:00.161000+00:00', - status: 1, - entity_type: 3, - entity_id: null, - recurrence_rule: null, - user_count: 1, - privacy_level: 2, - sku_ids: [], - user_rsvp: null, - guild_scheduled_event_exceptions: [], - entity_metadata: { location: 'the dark forest' } - }, - profile: { - id: '1338114140941586518', - name: 'self service', - icon_hash: null, - member_count: 2, - online_count: 1, - description: null, - banner_hash: null, - game_application_ids: [], - game_activity: {}, - tag: null, - badge: 0, - badge_color_primary: '#ff0000', - badge_color_secondary: '#800000', - badge_hash: null, - traits: [], - features: [], - visibility: 2, - custom_banner_hash: null, - premium_subscription_count: 0, - premium_tier: 0 - } - }, - vc: { - type: 0, - code: 'placeholder', - inviter: { - id: '1024720274928697384', - username: '1024720274928697384', - avatar: '040a0652f1c76af3b71bb2c58ee0057b', - discriminator: '0', - public_flags: 0, - flags: 0, - banner: null, - accent_color: 4259841, - global_name: 'Regalia, Goddess of OH GOD OH FU', - avatar_decoration_data: null, - collectibles: null, - banner_color: '#410001', - clan: null, - primary_guild: null - }, - expires_at: '2025-06-15T07:32:30+00:00', - guild: { - id: '1340545485542391879', - name: 'VRCooking', - splash: null, - banner: null, - description: null, - icon: '8e1948b83d79c11ccb32b9e54a5d85fd', - features: [ 'SOUNDBOARD', 'ACTIVITY_FEED_DISABLED_BY_USER' ], - verification_level: 0, - vanity_url_code: null, - nsfw_level: 0, - nsfw: false, - premium_subscription_count: 0, - premium_tier: 0 - }, - guild_id: '1340545485542391879', - channel: { id: '1368144987707019306', type: 2, name: 'Cooking' }, - guild_scheduled_event: { - id: '1381174024801095751', - guild_id: '1340545485542391879', - name: 'Cooking (Netrunners)', - description: 'Short circuited brain interfaces actually just means your brain is medium rare, yum.', - channel_id: '1368144987707019306', - creator_id: '1024720274928697384', - image: null, - scheduled_start_time: '2025-06-09T03:00:00+00:00', - scheduled_end_time: null, - status: 1, - entity_type: 2, - entity_id: null, - recurrence_rule: null, - user_count: 2, - privacy_level: 2, - sku_ids: [], - user_rsvp: null, - guild_scheduled_event_exceptions: [], - entity_metadata: {} - }, - profile: { - id: '1340545485542391879', - name: 'VRCooking', - icon_hash: '8e1948b83d79c11ccb32b9e54a5d85fd', - member_count: 18, - online_count: 13, - description: null, - banner_hash: null, - game_application_ids: [], - game_activity: {}, - tag: null, - badge: 0, - badge_color_primary: '#ff0000', - badge_color_secondary: '#800000', - badge_hash: null, - traits: [], - features: [], - visibility: 2, - custom_banner_hash: null, - premium_subscription_count: 0, - premium_tier: 0 - } - }, - known_vc: { - type: 0, - code: 'placeholder', - inviter: { - id: '1024720274928697384', - username: '1024720274928697384', - avatar: '040a0652f1c76af3b71bb2c58ee0057b', - discriminator: '0', - public_flags: 0, - flags: 0, - banner: null, - accent_color: 4259841, - global_name: 'Regalia, Goddess of OH GOD OH FU', - avatar_decoration_data: null, - collectibles: null, - banner_color: '#410001', - clan: null, - primary_guild: null - }, - expires_at: '2025-06-15T07:32:30+00:00', - guild: { - id: '112760669178241024', - name: 'Psychonauts 3', - splash: null, - banner: null, - description: null, - icon: '8e1948b83d79c11ccb32b9e54a5d85fd', - features: [ 'SOUNDBOARD', 'ACTIVITY_FEED_DISABLED_BY_USER' ], - verification_level: 0, - vanity_url_code: null, - nsfw_level: 0, - nsfw: false, - premium_subscription_count: 0, - premium_tier: 0 - }, - guild_id: '112760669178241024', - channel: { id: '1162005314908999790', type: 0, name: 'Hey.' }, - guild_scheduled_event: { - id: '1381174024801095751', - guild_id: '112760669178241024', - name: 'Cooking (Netrunners)', - description: 'Short circuited brain interfaces actually just means your brain is medium rare, yum.', - channel_id: '1162005314908999790', - creator_id: '1024720274928697384', - image: null, - scheduled_start_time: '2025-06-09T03:00:00+00:00', - scheduled_end_time: null, - status: 1, - entity_type: 2, - entity_id: null, - recurrence_rule: null, - user_count: 2, - privacy_level: 2, - sku_ids: [], - user_rsvp: null, - guild_scheduled_event_exceptions: [], - entity_metadata: {} - }, - profile: { - id: '112760669178241024', - name: 'Psychonauts 3', - icon_hash: '8e1948b83d79c11ccb32b9e54a5d85fd', - member_count: 18, - online_count: 13, - description: null, - banner_hash: null, - game_application_ids: [], - game_activity: {}, - tag: null, - badge: 0, - badge_color_primary: '#ff0000', - badge_color_secondary: '#800000', - badge_hash: null, - traits: [], - features: [], - visibility: 2, - custom_banner_hash: null, - premium_subscription_count: 0, - premium_tier: 0 - } - } } } diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql index 1dd9dfe..2c23561 100644 --- a/test/ooye-test-data.sql +++ b/test/ooye-test-data.sql @@ -1,54 +1,40 @@ BEGIN TRANSACTION; -INSERT INTO guild_active (guild_id, autocreate) VALUES -('112760669178241024', 1), -('66192955777486848', 1), -('665289423482519565', 0), -('1345641201902288987', 1); - INSERT INTO guild_space (guild_id, space_id, privacy_level) VALUES -('112760669178241024', '!jjmvBegULiLucuWEHU:cadence.moe', 0), -('1345641201902288987', '!CvQMeeqXIkgedUpkzv:cadence.moe', 0); +('112760669178241024', '!jjWAGMeQdNrVZSSfvz:cadence.moe', 0); -INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar, guild_id) VALUES -('112760669178241024', '!kLRqKKUQXcibIMtOpl:cadence.moe', 'heave', 'main', NULL, NULL, '112760669178241024'), -('687028734322147344', '!fGgIymcYWOqjbSRUdV:cadence.moe', 'slow-news-day', NULL, NULL, NULL, '112760669178241024'), -('497161350934560778', '!CzvdIdUQXgUjDVKxeU:cadence.moe', 'amanda-spam', NULL, NULL, NULL, '66192955777486848'), -('160197704226439168', '!hYnGGlPHlbujVVfktC:cadence.moe', 'the-stanley-parable-channel', 'bots', NULL, NULL, '112760669178241024'), -('1100319550446252084', '!BnKuBPCvyfOkhcUjEu:cadence.moe', 'worm-farm', NULL, NULL, NULL, '66192955777486848'), -('1162005314908999790', '!FuDZhlOAtqswlyxzeR:cadence.moe', 'Hey.', NULL, '1100319550446252084', NULL, '112760669178241024'), -('297272183716052993', '!rEOspnYqdOalaIFniV:cadence.moe', 'general', NULL, NULL, NULL, '66192955777486848'), -('122155380120748034', '!cqeGDbPiMFAhLsqqqq:cadence.moe', 'cadences-mind', 'coding', NULL, NULL, '112760669178241024'), -('176333891320283136', '!qzDBLKlildpzrrOnFZ:cadence.moe', '🌈丨davids-horse_she-took-the-kids', 'wonderland', NULL, 'mxc://cadence.moe/EVvrSkKIRONHjtRJsMLmHWLS', '112760669178241024'), -('489237891895768942', '!tnedrGVYKFNUdnegvf:tchncs.de', 'ex-room-doesnt-exist-any-more', NULL, NULL, NULL, '66192955777486848'), -('1160894080998461480', '!TqlyQmifxGUggEmdBN:cadence.moe', 'ooyexperiment', NULL, NULL, NULL, '66192955777486848'), -('1161864271370666075', '!mHmhQQPwXNananMUqq:cadence.moe', 'updates', NULL, NULL, NULL, '112760669178241024'), -('1438284564815548418', '!MHxNpwtgVqWOrmyoTn:cadence.moe', 'sin-cave', NULL, NULL, NULL, '665289423482519565'), -('598707048112193536', '!JBxeGYnzQwLnaooOLD:cadence.moe', 'winners', NULL, NULL, NULL, '1345641201902288987'); +INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar) VALUES +('112760669178241024', '!kLRqKKUQXcibIMtOpl:cadence.moe', 'heave', 'main', NULL, NULL), +('497161350934560778', '!CzvdIdUQXgUjDVKxeU:cadence.moe', 'amanda-spam', NULL, NULL, NULL), +('160197704226439168', '!hYnGGlPHlbujVVfktC:cadence.moe', 'the-stanley-parable-channel', 'bots', NULL, NULL), +('1100319550446252084', '!BnKuBPCvyfOkhcUjEu:cadence.moe', 'worm-farm', NULL, NULL, NULL), +('1162005314908999790', '!FuDZhlOAtqswlyxzeR:cadence.moe', 'Hey.', NULL, '1100319550446252084', NULL), +('297272183716052993', '!rEOspnYqdOalaIFniV:cadence.moe', 'general', NULL, NULL, NULL), +('122155380120748034', '!cqeGDbPiMFAhLsqqqq:cadence.moe', 'cadences-mind', 'coding', NULL, NULL), +('176333891320283136', '!qzDBLKlildpzrrOnFZ:cadence.moe', '🌈丨davids-horse_she-took-the-kids', 'wonderland', NULL, 'mxc://cadence.moe/EVvrSkKIRONHjtRJsMLmHWLS'), +('489237891895768942', '!tnedrGVYKFNUdnegvf:tchncs.de', 'ex-room-doesnt-exist-any-more', NULL, NULL, NULL), +('1160894080998461480', '!TqlyQmifxGUggEmdBN:cadence.moe', 'ooyexperiment', NULL, NULL, NULL); -INSERT INTO historical_channel_room (reference_channel_id, room_id, upgraded_timestamp) SELECT channel_id, room_id, 0 FROM channel_room; +INSERT INTO sim (user_id, sim_name, localpart, mxid) VALUES +('0', 'bot', '_ooye_bot', '@_ooye_bot:cadence.moe'), +('820865262526005258', 'crunch_god', '_ooye_crunch_god', '@_ooye_crunch_god:cadence.moe'), +('771520384671416320', 'bojack_horseman', '_ooye_bojack_horseman', '@_ooye_bojack_horseman:cadence.moe'), +('112890272819507200', '.wing.', '_ooye_.wing.', '@_ooye_.wing.:cadence.moe'), +('114147806469554185', 'extremity', '_ooye_extremity', '@_ooye_extremity:cadence.moe'), +('111604486476181504', 'kyuugryphon', '_ooye_kyuugryphon', '@_ooye_kyuugryphon:cadence.moe'), +('1109360903096369153', 'amanda', '_ooye_amanda', '@_ooye_amanda:cadence.moe'), +('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '_pk_zoego', '_ooye__pk_zoego', '@_ooye__pk_zoego:cadence.moe'), +('320067006521147393', 'papiophidian', '_ooye_papiophidian', '@_ooye_papiophidian:cadence.moe'), +('772659086046658620', 'cadence', '_ooye_cadence', '@_ooye_cadence:cadence.moe'); -INSERT INTO sim (user_id, username, sim_name, mxid) VALUES -('0', 'Matrix Bridge', 'bot', '@_ooye_bot:cadence.moe'), -('820865262526005258', 'Crunch God', 'crunch_god', '@_ooye_crunch_god:cadence.moe'), -('771520384671416320', 'Bojack Horseman', 'bojack_horseman', '@_ooye_bojack_horseman:cadence.moe'), -('112890272819507200', 'wing', '.wing.', '@_ooye_.wing.:cadence.moe'), -('114147806469554185', 'extremity', 'extremity', '@_ooye_extremity:cadence.moe'), -('111604486476181504', 'kyuugryphon', 'kyuugryphon', '@_ooye_kyuugryphon:cadence.moe'), -('1109360903096369153', 'Amanda', 'amanda', '@_ooye_amanda:cadence.moe'), -('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '_pk_zoego', '_pk_zoego', '@_ooye__pk_zoego:cadence.moe'), -('320067006521147393', 'papiophidian', 'papiophidian', '@_ooye_papiophidian:cadence.moe'), -('772659086046658620', 'cadence.worm', 'cadence', '@_ooye_cadence:cadence.moe'); +INSERT INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES +('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '196188877885538304', 'Azalea &flwr; 🌺'); INSERT INTO sim_member (mxid, room_id, hashed_profile_content) VALUES ('@_ooye_bojack_horseman:cadence.moe', '!hYnGGlPHlbujVVfktC:cadence.moe', NULL), ('@_ooye_cadence:cadence.moe', '!BnKuBPCvyfOkhcUjEu:cadence.moe', NULL); -INSERT INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES -('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '196188877885538304', 'Azalea &flwr; 🌺'); - -INSERT INTO message_room (message_id, historical_room_index) -WITH a (message_id, channel_id) AS (VALUES +INSERT INTO message_channel (message_id, channel_id) VALUES ('1106366167788044450', '122155380120748034'), ('1106366167788044451', '122155380120748034'), ('1106366167788044452', '122155380120748034'), @@ -75,15 +61,7 @@ WITH a (message_id, channel_id) AS (VALUES ('1273204543739396116', '687028734322147344'), ('1273743950028607530', '1100319550446252084'), ('1278002262400176128', '1100319550446252084'), -('1278001833876525057', '1100319550446252084'), -('1191567971970191490', '176333891320283136'), -('1144874214311067708', '687028734322147344'), -('1339000288144658482', '176333891320283136'), -('1381212840957972480', '112760669178241024'), -('1401760355339862066', '112760669178241024'), -('1439351590262800565', '1438284564815548418'), -('1404133238414376971', '112760669178241024')) -SELECT message_id, max(historical_room_index) as historical_room_index FROM a INNER JOIN historical_channel_room ON historical_channel_room.reference_channel_id = a.channel_id GROUP BY message_id; +('1278001833876525057', '1100319550446252084'); INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES ('$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg', 'm.room.message', 'm.text', '1126786462646550579', 0, 0, 1), @@ -98,8 +76,8 @@ INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part ('$oLyUTyZ_7e_SUzGNWZKz880ll9amLZvXGbArJCKai2Q', 'm.room.message', 'm.text', '1128084748338741392', 0, 0, 1), ('$FchUVylsOfmmbj-VwEs5Z9kY49_dt2zd0vWfylzy5Yo', 'm.room.message', 'm.text', '1143121514925928541', 0, 0, 1), ('$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4', 'm.room.message', 'm.text', '1106366167788044450', 0, 1, 1), -('$Ijf1MFCD39ktrNHxrA-i2aKoRWNYdAV2ZXYQeiZIgEU', 'm.room.message', 'm.image', '1106366167788044450', 1, 1, 1), -('$f9cjKiacXI9qPF_nUAckzbiKnJEi0LM399kOkhdd8f8', 'm.sticker', NULL, '1106366167788044450', 1, 0, 1), +('$Ijf1MFCD39ktrNHxrA-i2aKoRWNYdAV2ZXYQeiZIgEU', 'm.room.message', 'm.image', '1106366167788044450', 1, 1, 0), +('$f9cjKiacXI9qPF_nUAckzbiKnJEi0LM399kOkhdd8f8', 'm.sticker', NULL, '1106366167788044450', 1, 0, 0), ('$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd999', 'm.room.message', 'm.text', '1106366167788044451', 0, 0, 1), ('$Ijf1MFCD39ktrNHxrA-i2aKoRWNYdAV2ZXYQeiZI999', 'm.room.message', 'm.image', '1106366167788044451', 0, 0, 1), ('$f9cjKiacXI9qPF_nUAckzbiKnJEi0LM399kOkhdd999', 'm.sticker', NULL, '1106366167788044451', 0, 0, 1), @@ -122,15 +100,7 @@ INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part ('$qmyjr-ISJtnOM5WTWLI0fT7uSlqRLgpyin2d2NCglCU', 'm.room.message', 'm.text', '1273204543739396116', 0, 0, 0), ('$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4', 'm.room.message', 'm.text', '1273743950028607530', 0, 0, 0), ('$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF', 'm.room.message', 'm.text', '1278002262400176128', 0, 0, 1), -('$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM', 'm.room.message', 'm.text', '1278001833876525057', 0, 0, 1), -('$tBIT8mO7XTTCgIINyiAIy6M2MSoPAdJenRl_RLyYuaE', 'm.room.message', 'm.text', '1191567971970191490', 0, 0, 1), -('$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk', 'm.room.message', 'm.text', '1339000288144658482', 0, 0, 0), -('$AfrB8hzXkDMvuoWjSZkDdFYomjInWH7jMBPkwQMN8AI', 'm.room.message', 'm.text', '1381212840957972480', 0, 1, 1), -('$43baKEhJfD-RlsFQi0LB16Zxd8yMqp0HSVL00TDQOqM', 'm.room.message', 'm.image', '1381212840957972480', 1, 0, 1), -('$7P2O_VTQNHvavX5zNJ35DV-dbJB1Ag80tGQP_JzGdhk', 'm.room.message', 'm.text', '1401760355339862066', 0, 0, 0), -('$ielAnR6geu0P1Tl5UXfrbxlIf-SV9jrNprxrGXP3v7M', 'm.room.message', 'm.image', '1439351590262800565', 0, 0, 0), -('$uUKLcTQvik5tgtTGDKuzn0Ci4zcCvSoUcYn2X7mXm9I', 'm.room.message', 'm.text', '1404133238414376971', 0, 1, 1), -('$LhmoWWvYyn5_AHkfb6FaXmLI6ZOC1kloql5P40YDmIk', 'm.room.message', 'm.notice', '1404133238414376971', 1, 0, 1); +('$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM', 'm.room.message', 'm.text', '1278001833876525057', 0, 0, 1); INSERT INTO file (discord_url, mxc_url) VALUES ('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'), @@ -145,15 +115,12 @@ INSERT INTO file (discord_url, mxc_url) VALUES ('https://cdn.discordapp.com/emojis/230201364309868544.png', 'mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC'), ('https://cdn.discordapp.com/emojis/393635038903926784.gif', 'mxc://cadence.moe/WbYqNlACRuicynBfdnPYtmvc'), ('https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg', 'mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR'), -('https://cdn.discordapp.com/emojis/1125827250609201255.webp', 'mxc://cadence.moe/pgdGTxAyEltccRgZKxdqzHHP'), +('https://cdn.discordapp.com/emojis/1125827250609201255.png', 'mxc://cadence.moe/pgdGTxAyEltccRgZKxdqzHHP'), ('https://cdn.discordapp.com/avatars/320067006521147393/5fc4ad85c1ea876709e9a7d3374a78a1.png?size=1024', 'mxc://cadence.moe/JPzSmALLirnIprlSMKohSSoX'), ('https://cdn.discordapp.com/emojis/288858540888686602.png', 'mxc://cadence.moe/mwZaCtRGAQQyOItagDeCocEO'), ('https://cdn.discordapp.com/attachments/112760669178241024/1197621094786531358/Ins_1960637570.mp4', 'mxc://cadence.moe/kMqLycqMURhVpwleWkmASpnU'), ('https://cdn.discordapp.com/attachments/1099031887500034088/1112476845502365786/voice-message.ogg', 'mxc://cadence.moe/MRRPDggXQMYkrUjTpxQbmcxB'), -('https://cdn.discordapp.com/attachments/122155380120748034/1174514575220158545/the.yml', 'mxc://cadence.moe/HnQIYQmmlIKwOQsbFsIGpzPP'), -('https://cdn.discordapp.com/attachments/112760669178241024/1296237494987133070/100km.gif', 'mxc://cadence.moe/qDAotmebTfEIfsAIVCEZptLh'), -('https://cdn.discordapp.com/attachments/123/456/my_enemies.txt', 'mxc://cadence.moe/y89EOTRp2lbeOkgdsEleGOge'), -('https://cdn.discordapp.com/emojis/1254940125948022915.webp', 'mxc://cadence.moe/bvVJFgOIyNcAknKCbmaHDktG'); +('https://cdn.discordapp.com/attachments/122155380120748034/1174514575220158545/the.yml', 'mxc://cadence.moe/HnQIYQmmlIKwOQsbFsIGpzPP'); INSERT INTO emoji (emoji_id, name, animated, mxc_url) VALUES ('230201364309868544', 'hippo', 0, 'mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC'), @@ -163,56 +130,36 @@ INSERT INTO emoji (emoji_id, name, animated, mxc_url) VALUES ('551636841284108289', 'ae_botrac4r', 0, 'mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs'), ('975572106295259148', 'brillillillilliant_move', 0, 'mxc://cadence.moe/scfRIDOGKWFDEBjVXocWYQHik'), ('606664341298872324', 'online', 0, 'mxc://cadence.moe/LCEqjStXCxvRQccEkuslXEyZ'), -('288858540888686602', 'upstinky', 0, 'mxc://cadence.moe/mwZaCtRGAQQyOItagDeCocEO'), -('1437322787994992650', 'cx_marvelous', 0, 'mxc://cadence.moe/TPZdosVUjTIopsLijkygIbti'); +('288858540888686602', 'upstinky', 0, 'mxc://cadence.moe/mwZaCtRGAQQyOItagDeCocEO'); INSERT INTO member_cache (room_id, mxid, displayname, avatar_url, power_level) VALUES -('!jjmvBegULiLucuWEHU:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL, 50), ('!kLRqKKUQXcibIMtOpl:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL, 0), -('!kLRqKKUQXcibIMtOpl:cadence.moe', '@test_auto_invite:example.org', NULL, NULL, 0), +('!BpMdOUkWWhFxmTrENV:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'malformed mxc', 0), ('!fGgIymcYWOqjbSRUdV:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU', 0), ('!fGgIymcYWOqjbSRUdV:cadence.moe', '@rnl:cadence.moe', 'RNL', NULL, 0), ('!BnKuBPCvyfOkhcUjEu:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU', 0), -('!BnKuBPCvyfOkhcUjEu:cadence.moe', '@ami:the-apothecary.club', 'Ami (she/her)', NULL, 0), +('!maggESguZBqGBZtSnr:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU', 0), ('!CzvdIdUQXgUjDVKxeU:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU', 0), -('!TqlyQmifxGUggEmdBN:cadence.moe', '@Milan:tchncs.de', 'Milan', NULL, 0), +('!cBxtVRxDlZvSVhJXVK:cadence.moe', '@Milan:tchncs.de', 'Milan', NULL, 0), ('!TqlyQmifxGUggEmdBN:cadence.moe', '@ampflower:matrix.org', 'Ampflower 🌺', 'mxc://cadence.moe/PRfhXYBTOalvgQYtmCLeUXko', 0), ('!TqlyQmifxGUggEmdBN:cadence.moe', '@aflower:syndicated.gay', 'Rose', 'mxc://syndicated.gay/ZkBUPXCiXTjdJvONpLJmcbKP', 0), ('!TqlyQmifxGUggEmdBN:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL, 0), -('!iSyXgNxQcEuXoXpsSn:pussthecat.org', '@austin:tchncs.de', 'Austin Huang', 'mxc://tchncs.de/090a2b5e07eed2f71e84edad5207221e6c8f8b8e', 0), -('!zq94fae5bVKUubZLp7:agiadn.org', '@underscore_x:agiadn.org', 'underscore_x', NULL, 100); +('!BnKuBPCvyfOkhcUjEu:cadence.moe', '@ami:the-apothecary.club', 'Ami (she/her)', NULL, 0), +('!kLRqKKUQXcibIMtOpl:cadence.moe', '@test_auto_invite:example.org', NULL, NULL, 0), +('!BpMdOUkWWhFxmTrENV:cadence.moe', '@test_auto_invite:example.org', NULL, NULL, 100); INSERT INTO reaction (hashed_event_id, message_id, encoded_emoji) VALUES (5162930312280790092, '1141501302736695317', '%F0%9F%90%88'); INSERT INTO member_power (mxid, room_id, power_level) VALUES -('@test_auto_invite:example.org', '*', 150); +('@test_auto_invite:example.org', '*', 100); INSERT INTO lottie (sticker_id, mxc_url) VALUES ('860171525772279849', 'mxc://cadence.moe/ZtvvVbwMIdUZeovWVyGVFCeR'); -INSERT INTO auto_emoji (name, emoji_id) VALUES -('L1', '1144820033948762203'), -('L2', '1144820084079087647'); - -INSERT INTO media_proxy (permitted_hash) VALUES -(-429802515645771439), -(4558604729745184757); - -INSERT INTO invite (mxid, room_id, type, name, avatar, topic) VALUES -('@cadence:cadence.moe', '!zTMspHVUBhFLLSdmnS:cadence.moe', 'm.space', 'Data Horde', 'mxc://cadence.moe/TLqQOsTSrZkVKwBSWYTZNTrw', 'here is the space topic'), -('@cadence:cadence.moe', '!jjmvBegULiLucuWEHU:cadence.moe', 'm.space', 'Epicord', NULL, NULL), -('@cadence:cadence.moe', '!room:cadence.moe', NULL, 'some room', NULL, NULL), -('@rnl:cadence.moe', '!space:cadence.moe', NULL, 'somebody else''s space', NULL, NULL); - -INSERT INTO direct (mxid, room_id) VALUES -('@user1:example.org', '!existing:cadence.moe'), -('@user2:example.org', '!existing:cadence.moe'); - --- for cross-room reply test, in 'updates' room -UPDATE historical_channel_room SET room_id = '!mHmhQQPwXNananaOLD:cadence.moe' WHERE room_id = '!mHmhQQPwXNananMUqq:cadence.moe'; -INSERT INTO historical_channel_room (reference_channel_id, room_id, upgraded_timestamp) VALUES ('1161864271370666075', '!mHmhQQPwXNananMUqq:cadence.moe', 1767922455991); -INSERT INTO message_room (message_id, historical_room_index) SELECT '1458091145136443547', historical_room_index FROM historical_channel_room WHERE room_id = '!mHmhQQPwXNananaOLD:cadence.moe'; -INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES ('$pgzCQjq_y5sy8RvWOUuoF3obNHjs8iNvt9c-odrOCPY', 'm.room.message', 'm.image', '1458091145136443547', 0, 0, 0); +INSERT INTO "auto_emoji" ("name","emoji_id","guild_id") VALUES +('L1','1144820033948762203','529176156398682115'), +('L2','1144820084079087647','529176156398682115'), +('_','_','529176156398682115'); COMMIT; diff --git a/test/test.js b/test/test.js index e05b687..281df29 100644 --- a/test/test.js +++ b/test/test.js @@ -2,65 +2,46 @@ const fs = require("fs") const {join} = require("path") +const stp = require("stream").promises const sqlite = require("better-sqlite3") -const {Writable} = require("stream") const migrate = require("../src/db/migrate") const HeatSync = require("heatsync") -const {test, extend} = require("supertape") +const {test} = require("supertape") const data = require("./data") +/** @type {import("node-fetch").default} */ +// @ts-ignore +const fetch = require("node-fetch") const {green} = require("ansi-colors") const passthrough = require("../src/passthrough") const db = new sqlite(":memory:") const {reg} = require("../src/matrix/read-registration") -reg.ooye.discord_token = "Njg0MjgwMTkyNTUzODQ0NzQ3.Xl3zlw.baby" reg.ooye.server_origin = "https://matrix.cadence.moe" // so that tests will pass even when hard-coded reg.ooye.server_name = "cadence.moe" -reg.ooye.namespace_prefix = "_ooye_" -reg.sender_localpart = "_ooye_bot" -reg.id = "baby" -reg.as_token = "don't actually take authenticated actions on the server" -reg.hs_token = "don't actually take authenticated actions on the server" -reg.namespaces = { - users: [{regex: "@_ooye_.*:cadence.moe", exclusive: true}], - aliases: [{regex: "#_ooye_.*:cadence.moe", exclusive: true}] -} +reg.id = "baby" // don't actually take authenticated actions on the server +reg.as_token = "baby" +reg.hs_token = "baby" reg.ooye.bridge_origin = "https://bridge.example.org" -reg.ooye.time_zone = "Pacific/Auckland" -reg.ooye.max_file_size = 5000000 -reg.ooye.web_password = "password123" -reg.ooye.include_user_id_in_mxid = false +reg.ooye.invite = [] const sync = new HeatSync({watchFS: false}) const discord = { - // @ts-ignore - ignore guilds, because my data dump is missing random properties guilds: new Map([ - [data.guild.general.id, data.guild.general], - [data.guild.fna.id, data.guild.fna], - [data.guild.data_horde.id, data.guild.data_horde] - ]), - guildChannelMap: new Map([ - [data.guild.general.id, [data.channel.general.id, data.channel.updates.id]], - [data.guild.fna.id, []], - [data.guild.data_horde.id, [data.channel.saving_the_world.id]] + [data.guild.general.id, data.guild.general] ]), application: { id: "684280192553844747" }, - // @ts-ignore - ignore channels, because my data dump is missing random properties channels: new Map([ - [data.channel.general.id, data.channel.general], - [data.channel.updates.id, data.channel.updates], ["497161350934560778", { guild_id: "497159726455455754" }], ["498323546729086986", { guild_id: "497159726455455754", name: "bad-boots-prison" - }], - [data.channel.saving_the_world.id, data.channel.saving_the_world] + }] ]) } @@ -75,46 +56,47 @@ const file = sync.require("../src/matrix/file") file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not allowed to upload files during testing.\nURL: ${url}`) } ;(async () => { - /* c8 ignore start - download some more test files in slow mode */ - test("test files: download", async t => { - /** @param {{url: string, to: string}[]} files */ - async function allReporter(files) { - return new Promise(resolve => { - let resolved = 0 - const report = files.map(file => file.to.split("/").slice(-1)[0][0]) - files.map(download).forEach((p, i) => { - p.then(() => { - report[i] = green(".") - process.stderr.write("\r" + report.join("")) - if (++resolved === files.length) resolve(null) + /* c8 ignore start - maybe download some more test files in slow mode */ + if (process.argv.includes("--slow")) { + test("test files: download", async t => { + /** @param {{url: string, to: string}[]} files */ + async function allReporter(files) { + return new Promise(resolve => { + let resolved = 0 + const report = files.map(file => file.to.split("/").slice(-1)[0][0]) + files.map(download).forEach((p, i) => { + p.then(() => { + report[i] = green(".") + process.stderr.write("\r" + report.join("")) + if (++resolved === files.length) resolve(null) + }) }) }) - }) - } - async function download({url, to}) { - if (await fs.existsSync(to)) return - const res = await fetch(url) - // @ts-ignore - await res.body.pipeTo(Writable.toWeb(fs.createWriteStream(to, {encoding: "binary"}))) - } - await allReporter([ - {url: "https://cadence.moe/friends/ooye_test/RLMgJGfgTPjIQtvvWZsYjhjy.png", to: "test/res/RLMgJGfgTPjIQtvvWZsYjhjy.png"}, - {url: "https://cadence.moe/friends/ooye_test/bZFuuUSEebJYXUMSxuuSuLTa.png", to: "test/res/bZFuuUSEebJYXUMSxuuSuLTa.png"}, - {url: "https://cadence.moe/friends/ooye_test/qWmbXeRspZRLPcjseyLmeyXC.png", to: "test/res/qWmbXeRspZRLPcjseyLmeyXC.png"}, - {url: "https://cadence.moe/friends/ooye_test/wcouHVjbKJJYajkhJLsyeJAA.png", to: "test/res/wcouHVjbKJJYajkhJLsyeJAA.png"}, - {url: "https://cadence.moe/friends/ooye_test/WbYqNlACRuicynBfdnPYtmvc.gif", to: "test/res/WbYqNlACRuicynBfdnPYtmvc.gif"}, - {url: "https://cadence.moe/friends/ooye_test/HYcztccFIPgevDvoaWNsEtGJ.png", to: "test/res/HYcztccFIPgevDvoaWNsEtGJ.png"}, - {url: "https://cadence.moe/friends/ooye_test/lHfmJpzgoNyNtYHdAmBHxXix.png", to: "test/res/lHfmJpzgoNyNtYHdAmBHxXix.png"}, - {url: "https://cadence.moe/friends/ooye_test/MtRdXixoKjKKOyHJGWLsWLNU.png", to: "test/res/MtRdXixoKjKKOyHJGWLsWLNU.png"}, - {url: "https://cadence.moe/friends/ooye_test/HXfFuougamkURPPMflTJRxGc.png", to: "test/res/HXfFuougamkURPPMflTJRxGc.png"}, - {url: "https://cadence.moe/friends/ooye_test/ikYKbkhGhMERAuPPbsnQzZiX.png", to: "test/res/ikYKbkhGhMERAuPPbsnQzZiX.png"}, - {url: "https://cadence.moe/friends/ooye_test/AYPpqXzVJvZdzMQJGjioIQBZ.png", to: "test/res/AYPpqXzVJvZdzMQJGjioIQBZ.png"}, - {url: "https://cadence.moe/friends/ooye_test/UVuzvpVUhqjiueMxYXJiFEAj.png", to: "test/res/UVuzvpVUhqjiueMxYXJiFEAj.png"}, - {url: "https://ezgif.com/images/format-demo/butterfly.gif", to: "test/res/butterfly.gif"}, - {url: "https://ezgif.com/images/format-demo/butterfly.png", to: "test/res/butterfly.png"}, - ]) - }, {timeout: 60000}) - /* c8 ignore stop */ + } + async function download({url, to}) { + if (await fs.existsSync(to)) return + const res = await fetch(url) + await stp.pipeline(res.body, fs.createWriteStream(to, {encoding: "binary"})) + } + await allReporter([ + {url: "https://cadence.moe/friends/ooye_test/RLMgJGfgTPjIQtvvWZsYjhjy.png", to: "test/res/RLMgJGfgTPjIQtvvWZsYjhjy.png"}, + {url: "https://cadence.moe/friends/ooye_test/bZFuuUSEebJYXUMSxuuSuLTa.png", to: "test/res/bZFuuUSEebJYXUMSxuuSuLTa.png"}, + {url: "https://cadence.moe/friends/ooye_test/qWmbXeRspZRLPcjseyLmeyXC.png", to: "test/res/qWmbXeRspZRLPcjseyLmeyXC.png"}, + {url: "https://cadence.moe/friends/ooye_test/wcouHVjbKJJYajkhJLsyeJAA.png", to: "test/res/wcouHVjbKJJYajkhJLsyeJAA.png"}, + {url: "https://cadence.moe/friends/ooye_test/WbYqNlACRuicynBfdnPYtmvc.gif", to: "test/res/WbYqNlACRuicynBfdnPYtmvc.gif"}, + {url: "https://cadence.moe/friends/ooye_test/HYcztccFIPgevDvoaWNsEtGJ.png", to: "test/res/HYcztccFIPgevDvoaWNsEtGJ.png"}, + {url: "https://cadence.moe/friends/ooye_test/lHfmJpzgoNyNtYHdAmBHxXix.png", to: "test/res/lHfmJpzgoNyNtYHdAmBHxXix.png"}, + {url: "https://cadence.moe/friends/ooye_test/MtRdXixoKjKKOyHJGWLsWLNU.png", to: "test/res/MtRdXixoKjKKOyHJGWLsWLNU.png"}, + {url: "https://cadence.moe/friends/ooye_test/HXfFuougamkURPPMflTJRxGc.png", to: "test/res/HXfFuougamkURPPMflTJRxGc.png"}, + {url: "https://cadence.moe/friends/ooye_test/ikYKbkhGhMERAuPPbsnQzZiX.png", to: "test/res/ikYKbkhGhMERAuPPbsnQzZiX.png"}, + {url: "https://cadence.moe/friends/ooye_test/AYPpqXzVJvZdzMQJGjioIQBZ.png", to: "test/res/AYPpqXzVJvZdzMQJGjioIQBZ.png"}, + {url: "https://cadence.moe/friends/ooye_test/UVuzvpVUhqjiueMxYXJiFEAj.png", to: "test/res/UVuzvpVUhqjiueMxYXJiFEAj.png"}, + {url: "https://ezgif.com/images/format-demo/butterfly.gif", to: "test/res/butterfly.gif"}, + {url: "https://ezgif.com/images/format-demo/butterfly.png", to: "test/res/butterfly.png"}, + ]) + }, {timeout: 60000}) + } + /* c8 ignore end */ const p = migrate.migrate(db) test("migrate: migration works", async t => { @@ -130,50 +112,28 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not db.exec(fs.readFileSync(join(__dirname, "ooye-test-data.sql"), "utf8")) - require("./addbot.test") require("../src/db/orm.test") - require("../src/web/server.test") require("../src/discord/utils.test") require("../src/matrix/kstate.test") require("../src/matrix/api.test") require("../src/matrix/file.test") - require("../src/matrix/mreq.test") + require("../src/matrix/power.test") require("../src/matrix/read-registration.test") - require("../src/matrix/room-upgrade.test") require("../src/matrix/txnid.test") - require("../src/matrix/utils.test") require("../src/d2m/actions/create-room.test") require("../src/d2m/actions/create-space.test") require("../src/d2m/actions/register-user.test") require("../src/d2m/converters/edit-to-changes.test") require("../src/d2m/converters/emoji-to-key.test") - require("../src/d2m/converters/find-mentions.test") require("../src/d2m/converters/lottie.test") require("../src/d2m/converters/message-to-event.test") - require("../src/d2m/converters/message-to-event.test.components") - require("../src/d2m/converters/message-to-event.test.embeds") - require("../src/d2m/converters/message-to-event.test.pk") + require("../src/d2m/converters/message-to-event.embeds.test") + require("../src/d2m/converters/message-to-event.pk.test") require("../src/d2m/converters/pins-to-list.test") require("../src/d2m/converters/remove-reaction.test") require("../src/d2m/converters/thread-to-announcement.test") require("../src/d2m/converters/user-to-mxid.test") - require("../src/m2d/event-dispatcher.test") - require("../src/m2d/converters/diff-pins.test") require("../src/m2d/converters/event-to-message.test") - require("../src/m2d/converters/emoji.test") + require("../src/m2d/converters/utils.test") require("../src/m2d/converters/emoji-sheet.test") - require("../src/discord/interactions/invite.test") - require("../src/discord/interactions/matrix-info.test") - require("../src/discord/interactions/permissions.test") - require("../src/discord/interactions/privacy.test") - require("../src/discord/interactions/reactions.test") - require("../src/web/routes/download-discord.test") - require("../src/web/routes/download-matrix.test") - require("../src/web/routes/guild.test") - require("../src/web/routes/guild-settings.test") - require("../src/web/routes/info.test") - require("../src/web/routes/link.test") - require("../src/web/routes/log-in-with-matrix.test") - require("../src/web/routes/oauth.test") - require("../src/web/routes/password.test") })() diff --git a/test/web.js b/test/web.js deleted file mode 100644 index 250694a..0000000 --- a/test/web.js +++ /dev/null @@ -1,115 +0,0 @@ -const passthrough = require("../src/passthrough") -const h3 = require("h3") -const http = require("http") -const {SnowTransfer} = require("snowtransfer") -const assert = require("assert").strict -const domino = require("domino") -const {extend} = require("supertape") -const {reg} = require("../src/matrix/read-registration") - -const {AppService} = require("@cloudrac3r/in-your-element") -const defaultAs = new AppService(reg) - -/** - * @param {string} html - */ -function getContent(html) { - const doc = domino.createDocument(html) - doc.querySelectorAll("svg").cache.forEach(e => e.remove()) - const content = doc.getElementById("content") - assert(content) - return content.innerHTML.trim() -} - -const test = extend({ - has: operator => /** @param {string | RegExp} expected */ (html, expected, message = "should have substring in html content") => { - const content = getContent(html) - const is = expected instanceof RegExp ? content.match(expected) : content.includes(expected) - const {output, result} = operator.equal(content, expected.toString()) - return { - expected: expected.toString(), - message, - is, - result: result, - output: output - } - } -}) - -class Router { - constructor() { - /** @type {Map<string, h3.EventHandler>} */ - this.routes = new Map() - for (const method of ["get", "post", "put", "patch", "delete"]) { - this[method] = function(url, handler) { - const key = `${method} ${url}` - this.routes.set(key, handler) - } - } - } - - /** - * @param {string} method - * @param {string} inputUrl - * @param {{event?: any, params?: any, body?: any, sessionData?: any, getOauth2Token?: any, getClient?: (string) => {user: {getGuilds: () => Promise<DiscordTypes.RESTGetAPICurrentUserGuildsResult>}}, api?: Partial<import("../src/matrix/api")>, snow?: {[k in keyof SnowTransfer]?: Partial<SnowTransfer[k]>}, createRoom?: Partial<import("../src/d2m/actions/create-room")>, createSpace?: Partial<import("../src/d2m/actions/create-space")>, mxcDownloader?: import("../src/m2d/actions/emoji-sheet")["getAndConvertEmoji"], headers?: any}} [options] - */ - async test(method, inputUrl, options = {}) { - const url = new URL(inputUrl, "http://a") - const key = `${method} ${options.route || url.pathname}` - /* c8 ignore next */ - if (!this.routes.has(key)) throw new Error(`Route not found: "${key}"`) - - const req = { - method: method.toUpperCase(), - headers: options.headers || {}, - url - } - const event = options.event || {} - - if (typeof options.body === "object" && options.body.constructor === Object) { - options.body = JSON.stringify(options.body) - req.headers["content-type"] = "application/json" - } - - try { - return await this.routes.get(key)(Object.assign(event, { - __is_event__: true, - method: method.toUpperCase(), - path: `${url.pathname}${url.search}`, - _requestBody: options.body, - node: { - req, - res: new http.ServerResponse(req) - }, - context: { - api: options.api, - mxcDownloader: options.mxcDownloader, - params: options.params, - snow: options.snow, - createRoom: options.createRoom, - createSpace: options.createSpace, - getOauth2Token: options.getOauth2Token, - getClient: options.getClient, - sessions: { - h3: { - id: "h3", - createdAt: 0, - data: options.sessionData || {} - } - } - } - })) - } catch (error) { - // Post-process error data - defaultAs.app.options.onError(error) - throw error - } - } -} - -const router = new Router() - -passthrough.as = {router, on() {}, options: defaultAs.app.options} - -module.exports.router = router -module.exports.test = test