Compare commits

..

1 commit

Author SHA1 Message Date
419d61cf82 Sync webhook profiles to Matrix 2024-09-26 02:34:30 +12:00
142 changed files with 2236 additions and 14261 deletions

View file

@ -9,7 +9,6 @@ function addbot() {
return `Open this link to add the bot to a Discord server:\nhttps://discord.com/oauth2/authorize?client_id=${id}&scope=bot&permissions=1610883072 ` return `Open this link to add the bot to a Discord server:\nhttps://discord.com/oauth2/authorize?client_id=${id}&scope=bot&permissions=1610883072 `
} }
/* c8 ignore next 3 */
if (process.argv.find(a => a.endsWith("addbot") || a.endsWith("addbot.js"))) { if (process.argv.find(a => a.endsWith("addbot") || a.endsWith("addbot.js"))) {
console.log(addbot()) console.log(addbot())
} }

View file

@ -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
}
}
]
}
```

View file

@ -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 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`.)
# 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: 137
### <font size="+2">🦕</font>
* (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.
### <font size="-1">🪱</font>
* (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.
* (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) 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.

View file

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

View file

@ -1,108 +0,0 @@
# 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
* 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!
----
<br><br><br><br><br>
# 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
}
```

View file

@ -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: 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 /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 "easy mode" web button, REPLACE INTO state 1 and ensureSpace.
- When bot is added through "self-service" web button, REPLACE INTO state 0. - 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. - 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.

View file

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

813
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "out-of-your-element", "name": "out-of-your-element",
"version": "3.1.0", "version": "1.1.1",
"description": "A bridge between Matrix and Discord", "description": "A bridge between Matrix and Discord",
"main": "index.js", "main": "index.js",
"repository": { "repository": {
@ -18,52 +18,52 @@
"node": ">=20" "node": ">=20"
}, },
"dependencies": { "dependencies": {
"@chriscdn/promise-semaphore": "^3.0.1", "@chriscdn/promise-semaphore": "^2.0.1",
"@cloudrac3r/discord-markdown": "^2.6.6", "@cloudrac3r/discord-markdown": "^2.6.3",
"@cloudrac3r/giframe": "^0.4.3", "@cloudrac3r/giframe": "^0.4.3",
"@cloudrac3r/html-template-tag": "^5.0.1", "@cloudrac3r/html-template-tag": "^5.0.1",
"@cloudrac3r/in-your-element": "^1.1.1", "@cloudrac3r/in-your-element": "^1.0.0",
"@cloudrac3r/mixin-deep": "^3.0.1", "@cloudrac3r/mixin-deep": "^3.0.0",
"@cloudrac3r/pngjs": "^7.0.3", "@cloudrac3r/pngjs": "^7.0.3",
"@cloudrac3r/pug": "^4.0.4", "@cloudrac3r/pug": "^4.0.4",
"@cloudrac3r/turndown": "^7.1.4", "@cloudrac3r/turndown": "^7.1.4",
"@stackoverflow/stacks": "^2.5.4", "@stackoverflow/stacks": "^2.5.7",
"@stackoverflow/stacks-icons": "^6.0.2", "@stackoverflow/stacks-icons": "^6.0.2",
"ansi-colors": "^4.1.3", "ansi-colors": "^4.1.3",
"better-sqlite3": "^12.2.0", "better-sqlite3": "^11.1.2",
"chunk-text": "^2.0.1", "chunk-text": "^2.0.1",
"cloudstorm": "^0.14.0", "cloudstorm": "^0.10.10",
"discord-api-types": "^0.38.19",
"domino": "^2.1.6", "domino": "^2.1.6",
"enquirer": "^2.4.1", "enquirer": "^2.4.1",
"entities": "^5.0.0", "entities": "^5.0.0",
"get-relative-path": "^1.0.2", "get-stream": "^6.0.1",
"h3": "^1.15.1", "h3": "^1.12.0",
"heatsync": "^2.7.2", "heatsync": "^2.5.3",
"htmx.org": "^2.0.4", "lru-cache": "^10.4.3",
"lru-cache": "^11.0.2", "minimist": "^1.2.8",
"node-fetch": "^2.6.7",
"prettier-bytes": "^1.0.4", "prettier-bytes": "^1.0.4",
"sharp": "^0.33.4", "sharp": "^0.33.4",
"snowtransfer": "^0.15.0", "snowtransfer": "^0.10.5",
"stream-mime-type": "^1.0.2", "stream-mime-type": "^1.0.2",
"try-to-catch": "^3.0.1", "try-to-catch": "^3.0.1",
"uqr": "^0.1.2",
"xxhash-wasm": "^1.0.2", "xxhash-wasm": "^1.0.2",
"zod": "^4.0.17" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@cloudrac3r/tap-dot": "^2.0.3", "@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", "c8": "^10.1.2",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"supertape": "^11.3.0" "discord-api-types": "^0.37.60",
"supertape": "^10.4.0"
}, },
"scripts": { "scripts": {
"start": "node --enable-source-maps start.js", "start": "node start.js",
"setup": "node --enable-source-maps scripts/setup.js",
"addbot": "node addbot.js", "addbot": "node addbot.js",
"test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js | tap-dot", "test": "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", "test-slow": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js -- --slow | tap-dot",
"cover": "c8 -o test/coverage --skip-full -x db/migrations -x src/matrix/file.js -x src/matrix/api.js -x src/d2m/converters/rlottie-wasm.js -r html -r text supertape --no-check-assertions-count --format fail --no-worker test/test.js -- --slow" "cover": "c8 -o test/coverage --skip-full -x db/migrations -x 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"
} }
} }

183
readme.md
View file

@ -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) [![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? ## Why a new bridge?
* Modern: Supports new Discord features like replies, threads and stickers, and new Matrix features like edits, spaces and space membership. * Modern: Supports new Discord features like replies, threads and stickers, and new Matrix features like edits, spaces and space membership.
* 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) * 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. Messages sent during bridge downtime will still be bridged after it comes back up. * 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. * 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. * 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. * 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? ## 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. * Messages
* Edits
I've also added some interesting features that I haven't seen in any other bridge: * Deletions
* Text formatting, including spoilers
* Members using the PluralKit bot each get their own persistent accounts * Reactions
* Replies from PluralKit members are restyled into native Matrix replies * 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 * 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) For more information about features, [see the user guide.](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/user-guide.md)
@ -39,6 +51,149 @@ For more information about features, [see the user guide.](https://gitdab.com/ca
* This bridge is not designed for puppetting. * This bridge is not designed for puppetting.
* Direct Messaging is not supported until I figure out a good way of doing it. * Direct Messaging is not supported until I figure out a good way of doing it.
## 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
### <font size="+2">🦕</font>
* (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.
### <font size="-1">🪱</font>
* (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.

25
registration.example.yaml Normal file
View file

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

View file

@ -1,79 +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=<channel id here>")
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 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.onMessageCreate(discord, simulatedGatewayDispatchData)
preparedInsert.run(channelID, message.id)
}
last = messages.at(-1)?.id
}
process.exit()
}

View file

@ -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<K, V[]>}
*/
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}`)
}

View file

@ -2,7 +2,8 @@
// @ts-check // @ts-check
const assert = require("assert").strict 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 sqlite = require("better-sqlite3")
const HeatSync = require("heatsync") const HeatSync = require("heatsync")
@ -114,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.) // (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.transaction(() => {
db.prepare("DELETE FROM channel_room WHERE channel_id = ?").run(channel.id) 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)`) console.log(`-- -- Added to database (transferred properties from previous OOYE room)`)
})() })()
} else { } else {

216
scripts/setup.js → scripts/seed.js Normal file → Executable file
View file

@ -7,16 +7,17 @@ const sqlite = require("better-sqlite3")
const {scheduler} = require("timers/promises") const {scheduler} = require("timers/promises")
const {isDeepStrictEqual} = require("util") const {isDeepStrictEqual} = require("util")
const {createServer} = require("http") const {createServer} = require("http")
const {join} = require("path")
const {prompt} = require("enquirer") const {prompt} = require("enquirer")
const Input = require("enquirer/lib/prompts/input") const Input = require("enquirer/lib/prompts/input")
const fetch = require("node-fetch").default
const {magenta, bold, cyan} = require("ansi-colors") const {magenta, bold, cyan} = require("ansi-colors")
const HeatSync = require("heatsync") const HeatSync = require("heatsync")
const {SnowTransfer} = require("snowtransfer") const {SnowTransfer} = require("snowtransfer")
const DiscordTypes = require("discord-api-types/v10")
const {createApp, defineEventHandler, toNodeListener} = require("h3") 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 // Move database file if it's still in the old location
if (fs.existsSync("db")) { if (fs.existsSync("db")) {
if (fs.existsSync("db/ooye.db")) { if (fs.existsSync("db/ooye.db")) {
@ -37,6 +38,7 @@ const passthrough = require("../src/passthrough")
const db = new sqlite("ooye.db") const db = new sqlite("ooye.db")
const migrate = require("../src/db/migrate") const migrate = require("../src/db/migrate")
/** @type {import("heatsync").default} */ // @ts-ignore
const sync = new HeatSync({watchFS: false}) const sync = new HeatSync({watchFS: false})
Object.assign(passthrough, {sync, db}) Object.assign(passthrough, {sync, db})
@ -48,14 +50,28 @@ passthrough.select = orm.select
let registration = require("../src/matrix/read-registration") let registration = require("../src/matrix/read-registration")
let {reg, getTemplateRegistration, writeRegistration, readRegistration, checkRegistration, registrationFilePath} = registration let {reg, getTemplateRegistration, writeRegistration, readRegistration, checkRegistration, registrationFilePath} = registration
const {setupEmojis} = require("../src/m2d/actions/setup-emojis")
function die(message) { function die(message) {
console.error(message) console.error(message)
process.exit(1) process.exit(1)
} }
async function suggestWellKnown(serverUrlPrompt, url, otherwise) { 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 { try {
var json = await fetch(`${url}/.well-known/matrix/client`).then(res => res.json()) var json = await fetch(`${url}/.well-known/matrix/client`).then(res => res.json())
let baseURL = json["m.homeserver"].base_url.replace(/\/$/, "") let baseURL = json["m.homeserver"].base_url.replace(/\/$/, "")
@ -64,39 +80,23 @@ async function suggestWellKnown(serverUrlPrompt, url, otherwise) {
return `Did you mean: ${bold(baseURL)}? (Enter to accept)` return `Did you mean: ${bold(baseURL)}? (Enter to accept)`
} }
} catch (e) {} } 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 { try {
var res = await fetch(`${url}/_matrix/client/versions`) 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) { } catch (e) {
return e.message return e.message
} }
if (res.status !== 200) return `There is no Matrix server at that URL (${url}/_matrix/client/versions returned ${res.status})`
try { try {
var json = await res.json() var json = await res.json()
if (!Array.isArray(json?.versions) || !json.versions.includes("v1.11")) { if (!Array.isArray(json?.versions) || !json.versions.includes("v1.11")) {
return `OOYE needs Matrix version v1.11, but ${url} doesn't support this` return `OOYE needs Matrix version v1.11, but ${url} doesn't support this`
} }
} catch (e) { } catch (e) {
return suggestWellKnown(serverUrlPrompt, url, `There is no Matrix server at that URL (${url}/_matrix/client/versions is not JSON)`) return `There is no Matrix server at that URL (${url}/_matrix/client/versions is not JSON)`
} }
return true 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 () => { ;(async () => {
// create registration file with prompts... // create registration file with prompts...
if (!reg) { if (!reg) {
@ -121,14 +121,13 @@ function defineEchoHandler() {
const serverOrigin = await serverOriginPrompt.run() const serverOrigin = await serverOriginPrompt.run()
const app = createApp() const app = createApp()
app.use(defineEchoHandler()) app.use(defineEventHandler(() => "Out Of Your Element is listening.\n"))
const server = createServer(toNodeListener(app)) const server = createServer(toNodeListener(app))
await server.listen(6693) await server.listen(6693)
console.log("OOYE has its own web server. It needs to be accessible on the public internet.") 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("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("OOYE listens on localhost:6693, so you will probably have to set up a reverse proxy.")
console.log("Examples: https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/get-started.md#appendix")
console.log("Now listening on port 6693. Feel free to send some test requests.") console.log("Now listening on port 6693. Feel free to send some test requests.")
/** @type {{bridge_origin: string}} */ /** @type {{bridge_origin: string}} */
const bridgeOriginResponse = await prompt({ const bridgeOriginResponse = await prompt({
@ -142,23 +141,17 @@ function defineEchoHandler() {
const res = await fetch(url) const res = await fetch(url)
if (res.status !== 200) return `Server returned status code ${res.status}` if (res.status !== 200) return `Server returned status code ${res.status}`
const text = await res.text() const text = await res.text()
if (!text.startsWith("Out Of Your Element is listening.")) return `Server does not point to OOYE` if (text !== "Out Of Your Element is listening.\n") return `Server does not point to OOYE`
return true return true
} catch (e) { } catch (e) {
return e.message return e.message
} }
} }
}) })
bridgeOriginResponse.bridge_origin = bridgeOriginResponse.bridge_origin.replace(/\/+$/, "") // remove trailing slash
await server.close() await server.close()
console.log("What is your Discord bot token?") 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}} */ /** @type {{discord_token: string}} */
const discordTokenResponse = await prompt({ const discordTokenResponse = await prompt({
type: "input", type: "input",
@ -167,8 +160,8 @@ function defineEchoHandler() {
validate: async token => { validate: async token => {
process.stdout.write(magenta(" checking, please wait...")) process.stdout.write(magenta(" checking, please wait..."))
try { try {
snow = new SnowTransfer(token) const snow = new SnowTransfer(token)
client = await snow.requestHandler.request(`/applications/@me`, {}, "get", "json") await snow.user.getSelf()
return true return true
} catch (e) { } catch (e) {
return e.message return e.message
@ -176,55 +169,7 @@ function defineEchoHandler() {
} }
}) })
const mandatoryIntentFlags = DiscordTypes.ApplicationFlags.GatewayMessageContent | DiscordTypes.ApplicationFlags.GatewayMessageContentLimited
if (!(client.flags & mandatoryIntentFlags)) {
console.log(`On that same page, scroll down to Privileged Gateway Intents and enable all switches.`)
await prompt({
type: "invisible",
name: "intents",
message: "Press Enter when you've enabled them",
validate: async token => {
process.stdout.write(magenta("checking, please wait..."))
client = await snow.requestHandler.request(`/applications/@me`, {}, "get", "json")
if (client.flags & mandatoryIntentFlags) {
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 token => {
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("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}} */ /** @type {{discord_client_secret: string}} */
const clientSecretResponse = await prompt({ const clientSecretResponse = await prompt({
type: "input", type: "input",
@ -232,55 +177,27 @@ function defineEchoHandler() {
message: "Client secret" message: "Client 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 token => {
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) const template = getTemplateRegistration(serverNameResponse.server_name)
reg = { reg = {
...template, ...template,
url: bridgeOriginResponse.bridge_origin, url: bridgeOriginResponse.bridge_origin,
ooye: { ooye: {
...template.ooye, ...template.ooye,
...serverNameResponse,
...bridgeOriginResponse, ...bridgeOriginResponse,
server_origin: serverOrigin, server_origin: serverOrigin,
...discordTokenResponse, ...discordTokenResponse,
...clientSecretResponse, ...clientSecretResponse
...passwordResponse
} }
} }
registration.reg = reg registration.reg = reg
checkRegistration(reg) checkRegistration(reg)
writeRegistration(reg) writeRegistration(reg)
console.log(`Your responses have been saved as ${registrationFilePath}`) console.log(`✅ Registration file saved as ${registrationFilePath}`)
} else { } else {
try { console.log(`✅ Valid registration file found at ${registrationFilePath}`)
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 add it to homeserver.yaml and ${cyan("restart Synapse")}.`)
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(" 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(` In ${cyan("Conduit")}, you need to send the file contents to the #admins room.`)
console.log(" https://docs.conduit.rs/appservices.html") console.log(" https://docs.conduit.rs/appservices.html")
@ -289,18 +206,13 @@ function defineEchoHandler() {
// Done with user prompts, reg is now guaranteed to be valid // Done with user prompts, reg is now guaranteed to be valid
const api = require("../src/matrix/api") const api = require("../src/matrix/api")
const file = require("../src/matrix/file") const file = require("../src/matrix/file")
const utils = require("../src/m2d/converters/utils")
const DiscordClient = require("../src/d2m/discord-client") const DiscordClient = require("../src/d2m/discord-client")
const discord = new DiscordClient(reg.ooye.discord_token, "no") const discord = new DiscordClient(reg.ooye.discord_token, "no")
passthrough.discord = discord passthrough.discord = discord
const {as} = require("../src/matrix/appservice") const {as} = require("../src/matrix/appservice")
as.router.use("/**", defineEchoHandler()) console.log("⏳ Waiting until homeserver registration works... (Ctrl+C to cancel)")
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 itWorks = false
let lastError = null let lastError = null
@ -331,30 +243,82 @@ function defineEchoHandler() {
const mxid = `@${reg.sender_localpart}:${reg.ooye.server_name}` 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... // database ddl...
await migrate.migrate(db) await migrate.migrate(db)
// add initial rows to database, like adding the bot to sim... // add initial rows to database, like adding the bot to sim...
const client = await discord.snow.user.getSelf() 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)
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...") console.log("✅ Database is ready...")
// ensure appservice bot user is registered... // ensure appservice bot user is registered...
try {
await api.register(reg.sender_localpart) 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... // upload initial images...
const avatarUrl = await file.uploadDiscordFileToMxc("https://cadence.moe/friends/out_of_your_element.png") const avatarUrl = await file.uploadDiscordFileToMxc("https://cadence.moe/friends/out_of_your_element.png")
console.log("✅ Matrix appservice login works...") console.log("✅ Matrix appservice login works...")
// upload the L1 L2 emojis to user emojis // upload the L1 L2 emojis to some guild
await setupEmojis() 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...") console.log("✅ Emojis are ready...")
// set profile data on discord... // set profile data on discord...
const avatarImageBuffer = await fetch("https://cadence.moe/friends/out_of_your_element.png").then(res => res.arrayBuffer()) const avatarImageBuffer = await fetch("https://cadence.moe/friends/out_of_your_element.png").then(res => res.arrayBuffer())
await discord.snow.user.updateSelf({avatar: "data:image/png;base64," + Buffer.from(avatarImageBuffer).toString("base64")}) await discord.snow.user.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...") console.log("✅ Discord profile updated...")
// set profile data on homeserver... // set profile data on homeserver...

View file

@ -12,6 +12,7 @@ const {reg} = require("../src/matrix/read-registration")
const passthrough = require("../src/passthrough") const passthrough = require("../src/passthrough")
const db = new sqlite("ooye.db") const db = new sqlite("ooye.db")
/** @type {import("heatsync").default} */ // @ts-ignore
const sync = new HeatSync() const sync = new HeatSync()
Object.assign(passthrough, {sync, db}) 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") const discord = new DiscordClient(reg.ooye.discord_token, "half")
passthrough.discord = discord 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 passthrough.as = as
const orm = sync.require("../src/db/orm") const orm = sync.require("../src/db/orm")
@ -33,6 +39,4 @@ passthrough.select = orm.select
await discord.cloud.connect() await discord.cloud.connect()
console.log("Discord gateway started") console.log("Discord gateway started")
sync.require("../src/web/server") sync.require("../src/web/server")
require("../src/stdin")
})() })()

View file

@ -25,7 +25,7 @@ async function addReaction(data) {
if (!parentID) return // Nothing can be done if the parent message was never bridged. if (!parentID) return // Nothing can be done if the parent message was never bridged.
assert.equal(typeof parentID, "string") 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 shortcode = key.startsWith("mxc://") ? `:${data.emoji.name}:` : undefined
const roomID = await createRoom.ensureRoom(data.channel_id) const roomID = await createRoom.ensureRoom(data.channel_id)

View file

@ -6,21 +6,17 @@ const Ty = require("../../types")
const {reg} = require("../../matrix/read-registration") const {reg} = require("../../matrix/read-registration")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {discord, sync, db, select, from} = passthrough const {discord, sync, db, select} = passthrough
/** @type {import("../../matrix/file")} */ /** @type {import("../../matrix/file")} */
const file = sync.require("../../matrix/file") const file = sync.require("../../matrix/file")
/** @type {import("../../matrix/api")} */ /** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api") const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/mreq")} */
const mreq = sync.require("../../matrix/mreq")
/** @type {import("../../matrix/kstate")} */ /** @type {import("../../matrix/kstate")} */
const ks = sync.require("../../matrix/kstate") const ks = sync.require("../../matrix/kstate")
/** @type {import("../../discord/utils")} */ /** @type {import("../../discord/utils")} */
const dUtils = sync.require("../../discord/utils") const utils = sync.require("../../discord/utils")
/** @type {import("../../m2d/converters/utils")} */ /** @type {import("./create-space")}) */
const mUtils = sync.require("../../m2d/converters/utils") const createSpace = sync.require("./create-space") // watch out for the require loop
/** @type {import("./create-space")} */
const createSpace = sync.require("./create-space")
/** /**
* There are 3 levels of room privacy: * There are 3 levels of room privacy:
@ -30,7 +26,7 @@ const createSpace = sync.require("./create-space")
*/ */
const PRIVACY_ENUMS = { const PRIVACY_ENUMS = {
PRESET: ["private_chat", "public_chat", "public_chat"], 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 SPACE_HISTORY_VISIBILITY: ["invited", "world_readable", "world_readable"], // copying from element client
ROOM_HISTORY_VISIBILITY: ["shared", "shared", "world_readable"], // any events sent after <value> are visible, but for world_readable anybody can read without even joining ROOM_HISTORY_VISIBILITY: ["shared", "shared", "world_readable"], // any events sent after <value> 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 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 DEFAULT_PRIVACY_LEVEL = 0
const READ_ONLY_ROOM_EVENTS_DEFAULT_POWER = 50
/** @type {Map<string, Promise<string>>} channel ID -> Promise<room ID> */ /** @type {Map<string, Promise<string>>} channel ID -> Promise<room ID> */
const inflightRoomCreate = new Map() 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, name: string, topic?: string?, type: number, parent_id?: string?}} channel
* @param {{id: string}} guild * @param {{id: string}} guild
@ -56,7 +70,6 @@ function convertNameAndTopic(channel, guild, customName) {
let channelPrefix = let channelPrefix =
( parentChannel?.type === DiscordTypes.ChannelType.GuildForum ? "" ( parentChannel?.type === DiscordTypes.ChannelType.GuildForum ? ""
: channel.type === DiscordTypes.ChannelType.PublicThread ? "[⛓️] " : channel.type === DiscordTypes.ChannelType.PublicThread ? "[⛓️] "
: channel.type === DiscordTypes.ChannelType.AnnouncementThread ? "[⛓️] "
: channel.type === DiscordTypes.ChannelType.PrivateThread ? "[🔒⛓️] " : channel.type === DiscordTypes.ChannelType.PrivateThread ? "[🔒⛓️] "
: channel.type === DiscordTypes.ChannelType.GuildVoice ? "[🔊] " : channel.type === DiscordTypes.ChannelType.GuildVoice ? "[🔊] "
: "") : "")
@ -82,20 +95,27 @@ function convertNameAndTopic(channel, guild, customName) {
async function channelToKState(channel, guild, di) { async function channelToKState(channel, guild, di) {
// @ts-ignore // @ts-ignore
const parentChannel = discord.channels.get(channel.parent_id) const parentChannel = discord.channels.get(channel.parent_id)
/** Used for membership/permission checks. */ /** 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. */ /** 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 let parentSpaceID
if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum) { 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) 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 row = select("channel_room", ["nick", "custom_avatar"], {channel_id: channel.id}).get()
const customName = channelRow?.nick const customName = row?.nick
const customAvatar = channelRow?.custom_avatar const customAvatar = row?.custom_avatar
const hasCustomTopic = channelRow?.custom_topic
const [convertedName, convertedTopic] = convertNameAndTopic(channel, guild, customName) const [convertedName, convertedTopic] = convertNameAndTopic(channel, guild, customName)
const avatarEventContent = {} const avatarEventContent = {}
@ -105,8 +125,6 @@ async function channelToKState(channel, guild, di) {
avatarEventContent.url = {$url: file.guildIcon(guild)} 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] let history_visibility = PRIVACY_ENUMS.ROOM_HISTORY_VISIBILITY[privacyLevel]
if (channel["thread_metadata"]) history_visibility = "world_readable" if (channel["thread_metadata"]) history_visibility = "world_readable"
@ -122,9 +140,8 @@ async function channelToKState(channel, guild, di) {
join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]} join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]}
} }
const everyonePermissions = dUtils.getPermissions([], guild.roles, undefined, channel.permission_overwrites) const everyonePermissions = utils.getPermissions([], guild.roles, undefined, channel.permission_overwrites)
const everyoneCanSend = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendMessages) const everyoneCanMentionEveryone = utils.hasAllPermissions(everyonePermissions, ["MentionEveryone"])
const everyoneCanMentionEveryone = dUtils.hasAllPermissions(everyonePermissions, ["MentionEveryone"])
const globalAdmins = select("member_power", ["mxid", "power_level"], {room_id: "*"}).all() 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 globalAdminPower = globalAdmins.reduce((a, c) => (a[c.mxid] = c.power_level, a), {})
@ -133,7 +150,6 @@ async function channelToKState(channel, guild, di) {
const spacePowerEvent = await di.api.getStateEvent(guildSpaceID, "m.room.power_levels", "") const spacePowerEvent = await di.api.getStateEvent(guildSpaceID, "m.room.power_levels", "")
const spacePower = spacePowerEvent.users const spacePower = spacePowerEvent.users
/** @type {any} */
const channelKState = { const channelKState = {
"m.room.name/": {name: convertedName}, "m.room.name/": {name: convertedName},
"m.room.topic/": {topic: convertedTopic}, "m.room.topic/": {topic: convertedTopic},
@ -146,19 +162,14 @@ async function channelToKState(channel, guild, di) {
}, },
/** @type {{join_rule: string, [x: string]: any}} */ /** @type {{join_rule: string, [x: string]: any}} */
"m.room.join_rules/": join_rules, "m.room.join_rules/": join_rules,
/** @type {Ty.Event.M_Power_Levels} */
"m.room.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
},
notifications: { notifications: {
room: everyoneCanMentionEveryone ? 0 : 20 room: everyoneCanMentionEveryone ? 0 : 20
}, },
users: {...spacePower, ...globalAdminPower} users: {...spacePower, ...globalAdminPower}
}, },
"chat.schildi.hide_ui/read_receipts": { "chat.schildi.hide_ui/read_receipts": {
hidden: true
}, },
[`uk.half-shot.bridge/moe.cadence.ooye://discord/${guild.id}/${channel.id}`]: { [`uk.half-shot.bridge/moe.cadence.ooye://discord/${guild.id}/${channel.id}`]: {
bridgebot: `@${reg.sender_localpart}:${reg.ooye.server_name}`, bridgebot: `@${reg.sender_localpart}:${reg.ooye.server_name}`,
@ -169,7 +180,7 @@ async function channelToKState(channel, guild, di) {
network: { network: {
id: guild.id, id: guild.id,
displayname: guild.name, displayname: guild.name,
avatar_url: {$url: file.guildIcon(guild)} avatar_url: await file.uploadDiscordFileToMxc(file.guildIcon(guild))
}, },
channel: { channel: {
id: channel.id, id: channel.id,
@ -179,16 +190,6 @@ 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} return {spaceID: parentSpaceID, privacyLevel, channelKState}
} }
@ -233,8 +234,8 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) {
return roomID return roomID
}) })
// Put the newly created child into the space // Put the newly created child into the space, no need to await this
await _syncSpaceMember(channel, spaceID, roomID, guild.id) _syncSpaceMember(channel, spaceID, roomID)
return roomID return roomID
} }
@ -253,16 +254,15 @@ async function postApplyPowerLevels(kstate, callback) {
const powerLevelContent = kstate["m.room.power_levels/"] const powerLevelContent = kstate["m.room.power_levels/"]
const kstateWithoutPowerLevels = {...kstate} const kstateWithoutPowerLevels = {...kstate}
delete kstateWithoutPowerLevels["m.room.power_levels/"] delete kstateWithoutPowerLevels["m.room.power_levels/"]
delete kstateWithoutPowerLevels["chat.schildi.hide_ui/read_receipts"]
/** @type {string} */ /** @type {string} */
const roomID = await callback(kstateWithoutPowerLevels) const roomID = await callback(kstateWithoutPowerLevels)
// Now *really* apply the power level overrides on top of what Synapse *really* set // Now *really* apply the power level overrides on top of what Synapse *really* set
if (powerLevelContent) { if (powerLevelContent) {
const newRoomKState = await ks.roomToKState(roomID) const newRoomKState = await roomToKState(roomID)
const newRoomPowerLevelsDiff = ks.diffKState(newRoomKState, {"m.room.power_levels/": powerLevelContent}) const newRoomPowerLevelsDiff = ks.diffKState(newRoomKState, {"m.room.power_levels/": powerLevelContent})
await ks.applyKStateDiffToRoom(roomID, newRoomPowerLevelsDiff) await applyKStateDiffToRoom(roomID, newRoomPowerLevelsDiff)
} }
return roomID return roomID
@ -279,61 +279,6 @@ function channelToGuild(channel) {
return guild 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: Ensure flow:
1. Get IDs 1. Get IDs
@ -352,7 +297,6 @@ 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 {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) * @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<string>} room ID * @returns {Promise<string>} room ID
@ -367,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 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 creation = (async () => {
const {spaceID, privacyLevel, channelKState} = await channelToKState(channel, guild, {api}) const {spaceID, privacyLevel, channelKState} = await channelToKState(channel, guild, {api})
const roomID = await createRoom(channel, guild, spaceID, channelKState, privacyLevel) const roomID = await createRoom(channel, guild, spaceID, channelKState, privacyLevel)
@ -391,7 +335,7 @@ async function _syncRoom(channelID, shouldActuallySync) {
const {spaceID, channelKState} = await channelToKState(channel, guild, {api}) // calling this in both branches because we don't want to calculate this if not syncing const {spaceID, channelKState} = await channelToKState(channel, guild, {api}) // calling this in both branches because we don't want to calculate this if not syncing
// sync channel state to room // sync channel state to room
const roomKState = await ks.roomToKState(roomID) const roomKState = await roomToKState(roomID)
if (+roomKState["m.room.create/"].room_version <= 8) { 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 // 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/ // read more: https://spec.matrix.org/v1.8/rooms/v9/
@ -399,27 +343,27 @@ async function _syncRoom(channelID, shouldActuallySync) {
channelKState["m.room.join_rules/"] = {join_rule: "public"} channelKState["m.room.join_rules/"] = {join_rule: "public"}
} }
const roomDiff = ks.diffKState(roomKState, channelKState) const roomDiff = ks.diffKState(roomKState, channelKState)
const roomApply = ks.applyKStateDiffToRoom(roomID, roomDiff) const roomApply = applyKStateDiffToRoom(roomID, roomDiff)
db.prepare("UPDATE channel_room SET name = ? WHERE room_id = ?").run(channel.name, roomID) db.prepare("UPDATE channel_room SET name = ? WHERE room_id = ?").run(channel.name, roomID)
// sync room as space member // sync room as space member
const spaceApply = _syncSpaceMember(channel, spaceID, roomID, guild.id) const spaceApply = _syncSpaceMember(channel, spaceID, roomID)
await Promise.all([roomApply, spaceApply]) await Promise.all([roomApply, spaceApply])
return roomID 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) { function ensureRoom(channelID) {
return _syncRoom(channelID, false) 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) { function syncRoom(channelID) {
return _syncRoom(channelID, true) return _syncRoom(channelID, true)
} }
async function unbridgeChannel(channelID) { async function _unbridgeRoom(channelID) {
/** @ts-ignore @type {DiscordTypes.APIGuildChannel} */ /** @ts-ignore @type {DiscordTypes.APIGuildChannel} */
const channel = discord.channels.get(channelID) const channel = discord.channels.get(channelID)
assert.ok(channel) assert.ok(channel)
@ -428,86 +372,37 @@ async function unbridgeChannel(channelID) {
} }
/** /**
* @param {{id: string, topic?: string?}} channel channel-ish (just needs an id, topic is optional) * @param {{id: string, topic?: string?}} channel
* @param {string} guildID * @param {string} guildID
*/ */
async function unbridgeDeletedChannel(channel, guildID) { async function unbridgeDeletedChannel(channel, guildID) {
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
assert.ok(roomID) assert.ok(roomID)
const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").get() const spaceID = select("guild_space", "space_id", {guild_id: guildID}).pluck().get()
assert.ok(row) 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 // remove declaration that the room is bridged
try {
await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channel.id}`, {}) await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channel.id}`, {})
} catch (e) { if ("topic" in channel) {
if (String(e).includes("not in room")) {
botInRoom = false
} else {
throw e
}
}
if (botInRoom && "topic" in channel) {
// previously the Matrix topic would say the channel ID. we should remove that // previously the Matrix topic would say the channel ID. we should remove that
await api.sendState(roomID, "m.room.topic", "", {topic: channel.topic || ""}) 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)
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
if (!botInRoom) return
// demote admins in room
/** @type {Ty.Event.M_Power_Levels} */
const powerLevelContent = await api.getStateEvent(roomID, "m.room.power_levels", "")
powerLevelContent.users ??= {}
const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}`
for (const mxid of Object.keys(powerLevelContent.users)) {
if (powerLevelContent.users[mxid] >= 100 && mUtils.eventSenderIsFromDiscord(mxid) && mxid !== bot) {
delete powerLevelContent.users[mxid]
await api.sendState(roomID, "m.room.power_levels", "", powerLevelContent, mxid)
}
}
// send a notification in the room // send a notification in the room
await api.sendEvent(roomID, "m.room.message", { await api.sendEvent(roomID, "m.room.message", {
msgtype: "m.notice", msgtype: "m.notice",
body: "⚠️ This room was removed from the bridge." 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, 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 // leave room
await api.leaveRoom(roomID) await api.leaveRoom(roomID)
// delete room from database
db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channel.id)
} }
/** /**
@ -515,25 +410,14 @@ async function unbridgeDeletedChannel(channel, guildID) {
* @param {DiscordTypes.APIGuildTextChannel} channel * @param {DiscordTypes.APIGuildTextChannel} channel
* @param {string} spaceID * @param {string} spaceID
* @param {string} roomID * @param {string} roomID
* @param {string} guild_id
* @returns {Promise<string[]>} * @returns {Promise<string[]>}
*/ */
async function _syncSpaceMember(channel, spaceID, roomID, guild_id) { async function _syncSpaceMember(channel, spaceID, roomID) {
// If space is self-service then only permit changes to space parenting for threads const spaceKState = await roomToKState(spaceID)
// (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)
let spaceEventContent = {} let spaceEventContent = {}
if ( if (
channel.type !== DiscordTypes.ChannelType.PrivateThread // private threads do not belong in the space (don't offer people something they can't join) 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)
!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
)
) { ) {
spaceEventContent = { spaceEventContent = {
via: [reg.ooye.server_name] via: [reg.ooye.server_name]
@ -542,7 +426,7 @@ async function _syncSpaceMember(channel, spaceID, roomID, guild_id) {
const spaceDiff = ks.diffKState(spaceKState, { const spaceDiff = ks.diffKState(spaceKState, {
[`m.space.child/${roomID}`]: spaceEventContent [`m.space.child/${roomID}`]: spaceEventContent
}) })
return ks.applyKStateDiffToRoom(spaceID, spaceDiff) return applyKStateDiffToRoom(spaceID, spaceDiff)
} }
async function createAllForGuild(guildID) { async function createAllForGuild(guildID) {
@ -559,16 +443,15 @@ async function createAllForGuild(guildID) {
} }
module.exports.DEFAULT_PRIVACY_LEVEL = DEFAULT_PRIVACY_LEVEL 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.PRIVACY_ENUMS = PRIVACY_ENUMS
module.exports.createRoom = createRoom module.exports.createRoom = createRoom
module.exports.ensureRoom = ensureRoom module.exports.ensureRoom = ensureRoom
module.exports.syncRoom = syncRoom module.exports.syncRoom = syncRoom
module.exports.createAllForGuild = createAllForGuild module.exports.createAllForGuild = createAllForGuild
module.exports.channelToKState = channelToKState module.exports.channelToKState = channelToKState
module.exports.roomToKState = roomToKState
module.exports.applyKStateDiffToRoom = applyKStateDiffToRoom
module.exports.postApplyPowerLevels = postApplyPowerLevels module.exports.postApplyPowerLevels = postApplyPowerLevels
module.exports._convertNameAndTopic = convertNameAndTopic module.exports._convertNameAndTopic = convertNameAndTopic
module.exports.unbridgeChannel = unbridgeChannel module.exports._unbridgeRoom = _unbridgeRoom
module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel
module.exports.existsOrAutocreatable = existsOrAutocreatable
module.exports.assertExistsOrAutocreatable = assertExistsOrAutocreatable

View file

@ -14,7 +14,7 @@ test("channel2room: discoverable privacy room", async t => {
let called = 0 let called = 0
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
called++ called++
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe")
t.equal(type, "m.room.power_levels") t.equal(type, "m.room.power_levels")
t.equal(key, "") t.equal(key, "")
return {users: {"@example:matrix.org": 50}} return {users: {"@example:matrix.org": 50}}
@ -36,7 +36,7 @@ test("channel2room: linkable privacy room", async t => {
let called = 0 let called = 0
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
called++ called++
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe")
t.equal(type, "m.room.power_levels") t.equal(type, "m.room.power_levels")
t.equal(key, "") t.equal(key, "")
return {users: {"@example:matrix.org": 50}} return {users: {"@example:matrix.org": 50}}
@ -57,7 +57,7 @@ test("channel2room: invite-only privacy room", async t => {
let called = 0 let called = 0
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
called++ called++
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe")
t.equal(type, "m.room.power_levels") t.equal(type, "m.room.power_levels")
t.equal(key, "") t.equal(key, "")
return {users: {"@example:matrix.org": 50}} return {users: {"@example:matrix.org": 50}}
@ -76,7 +76,7 @@ test("channel2room: room where limited people can mention everyone", async t =>
let called = 0 let called = 0
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
called++ called++
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe")
t.equal(type, "m.room.power_levels") t.equal(type, "m.room.power_levels")
t.equal(key, "") t.equal(key, "")
return {users: {"@example:matrix.org": 50}} return {users: {"@example:matrix.org": 50}}
@ -94,109 +94,6 @@ test("channel2room: room where limited people can mention everyone", async t =>
t.equal(called, 1) t.equal(called, 1)
}) })
test("channel2room: matrix room that already has a custom topic set", async t => {
let called = 0
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
called++
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {}
}
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}}})
// @ts-ignore
delete expected["m.room.topic/"]
t.deepEqual(
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)),
expected
)
t.equal(called, 1)
})
test("channel2room: read-only discord channel", async t => {
let called = 0
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
called++
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {}
}
const expected = {
"chat.schildi.hide_ui/read_receipts": {},
"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": 100,
},
},
"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: {getStateEvent}}).then(x => x.channelKState)),
expected
)
t.equal(called, 1)
})
test("convertNameAndTopic: custom name and topic", t => { test("convertNameAndTopic: custom name and topic", t => {
t.deepEqual( t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, "hauntings"), _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, "hauntings"),

View file

@ -31,8 +31,6 @@ async function createSpace(guild, kstate) {
const topic = kstate["m.room.topic/"]?.topic || undefined const topic = kstate["m.room.topic/"]?.topic || undefined
assert(name) 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 globalAdmins = select("member_power", "mxid", {room_id: "*"}).pluck().all()
const roomID = await createRoom.postApplyPowerLevels(kstate, async kstate => { const roomID = await createRoom.postApplyPowerLevels(kstate, async kstate => {
@ -52,7 +50,7 @@ async function createSpace(guild, kstate) {
initial_state: await ks.kstateToState(kstate) initial_state: await ks.kstateToState(kstate)
}) })
}) })
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 return roomID
} }
@ -94,9 +92,6 @@ async function _syncSpace(guild, shouldActuallySync) {
const row = select("guild_space", ["space_id", "privacy_level"], {guild_id: guild.id}).get() const row = select("guild_space", ["space_id", "privacy_level"], {guild_id: guild.id}).get()
if (!row) { 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 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 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) const spaceID = await createSpace(guild, guildKState)
@ -118,9 +113,9 @@ async function _syncSpace(guild, shouldActuallySync) {
const guildKState = await guildToKState(guild, privacy_level) // calling this in both branches because we don't want to calculate this if not syncing const guildKState = await guildToKState(guild, privacy_level) // calling this in both branches because we don't want to calculate this if not syncing
// sync guild state to space // sync guild state to space
const spaceKState = await ks.roomToKState(spaceID) const spaceKState = await createRoom.roomToKState(spaceID)
const spaceDiff = ks.diffKState(spaceKState, guildKState) 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 // 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 // doing it this way rather than calling syncRoom for great efficiency gains
@ -129,10 +124,16 @@ async function _syncSpace(guild, shouldActuallySync) {
// don't try to update rooms with custom avatars though // 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() const roomsWithCustomAvatars = select("channel_room", "room_id", {}, "WHERE custom_avatar IS NOT NULL").pluck().all()
for await (const room of api.generateFullHierarchy(spaceID)) { const state = await ks.kstateToState(spaceKState)
if (room.avatar_url === newAvatarState.url) continue const childRooms = state.filter(({type, state_key, content}) => {
if (roomsWithCustomAvatars.includes(room.room_id)) continue return type === "m.space.child" && "via" in content && !roomsWithCustomAvatars.includes(state_key)
await api.sendState(room.room_id, "m.room.avatar", "", newAvatarState) }).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)
}
} }
} }
@ -179,9 +180,9 @@ async function syncSpaceFully(guildID) {
const guildKState = await guildToKState(guild, privacy_level) const guildKState = await guildToKState(guild, privacy_level)
// sync guild state to space // sync guild state to space
const spaceKState = await ks.roomToKState(spaceID) const spaceKState = await createRoom.roomToKState(spaceID)
const spaceDiff = ks.diffKState(spaceKState, guildKState) const spaceDiff = ks.diffKState(spaceKState, guildKState)
await ks.applyKStateDiffToRoom(spaceID, spaceDiff) await createRoom.applyKStateDiffToRoom(spaceID, spaceDiff)
const childRooms = await api.getFullHierarchy(spaceID) const childRooms = await api.getFullHierarchy(spaceID)
@ -228,11 +229,11 @@ async function syncSpaceExpressions(data, checkBeforeSync) {
} }
if (isDeepStrictEqual(existing, content)) return 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) update(spaceID, "emojis", "moe.cadence.ooye.pack.emojis", expression.emojisToState)
await update(spaceID, "stickers", "moe.cadence.ooye.pack.stickers", expression.stickersToState) update(spaceID, "stickers", "moe.cadence.ooye.pack.stickers", expression.stickersToState)
} }
module.exports.createSpace = createSpace module.exports.createSpace = createSpace

View file

@ -16,6 +16,7 @@ async function deleteMessage(data) {
const eventsToRedact = select("event_message", "event_id", {message_id: data.id}).pluck().all() 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 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) { 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 // Unfortunately, we can't specify a sender to do the redaction as, unless we find out that info via the audit logs
await api.redactEvent(row.room_id, eventID) await api.redactEvent(row.room_id, eventID)
@ -34,6 +35,7 @@ async function deleteMessageBulk(data) {
const sids = JSON.stringify(data.ids) const sids = JSON.stringify(data.ids)
const eventsToRedact = from("event_message").pluck("event_id").and("WHERE message_id IN (SELECT value FROM json_each(?))").all(sids) 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 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) { 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 // Awaiting will make it go slower, but since this could be a long-running operation either way, we want to leave rate limit capacity for other operations
await api.redactEvent(roomID, eventID) await api.redactEvent(roomID, eventID)

View file

@ -22,7 +22,9 @@ async function editMessage(message, guild, row) {
if (row && row.speedbump_webhook_id === message.webhook_id) { if (row && row.speedbump_webhook_id === message.webhook_id) {
// Handle the PluralKit public instance // Handle the PluralKit public instance
if (row.speedbump_id === "466378653216014359") { if (row.speedbump_id === "466378653216014359") {
senderMxid = await registerPkUser.syncUser(message.id, message.author, roomID, false) const root = await registerPkUser.fetchMessage(message.id)
assert(root.member)
senderMxid = await registerPkUser.ensureSimJoined(root, roomID)
} }
} }
@ -59,7 +61,7 @@ async function editMessage(message, guild, row) {
// 4. Send all the things. // 4. Send all the things.
if (eventsToSend.length) { if (eventsToSend.length) {
db.prepare("INSERT OR IGNORE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id) db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id)
} }
for (const content of eventsToSend) { for (const content of eventsToSend) {
const eventType = content.$type const eventType = content.$type

View file

@ -33,7 +33,7 @@ async function convert(stickerItem) {
if (res.status !== 200) throw new Error("Sticker data file not found.") if (res.status !== 200) throw new Error("Sticker data file not found.")
const text = await res.text() const text = await res.text()
// Convert to PNG (stream.Readable) // Convert to PNG (readable stream)
const readablePng = await convertLottie.convert(text) const readablePng = await convertLottie.convert(text)
// Upload to MXC // Upload to MXC

View file

@ -3,9 +3,10 @@
const assert = require("assert") const assert = require("assert")
const {reg} = require("../../matrix/read-registration") const {reg} = require("../../matrix/read-registration")
const Ty = require("../../types") const Ty = require("../../types")
const fetch = require("node-fetch").default
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {sync, db, select, from} = passthrough const {discord, sync, db, select} = passthrough
/** @type {import("../../matrix/api")} */ /** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api") const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/file")} */ /** @type {import("../../matrix/file")} */
@ -20,20 +21,6 @@ const registerUser = sync.require("./register-user")
* @prop {string} id * @prop {string} id
*/ */
/** @returns {Promise<Ty.PkMessage>} */
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()}`)
const root = await res.json()
if (!root.member) throw new Error(`PK API didn't return member data: ${JSON.stringify(root)}`)
return root
}
/** /**
* A sim is an account that is being simulated by the bridge to copy events from the other side. * A sim is an account that is being simulated by the bridge to copy events from the other side.
* @param {Ty.PkMessage} pkMessage * @param {Ty.PkMessage} pkMessage
@ -46,7 +33,7 @@ async function createSim(pkMessage) {
const mxid = `@${localpart}:${reg.ooye.server_name}` const mxid = `@${localpart}:${reg.ooye.server_name}`
// Save chosen name in the database forever // 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 // Register matrix user with that name
try { try {
@ -109,7 +96,6 @@ async function ensureSimJoined(pkMessage, roomID) {
} }
/** /**
* Generate profile data based on webhook displayname and configured avatar.
* @param {Ty.PkMessage} pkMessage * @param {Ty.PkMessage} pkMessage
* @param {WebhookAuthor} author * @param {WebhookAuthor} author
*/ */
@ -130,47 +116,49 @@ async function memberToStateContent(pkMessage, author) {
/** /**
* Sync profile data for a sim user. This function follows the following process: * Sync profile data for a sim user. This function follows the following process:
* 1. Look up data about proxy user from API * 1. Join the sim to the room if needed
* 2. If this fails, try to use previously cached data (won't sync) * 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. Create and join the sim to the room if needed * 3. Compare against the previously known state content, which is helpfully stored in the database
* 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 * 4. If the state content has changed, send it to Matrix and update it in the database for next time
* 5. Compare against the previously known state content, which is helpfully stored in the database * @param {WebhookAuthor} author
* 6. If the state content has changed, send it to Matrix and update it in the database for next time * @param {Ty.PkMessage} pkMessage
* @param {string} messageID to call API with * @param {string} roomID
* @param {WebhookAuthor} author for profile data
* @param {string} roomID room to join member to
* @param {boolean} shouldActuallySync whether to actually sync updated user data or just ensure it's joined
* @returns {Promise<string>} mxid of the updated sim * @returns {Promise<string>} mxid of the updated sim
*/ */
async function syncUser(messageID, author, roomID, shouldActuallySync) { async function syncUser(author, pkMessage, roomID) {
try {
// API lookup
var pkMessage = await fetchMessage(messageID)
db.prepare("INSERT OR IGNORE INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES (?, ?, ?)").run(pkMessage.member.uuid, pkMessage.sender, author.username)
} 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
const mxid = await ensureSimJoined(pkMessage, roomID) const mxid = await ensureSimJoined(pkMessage, roomID)
// Update the sim_proxy table, so mentions can look up the original sender later
if (shouldActuallySync) { db.prepare("INSERT OR IGNORE INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES (?, ?, ?)").run(pkMessage.member.uuid, pkMessage.sender, author.username)
// Build current profile data // Sync the member state
const content = await memberToStateContent(pkMessage, author) const content = await memberToStateContent(pkMessage, author)
const currentHash = registerUser._hashProfileContent(content, 0) const currentHash = registerUser._hashProfileContent(content, 0)
const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get() const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get()
// only do the actual sync if the hash has changed since we last looked
// Only do the actual sync if the hash has changed since we last looked
if (existingHash !== currentHash) { if (existingHash !== currentHash) {
await api.sendState(roomID, "m.room.member", mxid, content, mxid) 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) db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid)
} }
}
return mxid return mxid
} }
/** @returns {Promise<Ty.PkMessage>} */
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.syncUser = syncUser
module.exports.fetchMessage = fetchMessage

View file

@ -3,7 +3,6 @@
const assert = require("assert").strict const assert = require("assert").strict
const {reg} = require("../../matrix/read-registration") const {reg} = require("../../matrix/read-registration")
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const Ty = require("../../types")
const mixin = require("@cloudrac3r/mixin-deep") const mixin = require("@cloudrac3r/mixin-deep")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
@ -16,8 +15,6 @@ const file = sync.require("../../matrix/file")
const utils = sync.require("../../discord/utils") const utils = sync.require("../../discord/utils")
/** @type {import("../converters/user-to-mxid")} */ /** @type {import("../converters/user-to-mxid")} */
const userToMxid = sync.require("../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 /** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
let hasher = null let hasher = null
// @ts-ignore // @ts-ignore
@ -36,7 +33,7 @@ async function createSim(user) {
// Save chosen name in the database forever // 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 // 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 // Register matrix user with that name
try { try {
@ -100,12 +97,12 @@ async function ensureSimJoined(user, roomID) {
/** /**
* @param {DiscordTypes.APIUser} user * @param {DiscordTypes.APIUser} user
* @param {Omit<DiscordTypes.APIGuildMember, "user"> | undefined} member * @param {Omit<DiscordTypes.APIGuildMember, "user">} member
*/ */
async function memberToStateContent(user, member, guildID) { async function memberToStateContent(user, member, guildID) {
let displayname = user.username let displayname = user.username
if (user.global_name) displayname = user.global_name if (user.global_name) displayname = user.global_name
if (member?.nick) displayname = member.nick if (member.nick) displayname = member.nick
const content = { const content = {
displayname, displayname,
@ -120,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.userAvatar(user) // the user avatar only
const avatarPath = file.memberAvatar(guildID, user, member) // the member avatar or the user avatar const avatarPath = file.memberAvatar(guildID, user, member) // the member avatar or the user avatar
content["moe.cadence.ooye.member"].avatar = avatarPath content["moe.cadence.ooye.member"].avatar = avatarPath
@ -133,16 +130,13 @@ async function memberToStateContent(user, member, guildID) {
/** /**
* https://gitdab.com/cadence/out-of-your-element/issues/9 * https://gitdab.com/cadence/out-of-your-element/issues/9
* @param {DiscordTypes.APIUser} user * @param {DiscordTypes.APIUser} user
* @param {Omit<DiscordTypes.APIGuildMember, "user"> | undefined} member * @param {Omit<DiscordTypes.APIGuildMember, "user">} member
* @param {DiscordTypes.APIGuild} guild * @param {DiscordTypes.APIGuild} guild
* @param {DiscordTypes.APIGuildChannel} channel * @param {DiscordTypes.APIGuildChannel} channel
* @returns {number} 0 to 100 * @returns {number} 0 to 100
*/ */
function memberToPowerLevel(user, member, guild, channel) { function memberToPowerLevel(user, member, guild, channel) {
if (!member) return 0
const permissions = utils.getPermissions(member.roles, guild.roles, user.id, channel.permission_overwrites) const permissions = utils.getPermissions(member.roles, guild.roles, user.id, channel.permission_overwrites)
const everyonePermissions = utils.getPermissions([], guild.roles, undefined, channel.permission_overwrites)
/* /*
* PL 100 = Administrator = People who can brick the room. RATIONALE: * PL 100 = Administrator = People who can brick the room. RATIONALE:
* - Administrator. * - Administrator.
@ -162,14 +156,8 @@ function memberToPowerLevel(user, member, guild, channel) {
* - Moderate Members. * - Moderate Members.
*/ */
if (utils.hasSomePermissions(permissions, ["ManageMessages", "ManageNicknames", "ManageThreads", "KickMembers", "BanMembers", "MuteMembers", "DeafenMembers", "ModerateMembers"])) return 50 if (utils.hasSomePermissions(permissions, ["ManageMessages", "ManageNicknames", "ManageThreads", "KickMembers", "BanMembers", "MuteMembers", "DeafenMembers", "ModerateMembers"])) return 50
/* PL 50 = if room is read-only but the user has been specially allowed to send messages */
const everyoneCanSend = utils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendMessages)
const userCanSend = utils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.SendMessages)
if (!everyoneCanSend && userCanSend) return createRoom.READ_ONLY_ROOM_EVENTS_DEFAULT_POWER
/* PL 20 = Mention Everyone for technical reasons. */ /* PL 20 = Mention Everyone for technical reasons. */
const everyoneCanMentionEveryone = utils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.MentionEveryone) if (utils.hasSomePermissions(permissions, ["MentionEveryone"])) return 20
const userCanMentionEveryone = utils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.MentionEveryone)
if (!everyoneCanMentionEveryone && userCanMentionEveryone) return 20
return 0 return 0
} }
@ -191,7 +179,7 @@ function _hashProfileContent(content, powerLevel) {
* 4. Compare against the previously known state content, which is helpfully stored in the database * 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 * 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 {DiscordTypes.APIUser} user
* @param {Omit<DiscordTypes.APIGuildMember, "user"> | undefined} member * @param {Omit<DiscordTypes.APIGuildMember, "user">} member
* @param {DiscordTypes.APIGuildChannel} channel * @param {DiscordTypes.APIGuildChannel} channel
* @param {DiscordTypes.APIGuild} guild * @param {DiscordTypes.APIGuild} guild
* @param {string} roomID * @param {string} roomID
@ -204,14 +192,44 @@ async function syncUser(user, member, channel, guild, roomID) {
const currentHash = _hashProfileContent(content, powerLevel) const currentHash = _hashProfileContent(content, powerLevel)
const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get() const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get()
// only do the actual sync if the hash has changed since we last looked // only do the actual sync if the hash has changed since we last looked
const hashHasChanged = existingHash !== currentHash if (existingHash !== currentHash) {
// however, do not overwrite pre-existing data if we already have data and `member` is not accessible, because this would replace good data with bad data
const wouldOverwritePreExisting = existingHash && !member
if (hashHasChanged && !wouldOverwritePreExisting) {
// Update room member state // Update room member state
await api.sendState(roomID, "m.room.member", mxid, content, mxid) await api.sendState(roomID, "m.room.member", mxid, content, mxid)
// Update power levels // Update power levels
await api.setUserPower(roomID, mxid, powerLevel) 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
* @returns {Promise<string>} mxid of the updated sim
*/
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
if (existingHash !== currentHash) {
// Update room member state
await api.sendState(roomID, "m.room.member", mxid, content, mxid)
// Update cached hash // Update cached hash
db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid) db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid)
} }
@ -254,5 +272,5 @@ module.exports._hashProfileContent = _hashProfileContent
module.exports.ensureSim = ensureSim module.exports.ensureSim = ensureSim
module.exports.ensureSimJoined = ensureSimJoined module.exports.ensureSimJoined = ensureSimJoined
module.exports.syncUser = syncUser module.exports.syncUser = syncUser
module.exports.syncWebhook = syncWebhook
module.exports.syncAllUsersInRoom = syncAllUsersInRoom module.exports.syncAllUsersInRoom = syncAllUsersInRoom
module.exports._memberToPowerLevel = memberToPowerLevel

View file

@ -1,12 +1,10 @@
const {_memberToStateContent, _memberToPowerLevel} = require("./register-user") const {_memberToStateContent} = require("./register-user")
const {test} = require("supertape") const {test} = require("supertape")
const data = require("../../../test/data") const testData = require("../../../test/data")
const mixin = require("@cloudrac3r/mixin-deep")
const DiscordTypes = require("discord-api-types/v10")
test("member2state: without member nick or avatar", async t => { test("member2state: without member nick or avatar", async t => {
t.deepEqual( 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", avatar_url: "mxc://cadence.moe/UpAeIqeclhKfeiZNdIWNcXXL",
displayname: "kumaccino", 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 => { test("member2state: with global name, without member nick or avatar", async t => {
t.deepEqual( 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", avatar_url: "mxc://cadence.moe/JPzSmALLirnIprlSMKohSSoX",
displayname: "PapiOphidian", 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 => { test("member2state: with member nick and avatar", async t => {
t.deepEqual( 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", avatar_url: "mxc://cadence.moe/rfemHmAtcprjLEiPiEuzPhpl",
displayname: "The Expert's Submarine", 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)
})

View file

@ -43,7 +43,7 @@ async function removeSomeReactions(data) {
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>[]} reactions * @param {Ty.Event.Outer<Ty.Event.M_Reaction>[]} reactions
*/ */
async function removeReaction(data, 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) return converter.removeReaction(data, reactions, key)
} }
@ -52,8 +52,8 @@ async function removeReaction(data, reactions) {
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>[]} reactions * @param {Ty.Event.Outer<Ty.Event.M_Reaction>[]} reactions
*/ */
async function removeEmojiReaction(data, reactions) { async function removeEmojiReaction(data, reactions) {
const key = await emojiToKey.emojiToKey(data.emoji, data.message_id) const key = await emojiToKey.emojiToKey(data.emoji)
const discordPreferredEncoding = await emoji.encodeEmoji(key, undefined) const discordPreferredEncoding = emoji.encodeEmoji(key, undefined)
db.prepare("DELETE FROM reaction WHERE message_id = ? AND encoded_emoji = ?").run(data.message_id, discordPreferredEncoding) db.prepare("DELETE FROM reaction WHERE message_id = ? AND encoded_emoji = ?").run(data.message_id, discordPreferredEncoding)
return converter.removeEmojiReaction(data, reactions, key) return converter.removeEmojiReaction(data, reactions, key)

View file

@ -12,7 +12,6 @@ function debugRetrigger(message) {
} }
} }
const paused = new Set()
const emitter = new EventEmitter() const emitter = new EventEmitter()
/** /**
@ -26,15 +25,13 @@ const emitter = new EventEmitter()
* @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 * @returns {boolean} false if the event was found and the function will be ignored, true if the event was not found and the function will be retriggered
*/ */
function eventNotFoundThenRetrigger(messageID, fn, ...rest) { function eventNotFoundThenRetrigger(messageID, fn, ...rest) {
if (!paused.has(messageID)) {
const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get() const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get()
if (eventID) { if (eventID) {
debugRetrigger(`[retrigger] OK mid <-> eid = ${messageID} <-> ${eventID}`) debugRetrigger(`[retrigger] OK mid <-> eid = ${messageID} <-> ${eventID}`)
return false // event was found so don't retrigger return false // event was found so don't retrigger
} }
}
debugRetrigger(`[retrigger] WAIT mid = ${messageID}`) debugRetrigger(`[retrigger] WAIT mid <-> eid = ${messageID} <-> ${eventID}`)
emitter.once(messageID, () => { emitter.once(messageID, () => {
debugRetrigger(`[retrigger] TRIGGER mid = ${messageID}`) debugRetrigger(`[retrigger] TRIGGER mid = ${messageID}`)
fn(...rest) fn(...rest)
@ -49,25 +46,6 @@ function eventNotFoundThenRetrigger(messageID, fn, ...rest) {
return true // event was not found, then retrigger 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<T>} promise
* @returns {Promise<T>}
*/
async function pauseChanges(messageID, promise) {
try {
debugRetrigger(`[retrigger] PAUSE mid = ${messageID}`)
paused.add(messageID)
return await promise
} finally {
debugRetrigger(`[retrigger] RESUME mid = ${messageID}`)
paused.delete(messageID)
messageFinishedBridging(messageID)
}
}
/** /**
* Triggers any pending operations that were waiting on the corresponding event ID. * Triggers any pending operations that were waiting on the corresponding event ID.
* @param {string} messageID * @param {string} messageID
@ -81,4 +59,3 @@ function messageFinishedBridging(messageID) {
module.exports.eventNotFoundThenRetrigger = eventNotFoundThenRetrigger module.exports.eventNotFoundThenRetrigger = eventNotFoundThenRetrigger
module.exports.messageFinishedBridging = messageFinishedBridging module.exports.messageFinishedBridging = messageFinishedBridging
module.exports.pauseChanges = pauseChanges

View file

@ -28,24 +28,27 @@ async function sendMessage(message, channel, guild, row) {
const roomID = await createRoom.ensureRoom(message.channel_id) const roomID = await createRoom.ensureRoom(message.channel_id)
let senderMxid = null let senderMxid = null
if (!dUtils.isWebhookMessage(message)) { if (row && row.speedbump_webhook_id === message.webhook_id) {
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)
}
} else if (row && row.speedbump_webhook_id === message.webhook_id) {
// Handle the PluralKit public instance // Handle the PluralKit public instance
if (row.speedbump_id === "466378653216014359") { if (row.speedbump_id === "466378653216014359") {
senderMxid = await registerPkUser.syncUser(message.id, message.author, roomID, true) 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)
} }
const events = await messageToEvent.messageToEvent(message, guild, {}, {api, snow: discord.snow}) const events = await messageToEvent.messageToEvent(message, guild, {}, {api})
const eventIDs = [] const eventIDs = []
if (events.length) { if (events.length) {
db.prepare("INSERT OR IGNORE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id) db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id)
if (senderMxid) api.sendTyping(roomID, false, senderMxid).catch(() => {}) if (senderMxid) api.sendTyping(roomID, false, senderMxid)
} }
for (const event of events) { for (const event of events) {
const part = event === events[0] ? 0 : 1 const part = event === events[0] ? 0 : 1

View file

@ -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<string>} */ 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<string, Presence>} 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

View file

@ -6,8 +6,6 @@ const {discord, sync, db} = passthrough
const pinsToList = sync.require("../converters/pins-to-list") const pinsToList = sync.require("../converters/pins-to-list")
/** @type {import("../../matrix/api")} */ /** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api") const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/kstate")} */
const ks = sync.require("../../matrix/kstate")
/** /**
* @template {string | null | undefined} T * @template {string | null | undefined} T
@ -25,21 +23,13 @@ function convertTimestamp(timestamp) {
* @param {number?} convertedTimestamp * @param {number?} convertedTimestamp
*/ */
async function updatePins(channelID, roomID, convertedTimestamp) { async function updatePins(channelID, roomID, convertedTimestamp) {
try { const pins = await discord.snow.channel.getChannelPinnedMessages(channelID)
var discordPins = await discord.snow.channel.getChannelPinnedMessages(channelID) const eventIDs = pinsToList.pinsToList(pins)
} catch (e) { if (pins.length === eventIDs.length || eventIDs.length) {
if (e.message === `{"message": "Missing Access", "code": 50001}`) { await api.sendState(roomID, "m.room.pinned_events", "", {
return // Discord sends channel pins update events even for channels that the bot can't view/get pins in, just ignore it pinned: eventIDs
})
} }
throw e
}
const kstate = await ks.roomToKState(roomID)
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) db.prepare("UPDATE channel_room SET last_bridged_pin_timestamp = ? WHERE channel_id = ?").run(convertedTimestamp || 0, channelID)
} }

View file

@ -22,10 +22,6 @@ function eventCanBeEdited(ev) {
return true 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").GatewayMessageCreateDispatchData} message
* @param {import("discord-api-types/v10").APIGuild} guild * @param {import("discord-api-types/v10").APIGuild} guild
@ -125,58 +121,38 @@ async function editToChanges(message, guild, api) {
unchangedEvents.push(...eventsToReplace.filter(ev => !eventCanBeEdited(ev))) // Move them from eventsToRedact to unchangedEvents. unchangedEvents.push(...eventsToReplace.filter(ev => !eventCanBeEdited(ev))) // Move them from eventsToRedact to unchangedEvents.
eventsToReplace = eventsToReplace.filter(eventCanBeEdited) eventsToReplace = eventsToReplace.filter(eventCanBeEdited)
// 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)
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)
}
// We want to maintain exactly one part = 0 and one reaction_part = 0 database row at all times. // 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})[]} */ /** @type {({column: string, eventID: string, value?: number} | {column: string, nextEvent: true})[]} */
const promotions = [] const promotions = []
for (const column of ["part", "reaction_part"]) { for (const column of ["part", "reaction_part"]) {
const candidatesForParts = unchangedEvents.concat(eventsToReplace) const candidatesForParts = unchangedEvents.concat(eventsToReplace)
// If no events with part = 0 exist (or will exist), we need to do some management. // If no events with part = 0 exist (or will exist), we need to do some management.
if (!candidatesForParts.some(e => e.old[column] === 0)) { if (!candidatesForParts.some(e => e.old[column] === 0)) {
// Try to find an existing event to promote. Bigger order is better.
if (candidatesForParts.length) { 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") 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)) candidatesForParts.sort((a, b) => order(b) - order(a))
if (column === "part") { if (column === "part") {
promotions.push({column, eventID: candidatesForParts[0].old.event_id}) // part should be the first one 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 { } else {
promotions.push({column, eventID: candidatesForParts[candidatesForParts.length - 1].old.event_id}) // reaction_part should be the last one promotions.push({column, eventID: candidatesForParts[candidatesForParts.length - 1].old.event_id}) // reaction_part should be the last one
} }
} } else {
// Or, if there are no existing events to promote and new events will be sent, whatever gets sent will be the next part = 0. // No existing events to promote, but new events are being sent. Whatever gets sent will be the next part = 0.
else {
promotions.push({column, nextEvent: true}) 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 adding events, try to keep reactions attached to the bottom of the group (unless reactions have already been added)
if (eventsToSend.length && !promotions.length) { if (eventsToSend.length && !promotions.length) {
const existingReaction = select("reaction", "message_id", {message_id: message.id}).pluck().get() const existingReaction = select("reaction", "message_id", {message_id: message.id}).pluck().get()
if (!existingReaction) { if (!existingReaction) {
const existingPartZero = unchangedEvents.concat(eventsToReplace).find(p => p.old.reaction_part === 0) 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 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", 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 promotions.push({column: "reaction_part", nextEvent: true}) // the newly created event will have reaction_part = 0
} }
} }
}
// Removing unnecessary properties before returning // Removing unnecessary properties before returning
eventsToRedact = eventsToRedact.map(e => e.old.event_id) eventsToRedact = eventsToRedact.map(e => e.old.event_id)
@ -211,3 +187,4 @@ function makeReplacementEventContent(oldID, newFallbackContent, newInnerContent)
} }
module.exports.editToChanges = editToChanges module.exports.editToChanges = editToChanges
module.exports.makeReplacementEventContent = makeReplacementEventContent

View file

@ -4,14 +4,7 @@ const data = require("../../../test/data")
const Ty = require("../../types") const Ty = require("../../types")
test("edit2changes: edit by webhook", async t => { 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, {})
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"}}
}
})
t.deepEqual(eventsToRedact, []) t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, []) t.deepEqual(eventsToSend, [])
t.deepEqual(eventsToReplace, [{ t.deepEqual(eventsToReplace, [{
@ -35,15 +28,10 @@ test("edit2changes: edit by webhook", async t => {
}]) }])
t.equal(senderMxid, null) t.equal(senderMxid, null)
t.deepEqual(promotions, []) t.deepEqual(promotions, [])
t.equal(called, 1)
}) })
test("edit2changes: bot response", async t => { test("edit2changes: bot response", async t => {
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.bot_response, data.guild.general, { const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.bot_response, data.guild.general, {
getEvent(roomID, eventID) {
t.equal(eventID, "$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY")
return {content: {body: "dummy"}}
},
async getJoinedMembers(roomID) { async getJoinedMembers(roomID) {
t.equal(roomID, "!hYnGGlPHlbujVVfktC:cadence.moe") t.equal(roomID, "!hYnGGlPHlbujVVfktC:cadence.moe")
return new Promise(resolve => { return new Promise(resolve => {
@ -135,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 => { 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, {})
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"}}
}
})
t.deepEqual(eventsToRedact, []) t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, []) t.deepEqual(eventsToSend, [])
t.deepEqual(eventsToReplace, [{ t.deepEqual(eventsToReplace, [{
@ -164,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 => { 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, { 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"}}
}
})
t.deepEqual(eventsToRedact, []) t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, []) t.deepEqual(eventsToSend, [])
t.deepEqual(eventsToReplace, [{ t.deepEqual(eventsToReplace, [{
@ -202,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 => { 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, { 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"}}
}
})
t.deepEqual(eventsToRedact, []) t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, []) t.deepEqual(eventsToSend, [])
t.deepEqual(eventsToReplace, [{ t.deepEqual(eventsToReplace, [{
@ -232,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 => { 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, { 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"}}
}
})
t.deepEqual(eventsToRedact, []) t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, []) t.deepEqual(eventsToSend, [])
t.deepEqual(eventsToReplace, [{ t.deepEqual(eventsToReplace, [{
@ -314,31 +279,32 @@ test("edit2changes: generated embed", async t => {
}) })
test("edit2changes: generated embed on a reply", async t => { test("edit2changes: generated embed on a reply", async t => {
let called = 0 const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_on_reply, data.guild.general, {})
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_on_reply, data.guild.general, { t.deepEqual(eventsToRedact, [])
getEvent(roomID, eventID) { t.deepEqual(eventsToReplace, [{
called++ oldID: "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF",
t.equal(eventID, "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF") newContent: {
return { $type: "m.room.message",
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. // 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]" body: "> a Discord user: [Replied-to message content wasn't provided by Discord]"
+ "\n\nhttps://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM", + "\n\n* https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: "<mx-reply><blockquote><a href=\"https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM\">In reply to</a> a Discord user<br>[Replied-to message content wasn't provided by Discord]</blockquote></mx-reply><a href=\"https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM\">https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM</a>", formatted_body: "<mx-reply><blockquote><a href=\"https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM\">In reply to</a> a Discord user<br>[Replied-to message content wasn't provided by Discord]</blockquote></mx-reply>* <a href=\"https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM\">https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM</a>",
"m.mentions": {}, "m.mentions": {},
"m.new_content": {
body: "https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM",
format: "org.matrix.custom.html",
formatted_body: "<a href=\"https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM\">https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM</a>",
"m.mentions": {},
msgtype: "m.text",
},
"m.relates_to": { "m.relates_to": {
event_id: "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF", event_id: "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF",
rel_type: "m.replace", rel_type: "m.replace",
}, },
msgtype: "m.text", msgtype: "m.text",
} },
} }])
}
})
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToReplace, [])
t.deepEqual(eventsToSend, [{ t.deepEqual(eventsToSend, [{
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.notice", msgtype: "m.notice",
@ -358,5 +324,4 @@ test("edit2changes: generated embed on a reply", async t => {
"nextEvent": true, "nextEvent": true,
}]) }])
t.equal(senderMxid, "@_ooye_cadence:cadence.moe") t.equal(senderMxid, "@_ooye_cadence:cadence.moe")
t.equal(called, 1)
}) })

View file

@ -8,10 +8,9 @@ const file = sync.require("../../matrix/file")
/** /**
* @param {import("discord-api-types/v10").APIEmoji} emoji * @param {import("discord-api-types/v10").APIEmoji} emoji
* @param {string} message_id
* @returns {Promise<string>} * @returns {Promise<string>}
*/ */
async function emojiToKey(emoji, message_id) { async function emojiToKey(emoji) {
let key let key
if (emoji.id) { if (emoji.id) {
// Custom emoji // Custom emoji
@ -31,10 +30,7 @@ async function emojiToKey(emoji, message_id) {
// Default emoji // Default emoji
const name = emoji.name const name = emoji.name
assert(name) assert(name)
// If the reaction was used on Matrix already, it might be using a different arrangement of Variation Selector 16 characters. key = name
// 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
} }
return key return key
} }

View file

@ -21,7 +21,7 @@ const Rlottie = (async () => {
/** /**
* @param {string} text * @param {string} text
* @returns {Promise<stream.Readable>} * @returns {Promise<import("stream").Readable>}
*/ */
async function convert(text) { async function convert(text) {
const r = await Rlottie const r = await Rlottie
@ -41,7 +41,6 @@ async function convert(text) {
png.data = Buffer.from(rendered) 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. // 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. // We use Duplex.from to convert it into a good stream.
// @ts-ignore
return stream.Duplex.from(png.pack()) return stream.Duplex.from(png.pack())
} }

View file

@ -1,7 +1,7 @@
const {test} = require("supertape") const {test} = require("supertape")
const {messageToEvent} = require("./message-to-event") const {messageToEvent} = require("./message-to-event")
const data = require("../../../test/data") const data = require("../../../test/data")
const {db} = require("../../passthrough") const Ty = require("../../types")
test("message2event embeds: nothing but a field", async t => { test("message2event embeds: nothing but a field", async t => {
const events = await messageToEvent(data.message_with_embeds.nothing_but_a_field, data.guild.general, {}) const events = await messageToEvent(data.message_with_embeds.nothing_but_a_field, data.guild.general, {})
@ -33,9 +33,7 @@ test("message2event embeds: reply with just an embed", async t => {
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.notice", msgtype: "m.notice",
"m.mentions": {}, "m.mentions": {},
body: "> In reply to an unbridged message:" body: "| ## ⏺️ dynastic (@dynastic) https://twitter.com/i/user/719631291747078145"
+ "\n> PokemonGod: https://twitter.com/dynastic/status/1707484191963648161"
+ "\n\n| ## ⏺️ 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| 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| \n| ### Retweets"
+ "\n| 119" + "\n| 119"
@ -43,8 +41,7 @@ test("message2event embeds: reply with just an embed", async t => {
+ "\n| 5581" + "\n| 5581"
+ "\n| — Twitter", + "\n| — Twitter",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: '<blockquote>In reply to an unbridged message from PokemonGod:<br><a href=\"https://twitter.com/dynastic/status/1707484191963648161\">https://twitter.com/dynastic/status/1707484191963648161</a></blockquote>' formatted_body: '<blockquote><p><strong><a href="https://twitter.com/i/user/719631291747078145">⏺️ dynastic (@dynastic)</a></strong>'
+ '<blockquote><p><strong><a href="https://twitter.com/i/user/719631291747078145">⏺️ dynastic (@dynastic)</a></strong>'
+ '</p><p>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?' + '</p><p>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?'
+ '</p><p><strong>Retweets</strong><br>119</p><p><strong>Likes</strong><br>5581</p>— Twitter</blockquote>' + '</p><p><strong>Retweets</strong><br>119</p><p><strong>Likes</strong><br>5581</p>— Twitter</blockquote>'
}]) }])
@ -70,7 +67,7 @@ test("message2event embeds: image embed and attachment", async t => {
msgtype: "m.image", msgtype: "m.image",
url: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR", url: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR",
body: "Screenshot_20231001_034036.jpg", 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", filename: "Screenshot_20231001_034036.jpg",
info: { info: {
h: 1170, h: 1170,
@ -321,25 +318,6 @@ test("message2event embeds: youtube video", async t => {
}]) }])
}) })
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: "<font color=\"#ff4500\">@Realdditors</font> get real <a href=\"https://tenor.com/view/get-real-gif-26176788\">https://tenor.com/view/get-real-gif-26176788</a>",
"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: "<blockquote><p>🎞️ https://media.tenor.com/Bz5pfRIu81oAAAPo/get-real.mp4</p></blockquote>",
"m.mentions": {}
}])
})
test("message2event embeds: if discord creates an embed preview for a discord channel link, don't copy that embed", async t => { 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, {}, { const events = await messageToEvent(data.message_with_embeds.discord_server_included_punctuation_bad_discord, data.guild.general, {}, {
api: { api: {
@ -373,16 +351,3 @@ test("message2event embeds: if discord creates an embed preview for a discord ch
"m.mentions": {} "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: `<a href="https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E">https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E</a><br><br><br>Jutomi I'm gonna make these sounds in your walls tonight`,
"m.mentions": {}
}])
})

View file

@ -34,7 +34,6 @@ function getDiscordParseCallbacks(message, guild, useHTML) {
const mxid = select("sim", "mxid", {user_id: node.id}).pluck().get() const mxid = select("sim", "mxid", {user_id: node.id}).pluck().get()
const interaction = message.interaction_metadata || message.interaction const interaction = message.interaction_metadata || message.interaction
const username = message.mentions.find(ment => ment.id === node.id)?.username const username = message.mentions.find(ment => ment.id === node.id)?.username
|| message.referenced_message?.mentions.find(ment => ment.id === node.id)?.username
|| (interaction?.user.id === node.id ? interaction.user.username : null) || (interaction?.user.id === node.id ? interaction.user.username : null)
|| node.id || node.id
if (mxid && useHTML) { if (mxid && useHTML) {
@ -104,7 +103,7 @@ const embedTitleParser = markdown.markdownEngine.parserFor({
* @param {DiscordTypes.APIAttachment} attachment * @param {DiscordTypes.APIAttachment} attachment
*/ */
async function attachmentToEvent(mentions, attachment) { async function attachmentToEvent(mentions, attachment) {
const external_url = dUtils.getPublicUrlForCdn(attachment.url) const publicURL = dUtils.getPublicUrlForCdn(attachment.url)
const emoji = const emoji =
attachment.content_type?.startsWith("image/jp") ? "📸" attachment.content_type?.startsWith("image/jp") ? "📸"
: attachment.content_type?.startsWith("image/") ? "🖼️" : attachment.content_type?.startsWith("image/") ? "🖼️"
@ -118,9 +117,9 @@ async function attachmentToEvent(mentions, attachment) {
$type: "m.room.message", $type: "m.room.message",
"m.mentions": mentions, "m.mentions": mentions,
msgtype: "m.text", 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", format: "org.matrix.custom.html",
formatted_body: `<blockquote>${emoji} Uploaded SPOILER file: <a href="${external_url}">${external_url}</a> (${pb(attachment.size)})</blockquote>` formatted_body: `<blockquote>${emoji} Uploaded SPOILER file: <a href="${publicURL}">${publicURL}</a> (${pb(attachment.size)})</blockquote>`
} }
} }
// for large files, always link them instead of uploading so I don't use up all the space in the content repo // for large files, always link them instead of uploading so I don't use up all the space in the content repo
@ -129,9 +128,9 @@ async function attachmentToEvent(mentions, attachment) {
$type: "m.room.message", $type: "m.room.message",
"m.mentions": mentions, "m.mentions": mentions,
msgtype: "m.text", 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", format: "org.matrix.custom.html",
formatted_body: `${emoji} Uploaded file: <a href="${external_url}">${attachment.filename}</a> (${pb(attachment.size)})` formatted_body: `${emoji} Uploaded file: <a href="${publicURL}">${attachment.filename}</a> (${pb(attachment.size)})`
} }
} else if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) { } else if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) {
return { return {
@ -139,7 +138,7 @@ async function attachmentToEvent(mentions, attachment) {
"m.mentions": mentions, "m.mentions": mentions,
msgtype: "m.image", msgtype: "m.image",
url: await file.uploadDiscordFileToMxc(attachment.url), url: await file.uploadDiscordFileToMxc(attachment.url),
external_url, external_url: attachment.url,
body: attachment.description || attachment.filename, body: attachment.description || attachment.filename,
filename: attachment.filename, filename: attachment.filename,
info: { info: {
@ -155,7 +154,7 @@ async function attachmentToEvent(mentions, attachment) {
"m.mentions": mentions, "m.mentions": mentions,
msgtype: "m.video", msgtype: "m.video",
url: await file.uploadDiscordFileToMxc(attachment.url), url: await file.uploadDiscordFileToMxc(attachment.url),
external_url, external_url: attachment.url,
body: attachment.description || attachment.filename, body: attachment.description || attachment.filename,
filename: attachment.filename, filename: attachment.filename,
info: { info: {
@ -171,13 +170,13 @@ async function attachmentToEvent(mentions, attachment) {
"m.mentions": mentions, "m.mentions": mentions,
msgtype: "m.audio", msgtype: "m.audio",
url: await file.uploadDiscordFileToMxc(attachment.url), url: await file.uploadDiscordFileToMxc(attachment.url),
external_url, external_url: attachment.url,
body: attachment.description || attachment.filename, body: attachment.description || attachment.filename,
filename: attachment.filename, filename: attachment.filename,
info: { info: {
mimetype: attachment.content_type, mimetype: attachment.content_type,
size: attachment.size, size: attachment.size,
duration: attachment.duration_secs ? Math.round(attachment.duration_secs * 1000) : undefined duration: attachment.duration_secs ? attachment.duration_secs * 1000 : undefined
} }
} }
} else { } else {
@ -186,7 +185,7 @@ async function attachmentToEvent(mentions, attachment) {
"m.mentions": mentions, "m.mentions": mentions,
msgtype: "m.file", msgtype: "m.file",
url: await file.uploadDiscordFileToMxc(attachment.url), url: await file.uploadDiscordFileToMxc(attachment.url),
external_url, external_url: attachment.url,
body: attachment.description || attachment.filename, body: attachment.description || attachment.filename,
filename: attachment.filename, filename: attachment.filename,
info: { info: {
@ -198,14 +197,12 @@ async function attachmentToEvent(mentions, attachment) {
} }
/** /**
* @param {DiscordTypes.APIMessage} message * @param {import("discord-api-types/v10").APIMessage} message
* @param {DiscordTypes.APIGuild} guild * @param {import("discord-api-types/v10").APIGuild} guild
* @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean, alwaysReturnFormattedBody?: boolean, scanTextForMentions?: boolean}} options default values: * @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean}} options default values:
* - includeReplyFallback: true * - includeReplyFallback: true
* - includeEditFallbackStar: false * - includeEditFallbackStar: false
* - alwaysReturnFormattedBody: false - formatted_body will be skipped if it is the same as body because the message is plaintext. if you want the formatted_body to be returned anyway, for example to merge it with another message, then set this to true. * @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API
* - 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
*/ */
async function messageToEvent(message, guild, options = {}, di) { async function messageToEvent(message, guild, options = {}, di) {
const events = [] const events = []
@ -239,11 +236,8 @@ async function messageToEvent(message, guild, options = {}, di) {
const interaction = message.interaction_metadata || message.interaction const interaction = message.interaction_metadata || message.interaction
if (message.type === DiscordTypes.MessageType.ChatInputCommand && interaction && "name" in interaction) { if (message.type === DiscordTypes.MessageType.ChatInputCommand && interaction && "name" in interaction) {
// Commands are sent by the responding bot. Need to attach the metadata of the person using the command at the top. // Commands are sent by the responding bot. Need to attach the metadata of the person using the command at the top.
let content = message.content if (message.content) message.content = `\n${message.content}`
if (content) content = `\n${content}` message.content = `> ↪️ <@${interaction.user.id}> used \`/${interaction.name}\`${message.content}`
else if ((message.flags || 0) & DiscordTypes.MessageFlags.Loading) content = " — interaction loading..."
content = `> ↪️ <@${interaction.user.id}> used \`/${interaction.name}\`${content}`
message = {...message, content} // editToChanges reuses the object so we can't mutate it. have to clone it
} }
/** /**
@ -263,9 +257,7 @@ async function messageToEvent(message, guild, options = {}, di) {
- So make sure we don't do anything in this case. - So make sure we don't do anything in this case.
*/ */
const mentions = {} const mentions = {}
/** @type {{event_id: string, room_id: string, source: number}?} */
let repliedToEventRow = null let repliedToEventRow = null
let repliedToUnknownEvent = false
let repliedToEventSenderMxid = null let repliedToEventSenderMxid = null
if (message.mention_everyone) mentions.room = true if (message.mention_everyone) mentions.room = true
@ -281,8 +273,6 @@ async function messageToEvent(message, guild, options = {}, di) {
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) 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) { if (row) {
repliedToEventRow = row repliedToEventRow = row
} else if (message.referenced_message) {
repliedToUnknownEvent = true
} }
} else if (dUtils.isWebhookMessage(message) && message.embeds[0]?.author?.name?.endsWith("↩️")) { } 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 // It could be a PluralKit emulated reply, let's see if it has a message link
@ -402,7 +392,7 @@ async function messageToEvent(message, guild, options = {}, di) {
const id = match[3] const id = match[3]
const name = match[2] const name = match[2]
const animated = !!match[1] 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) { async function transformParsedVia(parsed) {
@ -413,10 +403,8 @@ async function messageToEvent(message, guild, options = {}, di) {
node.via = await getViaServersMemo(node.row.room_id) node.via = await getViaServersMemo(node.row.room_id)
} }
} }
;for (const maybeChildNodesArray of [node, node.content, node.items]) { if (Array.isArray(node.content)) {
if (Array.isArray(maybeChildNodesArray)) { await transformParsedVia(node.content)
await transformParsedVia(maybeChildNodesArray)
}
} }
} }
return parsed return parsed
@ -437,13 +425,8 @@ async function messageToEvent(message, guild, options = {}, di) {
return {body, html} return {body, html}
} }
/** // FIXME: What was the scanMentions parameter supposed to activate? It's unused.
* After converting Discord content to Matrix plaintext and HTML content, post-process the bodies and push the resulting text event async function addTextEvent(body, html, msgtype, {scanMentions}) {
* @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) {
// Star * prefix for fallback edits // Star * prefix for fallback edits
if (options.includeEditFallbackStar) { if (options.includeEditFallbackStar) {
body = "* " + body body = "* " + body
@ -451,14 +434,14 @@ async function messageToEvent(message, guild, options = {}, di) {
} }
const flags = message.flags || 0 const flags = message.flags || 0
if (flags & DiscordTypes.MessageFlags.IsCrosspost) { if (flags & 2) {
body = `[🔀 ${message.author.username}]\n` + body body = `[🔀 ${message.author.username}]\n` + body
html = `🔀 <strong>${message.author.username}</strong><br>` + html html = `🔀 <strong>${message.author.username}</strong><br>` + html
} }
// Fallback body/formatted_body for replies // Fallback body/formatted_body for replies
// This branch is optional - do NOT change anything apart from the reply fallback, since it may not be run // 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) { if (repliedToEventRow && options.includeReplyFallback !== false) {
let repliedToDisplayName let repliedToDisplayName
let repliedToUserHtml let repliedToUserHtml
if (repliedToEventRow?.source === 0 && repliedToEventSenderMxid) { if (repliedToEventRow?.source === 0 && repliedToEventSenderMxid) {
@ -480,26 +463,20 @@ async function messageToEvent(message, guild, options = {}, di) {
} }
if (repliedToContent == "") repliedToContent = "[Media]" if (repliedToContent == "") repliedToContent = "[Media]"
else if (!repliedToContent) repliedToContent = "[Replied-to message content wasn't provided by Discord]" else if (!repliedToContent) repliedToContent = "[Replied-to message content wasn't provided by Discord]"
const {body: repliedToBody, html: repliedToHtml} = await transformContent(repliedToContent) const repliedToHtml = markdown.toHTML(repliedToContent, {
if (repliedToEventRow) { discordCallback: getDiscordParseCallbacks(message, guild, true)
// Generate a reply pointing to the Matrix event we found })
const repliedToBody = markdown.toHTML(repliedToContent, {
discordCallback: getDiscordParseCallbacks(message, guild, false),
discordOnly: true,
escapeHTML: false,
})
html = `<mx-reply><blockquote><a href="https://matrix.to/#/${repliedToEventRow.room_id}/${repliedToEventRow.event_id}">In reply to</a> ${repliedToUserHtml}` html = `<mx-reply><blockquote><a href="https://matrix.to/#/${repliedToEventRow.room_id}/${repliedToEventRow.event_id}">In reply to</a> ${repliedToUserHtml}`
+ `<br>${repliedToHtml}</blockquote></mx-reply>` + `<br>${repliedToHtml}</blockquote></mx-reply>`
+ html + html
body = (`${repliedToDisplayName}: ` // scenario 1 part B for mentions body = (`${repliedToDisplayName}: ` // scenario 1 part B for mentions
+ repliedToBody).split("\n").map(line => "> " + line).join("\n") + repliedToBody).split("\n").map(line => "> " + line).join("\n")
+ "\n\n" + body + "\n\n" + body
} else { // repliedToUnknownEvent
// This reply can't point to the Matrix event because it isn't bridged, we need to indicate this.
assert(message.referenced_message)
const dateDisplay = dUtils.howOldUnbridgedMessage(message.referenced_message.timestamp, message.timestamp)
html = `<blockquote>In reply to ${dateDisplay} from ${repliedToDisplayName}:`
+ `<br>${repliedToHtml}</blockquote>`
+ html
body = (`In reply to ${dateDisplay}:\n${repliedToDisplayName}: `
+ repliedToBody).split("\n").map(line => "> " + line).join("\n")
+ "\n\n" + body
}
} }
const newTextMessageEvent = { const newTextMessageEvent = {
@ -511,7 +488,7 @@ async function messageToEvent(message, guild, options = {}, di) {
const isPlaintext = body === html const isPlaintext = body === html
if (!isPlaintext || options.alwaysReturnFormattedBody) { if (!isPlaintext) {
Object.assign(newTextMessageEvent, { Object.assign(newTextMessageEvent, {
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: html formatted_body: html
@ -529,62 +506,11 @@ async function messageToEvent(message, guild, options = {}, di) {
message.content = "changed the channel name to **" + message.content + "**" message.content = "changed the channel name to **" + message.content + "**"
} }
// Forwarded content appears first
if (message.message_reference?.type === DiscordTypes.MessageReferenceType.Forward && message.message_snapshots?.length) {
// Forwarded notice
const eventID = select("event_message", "event_id", {message_id: message.message_reference.message_id}).pluck().get()
const room = select("channel_room", ["room_id", "name", "nick"], {channel_id: message.message_reference.channel_id}).get()
const forwardedNotice = new mxUtils.MatrixStringBuilder()
if (room) {
const roomName = room && (room.nick || room.name)
const via = await getViaServersMemo(room.room_id)
if (eventID) {
forwardedNotice.addLine(
`[🔀 Forwarded from #${roomName}]`,
tag`🔀 <em>Forwarded from <a href="https://matrix.to/#/${room.room_id}/${eventID}?${via}">${roomName}</a></em>`
)
} else {
forwardedNotice.addLine(
`[🔀 Forwarded from #${roomName}]`,
tag`🔀 <em>Forwarded from <a href="https://matrix.to/#/${room.room_id}?${via}">${roomName}</a></em>`
)
}
} else {
forwardedNotice.addLine(
`[🔀 Forwarded message]`,
tag`🔀 <em>Forwarded message</em>`
)
}
// 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.msgtype = "m.notice"
event.body = event.body.split("\n").map(l => "» " + l).join("\n")
event.formatted_body = `<blockquote>${event.formatted_body}</blockquote>`
}
}
// 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 + "<br>" + forwardedEvents[0].formatted_body
} else {
await addTextEvent(body, formatted_body, "m.notice")
}
events.push(...forwardedEvents)
}
// Then text content
if (message.content) { if (message.content) {
// Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention. // Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention.
const matches = [...message.content.matchAll(/@ ?([a-z0-9._]+)\b/gi)] const matches = [...message.content.matchAll(/@ ?([a-z0-9._]+)\b/gi)]
if (options.scanTextForMentions !== false && matches.length && matches.some(m => m[1].match(/[a-z]/i) && m[1] !== "everyone" && m[1] !== "here")) { 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 writtenMentionsText = matches.map(m => m[1].toLowerCase())
const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get() const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
assert(roomID) assert(roomID)
@ -599,51 +525,9 @@ async function messageToEvent(message, guild, options = {}, di) {
} }
} }
// Text content appears first
const {body, html} = await transformContent(message.content) const {body, html} = await transformContent(message.content)
await addTextEvent(body, html, msgtype) await addTextEvent(body, html, msgtype, {scanMentions: true})
}
// 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"}) // 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`<strong>${event.name}</strong>`)
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} - <a href="https://matrix.to/#/${roomID}?${via}">${invite.channel.name}</a>`)
} 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 = `<blockquote>${html}</blockquote>`
await addTextEvent(body, html, "m.notice")
}
} }
// Then attachments // Then attachments
@ -653,12 +537,7 @@ async function messageToEvent(message, guild, options = {}, di) {
} }
// Then embeds // Then embeds
const urlPreviewEnabled = select("guild_space", "url_preview", {guild_id: guild?.id}).pluck().get() ?? 1
for (const embed of message.embeds || []) { 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") { if (embed.type === "image") {
continue // Matrix's own URL previews are fine for images. continue // Matrix's own URL previews are fine for images.
} }
@ -671,7 +550,7 @@ async function messageToEvent(message, guild, options = {}, di) {
const rep = new mxUtils.MatrixStringBuilder() const rep = new mxUtils.MatrixStringBuilder()
// Provider // Provider
if (embed.provider?.name && embed.provider.name !== "Tenor") { if (embed.provider?.name) {
if (embed.provider.url) { if (embed.provider.url) {
rep.addParagraph(`via ${embed.provider.name} ${embed.provider.url}`, tag`<sub><a href="${embed.provider.url}">${embed.provider.name}</a></sub>`) rep.addParagraph(`via ${embed.provider.name} ${embed.provider.url}`, tag`<sub><a href="${embed.provider.url}">${embed.provider.name}</a></sub>`)
} else { } else {
@ -720,9 +599,9 @@ async function messageToEvent(message, guild, options = {}, di) {
let chosenImage = embed.image?.url let chosenImage = embed.image?.url
// the thumbnail seems to be used for "article" type but displayed big at the bottom by discord // 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 (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}`) if (embed.footer?.text) rep.addLine(`${embed.footer.text}`, tag`${embed.footer.text}`)
let {body, formatted_body: html} = rep.get() let {body, formatted_body: html} = rep.get()
@ -730,7 +609,7 @@ async function messageToEvent(message, guild, options = {}, di) {
html = `<blockquote>${html}</blockquote>` html = `<blockquote>${html}</blockquote>`
// Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person // 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 // Then stickers

View file

@ -337,7 +337,7 @@ test("message2event: attachment with no content", async t => {
msgtype: "m.image", msgtype: "m.image",
url: "mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM", url: "mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM",
body: "image.png", 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", filename: "image.png",
info: { info: {
mimetype: "image/png", mimetype: "image/png",
@ -373,7 +373,7 @@ test("message2event: stickers", async t => {
msgtype: "m.image", msgtype: "m.image",
url: "mxc://cadence.moe/ZDCNYnkPszxGKgObUIFmvjus", url: "mxc://cadence.moe/ZDCNYnkPszxGKgObUIFmvjus",
body: "image.png", 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", filename: "image.png",
info: { info: {
mimetype: "image/png", mimetype: "image/png",
@ -427,7 +427,7 @@ test("message2event: skull webp attachment with content", async t => {
mimetype: "image/webp", mimetype: "image/webp",
size: 74290 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", filename: "skull.webp",
url: "mxc://cadence.moe/sDxWmDErBhYBxtDcJQgBETes" url: "mxc://cadence.moe/sDxWmDErBhYBxtDcJQgBETes"
}]) }])
@ -461,7 +461,7 @@ test("message2event: reply to skull webp attachment with content", async t => {
mimetype: "image/jpeg", mimetype: "image/jpeg",
size: 85906 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", filename: "RDT_20230704_0936184915846675925224905.jpg",
url: "mxc://cadence.moe/WlAbFSiNRIHPDEwKdyPeGywa" url: "mxc://cadence.moe/WlAbFSiNRIHPDEwKdyPeGywa"
}]) }])
@ -532,43 +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: "<a href=\"https://matrix.to/#/@_ooye_extremity:cadence.moe\">@_ooye_extremity:cadence.moe</a> 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: "> okay 🤍 yay 🤍: @extremity: you owe me $30\n\nkys",
format: "org.matrix.custom.html",
formatted_body:
'<mx-reply><blockquote><a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$7P2O_VTQNHvavX5zNJ35DV-dbJB1Ag80tGQP_JzGdhk">In reply to</a> <a href="https://matrix.to/#/@cadence:cadence.moe">okay 🤍 yay 🤍</a>'
+ '<br><a href="https://matrix.to/#/@_ooye_extremity:cadence.moe">@extremity</a> you owe me $30</blockquote></mx-reply>'
+ 'kys'
}])
})
test("message2event: reply with a video", async t => { test("message2event: reply with a video", async t => {
const events = await messageToEvent(data.message.reply_with_video, data.guild.general, { const events = await messageToEvent(data.message.reply_with_video, data.guild.general, {
api: { api: {
@ -588,7 +551,7 @@ test("message2event: reply with a video", async t => {
body: "Ins_1960637570.mp4", body: "Ins_1960637570.mp4",
filename: "Ins_1960637570.mp4", filename: "Ins_1960637570.mp4",
url: "mxc://cadence.moe/kMqLycqMURhVpwleWkmASpnU", 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: { info: {
h: 854, h: 854,
mimetype: "video/mp4", mimetype: "video/mp4",
@ -609,10 +572,10 @@ test("message2event: voice message", async t => {
t.deepEqual(events, [{ t.deepEqual(events, [{
$type: "m.room.message", $type: "m.room.message",
body: "voice-message.ogg", 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", filename: "voice-message.ogg",
info: { info: {
duration: 3960, duration: 3960.0000381469727,
mimetype: "audio/ogg", mimetype: "audio/ogg",
size: 10584, size: 10584,
}, },
@ -632,7 +595,7 @@ test("message2event: misc file", async t => {
}, { }, {
$type: "m.room.message", $type: "m.room.message",
body: "the.yml", 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", filename: "the.yml",
info: { info: {
mimetype: "text/plain; charset=utf-8", mimetype: "text/plain; charset=utf-8",
@ -761,20 +724,6 @@ test("message2event: infinidoge's reply to ami's matrix smalltext singleline rep
}]) }])
}) })
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`,
format: "org.matrix.custom.html",
formatted_body: `<blockquote>In reply to a 1-day-old unbridged message from Occimyy:<br>BILLY BOB THE GREAT</blockquote>enigmatic`,
"m.mentions": {}
}])
})
test("message2event: simple written @mention for matrix user", async t => { test("message2event: simple written @mention for matrix user", async t => {
const events = await messageToEvent(data.message.simple_written_at_mention_for_matrix, data.guild.general, {}, { const events = await messageToEvent(data.message.simple_written_at_mention_for_matrix, data.guild.general, {}, {
api: { api: {
@ -1065,311 +1014,3 @@ test("message2event: @everyone within a link", async t => {
"m.mentions": {} "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: "🔀 <em>Forwarded message</em>",
"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: {
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: `🔀 <em>Forwarded from <a href="https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$tBIT8mO7XTTCgIINyiAIy6M2MSoPAdJenRl_RLyYuaE?via=cadence.moe&amp;via=matrix.org">wonderland</a></em>`
+ `<br><blockquote>What's cooking, good looking? <img data-mx-emoticon height="32" src="mxc://cadence.moe/WbYqNlACRuicynBfdnPYtmvc" title=":hipposcope:" alt=":hipposcope:"></blockquote>`,
"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",
},
{
$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: "<blockquote><blockquote><p><strong>This man</strong></p><p><strong>This man is 100 km away from your house</strong></p><p><strong>Distance away</strong><br>99 km</p><p><strong>Distance away</strong><br>98 km</p></blockquote></blockquote>",
"m.mentions": {},
msgtype: "m.notice"
}
])
})
test("message2event: constructed forwarded text", async t => {
const events = await messageToEvent(data.message.constructed_forwarded_text, {}, {}, {
api: {
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: `🔀 <em>Forwarded from <a href="https://matrix.to/#/!CzvdIdUQXgUjDVKxeU:cadence.moe?via=cadence.moe&amp;via=matrix.org">amanda-spam</a></em>`
+ `<br><blockquote>What's cooking, good looking?</blockquote>`,
"m.mentions": {},
msgtype: "m.notice",
},
{
$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: `🔀 <em>Forwarded message</em>`
+ `<br><blockquote>If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile <a href="https://social.luca.run/@luca/113950834185678114">https://social.luca.run/@luca/113950834185678114</a></blockquote>`,
"m.mentions": {},
msgtype: "m.notice"
}
])
})
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: "<a href=\"https://discord.gg/placeholder?event=1381190945646710824\">https://discord.gg/placeholder?event=1381190945646710824</a>",
"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: "<a href=\"https://discord.gg/placeholder?event=1381190945646710824\">https://discord.gg/placeholder?event=1381190945646710824</a>",
"m.mentions": {},
msgtype: "m.text",
},
{
$type: "m.room.message",
msgtype: "m.notice",
body: `| Scheduled Event - 8 June at 10:00pm NZT9 June at 12:00am NZT`
+ `\n| ## forest exploration`
+ `\n| `
+ `\n| 📍 the dark forest`,
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p>Scheduled Event - 8 June at 10:00pm NZT9 June at 12:00am NZT</p>`
+ `<strong>forest exploration</strong>`
+ `<p>📍 the dark forest</p></blockquote>`,
"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: "<a href=\"https://discord.gg/placeholder?event=1381174024801095751\">https://discord.gg/placeholder?event=1381174024801095751</a>",
"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: `<blockquote><p>Scheduled Event - 9 June at 3:00 pm NZT</p>`
+ `<strong>Cooking (Netrunners)</strong><br>Short circuited brain interfaces actually just means your brain is medium rare, yum.`
+ `<p>🔊 Cooking</p></blockquote>`,
"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: {
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: "<a href=\"https://discord.gg/placeholder?event=1381174024801095751\">https://discord.gg/placeholder?event=1381174024801095751</a>",
"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: `<blockquote><p>Scheduled Event - 9 June at 3:00 pm NZT</p>`
+ `<strong>Cooking (Netrunners)</strong><br>Short circuited brain interfaces actually just means your brain is medium rare, yum.`
+ `<p>🔊 Hey. - <a href="https://matrix.to/#/!FuDZhlOAtqswlyxzeR:cadence.moe?via=cadence.moe">Hey.</a></p></blockquote>`,
"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: {
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: "<ol start=\"1\"><li>Don't be a dick</li><li>Follow rule number 1</li><li>Follow Discord TOS</li><li>Do <strong>not</strong> post NSFW content, shock content, suggestive content</li><li>Please keep <a href=\"https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe?via=cadence.moe&via=federated.nexus\">#wonderland</a> professional and helpful, no random off-topic joking</li></ol>This list will probably change in the future",
"m.mentions": {},
msgtype: "m.text"
}
])
t.equal(called, 1)
})

View file

@ -4,28 +4,16 @@ const {select} = require("../../passthrough")
/** /**
* @param {import("discord-api-types/v10").RESTGetAPIChannelPinsResult} pins * @param {import("discord-api-types/v10").RESTGetAPIChannelPinsResult} pins
* @param {{"m.room.pinned_events/"?: {pinned?: string[]}}} kstate
*/ */
function pinsToList(pins, kstate) { function pinsToList(pins) {
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.find(m => m.id === messageID) // if it is bridged then remove it from the filter
})
/** @type {string[]} */ /** @type {string[]} */
const result = [] const result = []
for (const message of pins) { for (const message of pins) {
const eventID = select("event_message", "event_id", {message_id: message.id, part: 0}).pluck().get() const eventID = select("event_message", "event_id", {message_id: message.id, part: 0}).pluck().get()
if (eventID && !alreadyPinned.includes(eventID)) result.push(eventID) if (eventID) result.push(eventID)
} }
result.reverse() result.reverse()
return alreadyPinned.concat(result) return result
} }
module.exports.pinsToList = pinsToList module.exports.pinsToList = pinsToList

View file

@ -3,59 +3,10 @@ const data = require("../../../test/data")
const {pinsToList} = require("./pins-to-list") const {pinsToList} = require("./pins-to-list")
test("pins2list: converts known IDs, ignores unknown IDs", t => { test("pins2list: converts known IDs, ignores unknown IDs", t => {
const result = pinsToList(data.pins.faked, {}) const result = pinsToList(data.pins.faked)
t.deepEqual(result, [ t.deepEqual(result, [
"$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4", "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4",
"$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA",
"$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" "$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 result = pinsToList(data.pins.faked.slice(0, -2), {
"m.room.pinned_events/": {
pinned: [
"$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA",
"$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4"
]
}
})
t.deepEqual(result, [
"$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA",
"$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg",
])
})

View file

@ -60,11 +60,11 @@ function* generateLocalpartAlternatives(preferences) {
*/ */
function userToSimName(user) { function userToSimName(user) {
if (!SPECIAL_USER_MAPPINGS.has(user.id)) { // skip this check for known special users 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? // 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") 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) // 2. Register based on username (could be new or old format)

View file

@ -1,15 +1,10 @@
// @ts-check // @ts-check
const DiscordTypes = require("discord-api-types/v10") const { SnowTransfer } = require("snowtransfer")
const {Endpoints, SnowTransfer} = require("snowtransfer") const { Client: CloudStorm } = require("cloudstorm")
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 passthrough = require("../passthrough") const passthrough = require("../passthrough")
const {sync} = passthrough const { sync } = passthrough
/** @type {import("./discord-packets")} */ /** @type {import("./discord-packets")} */
const discordPackets = sync.require("./discord-packets") const discordPackets = sync.require("./discord-packets")
@ -29,7 +24,7 @@ class DiscordClient {
intents: [ intents: [
"DIRECT_MESSAGES", "DIRECT_MESSAGE_REACTIONS", "DIRECT_MESSAGE_TYPING", "DIRECT_MESSAGES", "DIRECT_MESSAGE_REACTIONS", "DIRECT_MESSAGE_TYPING",
"GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS", "GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS",
"MESSAGE_CONTENT", "GUILD_PRESENCES" "MESSAGE_CONTENT"
], ],
ws: { ws: {
compress: false, compress: false,
@ -37,15 +32,15 @@ class DiscordClient {
} }
}) })
this.ready = false 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 // @ts-ignore avoid setting as or null because we know we need to wait for ready anyways
this.user = null this.user = null
/** @type {Pick<DiscordTypes.APIApplication, "id" | "flags">} */ /** @type {Pick<import("discord-api-types/v10").APIApplication, "id" | "flags">} */
// @ts-ignore // @ts-ignore
this.application = null this.application = null
/** @type {Map<string, DiscordTypes.APIChannel>} */ /** @type {Map<string, import("discord-api-types/v10").APIChannel>} */
this.channels = new Map() this.channels = new Map()
/** @type {Map<string, DiscordTypes.APIGuild & {members: DiscordTypes.APIGuildMember[]}>} */ // we get members from the GUILD_CREATE and we do maintain it /** @type {Map<string, import("discord-api-types/v10").APIGuild>} */
this.guilds = new Map() this.guilds = new Map()
/** @type {Map<string, Array<string>>} */ /** @type {Map<string, Array<string>>} */
this.guildChannelMap = new Map() this.guildChannelMap = new Map()
@ -62,6 +57,9 @@ class DiscordClient {
addEventLogger("error", "Error") addEventLogger("error", "Error")
addEventLogger("disconnected", "Disconnected") addEventLogger("disconnected", "Disconnected")
addEventLogger("ready", "Ready") addEventLogger("ready", "Ready")
this.snow.requestHandler.on("requestError", (requestID, error) => {
console.error("request error:", error)
})
} }
} }

View file

@ -4,11 +4,7 @@
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../passthrough") const passthrough = require("../passthrough")
const {sync, db} = passthrough const { sync } = passthrough
function populateGuildID(guildID, channelID) {
db.prepare("UPDATE channel_room SET guild_id = ? WHERE channel_id = ?").run(guildID, channelID)
}
const utils = { const utils = {
/** /**
@ -32,7 +28,6 @@ const utils = {
console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`) console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`)
} else if (message.t === "GUILD_CREATE") { } 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) client.guilds.set(message.d.id, message.d)
const arr = [] const arr = []
client.guildChannelMap.set(message.d.id, arr) client.guildChannelMap.set(message.d.id, arr)
@ -41,25 +36,17 @@ const utils = {
channel.guild_id = message.d.id channel.guild_id = message.d.id
arr.push(channel.id) arr.push(channel.id)
client.channels.set(channel.id, channel) client.channels.set(channel.id, channel)
populateGuildID(message.d.id, channel.id)
} }
for (const thread of message.d.threads || []) { for (const thread of message.d.threads || []) {
// @ts-ignore // @ts-ignore
thread.guild_id = message.d.id thread.guild_id = message.d.id
arr.push(thread.id) arr.push(thread.id)
client.channels.set(thread.id, thread) client.channels.set(thread.id, thread)
populateGuildID(message.d.id, thread.id)
} }
if (listen === "full") { if (listen === "full") {
try { eventDispatcher.checkMissedExpressions(message.d)
await eventDispatcher.checkMissedExpressions(message.d) eventDispatcher.checkMissedPins(client, message.d)
await eventDispatcher.checkMissedPins(client, message.d) eventDispatcher.checkMissedMessages(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)
}
} }
} else if (message.t === "GUILD_UPDATE") { } else if (message.t === "GUILD_UPDATE") {
@ -102,20 +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") { } else if (message.t === "THREAD_CREATE") {
client.channels.set(message.d.id, message.d) client.channels.set(message.d.id, message.d)
if (message.d["guild_id"]) {
populateGuildID(message.d["guild_id"], message.d.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") { } else if (message.t === "CHANNEL_UPDATE" || message.t === "THREAD_UPDATE") {
client.channels.set(message.d.id, message.d) client.channels.set(message.d.id, message.d)
@ -137,15 +113,14 @@ const utils = {
client.guildChannelMap.delete(message.d.id) client.guildChannelMap.delete(message.d.id)
} else if (message.t === "CHANNEL_CREATE") { } else if (message.t === "CHANNEL_CREATE" || message.t === "CHANNEL_DELETE") {
if (message.t === "CHANNEL_CREATE") {
client.channels.set(message.d.id, message.d) 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 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
populateGuildID(message.d["guild_id"], message.d.id)
const channels = client.guildChannelMap.get(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) if (channels && !channels.includes(message.d.id)) channels.push(message.d.id)
} }
} else {
} else if (message.t === "CHANNEL_DELETE") {
client.channels.delete(message.d.id) client.channels.delete(message.d.id)
if (message.d["guild_id"]) { if (message.d["guild_id"]) {
const channels = client.guildChannelMap.get(message.d["guild_id"]) const channels = client.guildChannelMap.get(message.d["guild_id"])
@ -155,19 +130,58 @@ const utils = {
} }
} }
} }
}
// Event dispatcher for OOYE bridge operations // Event dispatcher for OOYE bridge operations
if (listen === "full" && message.t) { if (listen === "full") {
try { 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) await eventDispatcher.onSomeReactionsRemoved(client, message.d)
} else if (message.t === "INTERACTION_CREATE") { } else if (message.t === "INTERACTION_CREATE") {
await interactions.dispatchInteraction(message.d) await interactions.dispatchInteraction(message.d)
} else if (message.t in eventDispatcher) {
await eventDispatcher[message.t](client, message.d)
} }
} catch (e) { } catch (e) {
// Let OOYE try to handle errors too // Let OOYE try to handle errors too
await eventDispatcher.onError(client, e, message) await eventDispatcher.onError(client, e, message)

View file

@ -2,6 +2,7 @@
const assert = require("assert").strict const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const util = require("util")
const {sync, db, select, from} = require("../passthrough") const {sync, db, select, from} = require("../passthrough")
/** @type {import("./actions/send-message")}) */ /** @type {import("./actions/send-message")}) */
@ -26,18 +27,19 @@ const updatePins = sync.require("./actions/update-pins")
const api = sync.require("../matrix/api") const api = sync.require("../matrix/api")
/** @type {import("../discord/utils")} */ /** @type {import("../discord/utils")} */
const dUtils = sync.require("../discord/utils") const dUtils = sync.require("../discord/utils")
/** @type {import("../m2d/converters/utils")} */
const mxUtils = require("../m2d/converters/utils")
/** @type {import("./actions/speedbump")} */ /** @type {import("./actions/speedbump")} */
const speedbump = sync.require("./actions/speedbump") const speedbump = sync.require("./actions/speedbump")
/** @type {import("./actions/retrigger")} */ /** @type {import("./actions/retrigger")} */
const retrigger = sync.require("./actions/retrigger") const retrigger = sync.require("./actions/retrigger")
/** @type {import("./actions/set-presence")} */
const setPresence = sync.require("./actions/set-presence")
/** @type {import("../m2d/event-dispatcher")} */
const matrixEventDispatcher = sync.require("../m2d/event-dispatcher")
const {Semaphore} = require("@chriscdn/promise-semaphore") /** @type {any} */ // @ts-ignore bad types from semaphore
const Semaphore = require("@chriscdn/promise-semaphore")
const checkMissedPinsSema = new Semaphore() const checkMissedPinsSema = new Semaphore()
let lastReportedEvent = 0
// Grab Discord events we care about for the bridge, check them, and pass them on // Grab Discord events we care about for the bridge, check them, and pass them on
module.exports = { module.exports = {
@ -47,14 +49,48 @@ module.exports = {
* @param {import("cloudstorm").IGatewayMessage} gatewayMessage * @param {import("cloudstorm").IGatewayMessage} gatewayMessage
*/ */
async onError(client, e, 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
if (Date.now() - lastReportedEvent < 5000) return
lastReportedEvent = Date.now()
const channelID = gatewayMessage.d["channel_id"] const channelID = gatewayMessage.d["channel_id"]
if (!channelID) return if (!channelID) return
const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get() const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
if (!roomID) return if (!roomID) return
if (gatewayMessage.t === "TYPING_START") return 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)
}
}
await matrixEventDispatcher.sendError(roomID, "Discord", gatewayMessage.t, e, gatewayMessage) const builder = new mxUtils.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) {
builder.addLine(`Error trace:\n${stackLines.join("\n")}`, `<details><summary>Error trace</summary><pre>${stackLines.join("\n")}</pre></details>`)
}
builder.addLine("", `<details><summary>Original payload</summary><pre>${util.inspect(gatewayMessage.d, false, 4, false)}</pre></details>`)
await api.sendEvent(roomID, "m.room.message", {
...builder.get(),
"moe.cadence.ooye.error": {
source: "discord",
payload: gatewayMessage
},
"m.mentions": {
user_ids: ["@cadence:cadence.moe"]
}
})
}, },
/** /**
@ -67,22 +103,13 @@ module.exports = {
async checkMissedMessages(client, guild) { async checkMissedMessages(client, guild) {
if (guild.unavailable) return if (guild.unavailable) return
const bridgedChannels = select("channel_room", "channel_id").pluck().all() const bridgedChannels = select("channel_room", "channel_id").pluck().all()
const preparedExists = db.prepare("SELECT channel_id FROM message_channel WHERE channel_id = ? LIMIT 1") const prepared = select("event_message", "event_id", {}, "WHERE message_id = ?").pluck()
const preparedGet = select("event_message", "event_id", {}, "WHERE message_id = ?").pluck() for (const channel of guild.channels.concat(guild.threads)) {
/** @type {(DiscordTypes.APIChannel & {type: DiscordTypes.GuildChannelType})[]} */
let channels = []
channels = channels.concat(guild.channels, guild.threads)
for (const channel of channels) {
if (!bridgedChannels.includes(channel.id)) continue if (!bridgedChannels.includes(channel.id)) continue
if (!("last_message_id" in channel) || !channel.last_message_id) continue if (!("last_message_id" in channel) || !channel.last_message_id) continue
const latestWasBridged = prepared.get(channel.last_message_id)
// Skip if channel is already up-to-date
const latestWasBridged = preparedGet.get(channel.last_message_id)
if (latestWasBridged) continue 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 // Permissions check
const member = guild.members.find(m => m.user?.id === client.user.id) const member = guild.members.find(m => m.user?.id === client.user.id)
if (!member) return if (!member) return
@ -104,28 +131,17 @@ module.exports = {
} }
} }
let latestBridgedMessageIndex = messages.findIndex(m => { 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`) // 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. 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<string, DiscordTypes.APIGuildMember | undefined>} 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--) { for (let i = Math.min(messages.length, latestBridgedMessageIndex)-1; i >= 0; i--) {
const message = messages[i] const simulatedGatewayDispatchData = {
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, {
guild_id: guild.id, guild_id: guild.id,
member: members.get(message.author.id),
// @ts-ignore
backfill: true, backfill: true,
...message ...messages[i]
}) }
await module.exports.onMessageCreate(client, simulatedGatewayDispatchData)
} }
} }
}, },
@ -163,7 +179,7 @@ module.exports = {
*/ */
async checkMissedExpressions(guild) { async checkMissedExpressions(guild) {
const data = {guild_id: guild.id, ...guild} const data = {guild_id: guild.id, ...guild}
await createSpace.syncSpaceExpressions(data, true) createSpace.syncSpaceExpressions(data, true)
}, },
/** /**
@ -172,10 +188,10 @@ module.exports = {
* @param {import("./discord-client")} client * @param {import("./discord-client")} client
* @param {DiscordTypes.APIThreadChannel} thread * @param {DiscordTypes.APIThreadChannel} thread
*/ */
async THREAD_CREATE(client, thread) { async onThreadCreate(client, thread) {
const channelID = thread.parent_id || undefined const channelID = thread.parent_id || undefined
const parentRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get() 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) 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) await announceThread.announceThread(parentRoomID, threadRoomID, thread)
}, },
@ -184,7 +200,7 @@ module.exports = {
* @param {import("./discord-client")} client * @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayGuildUpdateDispatchData} guild * @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() const spaceID = select("guild_space", "space_id", {guild_id: guild.id}).pluck().get()
if (!spaceID) return if (!spaceID) return
await createSpace.syncSpace(guild) await createSpace.syncSpace(guild)
@ -193,26 +209,19 @@ module.exports = {
/** /**
* @param {import("./discord-client")} client * @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayChannelUpdateDispatchData} channelOrThread * @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() const roomID = select("channel_room", "room_id", {channel_id: channelOrThread.id}).pluck().get()
if (!roomID) return // No target room to update the data on if (!roomID) return // No target room to update the data on
await createRoom.syncRoom(channelOrThread.id) 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 {import("./discord-client")} client
* @param {DiscordTypes.GatewayChannelPinsUpdateDispatchData} data * @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() const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get()
if (!roomID) return // No target room to update pins in if (!roomID) return // No target room to update pins in
const convertedTimestamp = updatePins.convertTimestamp(data.last_pin_timestamp) const convertedTimestamp = updatePins.convertTimestamp(data.last_pin_timestamp)
@ -223,7 +232,7 @@ module.exports = {
* @param {import("./discord-client")} client * @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayChannelDeleteDispatchData} channel * @param {DiscordTypes.GatewayChannelDeleteDispatchData} channel
*/ */
async CHANNEL_DELETE(client, channel) { async onChannelDelete(client, channel) {
const guildID = channel["guild_id"] const guildID = channel["guild_id"]
if (!guildID) return // channel must have been a DM channel or something if (!guildID) return // channel must have been a DM channel or something
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
@ -236,11 +245,10 @@ module.exports = {
* @param {import("./discord-client")} client * @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message * @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. if (message.author.username === "Deleted User") return // Nothing we can do for deleted users.
const channel = client.channels.get(message.channel_id) const channel = client.channels.get(message.channel_id)
if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages. if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages.
const guild = client.guilds.get(channel.guild_id) const guild = client.guilds.get(channel.guild_id)
assert(guild) assert(guild)
@ -251,13 +259,11 @@ module.exports = {
if (dUtils.isEphemeralMessage(message)) return // Ephemeral messages are for the eyes of the receiver only! 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) const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id)
if (affected) return if (affected) return
// @ts-ignore // @ts-ignore
await sendMessage.sendMessage(message, channel, guild, row) await sendMessage.sendMessage(message, channel, guild, row),
retrigger.messageFinishedBridging(message.id) retrigger.messageFinishedBridging(message.id)
}, },
@ -266,12 +272,15 @@ module.exports = {
* @param {import("./discord-client")} client * @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayMessageUpdateDispatchData} data * @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. // 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. // 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. // Otherwise, if there are embeds, then the system generated URL preview embeds.
if (!(typeof data.content === "string" || "embeds" 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) { if (data.webhook_id) {
const row = select("webhook", "webhook_id", {webhook_id: data.webhook_id}).pluck().get() 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 (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.
@ -283,26 +292,23 @@ module.exports = {
const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id) const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id)
if (affected) return if (affected) return
// Check that the sending-to room exists, and deal with Eventual Consistency(TM)
if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.MESSAGE_UPDATE, client, data)) return
/** @type {DiscordTypes.GatewayMessageCreateDispatchData} */ /** @type {DiscordTypes.GatewayMessageCreateDispatchData} */
// @ts-ignore // @ts-ignore
const message = data const message = data
const channel = client.channels.get(message.channel_id) const channel = client.channels.get(message.channel_id)
if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages. if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages.
const guild = client.guilds.get(channel.guild_id) const guild = client.guilds.get(channel.guild_id)
assert(guild) assert(guild)
// @ts-ignore // @ts-ignore
await retrigger.pauseChanges(message.id, editMessage.editMessage(message, guild, row)) await editMessage.editMessage(message, guild, row)
}, },
/** /**
* @param {import("./discord-client")} client * @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayMessageReactionAddDispatchData} data * @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.user_id === client.user.id) return // m2d reactions are added by the discord bot user - do not reflect them back to matrix.
await addReaction.addReaction(data) await addReaction.addReaction(data)
}, },
@ -319,9 +325,9 @@ module.exports = {
* @param {import("./discord-client")} client * @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayMessageDeleteDispatchData} data * @param {DiscordTypes.GatewayMessageDeleteDispatchData} data
*/ */
async MESSAGE_DELETE(client, data) { async onMessageDelete(client, data) {
speedbump.onMessageDelete(data.id) 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) await deleteMessage.deleteMessage(data)
}, },
@ -329,7 +335,7 @@ module.exports = {
* @param {import("./discord-client")} client * @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayMessageDeleteBulkDispatchData} data * @param {DiscordTypes.GatewayMessageDeleteBulkDispatchData} data
*/ */
async MESSAGE_DELETE_BULK(client, data) { async onMessageDeleteBulk(client, data) {
await deleteMessage.deleteMessageBulk(data) await deleteMessage.deleteMessageBulk(data)
}, },
@ -337,7 +343,7 @@ module.exports = {
* @param {import("./discord-client")} client * @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayTypingStartDispatchData} data * @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() const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get()
if (!roomID) return if (!roomID) return
const mxid = from("sim").join("sim_member", "mxid").where({user_id: data.user_id, room_id: roomID}).pluck("mxid").get() const mxid = from("sim").join("sim_member", "mxid").where({user_id: data.user_id, room_id: roomID}).pluck("mxid").get()
@ -350,27 +356,9 @@ module.exports = {
/** /**
* @param {import("./discord-client")} client * @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) 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.GatewayPresenceUpdateDispatchData} data
*/
PRESENCE_UPDATE(client, data) {
const status = data.status
if (!status) return
setPresence.presenceTracker.incomingPresence(data.user.id, data.guild_id, status)
} }
} }

View file

@ -6,8 +6,7 @@ const {join} = require("path")
async function migrate(db) { async function migrate(db) {
let files = fs.readdirSync(join(__dirname, "migrations")) let files = fs.readdirSync(join(__dirname, "migrations"))
files = files.sort() files = files.sort()
db.prepare("CREATE TABLE IF NOT EXISTS migration (filename TEXT NOT NULL, PRIMARY KEY (filename)) WITHOUT ROWID").run() db.prepare("CREATE TABLE IF NOT EXISTS migration (filename TEXT NOT NULL)").run()
/** @type {string} */
let progress = db.prepare("SELECT * FROM migration").pluck().get() let progress = db.prepare("SELECT * FROM migration").pluck().get()
if (!progress) { if (!progress) {
progress = "" progress = ""
@ -38,8 +37,6 @@ async function migrate(db) {
if (migrationRan) { if (migrationRan) {
console.log("Database migrations all done.") console.log("Database migrations all done.")
} }
db.pragma("foreign_keys = on")
} }
module.exports.migrate = migrate module.exports.migrate = migrate

View file

@ -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 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 = ?") const stmt = db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE hashed_profile_content = ?")
db.transaction(() => { db.transaction(() => {
/* c8 ignore next 6 */
for (let s of contents) { for (let s of contents) {
let b = Buffer.isBuffer(s) ? Uint8Array.from(s) : Uint8Array.from(Buffer.from(s)) let b = Buffer.isBuffer(s) ? Uint8Array.from(s) : Uint8Array.from(Buffer.from(s))
const unsignedHash = hasher.h64Raw(b) const unsignedHash = hasher.h64Raw(b)

View file

@ -1,5 +0,0 @@
BEGIN TRANSACTION;
ALTER TABLE channel_room ADD COLUMN guild_id TEXT;
COMMIT;

View file

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

View file

@ -1,225 +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 ('sim','sim','625 1'),
('reaction','reaction','3242 1'),
('channel_room','channel_room','389 1'),
('channel_room','sqlite_autoindex_channel_room_1','389 1'),
('media_proxy','media_proxy','5068 1'),
('sim_proxy','sim_proxy','36 1'),
('webhook','webhook','155 1'),
('member_cache','member_cache','784 3 1'),
('member_power','member_power','1 1 1'),
('file','file','21862 1'),
('message_channel','message_channel','366884 1'),
('lottie','lottie','19 1'),
('event_message','event_message','382920 1 1'),
('migration',NULL,'1'),
('sim_member','sim_member','2871 7 1'),
('guild_space','guild_space','32 1'),
('guild_active','guild_active','34 1'),
('emoji','emoji','2563 1'),
('auto_emoji','auto_emoji','3 1');
DELETE FROM "sqlite_stat4";
INSERT INTO "sqlite_stat4" ("tbl","idx","neq","nlt","ndlt","sample") VALUES ('sim','sim','1','69','69',X'0231313137363631373038303932333039353039'),
('sim','sim','1','139','139',X'0231313530383936363934333439373931323332'),
('sim','sim','1','209','209',X'0231323231383737363334373737323139303732'),
('sim','sim','1','279','279',X'0231333039313431353735353334313136383636'),
('sim','sim','1','349','349',X'0231333935343433383235363034313635363434'),
('sim','sim','1','419','419',X'0231353335363239373830383338353134373030'),
('sim','sim','1','489','489',X'0231363930333339333730353930363636383034'),
('sim','sim','1','559','559',X'0231383535353736303637393137323137383133'),
('reaction','reaction','1','360','360',X'020699d5faceefb5fb4f'),
('reaction','reaction','1','721','721',X'0206b61095e98b6b2fb1'),
('reaction','reaction','1','1082','1082',X'0206d1dcb418603a5eaa'),
('reaction','reaction','1','1443','1443',X'0206ef9fc42b9df746ad'),
('reaction','reaction','1','1804','1804',X'02060f38c1f98f130605'),
('reaction','reaction','1','2165','2165',X'02062b53df6dab7b1067'),
('reaction','reaction','1','2526','2526',X'020645dd7e7f60c4aac7'),
('reaction','reaction','1','2887','2887',X'0206658d2fe735805979'),
('channel_room','channel_room','1','43','43',X'023331313434393131333330393139333231363330'),
('channel_room','channel_room','1','87','87',X'023331313835343033343830303934303335393738'),
('channel_room','channel_room','1','131','131',X'023331323139353036353836343139303638393839'),
('channel_room','channel_room','1','175','175',X'023331323336353538333034323331303334393630'),
('channel_room','channel_room','1','219','219',X'023331323933373932323135333930323234343235'),
('channel_room','channel_room','1','263','263',X'023331333333323139363936393333323038303937'),
('channel_room','channel_room','1','307','307',X'0231343835363635393733363433333738363938'),
('channel_room','channel_room','1','351','351',X'0231373039303432313039353632323234363731'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','6 6','6 6',X'034b3321416a6c4c49464e6248646474424a6d4d73503a636164656e63652e6d6f6531313531333434383735363139343833373233'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','34 34','34 34',X'034b3321474b4a63424a6b527a47634e4855686c50613a636164656e63652e6d6f6531303237393433323532323237323630343637'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','43 43','43 43',X'034b3121484b50534d62736d694673506d6268414f513a636164656e63652e6d6f65313931343837343839393433343034353434'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','58 58','58 58',X'034b33214a4479425a685545706874784f6e6f6569513a636164656e63652e6d6f6531323937323836373434353633313236333532'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','87 87','87 87',X'034b33214e544d724e686e715271695755654d494d523a636164656e63652e6d6f6531323235323434353738393939373031353536'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','108 108','108 108',X'034b332151444e44796656674e7657565345656876713a636164656e63652e6d6f6531313432333134303935353535363435343830'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','131 131','131 131',X'034b3121544171536b575752654b43506f584c6a75483a636164656e63652e6d6f65383737303730363531343733363631393532'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','175 175','175 175',X'034b3321594249486864714e697255585941587845563a636164656e63652e6d6f6531323335303831373939353936373639333730'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','177 177','177 177',X'034b3321594b46454e79716667696951686956496b533a636164656e63652e6d6f6531323934363237303431343530333933373034'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','186 186','186 186',X'034b3321596f54644f55766a53765349767266716c653a636164656e63652e6d6f6531323734313936373733383435333430323933'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','202 202','202 202',X'034b3121625877616673695372655647676470535a463a636164656e63652e6d6f65373339303137363739373936343336393932'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','208 208','208 208',X'034b3321634a4b6843764943795377717a47634551423a636164656e63652e6d6f6531323732363632303331323238373331343834'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','219 219','219 219',X'034b3121656455786a56647a6755765844554951434b3a636164656e63652e6d6f65343937313631333530393334353630373738'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','242 242','242 242',X'034b31216a4d746e6e6f51414e4278466a486458494d3a636164656e63652e6d6f65373634353135323932303539323731313939'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','263 263','263 263',X'034b31216c7a776870666a5a6e59797468656a7453483a636164656e63652e6d6f65383838343831373132383438343030343534'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','264 264','264 264',X'034b33216d454c5846716a426958726d7558796943723a636164656e63652e6d6f6531313936393134373631303430393234373432'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','268 268','268 268',X'034b33216d557765577571546761574a767769576a653a636164656e63652e6d6f6531323936373131393236333032333830303834'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','291 291','291 291',X'034b3321704761494e45534643587a634e42497a724e3a636164656e63652e6d6f6531303237343531333333333533313532353232'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','306 306','306 306',X'034b3321717646656248564f4b6876454e54494563763a636164656e63652e6d6f6531323737373238383139323232303230313436'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','307 307','307 307',X'034b332171767370666d716f476449634a66794c506c3a636164656e63652e6d6f6531323936393138333638393539343633343735'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','351 351','351 351',X'034b3321774e7a7741724a47796f4c5168426f544e4b3a636164656e63652e6d6f6531303238303436373930333435333739383930'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','368 368','368 368',X'034b3321794e6d504c7765654a69756570725a677a733a636164656e63652e6d6f6531323531393631373233373731313632363234'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','376 376','376 376',X'034b3121796a4879795772466f704c66646878564e423a636164656e63652e6d6f65333336313537353037303734353233313336'),
('channel_room','sqlite_autoindex_channel_room_1','1 1','379 379','379 379',X'034b31217a4c4f6b62766b44587551465948594555673a636164656e63652e6d6f65393933383838313433373030393330363331'),
('media_proxy','media_proxy','1','563','563',X'02069e6054680b610946'),
('media_proxy','media_proxy','1','1127','1127',X'0206bb489b717c9320e4'),
('media_proxy','media_proxy','1','1691','1691',X'0206d75f602775b7a27c'),
('media_proxy','media_proxy','1','2255','2255',X'0206f2c705ddca4e2b14'),
('media_proxy','media_proxy','1','2819','2819',X'02061060db7a5151967b'),
('media_proxy','media_proxy','1','3383','3383',X'02062cc47366f7550d22'),
('media_proxy','media_proxy','1','3947','3947',X'020647d275ec0d781fc7'),
('media_proxy','media_proxy','1','4511','4511',X'02066402024a7ea38249'),
('sim_proxy','sim_proxy','1','4','4',X'025531316564343731342d636635652d346333372d393331382d376136353266383732636634'),
('sim_proxy','sim_proxy','1','9','9',X'025533346636333932642d323263372d346337382d393063372d326536323734313535613266'),
('sim_proxy','sim_proxy','1','14','14',X'025535396662363131392d626133392d346565382d393738612d386432376366303631393633'),
('sim_proxy','sim_proxy','1','19','19',X'025539373066366536332d646234632d346531342d383063362d336639343938643961363665'),
('sim_proxy','sim_proxy','1','24','24',X'025561636231613335642d313336662d343362332d626365622d326566646634616265306436'),
('sim_proxy','sim_proxy','1','29','29',X'025563316635623735392d336136342d343633342d623634632d643461656436316539656632'),
('sim_proxy','sim_proxy','1','34','34',X'025566323230373135632d633436332d343532622d626233612d373662646662306365353537'),
('webhook','webhook','1','17','17',X'023331313532383834313435343038373230393936'),
('webhook','webhook','1','35','35',X'023331313939303936333434333830343631313138'),
('webhook','webhook','1','53','53',X'023331323331383036353337373032353736313938'),
('webhook','webhook','1','71','71',X'023331323933373836383939343238383036363536'),
('webhook','webhook','1','89','89',X'023331333132363031353130363535353537373132'),
('webhook','webhook','1','107','107',X'0231323937323734313733303636333133373339'),
('webhook','webhook','1','125','125',X'0231353239313736313536333938363832313137'),
('webhook','webhook','1','143','143',X'0231363837303238373334333232313437333434'),
('member_cache','member_cache','4 1','73 74','48 74',X'034b3921496f4866536e67625a6762747061747a494e3a636164656e63652e6d6f65406875636b6c65746f6e3a636164656e63652e6d6f65'),
('member_cache','member_cache','2 1','86 87','57 87',X'034b2d214b5169714663546e764f6f4f424475746a7a3a636164656e63652e6d6f6540726e6c3a636164656e63652e6d6f65'),
('member_cache','member_cache','4 1','101 104','68 104',X'034b43214e446249714e704a795076664b526e4e63723a636164656e63652e6d6f6540776f756e6465645f77617272696f723a6d61747269782e6f7267'),
('member_cache','member_cache','4 1','110 113','73 113',X'034b3b214f485844457370624d485348716c4445614f3a636164656e63652e6d6f6540717561647261646963616c3a6d61747269782e6f7267'),
('member_cache','member_cache','5 1','171 175','111 175',X'034b3b215450616f6a5454444446444847776c7276743a636164656e63652e6d6f6540766962656973766572796f3a6d61747269782e6f7267'),
('member_cache','member_cache','39 1','180 208','116 208',X'034b4d2154716c79516d69667847556767456d64424e3a636164656e63652e6d6f6540726f626c6b796f6772653a6372616674696e67636f6d72616465732e6e6574'),
('member_cache','member_cache','4 1','231 231','126 231',X'034b3b2156624f77675559777146614e4c5345644e413a636164656e63652e6d6f654061666c6f7765723a73796e646963617465642e676179'),
('member_cache','member_cache','9 1','262 263','141 263',X'034b3b21594b46454e79716667696951686956496b533a636164656e63652e6d6f654062656e6d61633a636861742e62656e6d61632e78797a'),
('member_cache','member_cache','3 1','283 283','149 283',X'034b35215a615a4d78456f52724d6d4e49554d79446c3a636164656e63652e6d6f6540636164656e63653a636164656e63652e6d6f65'),
('member_cache','member_cache','88 1','307 351','166 351',X'034b3b2163427874565278446c5a765356684a58564b3a636164656e63652e6d6f65406a61736b617274683a736c656570696e672e746f776e'),
('member_cache','member_cache','11 1','408 415','177 415',X'034b5121654856655270706e6c6f57587177704a6e553a636164656e63652e6d6f65406a61636b736f6e6368656e3636363a6a61636b736f6e6368656e3636362e636f6d'),
('member_cache','member_cache','7 1','423 424','181 424',X'034b4b2165724f7079584e465a486a48724568784e583a636164656e63652e6d6f6540616d796973636f6f6c7a3a6d61747269782e6174697573616d792e636f6d'),
('member_cache','member_cache','96 1','436 439','187 439',X'034b4b21676865544b5a7451666c444e7070684c49673a636164656e63652e6d6f6540616c65783a73706163652e67616d65727374617665726e2e6f6e6c696e65'),
('member_cache','member_cache','96 1','436 527','187 527',X'034b3121676865544b5a7451666c444e7070684c49673a636164656e63652e6d6f654078796c6f626f6c3a616d6265722e74656c'),
('member_cache','member_cache','10 1','546 555','197 555',X'0351312169537958674e7851634575586f587073536e3a707573737468656361742e6f726740797562697175653a6e6f70652e63686174'),
('member_cache','member_cache','13 1','594 601','224 601',X'034b2b216c7570486a715444537a774f744d59476d493a636164656e63652e6d6f6540656c6c69753a68617368692e7265'),
('member_cache','member_cache','2 1','614 615','229 615',X'034b2f216d584978494644676c4861734e53427371773a636164656e63652e6d6f654077696e673a666561746865722e6f6e6c'),
('member_cache','member_cache','4 1','616 619','230 619',X'034b2f216d616767455367755a427147425a74536e723a636164656e63652e6d6f654077696e673a666561746865722e6f6e6c'),
('member_cache','member_cache','4 1','659 660','259 660',X'034b332172454f73706e5971644f414c4149466e69563a636164656e63652e6d6f6540656c797369613a636164656e63652e6d6f65'),
('member_cache','member_cache','4 1','699 701','284 701',X'034b3521766e717a56767678534a586c5a504f5276533a636164656e63652e6d6f6540636164656e63653a636164656e63652e6d6f65'),
('member_cache','member_cache','1 1','703 703','285 703',X'034b3521767165714c474851616842464a56566779483a636164656e63652e6d6f654063696465723a6361746769726c2e636c6f7564'),
('member_cache','member_cache','4 1','705 705','287 705',X'034b35217750454472596b77497a6f744e66706e57503a636164656e63652e6d6f6540636164656e63653a636164656e63652e6d6f65'),
('member_cache','member_cache','35 1','709 709','288 709',X'034b2d2177574f667376757356486f4e4e567242585a3a636164656e63652e6d6f654061613a6361747669626572732e6d65'),
('member_cache','member_cache','14 1','747 749','291 749',X'034b3721776c534544496a44676c486d42474b7254703a636164656e63652e6d6f654062616461746e616d65733a62616461742e646576'),
('member_power','member_power','1 1','0 0','0 0',X'03350f40636164656e63653a636164656e63652e6d6f652a'),
('file','file','1','2429','2429',X'03815f68747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f313039393033313838373530303033343038382f313333313336303134333238333036303833372f50584c5f32303235303132315f3230323934323137372e6a7067'),
('file','file','1','4859','4859',X'03817568747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f313134353832313533383832323637323436362f313330323232393131303834373936373331352f53637265656e73686f745f32303234313130325f3034313332365f5265646469742e6a7067'),
('file','file','1','7289','7289',X'03815968747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f313231393439383932363436363636323433302f313239373634363930353038353636313234362f494d475f32303234313032305f3135323230302e6a7067'),
('file','file','1','9719','9719',X'03814168747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3135393136353731343139343735393638302f313236383537333933363531343337313635392f494d475f353433362e6a7067'),
('file','file','1','12149','12149',X'03813b68747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3236363736373539303634313233383032372f313237323430333931313939383730313630392f696d6167652e706e67'),
('file','file','1','14579','14579',X'03816b68747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3539383730363933323736303434343936392f313237373532343532343330383632373437372f45585445524e414c5f454449545f323032345f4d5f64726166745f322e646f6378'),
('file','file','1','17009','17009',X'03815768747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3635353231363137333639363238363734362f313333333630333132383634313036303938342f323032352d30312d32375f31372e30312e31352e706e67'),
('file','file','1','19439','19439',X'027f68747470733a2f2f63646e2e646973636f72646170702e636f6d2f656d6f6a69732f313230323936303730343936313931323836322e706e67'),
('message_channel','message_channel','1','40764','40764',X'023331313630333434353733303030383232383735'),
('message_channel','message_channel','1','81529','81529',X'023331313830323437393130343238393837343333'),
('message_channel','message_channel','1','122294','122294',X'023331313938303533383732383337363131363230'),
('message_channel','message_channel','1','163059','163059',X'023331323237373739373330333839303738303537'),
('message_channel','message_channel','1','203824','203824',X'023331323437303438333039303031313538373137'),
('message_channel','message_channel','1','244589','244589',X'023331323635363939353734333034303830303034'),
('message_channel','message_channel','1','285354','285354',X'023331323835343637363238323434313732383234'),
('message_channel','message_channel','1','326119','326119',X'023331333038373932333935313031333736353732'),
('lottie','lottie','1','2','2',X'0231373439303534363630373639323138363331'),
('lottie','lottie','1','5','5',X'0231373534313037353339323030363731373635'),
('lottie','lottie','1','8','8',X'0231373936313430363338303933343433303932'),
('lottie','lottie','1','11','11',X'0231373936313431373032363935343835353030'),
('lottie','lottie','1','14','14',X'0231383136303837373932323931323832393434'),
('lottie','lottie','1','17','17',X'0231383233393736313032393736323930383636'),
('event_message','event_message','11 1','14788 14796','14356 14796',X'03336531313532303033373639343137303839303434246d44714a474b3530424a715170394273684e534d64365f3768494354776e70362d793130786d6669766563'),
('event_message','event_message','11 1','33806 33815','32914 33815',X'033365313135373630333638373035373836363736322459526f7139484b376e55677a397668796f6e3053424a49497978497a5750734e4f6e39756f765644664d45'),
('event_message','event_message','1 1','42546 42546','41335 42546',X'03336531313630363236383737353835363938393237245033436e4f6d6a35462d6939454e4e79586b70796f5679306b74324a654464764276326e746d33566a4355'),
('event_message','event_message','2 1','85093 85093','81999 85093',X'0333653131383039393939363531323930343831303424496748666562784533746e623047412d534f7176594a354e4e55385735706c68523159676854636a554734'),
('event_message','event_message','11 1','116157 116165','111525 116165',X'03336531313933363837333730363137333237363536245a32305734766c737079566e387a6e2d526a6f64694a51745f5a7851644f5f33744e415169453755356477'),
('event_message','event_message','1 1','127640 127640','122328 127640',X'0333653132303132353637313733373633303332323624702d626f415672476a4b45327a7158664f3738387a5a597a376a42624648717431334d386464705467476f'),
('event_message','event_message','16 1','140270 140281','134379 140281',X'03336531323039333735363534373138343830343235246c374a4f7543526b7756306d627a69356e5843496b4538416a6951374f67473455456c2d7053445a516649'),
('event_message','event_message','11 1','162065 162071','154933 162071',X'0333653132323434383135393033313937373537373424674c77513179796e4b6d5859496b5a597a4a55627a66557a55552d714c4b5f524f454e4250325f6e44766b'),
('event_message','event_message','1 1','170187 170187','162530 170187',X'0333653132323937373533363839343532373038333424656c6c76416a544a5847627936767249767470677a555572787231716f5a75536e50525f474b4e35455945'),
('event_message','event_message','11 1','178736 178736','170762 178736',X'03336531323333353238303533323338333337353537242d39304668552d36455373594b6435484d7237666d6a414a5f6a576149616e356c4776384e655436564959'),
('event_message','event_message','10 1','180317 180325','172228 180325',X'03336531323334363237303030393333383130323637246a4679416449665a4f54432d2d735971715472473735374c7a50504f34386439657963485477686d797751'),
('event_message','event_message','1 1','212734 212734','203278 212734',X'03336531323438303131373930303633373637363734246557493950456f4d576b614b4d33416a7030782d6d455f6c4b4a6c495f6d6d44686f6379464b5170534f59'),
('event_message','event_message','11 1','228100 228103','217856 228103',X'033365313235333734353736363733373132313336322447326a7a746e5977716a3676304a7a4168576e30725950596470667a5f496f76426771574a4876434c516b'),
('event_message','event_message','11 1','240172 240181','229123 240181',X'033365313235393239383538323233303630313735382472635679454c5a76647453547a37366f3669434a4f614a316a756f683835535778494d546c5072517a676f'),
('event_message','event_message','10 1','240259 240264','229132 240264',X'0333653132353933303030343035333535373235323024535849376a465f696e71424d714c4f564b347659746852644b31724747502d435a5f355a354f6b774e3751'),
('event_message','event_message','2 1','255280 255281','243230 255281',X'03336531323636373837343338353137343234313738246e6e4e576f526a54495757723441463770625454746b7a73784c4d336b312d6d6645665031496b43586e59'),
('event_message','event_message','1 1','297828 297828','283436 297828',X'0333653132383637303937353334363834323032313024544342465970356f39767a2d4f6b2d33654f4e433772426d354966615934476b48536e58445257474f4767'),
('event_message','event_message','1 1','340375 340375','323489 340375',X'033365313330393336363936343530353934303030392431664536386d2d50546e786d5474345458584c35754847594139353779396c76582d50797150496d395f30'),
('event_message','event_message','11 1','343791 343799','326730 343799',X'03336531333131353731333337393331363537323737246731767241315269725951592d3933304f6973587142372d6961686e34684e492d6462374952714176616b'),
('event_message','event_message','10 1','363207 363216','344868 363216',X'0333653133323434393732323633393438393834393524784f78636e4749364269545941734d7a4f7557316f526e68356b675378544e30466442386a3037695f4f67'),
('event_message','event_message','10 1','363219 363219','344871 363219',X'033365313332343439373532363733323039393732352432586f7938567937643843375a47767472657966326b4c4f4159397546386b4755364c3642435f446b6d77'),
('event_message','event_message','10 1','363340 363343','344918 363343',X'03336531333234353037313732333634373530393830244f46645731396649534176706d34646c36367a2d5543337236432d436354506e34752d63444f7036733345'),
('event_message','event_message','11 1','369452 369456','350712 369456',X'0333653133323736353831313733343439323336383024574d774330644574417277375f4562554a534465546532577174506d3747584347774570646c4f79326d30'),
('event_message','event_message','10 1','372353 372356','353425 372356',X'0333653133323933313737353039313234353035373224366259364d313472667163486d5854476349716d4a4366467471796839794472375a6a487463715a6d4f34'),
('sim_member','sim_member','225 1','0 12','0 12',X'034b4721414956694e775a64636b4652764c4f4567433a636164656e63652e6d6f65405f6f6f79655f616b6972615f6e6965723a636164656e63652e6d6f65'),
('sim_member','sim_member','2 1','319 319','14 319',X'034b43214456706f6e54524d56456570486378744c423a636164656e63652e6d6f65405f6f6f79655f656e746f6c6f6d613a636164656e63652e6d6f65'),
('sim_member','sim_member','68 1','391 440','26 440',X'034b4921457a54624a496c496d45534f746b4e644e4a3a636164656e63652e6d6f65405f6f6f79655f73617475726461797465643a636164656e63652e6d6f65'),
('sim_member','sim_member','8 1','638 639','59 639',X'034b3f21497a4f675169446e757346516977796d614c3a636164656e63652e6d6f65405f6f6f79655f636f6f6b69653a636164656e63652e6d6f65'),
('sim_member','sim_member','31 1','743 771','86 771',X'034b49214d5071594e414a62576b72474f544a7461703a636164656e63652e6d6f65405f6f6f79655f746865666f6f6c323239343a636164656e63652e6d6f65'),
('sim_member','sim_member','26 1','774 787','87 787',X'034b49214d687950614b4250506f496c7365794d6d743a636164656e63652e6d6f65405f6f6f79655f6b79757567727970686f6e3a636164656e63652e6d6f65'),
('sim_member','sim_member','27 1','877 881','104 881',X'034b45215063734371724f466a48476f41424270414c3a636164656e63652e6d6f65405f6f6f79655f62696c6c795f626f623a636164656e63652e6d6f65'),
('sim_member','sim_member','16 1','956 959','117 959',X'034b43215158526f4a777a63506d5047546d454b454d3a636164656e63652e6d6f65405f6f6f79655f626f7472616334723a636164656e63652e6d6f65'),
('sim_member','sim_member','32 1','997 1012','121 1012',X'034b3f215170676c734e587a4c7751594d4c6c734f503a636164656e63652e6d6f65405f6f6f79655f6a75746f6d693a636164656e63652e6d6f65'),
('sim_member','sim_member','7 1','1274 1279','157 1279',X'034b4121554d6f6e68556765644d47585a78466658753a636164656e63652e6d6f65405f6f6f79655f6d696e696d75733a636164656e63652e6d6f65'),
('sim_member','sim_member','27 1','1415 1439','188 1439',X'034b3d21595868717249786d586e47736961796a59783a636164656e63652e6d6f65405f6f6f79655f73746161663a636164656e63652e6d6f65'),
('sim_member','sim_member','16 1','1597 1599','217 1599',X'034b512163466a4479477274466d48796d794c6652453a636164656e63652e6d6f65405f6f6f79655f626f6a61636b5f686f7273656d616e3a636164656e63652e6d6f65'),
('sim_member','sim_member','27 1','1758 1761','248 1761',X'034b3d2168665a74624d656f5355564e424850736a743a636164656e63652e6d6f65405f6f6f79655f617a7572653a636164656e63652e6d6f65'),
('sim_member','sim_member','25 1','1865 1886','270 1886',X'034b3f216b4c52714b4b555158636962494d744f706c3a636164656e63652e6d6f65405f6f6f79655f7361796f72693a636164656e63652e6d6f65'),
('sim_member','sim_member','19 1','1918 1919','276 1919',X'034b3d216b68497350756c465369736d43646c596e493a636164656e63652e6d6f65405f6f6f79655f617a7572653a636164656e63652e6d6f65'),
('sim_member','sim_member','33 1','1986 2015','286 2015',X'034b3d216d5451744d736a534c4f646c576f7265594d3a636164656e63652e6d6f65405f6f6f79655f73746161663a636164656e63652e6d6f65'),
('sim_member','sim_member','37 1','2027 2028','289 2028',X'034b4d216d616767455367755a427147425a74536e723a636164656e63652e6d6f65405f6f6f79655f2e7265616c2e706572736f6e2e3a636164656e63652e6d6f65'),
('sim_member','sim_member','28 1','2117 2130','297 2130',X'034b3f216e4e595a794b6f4e70797859417a50466f733a636164656e63652e6d6f65405f6f6f79655f6a75746f6d693a636164656e63652e6d6f65'),
('sim_member','sim_member','20 1','2230 2239','310 2239',X'034b41217046504c7270594879487a784e4c69594b413a636164656e63652e6d6f65405f6f6f79655f686578676f61743a636164656e63652e6d6f65'),
('sim_member','sim_member','30 1','2381 2398','332 2398',X'034b3b21717a44626c4b6c69444c577a52524f6e465a3a636164656e63652e6d6f65405f6f6f79655f6d6e696b3a636164656e63652e6d6f65'),
('sim_member','sim_member','38 1','2490 2518','344 2518',X'034b3d2173445250714549546e4f4e57474176496b423a636164656e63652e6d6f65405f6f6f79655f727974686d3a636164656e63652e6d6f65'),
('sim_member','sim_member','11 1','2555 2559','358 2559',X'034b4321746751436d526b426e6474516362687150583a636164656e63652e6d6f65405f6f6f79655f6a6f7365707065793a636164656e63652e6d6f65'),
('sim_member','sim_member','47 1','2633 2666','377 2666',X'034b4b217750454472596b77497a6f744e66706e57503a636164656e63652e6d6f65405f6f6f79655f6e61706f6c656f6e333038393a636164656e63652e6d6f65'),
('sim_member','sim_member','52 1','2817 2837','414 2837',X'034b43217a66654e574d744b4f764f48766f727979563a636164656e63652e6d6f65405f6f6f79655f696e736f676e69613a636164656e63652e6d6f65'),
('guild_space','guild_space','1','3','3',X'0231313132373630363639313738323431303234'),
('guild_space','guild_space','1','7','7',X'023331313534383638343234373234343633363837'),
('guild_space','guild_space','1','11','11',X'023331323139303338323637383430393235383138'),
('guild_space','guild_space','1','15','15',X'023331323839353939363232353930383930313335'),
('guild_space','guild_space','1','19','19',X'0231323733383737363437323234393935383431'),
('guild_space','guild_space','1','23','23',X'0231353239313736313536333938363832313135'),
('guild_space','guild_space','1','27','27',X'0231373535303134333534373334313533383138'),
('guild_space','guild_space','1','31','31',X'0231393933383838313432343535323130303834'),
('guild_active','guild_active','1','3','3',X'0231313132373630363639313738323431303234'),
('guild_active','guild_active','1','7','7',X'023331313534383638343234373234343633363837'),
('guild_active','guild_active','1','11','11',X'023331323139303338323637383430393235383138'),
('guild_active','guild_active','1','15','15',X'023331323839353939363232353930383930313335'),
('guild_active','guild_active','1','19','19',X'023331333333323139363936393333323038303934'),
('guild_active','guild_active','1','23','23',X'0231343735353939303338353336373434393630'),
('guild_active','guild_active','1','27','27',X'022f3636313932393535373737343836383438'),
('guild_active','guild_active','1','31','31',X'0231383737303635303431393930353136373637'),
('emoji','emoji','1','284','284',X'023331313132323031303430303637303339333332'),
('emoji','emoji','1','569','569',X'023331323334393032303131393436383630363638'),
('emoji','emoji','1','854','854',X'0231323735313734373438353034313935303732'),
('emoji','emoji','1','1139','1139',X'0231333837343730383630303134393737303235'),
('emoji','emoji','1','1424','1424',X'0231353435363639393734303130383838313932'),
('emoji','emoji','1','1709','1709',X'0231363339313034333030393139393437323634'),
('emoji','emoji','1','1994','1994',X'0231373532363932363230303638373832313231'),
('emoji','emoji','1','2279','2279',X'0231383935343737353331303434363138323430'),
('auto_emoji','auto_emoji','1','0','0',X'02114c31'),
('auto_emoji','auto_emoji','1','1','1',X'02114c32'),
('auto_emoji','auto_emoji','1','2','2',X'020f5f');
ANALYZE sqlite_schema;
COMMIT;

View file

@ -1,5 +0,0 @@
BEGIN TRANSACTION;
ALTER TABLE channel_room ADD COLUMN custom_topic INTEGER DEFAULT 0;
COMMIT;

View file

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

View file

@ -1,5 +0,0 @@
BEGIN TRANSACTION;
ALTER TABLE guild_space ADD COLUMN presence INTEGER NOT NULL DEFAULT 1;
COMMIT;

View file

@ -1,5 +0,0 @@
BEGIN TRANSACTION;
ALTER TABLE guild_space ADD COLUMN url_preview INTEGER NOT NULL DEFAULT 1;
COMMIT;

View file

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

View file

@ -1,5 +0,0 @@
BEGIN TRANSACTION;
ALTER TABLE reaction ADD COLUMN original_encoding TEXT;
COMMIT;

View file

@ -1,9 +0,0 @@
BEGIN TRANSACTION;
CREATE TABLE direct (
mxid TEXT NOT NULL,
room_id TEXT NOT NULL,
PRIMARY KEY (mxid)
) WITHOUT ROWID;
COMMIT;

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

@ -1,9 +1,4 @@
export type Models = { export type Models = {
auto_emoji: {
name: string
emoji_id: string
}
channel_room: { channel_room: {
channel_id: string channel_id: string
room_id: string room_id: string
@ -15,20 +10,6 @@ export type Models = {
speedbump_id: string | null speedbump_id: string | null
speedbump_webhook_id: string | null speedbump_webhook_id: string | null
speedbump_checked: number | 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: { event_message: {
@ -50,21 +31,11 @@ export type Models = {
guild_id: string guild_id: string
space_id: string space_id: string
privacy_level: number privacy_level: number
presence: 0 | 1
url_preview: 0 | 1
} }
guild_active: { guild_active: {
guild_id: string guild_id: string
autocreate: 0 | 1 autocreate: number
}
invite: {
mxid: string
room_id: string
type: string | null
name: string | null
avatar: string | null
} }
lottie: { lottie: {
@ -72,10 +43,6 @@ export type Models = {
mxc_url: string mxc_url: string
} }
media_proxy: {
permitted_hash: number
}
member_cache: { member_cache: {
room_id: string room_id: string
mxid: string mxid: string
@ -120,11 +87,27 @@ export type Models = {
webhook_token: string webhook_token: string
} }
emoji: {
emoji_id: string
name: string
animated: number
mxc_url: string
}
reaction: { reaction: {
hashed_event_id: number hashed_event_id: number
message_id: string message_id: string
encoded_emoji: string encoded_emoji: string
original_encoding: string | null }
auto_emoji: {
name: string
emoji_id: string
guild_id: string
}
media_proxy: {
permitted_hash: number
} }
} }
@ -141,4 +124,3 @@ export type PickTypeOf<T, K extends AllKeys<T>> = T extends { [k in K]?: any } ?
export type Merge<U> = {[x in AllKeys<U>]: PickTypeOf<U, x>} export type Merge<U> = {[x in AllKeys<U>]: PickTypeOf<U, x>}
export type Nullable<T> = {[k in keyof T]: T[k] | null} export type Nullable<T> = {[k in keyof T]: T[k] | null}
export type Numberish<T> = {[k in keyof T]: T[k] extends number ? (number | bigint) : T[k]} export type Numberish<T> = {[k in keyof T]: T[k] extends number ? (number | bigint) : T[k]}
export type ValueOrArray<T> = {[k in keyof T]: T[k][] | T[k]}

View file

@ -8,7 +8,7 @@ const U = require("./orm-defs")
* @template {keyof U.Models[Table]} Col * @template {keyof U.Models[Table]} Col
* @param {Table} table * @param {Table} table
* @param {Col[] | Col} cols * @param {Col[] | Col} cols
* @param {Partial<U.ValueOrArray<U.Numberish<U.Models[Table]>>>} where * @param {Partial<U.Numberish<U.Models[Table]>>} where
* @param {string} [e] * @param {string} [e]
*/ */
function select(table, cols, where = {}, e = "") { function select(table, cols, where = {}, e = "") {

View file

@ -6,17 +6,17 @@ const data = require("../../test/data")
const {db, select, from} = require("../passthrough") const {db, select, from} = require("../passthrough")
test("orm: select: get works", t => { 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) t.equal(row?.guild_id, data.guild.general.id)
}) })
test("orm: from: get works", t => { 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) t.equal(row?.guild_id, data.guild.general.id)
}) })
test("orm: select: get pluck works", t => { 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) t.equal(guildID, data.guild.general.id)
}) })
@ -30,13 +30,8 @@ test("orm: select: all, where and pluck works on multiple columns", t => {
t.deepEqual(names, ["cadence [they]"]) 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 => { 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) 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() 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) 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, 100)
})

View file

@ -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<string, Promise<{name: string, value: string}[]>>} spaceID -> list of rooms */
const cache = new Map()
/** @type {Map<string, string>} roomID -> spaceID */
const reverseCache = new Map()
// Manage clearing the cache
sync.addTemporaryListener(as, "type:m.room.name", /** @param {Ty.Event.StateOuter<Ty.Event.M_Room_Name>} 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

View file

@ -2,69 +2,39 @@
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const assert = require("assert/strict") const assert = require("assert/strict")
const {InteractionMethods} = require("snowtransfer") const {discord, sync, db, select, from} = require("../../passthrough")
const {id: botID} = require("../../../addbot")
const {discord, sync, db, select} = 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")} */ /** @type {import("../../matrix/api")} */
const api = sync.require("../../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 {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction
* @param {{api: typeof api}} di * @returns {Promise<DiscordTypes.APIInteractionResponse>}
* @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
*/ */
async function* _interact({data, channel, guild_id}, {api}) { async function _interact({data, channel, guild_id}) {
// Check guild exists - it might not exist if the application was added with applications.commands scope and not bot scope // Check guild is bridged
const guild = discord.guilds.get(guild_id) const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get()
if (!guild) return yield {createInteractionResponse: { const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
if (!spaceID || !roomID) return {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: { 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 flags: DiscordTypes.MessageFlags.Ephemeral
} }
}} }
// Get named MXID // Get named MXID
/** @type {DiscordTypes.APIApplicationCommandInteractionDataStringOption[] | undefined} */ // @ts-ignore /** @type {DiscordTypes.APIApplicationCommandInteractionDataStringOption[] | undefined} */ // @ts-ignore
const options = data.options 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] const mxid = input.match(/@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)/)?.[0]
if (!mxid) return yield {createInteractionResponse: { if (!mxid) return {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: { data: {
content: "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`", 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 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 // Check for existing invite to the space
let spaceMember let spaceMember
@ -72,17 +42,24 @@ async function* _interact({data, channel, guild_id}, {api}) {
spaceMember = await api.getStateEvent(spaceID, "m.room.member", mxid) spaceMember = await api.getStateEvent(spaceID, "m.room.member", mxid)
} catch (e) {} } catch (e) {}
if (spaceMember && spaceMember.membership === "invite") { if (spaceMember && spaceMember.membership === "invite") {
return yield {editOriginalInteractionResponse: { return {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: `\`${mxid}\` already has an invite, which they haven't accepted yet.`, content: `\`${mxid}\` already has an invite, which they haven't accepted yet.`,
}} flags: DiscordTypes.MessageFlags.Ephemeral
}
}
} }
// Invite Matrix user if not in space // Invite Matrix user if not in space
if (!spaceMember || spaceMember.membership !== "join") { if (!spaceMember || spaceMember.membership !== "join") {
await api.inviteToRoom(spaceID, mxid) await api.inviteToRoom(spaceID, mxid)
return yield {editOriginalInteractionResponse: { return {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: `You invited \`${mxid}\` to the server.` content: `You invited \`${mxid}\` to the server.`
}} }
}
} }
// The Matrix user *is* in the space, maybe we want to invite them to this channel? // The Matrix user *is* in the space, maybe we want to invite them to this channel?
@ -91,8 +68,11 @@ async function* _interact({data, channel, guild_id}, {api}) {
roomMember = await api.getStateEvent(roomID, "m.room.member", mxid) roomMember = await api.getStateEvent(roomID, "m.room.member", mxid)
} catch (e) {} } catch (e) {}
if (!roomMember || (roomMember.membership !== "join" && roomMember.membership !== "invite")) { if (!roomMember || (roomMember.membership !== "join" && roomMember.membership !== "invite")) {
return yield {editOriginalInteractionResponse: { return {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: `\`${mxid}\` is already in this server. Would you like to additionally invite them to this specific channel?`, content: `\`${mxid}\` is already in this server. Would you like to additionally invite them to this specific channel?`,
flags: DiscordTypes.MessageFlags.Ephemeral,
components: [{ components: [{
type: DiscordTypes.ComponentType.ActionRow, type: DiscordTypes.ComponentType.ActionRow,
components: [{ components: [{
@ -102,21 +82,25 @@ async function* _interact({data, channel, guild_id}, {api}) {
label: "Sure", label: "Sure",
}] }]
}] }]
}} }
}
} }
// The Matrix user *is* in the space and in the channel. // The Matrix user *is* in the space and in the channel.
return yield {editOriginalInteractionResponse: { return {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: `\`${mxid}\` is already in this server and this channel.`, content: `\`${mxid}\` is already in this server and this channel.`,
}} flags: DiscordTypes.MessageFlags.Ephemeral
}
}
} }
/** /**
* @param {DiscordTypes.APIMessageComponentGuildInteraction} interaction * @param {DiscordTypes.APIMessageComponentGuildInteraction} interaction
* @param {{api: typeof api}} di
* @returns {Promise<DiscordTypes.APIInteractionResponse>} * @returns {Promise<DiscordTypes.APIInteractionResponse>}
*/ */
async function _interactButton({channel, message}, {api}) { async function _interactButton({channel, message}) {
const mxid = message.content.match(/`(@(?:[^:]+):(?:[a-z0-9:-]+\.[a-z0-9.:-]+))`/)?.[1] const mxid = message.content.match(/`(@(?:[^:]+):(?:[a-z0-9:-]+\.[a-z0-9.:-]+))`/)?.[1]
assert(mxid) assert(mxid)
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
@ -131,23 +115,14 @@ async function _interactButton({channel, message}, {api}) {
} }
} }
/* c8 ignore start */ /** @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction */
/** @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction & {channel: DiscordTypes.APIGuildTextChannel}} interaction */
async function interact(interaction) { async function interact(interaction) {
for await (const response of _interact(interaction, {api})) { await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interact(interaction))
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.APIMessageComponentGuildInteraction} interaction */ /** @param {DiscordTypes.APIMessageComponentGuildInteraction} interaction */
async function interactButton(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 module.exports.interact = interact

View file

@ -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<T>} ai
* @returns {Promise<T[]>}
*/
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)
})

View file

@ -1,63 +1,51 @@
// @ts-check // @ts-check
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const {discord, sync, from} = require("../../passthrough") const {discord, sync, db, select, from} = require("../../passthrough")
/** @type {import("../../matrix/api")} */ /** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api") const api = sync.require("../../matrix/api")
/** /** @param {DiscordTypes.APIContextMenuGuildInteraction} interaction */
* @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction /** @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction */
* @param {{api: typeof api}} di async function interact({id, token, guild_id, channel, data}) {
* @returns {Promise<DiscordTypes.APIInteractionResponse>}
*/
async function _interact({guild_id, data}, {api}) {
const message = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id") const message = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id")
.select("name", "nick", "source", "channel_id", "room_id", "event_id").where({message_id: data.target_id, part: 0}).get() .select("name", "nick", "source", "room_id", "event_id").where({message_id: data.target_id}).get()
if (!message) { if (!message) {
return { return discord.snow.interaction.createInteractionResponse(id, token, {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: { data: {
content: "This message hasn't been bridged to Matrix.", content: "This message hasn't been bridged to Matrix.",
flags: DiscordTypes.MessageFlags.Ephemeral flags: DiscordTypes.MessageFlags.Ephemeral
} }
} })
} }
const idInfo = `\n-# Room ID: \`${message.room_id}\`\n-# Event ID: \`${message.event_id}\`` const idInfo = `\n-# Room ID: \`${message.room_id}\`\n-# Event ID: \`${message.event_id}\``
const roomName = message.nick || message.name
if (message.source === 1) { // from Discord if (message.source === 1) { // from Discord
const userID = data.resolved.messages[data.target_id].author.id const userID = data.resolved.messages[data.target_id].author.id
return { return discord.snow.interaction.createInteractionResponse(id, token, {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: { data: {
content: `Bridged <@${userID}> https://discord.com/channels/${guild_id}/${message.channel_id}/${data.target_id} on Discord to [${roomName}](<https://matrix.to/#/${message.room_id}/${message.event_id}>) on Matrix.` content: `Bridged <@${userID}> https://discord.com/channels/${guild_id}/${channel.id}/${data.target_id} on Discord to [${message.nick || message.name}](<https://matrix.to/#/${message.room_id}/${message.event_id}>) on Matrix.`
+ idInfo, + idInfo,
flags: DiscordTypes.MessageFlags.Ephemeral flags: DiscordTypes.MessageFlags.Ephemeral
} }
} })
} }
// from Matrix // from Matrix
const event = await api.getEvent(message.room_id, message.event_id) const event = await api.getEvent(message.room_id, message.event_id)
return { return discord.snow.interaction.createInteractionResponse(id, token, {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: { data: {
content: `Bridged [${event.sender}](<https://matrix.to/#/${event.sender}>)'s message in [${roomName}](<https://matrix.to/#/${message.room_id}/${message.event_id}>) on Matrix to https://discord.com/channels/${guild_id}/${message.channel_id}/${data.target_id} on Discord.` content: `Bridged [${event.sender}](<https://matrix.to/#/${event.sender}>)'s message in [${message.nick || message.name}](<https://matrix.to/#/${message.room_id}/${message.event_id}>) on Matrix to https://discord.com/channels/${guild_id}/${channel.id}/${data.target_id} on Discord.`
+ idInfo, + idInfo,
flags: DiscordTypes.MessageFlags.Ephemeral 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}))
} }
module.exports.interact = interact module.exports.interact = interact
module.exports._interact = _interact

View file

@ -1,73 +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](<https://matrix.to/#/!CzvdIdUQXgUjDVKxeU:cadence.moe/$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10>) 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"
}
}
}
})
t.equal(
msg.data.content,
"Bridged [@cadence:cadence.moe](<https://matrix.to/#/@cadence:cadence.moe>)'s message in [main](<https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4>) on Matrix to https://discord.com/channels/112760669178241024/112760669178241024/1128118177155526666 on Discord."
+ "\n-# Room ID: `!kLRqKKUQXcibIMtOpl:cadence.moe`"
+ "\n-# Event ID: `$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4`"
)
t.equal(called, 1)
})

View file

@ -2,41 +2,34 @@
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const Ty = require("../../types") 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 assert = require("assert/strict")
const {id: botID} = require("../../../addbot")
const {InteractionMethods} = require("snowtransfer")
/** @type {import("../../matrix/api")} */ /** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api") const api = sync.require("../../matrix/api")
/** /**
* @param {DiscordTypes.APIContextMenuGuildInteraction} interaction * @param {DiscordTypes.APIContextMenuGuildInteraction} interaction
* @param {{api: typeof api}} di * @returns {Promise<DiscordTypes.APIInteractionResponse>}
* @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
*/ */
async function* _interact({data, guild_id}, {api}) { async function _interact({data, channel, guild_id}) {
// Get message info const row = select("event_message", ["event_id", "source"], {message_id: data.target_id}).get()
const row = from("event_message") assert(row)
.join("message_channel", "message_id")
.select("event_id", "source", "channel_id")
.where({message_id: data.target_id})
.get()
// Can't operate on Discord users // Can't operate on Discord users
if (!row || row.source === 1) { // not bridged or sent by a discord user if (row.source === 1) { // discord
return yield {createInteractionResponse: { return {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: { 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 flags: DiscordTypes.MessageFlags.Ephemeral
} }
}} }
} }
// Get the message sender, the person that will be inspected/edited // Get the message sender, the person that will be inspected/edited
const eventID = row.event_id const eventID = row.event_id
const roomID = select("channel_room", "room_id", {channel_id: row.channel_id}).pluck().get() const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
assert(roomID) assert(roomID)
const event = await api.getEvent(roomID, eventID) const event = await api.getEvent(roomID, eventID)
const sender = event.sender const sender = event.sender
@ -52,16 +45,16 @@ async function* _interact({data, guild_id}, {api}) {
// Administrators equal to the bot cannot be demoted // Administrators equal to the bot cannot be demoted
if (userPower >= 100) { if (userPower >= 100) {
return yield {createInteractionResponse: { return {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: { data: {
content: `\`${sender}\` has administrator permissions. This cannot be edited.`, content: `\`${sender}\` has administrator permissions. This cannot be edited.`,
flags: DiscordTypes.MessageFlags.Ephemeral flags: DiscordTypes.MessageFlags.Ephemeral
} }
}} }
} }
yield {createInteractionResponse: { return {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: { data: {
content: `Showing permissions for \`${sender}\`. Click to edit.`, content: `Showing permissions for \`${sender}\`. Click to edit.`,
@ -82,10 +75,6 @@ async function* _interact({data, guild_id}, {api}) {
label: "Moderator", label: "Moderator",
value: "moderator", value: "moderator",
default: userPower >= 50 && userPower < 100 default: userPower >= 50 && userPower < 100
}, {
label: "Admin (you cannot undo this!)",
value: "admin",
default: userPower === 100
} }
] ]
} }
@ -93,32 +82,27 @@ async function* _interact({data, guild_id}, {api}) {
} }
] ]
} }
}} }
} }
/** /**
* @param {DiscordTypes.APIMessageComponentSelectMenuInteraction} interaction * @param {DiscordTypes.APIMessageComponentSelectMenuInteraction} interaction
* @param {{api: typeof api}} di
* @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[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 // Get the person that will be inspected/edited
const mxid = message.content.match(/`(@(?:[^:]+):(?:[a-z0-9:-]+\.[a-z0-9.:-]+))`/)?.[1] const mxid = message.content.match(/`(@(?:[^:]+):(?:[a-z0-9:-]+\.[a-z0-9.:-]+))`/)?.[1]
assert(mxid) assert(mxid)
const permission = data.values[0] const permission = data.values[0]
const power = const power = permission === "moderator" ? 50 : 0
( permission === "admin" ? 100
: permission === "moderator" ? 50
: 0)
yield {createInteractionResponse: { await discord.snow.interaction.createInteractionResponse(id, token, {
type: DiscordTypes.InteractionResponseType.UpdateMessage, type: DiscordTypes.InteractionResponseType.UpdateMessage,
data: { data: {
content: `Updating \`${mxid}\` to **${permission}**, please wait...`, content: `Updating \`${mxid}\` to **${permission}**, please wait...`,
components: [] components: []
} }
}} })
// Get the space, where the power levels will be inspected/edited // Get the space, where the power levels will be inspected/edited
const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get() const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get()
@ -128,40 +112,17 @@ async function* _interactEdit({data, guild_id, message}, {api}) {
await api.setUserPowerCascade(spaceID, mxid, power) await api.setUserPowerCascade(spaceID, mxid, power)
// ACK // ACK
yield {editOriginalInteractionResponse: { await discord.snow.interaction.editOriginalInteractionResponse(discord.application.id, token, {
content: `Updated \`${mxid}\` to **${permission}**.`, content: `Updated \`${mxid}\` to **${permission}**.`,
components: [] components: []
}} })
} }
/* c8 ignore start */
/** @param {DiscordTypes.APIContextMenuGuildInteraction} interaction */ /** @param {DiscordTypes.APIContextMenuGuildInteraction} interaction */
async function interact(interaction) { async function interact(interaction) {
for await (const response of _interact(interaction, {api})) { await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interact(interaction))
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)
}
}
} }
module.exports.interact = interact module.exports.interact = interact
module.exports.interactEdit = interactEdit module.exports.interactEdit = interactEdit
module.exports._interact = _interact module.exports._interact = _interact
module.exports._interactEdit = _interactEdit

View file

@ -1,199 +0,0 @@
const {test} = require("supertape")
const DiscordTypes = require("discord-api-types/v10")
const {select, db} = require("../../passthrough")
const {_interact, _interactEdit} = require("./permissions")
/**
* @template T
* @param {AsyncIterable<T>} ai
* @returns {Promise<T[]>}
*/
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"
}, {
api: {
async getEvent(roomID, eventID) {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") // room ID
t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4")
return {
sender: "@cadence:cadence.moe"
}
},
async getStateEvent(roomID, type, key) {
called++
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {
users: {}
}
}
}
}))
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, 2)
})
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"
}, {
api: {
async getEvent(roomID, eventID) {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") // room ID
t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4")
return {
sender: "@cadence:cadence.moe"
}
},
async getStateEvent(roomID, type, key) {
called++
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {
users: {
"@cadence:cadence.moe": 50
}
}
}
}
}))
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, 2)
})
test("permissions: reports permissions of selected matrix user (admin)", async t => {
let called = 0
const msgs = await fromAsync(_interact({
data: {
target_id: "1128118177155526666"
},
guild_id: "112760669178241024"
}, {
api: {
async getEvent(roomID, eventID) {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") // room ID
t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4")
return {
sender: "@cadence:cadence.moe"
}
},
async getStateEvent(roomID, type, key) {
called++
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {
users: {
"@cadence:cadence.moe": 100
}
}
}
}
}))
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, 2)
})
test("permissions: can update user to moderator", async t => {
let called = 0
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 setUserPowerCascade(roomID, mxid, power) {
called++
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID
t.equal(mxid, "@cadence:cadence.moe")
t.equal(power, 50)
}
}
}))
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.equal(called, 1)
})
test("permissions: can update user to default", async t => {
let called = 0
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 setUserPowerCascade(roomID, mxid, power) {
called++
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID
t.equal(mxid, "@cadence:cadence.moe")
t.equal(power, 0)
}
}
}))
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.equal(called, 1)
})

View file

@ -3,37 +3,32 @@
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const {discord, sync, db, select} = require("../../passthrough") const {discord, sync, db, select} = require("../../passthrough")
const {id: botID} = require("../../../addbot") const {id: botID} = require("../../../addbot")
const {InteractionMethods} = require("snowtransfer")
/** @type {import("../../d2m/actions/create-space")} */ /** @type {import("../../d2m/actions/create-space")} */
const createSpace = sync.require("../../d2m/actions/create-space") const createSpace = sync.require("../../d2m/actions/create-space")
/** /**
* @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction * @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction
* @param {{createSpace: typeof createSpace}} di
* @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
*/ */
async function* _interact({data, guild_id}, {createSpace}) { async function interact({id, token, data, guild_id}) {
// Check guild is bridged // Check guild is bridged
const current = select("guild_space", "privacy_level", {guild_id}).pluck().get() const current = select("guild_space", "privacy_level", {guild_id}).pluck().get()
if (current == null) { if (current == null) return {
return yield {createInteractionResponse: {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: { data: {
content: "This server isn't bridged to Matrix, so you can't set the Matrix privacy level.", content: "This server isn't bridged to Matrix, so you can't set the Matrix privacy level.",
flags: DiscordTypes.MessageFlags.Ephemeral flags: DiscordTypes.MessageFlags.Ephemeral
} }
}}
} }
// Get input level // Get input level
/** @type {DiscordTypes.APIApplicationCommandInteractionDataStringOption[] | undefined} */ // @ts-ignore /** @type {DiscordTypes.APIApplicationCommandInteractionDataStringOption[] | undefined} */ // @ts-ignore
const options = data.options const options = data.options
const input = options?.[0]?.value || "" const input = options?.[0].value || ""
const levels = ["invite", "link", "directory"] const levels = ["invite", "link", "directory"]
const level = levels.findIndex(x => input === x) const level = levels.findIndex(x => input === x)
if (level === -1) { if (level === -1) {
return yield {createInteractionResponse: { return discord.snow.interaction.createInteractionResponse(id, token, {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: { data: {
content: "**Usage: `/privacy <level>`**. This will set who can join the space on Matrix-side. There are three levels:" content: "**Usage: `/privacy <level>`**. 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]}\`**`, + `\n**Current privacy level: \`${levels[current]}\`**`,
flags: DiscordTypes.MessageFlags.Ephemeral flags: DiscordTypes.MessageFlags.Ephemeral
} }
}} })
} }
yield {createInteractionResponse: { await discord.snow.interaction.createInteractionResponse(id, token, {
type: DiscordTypes.InteractionResponseType.DeferredChannelMessageWithSource, type: DiscordTypes.InteractionResponseType.DeferredChannelMessageWithSource,
data: { data: {
flags: DiscordTypes.MessageFlags.Ephemeral flags: DiscordTypes.MessageFlags.Ephemeral
} }
}} })
db.prepare("UPDATE guild_space SET privacy_level = ? WHERE guild_id = ?").run(level, guild_id) 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 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]}\`.` 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
module.exports._interact = _interact

View file

@ -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<T>} ai
* @returns {Promise<T[]>}
*/
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")
})

View file

@ -1,40 +1,28 @@
// @ts-check // @ts-check
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const {discord, sync, select, from} = require("../../passthrough") const {discord, sync, db, select, from} = require("../../passthrough")
const {id: botID} = require("../../../addbot")
const {InteractionMethods} = require("snowtransfer")
/** @type {import("../../matrix/api")} */ /** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api") const api = sync.require("../../matrix/api")
/** @type {import("../../m2d/converters/utils")} */ /** @type {import("../../m2d/converters/utils")} */
const utils = sync.require("../../m2d/converters/utils") const utils = sync.require("../../m2d/converters/utils")
/** /** @param {DiscordTypes.APIContextMenuGuildInteraction} interaction */
* @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction /** @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction */
* @param {{api: typeof api}} di async function interact({id, token, data}) {
* @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
*/
async function* _interact({data}, {api}) {
const row = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id") 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() .select("event_id", "room_id").where({message_id: data.target_id}).get()
if (!row) { if (!row) {
return yield {createInteractionResponse: { return discord.snow.interaction.createInteractionResponse(id, token, {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: { data: {
content: "This message hasn't been bridged to Matrix.", content: "This message hasn't been bridged to Matrix.",
flags: DiscordTypes.MessageFlags.Ephemeral 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") const reactions = await api.getFullRelations(row.room_id, row.event_id, "m.annotation")
/** @type {Map<string, string[]>} */ /** @type {Map<string, string[]>} */
@ -49,28 +37,22 @@ async function* _interact({data}, {api}) {
} }
if (inverted.size === 0) { if (inverted.size === 0) {
return yield {editOriginalInteractionResponse: { return discord.snow.interaction.createInteractionResponse(id, token, {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: "Nobody from Matrix reacted to this message.", content: "Nobody from Matrix reacted to this message.",
}} flags: DiscordTypes.MessageFlags.Ephemeral
}
})
} }
return yield {editOriginalInteractionResponse: { return discord.snow.interaction.createInteractionResponse(id, token, {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: [...inverted.entries()].map(([key, value]) => `${key}${value.join(" ⬩ ")}`).join("\n"), content: [...inverted.entries()].map(([key, value]) => `${key}${value.join(" ⬩ ")}`).join("\n"),
}} 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)
} else if (response.editOriginalInteractionResponse) {
await discord.snow.interaction.editOriginalInteractionResponse(botID, interaction.token, response.editOriginalInteractionResponse)
}
} }
})
} }
module.exports.interact = interact module.exports.interact = interact
module.exports._interact = _interact

View file

@ -1,99 +0,0 @@
const {test} = require("supertape")
const {_interact} = require("./reactions")
/**
* @template T
* @param {AsyncIterable<T>} ai
* @returns {Promise<T[]>}
*/
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)
})

View file

@ -7,6 +7,7 @@ const {id} = require("../../addbot")
const matrixInfo = sync.require("./interactions/matrix-info.js") const matrixInfo = sync.require("./interactions/matrix-info.js")
const invite = sync.require("./interactions/invite.js") const invite = sync.require("./interactions/invite.js")
const permissions = sync.require("./interactions/permissions.js") const permissions = sync.require("./interactions/permissions.js")
const bridge = sync.require("./interactions/bridge.js")
const reactions = sync.require("./interactions/reactions.js") const reactions = sync.require("./interactions/reactions.js")
const privacy = sync.require("./interactions/privacy.js") const privacy = sync.require("./interactions/privacy.js")
@ -38,6 +39,20 @@ discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{
name: "user" 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", name: "privacy",
contexts: [DiscordTypes.InteractionContextType.Guild], contexts: [DiscordTypes.InteractionContextType.Guild],
@ -64,9 +79,7 @@ discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{
}] }]
} }
] ]
}]).catch(e => { }])
console.error(e)
})
async function dispatchInteraction(interaction) { async function dispatchInteraction(interaction) {
const interactionId = interaction.data.custom_id || interaction.data.name const interactionId = interaction.data.custom_id || interaction.data.name
@ -81,6 +94,8 @@ async function dispatchInteraction(interaction) {
await permissions.interact(interaction) await permissions.interact(interaction)
} else if (interactionId === "permissions_edit") { } else if (interactionId === "permissions_edit") {
await permissions.interactEdit(interaction) await permissions.interactEdit(interaction)
} else if (interactionId === "bridge") {
await bridge.interact(interaction)
} else if (interactionId === "Reactions") { } else if (interactionId === "Reactions") {
await reactions.interact(interaction) await reactions.interact(interaction)
} else if (interactionId === "privacy") { } else if (interactionId === "privacy") {

View file

@ -113,7 +113,7 @@ function isWebhookMessage(message) {
* @param {Pick<DiscordTypes.APIMessage, "flags">} message * @param {Pick<DiscordTypes.APIMessage, "flags">} message
*/ */
function isEphemeralMessage(message) { function isEphemeralMessage(message) {
return Boolean(message.flags && (message.flags & DiscordTypes.MessageFlags.Ephemeral)) return message.flags && (message.flags & DiscordTypes.MessageFlags.Ephemeral)
} }
/** @param {string} snowflake */ /** @param {string} snowflake */
@ -136,24 +136,6 @@ function getPublicUrlForCdn(url) {
return `${reg.ooye.bridge_origin}/download/discord${match[1]}/${match[2]}/${match[3]}/${match[4]}` 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
}
module.exports.getPermissions = getPermissions module.exports.getPermissions = getPermissions
module.exports.hasPermission = hasPermission module.exports.hasPermission = hasPermission
module.exports.hasSomePermissions = hasSomePermissions module.exports.hasSomePermissions = hasSomePermissions
@ -163,4 +145,3 @@ module.exports.isEphemeralMessage = isEphemeralMessage
module.exports.snowflakeToTimestampExact = snowflakeToTimestampExact module.exports.snowflakeToTimestampExact = snowflakeToTimestampExact
module.exports.timestampToSnowflakeInexact = timestampToSnowflakeInexact module.exports.timestampToSnowflakeInexact = timestampToSnowflakeInexact
module.exports.getPublicUrlForCdn = getPublicUrlForCdn module.exports.getPublicUrlForCdn = getPublicUrlForCdn
module.exports.howOldUnbridgedMessage = howOldUnbridgedMessage

View file

@ -84,67 +84,6 @@ test("getPermissions: channel overwrite to allow role works", t => {
t.equal((permissions & want), want) 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(userRoles, guildRoles, userID, overwrites)
const want = BigInt(1 << 10 | 1 << 16)
t.equal((permissions & want), want)
})
test("hasSomePermissions: detects the permission", t => { test("hasSomePermissions: detects the permission", t => {
const userPermissions = DiscordTypes.PermissionFlagsBits.MentionEveryone | DiscordTypes.PermissionFlagsBits.BanMembers const userPermissions = DiscordTypes.PermissionFlagsBits.MentionEveryone | DiscordTypes.PermissionFlagsBits.BanMembers
const canRemoveMembers = utils.hasSomePermissions(userPermissions, ["KickMembers", "BanMembers"]) const canRemoveMembers = utils.hasSomePermissions(userPermissions, ["KickMembers", "BanMembers"])
@ -168,15 +107,3 @@ test("hasAllPermissions: doesn't detect not the permissions", t => {
const canRemoveMembers = utils.hasAllPermissions(userPermissions, ["KickMembers", "BanMembers"]) const canRemoveMembers = utils.hasAllPermissions(userPermissions, ["KickMembers", "BanMembers"])
t.equal(canRemoveMembers, false) 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")
})

View file

@ -20,25 +20,12 @@ async function addReaction(event) {
if (!messageID) return // Nothing can be done if the parent message was never bridged. if (!messageID) return // Nothing can be done if the parent message was never bridged.
const key = event.content["m.relates_to"].key 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 if (!discordPreferredEncoding) return
try {
await discord.snow.channel.createReaction(channelID, messageID, discordPreferredEncoding) // acting as the discord bot itself 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
}
throw e
}
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 module.exports.addReaction = addReaction

View file

@ -2,7 +2,7 @@
const assert = require("assert").strict const assert = require("assert").strict
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const stream = require("stream") const {Readable} = require("stream")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {discord, db, select} = passthrough const {discord, db, select} = passthrough
@ -57,7 +57,7 @@ async function withWebhook(channelID, callback) {
/** /**
* @param {string} channelID * @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] * @param {string} [threadID]
*/ */
async function sendMessageWithWebhook(channelID, data, threadID) { async function sendMessageWithWebhook(channelID, data, threadID) {
@ -70,7 +70,7 @@ async function sendMessageWithWebhook(channelID, data, threadID) {
/** /**
* @param {string} channelID * @param {string} channelID
* @param {string} messageID * @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] * @param {string} [threadID]
*/ */
async function editMessageWithWebhook(channelID, messageID, data, threadID) { async function editMessageWithWebhook(channelID, messageID, data, threadID) {

View file

@ -1,6 +1,9 @@
// @ts-check // @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") const {sync} = require("../../passthrough")
/** @type {import("../converters/emoji-sheet")} */ /** @type {import("../converters/emoji-sheet")} */
@ -15,15 +18,16 @@ const api = sync.require("../../matrix/api")
*/ */
async function getAndConvertEmoji(mxc) { async function getAndConvertEmoji(mxc) {
const abortController = new AbortController() 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 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. // 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. // So we set no agent to ensure we are not connection pooling.
const res = await api.getMedia(mxc, {signal: abortController.signal}) // @ts-ignore the signal is slightly different from the type it wants (still works fine)
const readable = stream.Readable.fromWeb(res.body) const res = await api.getMedia(mxc, {agent: false, signal: abortController.signal})
return emojiSheetConverter.convertImageStream(readable, () => { return emojiSheetConverter.convertImageStream(res.body, () => {
abortController.abort() abortController.abort()
readable.emit("end") res.body.pause()
readable.on("error", () => {}) // DOMException [AbortError]: This operation was aborted res.body.emit("end")
}) })
} }

View file

@ -13,6 +13,7 @@ const utils = sync.require("../converters/utils")
*/ */
async function deleteMessage(event) { async function deleteMessage(event) {
const rows = from("event_message").join("message_channel", "message_id").select("channel_id", "message_id").where({event_id: event.redacts}).all() 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) { for (const row of rows) {
db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(row.message_id) 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) await discord.snow.channel.deleteMessage(row.channel_id, row.message_id, event.content.reason)

View file

@ -2,9 +2,10 @@
const Ty = require("../../types") const Ty = require("../../types")
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const stream = require("stream") const {Readable} = require("stream")
const assert = require("assert").strict const assert = require("assert").strict
const crypto = require("crypto") const crypto = require("crypto")
const fetch = require("node-fetch").default
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {sync, discord, db, select} = passthrough const {sync, discord, db, select} = passthrough
@ -22,8 +23,8 @@ const editMessage = sync.require("../../d2m/actions/edit-message")
const emojiSheet = sync.require("../actions/emoji-sheet") 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 * @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<DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]}>} * @returns {Promise<DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]}>}
*/ */
async function resolvePendingFiles(message) { async function resolvePendingFiles(message) {
if (!message.pendingFiles) return message if (!message.pendingFiles) return message
@ -37,14 +38,16 @@ async function resolvePendingFiles(message) {
if ("key" in p) { if ("key" in p) {
// Encrypted file // Encrypted file
const d = crypto.createDecipheriv("aes-256-ctr", Buffer.from(p.key, "base64url"), Buffer.from(p.iv, "base64url")) 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 { return {
name: p.name, name: p.name,
file: d file: d
} }
} else { } else {
// Unencrypted file // 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 { return {
name: p.name, name: p.name,
file: body file: body
@ -62,7 +65,7 @@ async function resolvePendingFiles(message) {
/** @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker} 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) { async function sendEvent(event) {
const row = select("channel_room", ["channel_id", "thread_parent"], {room_id: event.room_id}).get() 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 if (!row) return // allow the bot to exist in unbridged rooms, just don't do anything with it
let channelID = row.channel_id let channelID = row.channel_id
let threadID = undefined let threadID = undefined
if (row.thread_parent) { if (row.thread_parent) {
@ -99,13 +102,14 @@ async function sendEvent(event) {
for (const id of messagesToDelete) { for (const id of messagesToDelete) {
db.prepare("DELETE FROM message_channel 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) await channelWebhook.deleteMessageWithWebhook(channelID, id, threadID)
} }
for (const message of messagesToSend) { for (const message of messagesToSend) {
const reactionPart = messagesToEdit.length === 0 && 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) const messageResponse = await channelWebhook.sendMessageWithWebhook(channelID, message, threadID)
db.prepare("INSERT INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(messageResponse.id, threadID || channelID) 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 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 eventPart = 1

View file

@ -1,26 +0,0 @@
// @ts-check
const fs = require("fs")
const {join} = require("path")
const passthrough = require("../../passthrough")
const {id} = require("../../../addbot")
async function setupEmojis() {
const {discord, db} = passthrough
const emojis = await discord.snow.assets.getAppEmojis(id)
for (const name of ["L1", "L2"]) {
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

View file

@ -1,25 +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_channel", "message_id").where({event_id, part: 0}).select("channel_id", "message_id").get()
if (!row) continue
if (added) {
discord.snow.channel.addChannelPinnedMessage(row.channel_id, row.message_id, "Message pinned on Matrix")
} else {
discord.snow.channel.removeChannelPinnedMessage(row.channel_id, row.message_id, "Message unpinned on Matrix")
}
}
}
module.exports.updatePins = updatePins

View file

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

View file

@ -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]]
)
})

View file

@ -1,7 +1,6 @@
// @ts-check // @ts-check
const assert = require("assert").strict const assert = require("assert").strict
const stream = require("stream")
const {pipeline} = require("stream").promises const {pipeline} = require("stream").promises
const sharp = require("sharp") const sharp = require("sharp")
const {GIFrame} = require("@cloudrac3r/giframe") 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 * @param {() => any} stopStream
* @returns {Promise<Buffer | undefined>} Uncompressed PNG image * @returns {Promise<Buffer | undefined>} Uncompressed PNG image
*/ */

View file

@ -1,19 +1,19 @@
// @ts-check // @ts-check
const fsp = require("fs").promises const assert = require("assert").strict
const {join} = require("path") const Ty = require("../../types")
const emojisp = fsp.readFile(join(__dirname, "emojis.txt"), "utf8").then(content => content.split("\n"))
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {select} = passthrough const {sync, select} = passthrough
/** /**
* @param {string} input * @param {string} input
* @param {string | null | undefined} shortcode * @param {string | null | undefined} shortcode
* @returns {string?} * @returns {string?}
*/ */
function encodeCustomEmoji(input, shortcode) { function encodeEmoji(input, shortcode) {
let discordPreferredEncoding
if (input.startsWith("mxc://")) {
// Custom emoji // Custom emoji
let row = select("emoji", ["emoji_id", "name"], {mxc_url: input}).get() let row = select("emoji", ["emoji_id", "name"], {mxc_url: input}).get()
if (!row && shortcode) { if (!row && shortcode) {
@ -22,77 +22,36 @@ function encodeCustomEmoji(input, shortcode) {
row = select("emoji", ["emoji_id", "name"], {name: name}).get() row = select("emoji", ["emoji_id", "name"], {name: name}).get()
} }
if (!row) { if (!row) {
// We don't have this emoji and there's no realistic way to just-in-time upload a new emoji somewhere. Sucks! // 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 null
} }
return encodeURIComponent(`${row.name}:${row.emoji_id}`) // Cool, we got an exact or a candidate emoji.
} discordPreferredEncoding = encodeURIComponent(`${row.name}:${row.emoji_id}`)
/**
* @param {string} input
* @returns {Promise<string?>} 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<string?>}
*/
async function encodeEmoji(input, shortcode) {
if (input.startsWith("mxc://")) {
return encodeCustomEmoji(input, shortcode)
} else { } 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 module.exports.encodeEmoji = encodeEmoji

View file

@ -1,52 +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")
})

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
const Ty = require("../../types") const Ty = require("../../types")
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const stream = require("stream") const {Readable} = require("stream")
const chunk = require("chunk-text") const chunk = require("chunk-text")
const TurndownService = require("@cloudrac3r/turndown") const TurndownService = require("@cloudrac3r/turndown")
const domino = require("domino") const domino = require("domino")
@ -19,8 +19,6 @@ const dUtils = sync.require("../../discord/utils")
const file = sync.require("../../matrix/file") const file = sync.require("../../matrix/file")
/** @type {import("./emoji-sheet")} */ /** @type {import("./emoji-sheet")} */
const emojiSheet = sync.require("./emoji-sheet") const emojiSheet = sync.require("./emoji-sheet")
/** @type {import("../actions/setup-emojis")} */
const setupEmojis = sync.require("../actions/setup-emojis")
/** @type {[RegExp, string][]} */ /** @type {[RegExp, string][]} */
const markdownEscapes = [ const markdownEscapes = [
@ -156,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 */ /** @type {string[]} SPRITE SHEET EMOJIS FEATURE: mxc urls for the currently processing message */
let endOfMessageEmojis = [] let endOfMessageEmojis = []
turndownService.addRule("emoji", { turndownService.addRule("emoji", {
@ -281,18 +258,7 @@ async function getMemberFromCacheOrHomeserver(roomID, mxid, api) {
const row = select("member_cache", ["displayname", "avatar_url"], {room_id: roomID, mxid}).get() const row = select("member_cache", ["displayname", "avatar_url"], {room_id: roomID, mxid}).get()
if (row) return row if (row) return row
return api.getStateEvent(roomID, "m.room.member", mxid).then(event => { return api.getStateEvent(roomID, "m.room.member", mxid).then(event => {
const room = select("channel_room", "room_id", {room_id: roomID}).get() db.prepare("REPLACE INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?)").run(roomID, mxid, event?.displayname || null, event?.avatar_url || null)
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 = ?").run(
roomID, mxid,
displayname, avatar_url,
displayname, avatar_url
)
}
return event return event
}).catch(() => { }).catch(() => {
return {displayname: null, avatar_url: null} return {displayname: null, avatar_url: null}
@ -338,7 +304,7 @@ function getUserOrProxyOwnerID(mxid) {
* 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. * 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 generate the correct pending file of the sprite sheet. * This function will strip them from the content and generate the correct pending file of the sprite sheet.
* @param {string} content * @param {string} content
* @param {{id: string, filename: string}[]} attachments * @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 {({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} pendingFiles
* @param {(mxc: string) => Promise<Buffer | undefined>} mxcDownloader function that will download the mxc URLs and convert to uncompressed PNG data. use `getAndConvertEmoji` or a mock. * @param {(mxc: string) => Promise<Buffer | undefined>} mxcDownloader function that will download the mxc URLs and convert to uncompressed PNG data. use `getAndConvertEmoji` or a mock.
*/ */
@ -352,9 +318,9 @@ async function uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles,
// Create a sprite sheet of known and unknown emojis from the end of the message // Create a sprite sheet of known and unknown emojis from the end of the message
const buffer = await emojiSheet.compositeMatrixEmojis(endOfMessageEmojis, mxcDownloader) const buffer = await emojiSheet.compositeMatrixEmojis(endOfMessageEmojis, mxcDownloader)
// Attach it // Attach it
const filename = "emojis.png" const name = "emojis.png"
attachments.push({id: String(attachments.length), filename}) attachments.push({id: String(attachments.length), name})
pendingFiles.push({name: filename, buffer}) pendingFiles.push({name, buffer})
return content return content
} }
@ -364,9 +330,9 @@ async function uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles,
*/ */
async function handleRoomOrMessageLinks(input, di) { async function handleRoomOrMessageLinks(input, di) {
let offset = 0 let offset = 0
for (const match of [...input.matchAll(/("?https:\/\/matrix.to\/#\/((?:#|%23|!)[^"/, ?)]+)(?:\/(\$[^"/ ?)]+))?(?:\?[^",:!? )]*?)?)(">|[,<\n )]|$)/g)]) { for (const match of [...input.matchAll(/("?https:\/\/matrix.to\/#\/(![^"/, ?)]+)(?:\/(\$[^"/ ?)]+))?(?:\?[^",:!? )]*?)?)(">|[,<\n )]|$)/g)]) {
assert(typeof match.index === "number") assert(typeof match.index === "number")
let [_, attributeValue, roomID, eventID, endMarker] = match const [_, attributeValue, roomID, eventID, endMarker] = match
let result let result
const resultType = endMarker === '">' ? "html" : "plain" const resultType = endMarker === '">' ? "html" : "plain"
@ -384,16 +350,6 @@ async function handleRoomOrMessageLinks(input, di) {
// Don't process links that are part of the reply fallback, they'll be removed entirely by turndown // 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 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("channel_room", "channel_id", {room_id: roomID}).pluck().get() const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get()
if (!channelID) continue if (!channelID) continue
if (!eventID) { if (!eventID) {
@ -449,7 +405,7 @@ async function checkWrittenMentions(content, senderMxid, roomID, guild, di) {
allowedMentionsParse: ["everyone"] 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]}) const results = await di.snow.guild.searchGuildMembers(guild.id, {query: writtenMentionMatch[1]})
if (results[0]) { if (results[0]) {
assert(results[0].user) assert(results[0].user)
@ -481,23 +437,6 @@ const attachmentEmojis = new Map([
["m.file", "📄"] ["m.file", "📄"]
]) ])
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} event * @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 {import("discord-api-types/v10").APIGuild} guild
@ -516,7 +455,7 @@ async function eventToMessage(event, guild, di) {
// Try to extract an accurate display name and avatar URL from the member event // 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) const member = await getMemberFromCacheOrHomeserver(event.room_id, event.sender, di?.api)
if (member.displayname) displayName = member.displayname 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), // 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 // put the excess characters into displayNameRunoff, later to be put at the top of the message
let [displayNameShortened, displayNameRunoff] = splitDisplayName(displayName) let [displayNameShortened, displayNameRunoff] = splitDisplayName(displayName)
@ -526,7 +465,6 @@ async function eventToMessage(event, guild, di) {
} }
let content = event.content.body // ultimate fallback let content = event.content.body // ultimate fallback
/** @type {{id: string, filename: string}[]} */
const attachments = [] const attachments = []
/** @type {({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} */ /** @type {({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} */
const pendingFiles = [] const pendingFiles = []
@ -534,45 +472,7 @@ async function eventToMessage(event, guild, di) {
const ensureJoined = [] const ensureJoined = []
// Convert content depending on what the message is // 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 if (event.type === "m.room.message" && (event.content.msgtype === "m.text" || event.content.msgtype === "m.emote")) {
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")) {
content = ""
const filename = event.content.filename || event.content.body
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})
}
// Check if we also need to process a text event for this image - if it has a caption that's different from its filename
if ((event.content.body && event.content.filename && event.content.body !== event.content.filename) || event.content.formatted_body) {
shouldProcessTextEvent = true
}
}
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})
} else if (shouldProcessTextEvent) {
// 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. // 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 // this event ---is an edit of--> original event ---is a reply to--> past event
await (async () => { await (async () => {
@ -586,12 +486,13 @@ async function eventToMessage(event, guild, di) {
if (!messageIDsToEdit.length) return if (!messageIDsToEdit.length) return
// Ok, it's an edit. // 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. // Is it editing a reply? We need special handling if it is.
// Get the original event, then check if it was a reply // Get the original event, then check if it was a reply
const originalEvent = await di.api.getEvent(event.room_id, originalEventId) 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 if (!repliedToEventId) return
// After all that, it's an edit of a reply. // After all that, it's an edit of a reply.
@ -647,32 +548,41 @@ async function eventToMessage(event, guild, di) {
return return
} }
replyLine = await getL1L2ReplyLine() // @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() 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) { if (row) {
replyLine += `https://discord.com/channels/${guild.id}/${row.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 the event has been edited, the homeserver will include the relation in `unsigned`.
if (repliedToEvent.unsigned?.["m.relations"]?.["m.replace"]?.content?.["m.new_content"]) { 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 = repliedToEvent.unsigned["m.relations"]["m.replace"] // Note: this changes which event_id is in repliedToEvent.
repliedToEvent.content = repliedToEvent.content["m.new_content"] 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 let contentPreview
const fileReplyContentAlternative = attachmentEmojis.get(repliedToEvent.content.msgtype)
if (fileReplyContentAlternative) { if (fileReplyContentAlternative) {
contentPreview = " " + fileReplyContentAlternative contentPreview = " " + fileReplyContentAlternative
} else if (repliedToEvent.unsigned?.redacted_because) { } else if (repliedToEvent.unsigned?.redacted_because) {
contentPreview = " (in reply to a deleted message)" contentPreview = " (in reply to a deleted message)"
} else if (typeof repliedToContent !== "string") {
// 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 { } else {
// Generate a reply preview for a standard message // 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(/.*<\/mx-reply>/s, "") // Remove everything before replies, so just use the actual message body
repliedToContent = repliedToContent.replace(/^\s*<blockquote>.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards repliedToContent = repliedToContent.replace(/^\s*<blockquote>.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards
repliedToContent = repliedToContent.replace(/(?:\n|<br>)+/g, " ") // Should all be on one line repliedToContent = repliedToContent.replace(/(?:\n|<br>)+/g, " ") // Should all be on one line
@ -690,18 +600,10 @@ async function eventToMessage(event, guild, di) {
contentPreview = ": " + contentPreviewChunks[0] contentPreview = ": " + contentPreviewChunks[0]
if (contentPreviewChunks.length > 1) contentPreview = contentPreview.replace(/[,.']$/, "") + "..." if (contentPreviewChunks.length > 1) contentPreview = contentPreview.replace(/[,.']$/, "") + "..."
} else { } else {
console.log("Unable to generate reply preview for this replied-to event because we stripped all of it:", repliedToEvent)
contentPreview = "" contentPreview = ""
} }
} }
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) senderName = sender.match(/@([^:]*)/)?.[1]
if (senderName) replyLine += `**Ⓜ${senderName}**`
}
replyLine = `-# > ${replyLine}${contentPreview}\n` replyLine = `-# > ${replyLine}${contentPreview}\n`
})() })()
@ -857,13 +759,47 @@ async function eventToMessage(event, guild, di) {
// @ts-ignore bad type from turndown // @ts-ignore bad type from turndown
content = turndownService.escape(content) 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 content = displayNameRunoff + replyLine + content
// Split into 2000 character chunks // Split into 2000 character chunks
const chunks = chunk(content, 2000) 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 => ({ const messages = chunks.map(content => ({
content, content,
allowed_mentions: { allowed_mentions: {

View file

@ -559,7 +559,7 @@ test("event2message: lists are bridged correctly", async t => {
"transaction_id": "m1692967313951.441" "transaction_id": "m1692967313951.441"
}, },
"event_id": "$l-xQPY5vNJo3SNxU9d8aOWNVD1glMslMyrp4M_JEF70", "event_id": "$l-xQPY5vNJo3SNxU9d8aOWNVD1glMslMyrp4M_JEF70",
"room_id": "!kLRqKKUQXcibIMtOpl:cadence.moe" "room_id": "!BpMdOUkWWhFxmTrENV:cadence.moe"
}), }),
{ {
ensureJoined: [], ensureJoined: [],
@ -662,7 +662,7 @@ test("event2message: code block contents are formatted correctly and not escaped
formatted_body: "<pre><code>input = input.replace(/(&lt;\\/?([^ &gt;]+)[^&gt;]*&gt;)?\\n(&lt;\\/?([^ &gt;]+)[^&gt;]*&gt;)?/g,\n_input_ = input = input.replace(/(&lt;\\/?([^ &gt;]+)[^&gt;]*&gt;)?\\n(&lt;\\/?([^ &gt;]+)[^&gt;]*&gt;)?/g,\n</code></pre>\n<p><code>input = input.replace(/(&lt;\\/?([^ &gt;]+)[^&gt;]*&gt;)?\\n(&lt;\\/?([^ &gt;]+)[^&gt;]*&gt;)?/g,</code></p>\n" formatted_body: "<pre><code>input = input.replace(/(&lt;\\/?([^ &gt;]+)[^&gt;]*&gt;)?\\n(&lt;\\/?([^ &gt;]+)[^&gt;]*&gt;)?/g,\n_input_ = input = input.replace(/(&lt;\\/?([^ &gt;]+)[^&gt;]*&gt;)?\\n(&lt;\\/?([^ &gt;]+)[^&gt;]*&gt;)?/g,\n</code></pre>\n<p><code>input = input.replace(/(&lt;\\/?([^ &gt;]+)[^&gt;]*&gt;)?\\n(&lt;\\/?([^ &gt;]+)[^&gt;]*&gt;)?/g,</code></p>\n"
}, },
event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe"
}), }),
{ {
ensureJoined: [], ensureJoined: [],
@ -692,7 +692,7 @@ test("event2message: code blocks use double backtick as delimiter when necessary
formatted_body: "<code>backtick in ` the middle</code>, <code>backtick at the edge`</code>" formatted_body: "<code>backtick in ` the middle</code>, <code>backtick at the edge`</code>"
}, },
event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe"
}), }),
{ {
ensureJoined: [], ensureJoined: [],
@ -722,7 +722,7 @@ test("event2message: inline code is converted to code block if it contains both
formatted_body: "<code>` one two ``</code>" formatted_body: "<code>` one two ``</code>"
}, },
event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe"
}), }),
{ {
ensureJoined: [], ensureJoined: [],
@ -752,7 +752,7 @@ test("event2message: code blocks are uploaded as attachments instead if they con
formatted_body: 'So if you run code like this<pre><code class="language-java">System.out.println("```");</code></pre>it should print a markdown formatted code block' formatted_body: 'So if you run code like this<pre><code class="language-java">System.out.println("```");</code></pre>it should print a markdown formatted code block'
}, },
event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe"
}), }),
{ {
ensureJoined: [], ensureJoined: [],
@ -784,7 +784,7 @@ test("event2message: code blocks are uploaded as attachments instead if they con
formatted_body: 'So if you run code like this<pre><code>System.out.println("```");</code></pre>it should print a markdown formatted code block' formatted_body: 'So if you run code like this<pre><code>System.out.println("```");</code></pre>it should print a markdown formatted code block'
}, },
event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe"
}), }),
{ {
ensureJoined: [], ensureJoined: [],
@ -821,7 +821,7 @@ test("event2message: characters are encoded properly in code blocks", async t =>
+ '\n</code></pre>' + '\n</code></pre>'
}, },
event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe"
}), }),
{ {
ensureJoined: [], ensureJoined: [],
@ -902,7 +902,7 @@ test("event2message: lists have appropriate line breaks", async t => {
'm.mentions': {}, 'm.mentions': {},
msgtype: 'm.text' msgtype: 'm.text'
}, },
room_id: '!TqlyQmifxGUggEmdBN:cadence.moe', room_id: '!cBxtVRxDlZvSVhJXVK:cadence.moe',
sender: '@Milan:tchncs.de', sender: '@Milan:tchncs.de',
type: 'm.room.message', type: 'm.room.message',
}), }),
@ -943,7 +943,7 @@ test("event2message: ordered list start attribute works", async t => {
'm.mentions': {}, 'm.mentions': {},
msgtype: 'm.text' msgtype: 'm.text'
}, },
room_id: '!TqlyQmifxGUggEmdBN:cadence.moe', room_id: '!cBxtVRxDlZvSVhJXVK:cadence.moe',
sender: '@Milan:tchncs.de', sender: '@Milan:tchncs.de',
type: 'm.room.message', type: 'm.room.message',
}), }),
@ -1088,7 +1088,7 @@ test("event2message: rich reply to a rich reply to a multi-line message should c
content: { 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)", 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", format: "org.matrix.custom.html",
formatted_body: "<mx-reply><blockquote><a href=\"https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$A0Rj559NKOh2VndCZSTJXcvgi42gZWVfVQt73wA2Hn0?via=matrix.org&via=cadence.moe&via=syndicated.gay\">In reply to</a> <a href=\"https://matrix.to/#/@cadence:cadence.moe\">@cadence:cadence.moe</a><br />I just checked in a fix that will probably work, can you try reproducing this on the latest <code>main</code> branch and see if I fixed it?</blockquote></mx-reply>will try later (tomorrow if I don't forgor)", formatted_body: "<mx-reply><blockquote><a href=\"https://matrix.to/#/!cBxtVRxDlZvSVhJXVK:cadence.moe/$A0Rj559NKOh2VndCZSTJXcvgi42gZWVfVQt73wA2Hn0?via=matrix.org&via=cadence.moe&via=syndicated.gay\">In reply to</a> <a href=\"https://matrix.to/#/@cadence:cadence.moe\">@cadence:cadence.moe</a><br />I just checked in a fix that will probably work, can you try reproducing this on the latest <code>main</code> branch and see if I fixed it?</blockquote></mx-reply>will try later (tomorrow if I don't forgor)",
"m.relates_to": { "m.relates_to": {
"m.in_reply_to": { "m.in_reply_to": {
event_id: "$A0Rj559NKOh2VndCZSTJXcvgi42gZWVfVQt73wA2Hn0" event_id: "$A0Rj559NKOh2VndCZSTJXcvgi42gZWVfVQt73wA2Hn0"
@ -1111,7 +1111,7 @@ test("event2message: rich reply to a rich reply to a multi-line message should c
"msgtype": "m.text", "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?", "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", "format": "org.matrix.custom.html",
"formatted_body": "<mx-reply><blockquote><a href=\"https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$u4OD19vd2GETkOyhgFVla92oDKI4ojwBf2-JeVCG7EI?via=cadence.moe&via=matrix.org&via=conduit.rory.gay\">In reply to</a> <a href=\"https://matrix.to/#/@solonovamax:matrix.org\">@solonovamax:matrix.org</a><br /><p>multipart messages will be deleted if the message is edited to require less space</p>\n<p>steps to reproduce:</p>\n<ol>\n<li>send a message that is longer than 2000 characters (discord character limit)</li>\n</ol>\n<ul>\n<li>bot will split message into two messages on discord</li>\n</ul>\n<ol start=\"2\">\n<li>edit message to be under 2000 characters (discord character limit)</li>\n</ol>\n<ul>\n<li>bot will delete one of the messages on discord, and then edit the other one to include the edited content</li>\n<li>the bot will <em>then</em> delete the message on matrix (presumably) because one of the messages on discord was deleted (by</li>\n</ul>\n</blockquote></mx-reply>I just checked in a fix that will probably work, can you try reproducing this on the latest <code>main</code> branch and see if I fixed it?", "formatted_body": "<mx-reply><blockquote><a href=\"https://matrix.to/#/!cBxtVRxDlZvSVhJXVK:cadence.moe/$u4OD19vd2GETkOyhgFVla92oDKI4ojwBf2-JeVCG7EI?via=cadence.moe&via=matrix.org&via=conduit.rory.gay\">In reply to</a> <a href=\"https://matrix.to/#/@solonovamax:matrix.org\">@solonovamax:matrix.org</a><br /><p>multipart messages will be deleted if the message is edited to require less space</p>\n<p>steps to reproduce:</p>\n<ol>\n<li>send a message that is longer than 2000 characters (discord character limit)</li>\n</ol>\n<ul>\n<li>bot will split message into two messages on discord</li>\n</ul>\n<ol start=\"2\">\n<li>edit message to be under 2000 characters (discord character limit)</li>\n</ol>\n<ul>\n<li>bot will delete one of the messages on discord, and then edit the other one to include the edited content</li>\n<li>the bot will <em>then</em> delete the message on matrix (presumably) because one of the messages on discord was deleted (by</li>\n</ul>\n</blockquote></mx-reply>I just checked in a fix that will probably work, can you try reproducing this on the latest <code>main</code> branch and see if I fixed it?",
"m.relates_to": { "m.relates_to": {
"m.in_reply_to": { "m.in_reply_to": {
"event_id": "$u4OD19vd2GETkOyhgFVla92oDKI4ojwBf2-JeVCG7EI" "event_id": "$u4OD19vd2GETkOyhgFVla92oDKI4ojwBf2-JeVCG7EI"
@ -1123,7 +1123,7 @@ test("event2message: rich reply to a rich reply to a multi-line message should c
"age": 19069564 "age": 19069564
}, },
"event_id": "$A0Rj559NKOh2VndCZSTJXcvgi42gZWVfVQt73wA2Hn0", "event_id": "$A0Rj559NKOh2VndCZSTJXcvgi42gZWVfVQt73wA2Hn0",
"room_id": "!TqlyQmifxGUggEmdBN:cadence.moe" "room_id": "!cBxtVRxDlZvSVhJXVK:cadence.moe"
}) })
}, },
snow: { snow: {
@ -2625,52 +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: "<mx-reply><blockquote><a href=\"https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$f-noT-d-Eo_Xgpc05Ww89ErUXku4NwKWYGHLzWKo1kU?via=cadence.moe\">In reply to</a> <a href=\"https://matrix.to/#/@ampflower:matrix.org\">@ampflower:matrix.org</a> changed the room topic<br></blockquote></mx-reply>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, {
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: raw mentioning discord users in plaintext body works", async t => { test("event2message: raw mentioning discord users in plaintext body works", async t => {
t.deepEqual( t.deepEqual(
await eventToMessage({ await eventToMessage({
@ -3003,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 <a href="https://matrix.to/#/%23worm-farm%3Acadence.moe?via=cadence.moe">worm-farm</a> 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 <https://matrix.to/#/#worm-farm:cadence.moe?via=cadence.moe> 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 => { test("event2message: mentioning known bridged events works (plaintext body)", async t => {
t.deepEqual( t.deepEqual(
await eventToMessage({ await eventToMessage({
@ -3531,7 +3358,7 @@ test("event2message: caches the member if the member is not known", async t => {
}, },
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
origin_server_ts: 1688301929913, origin_server_ts: 1688301929913,
room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe", room_id: "!should_be_newly_cached:cadence.moe",
sender: "@should_be_newly_cached:cadence.moe", sender: "@should_be_newly_cached:cadence.moe",
type: "m.room.message", type: "m.room.message",
unsigned: { unsigned: {
@ -3541,7 +3368,7 @@ test("event2message: caches the member if the member is not known", async t => {
api: { api: {
getStateEvent: async (roomID, type, stateKey) => { getStateEvent: async (roomID, type, stateKey) => {
called++ called++
t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") t.equal(roomID, "!should_be_newly_cached:cadence.moe")
t.equal(type, "m.room.member") t.equal(type, "m.room.member")
t.equal(stateKey, "@should_be_newly_cached:cadence.moe") t.equal(stateKey, "@should_be_newly_cached:cadence.moe")
return { return {
@ -3565,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"} {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") 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 => { test("event2message: skips caching the member if the member does not exist, somehow", async t => {
let called = 0 let called = 0
t.deepEqual( t.deepEqual(
@ -3674,7 +3453,7 @@ test("event2message: overly long usernames are shifted into the message content"
}, },
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
origin_server_ts: 1688301929913, 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", sender: "@should_be_newly_cached_2:cadence.moe",
type: "m.room.message", type: "m.room.message",
unsigned: { unsigned: {
@ -3684,7 +3463,7 @@ test("event2message: overly long usernames are shifted into the message content"
api: { api: {
getStateEvent: async (roomID, type, stateKey) => { getStateEvent: async (roomID, type, stateKey) => {
called++ 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(type, "m.room.member")
t.equal(stateKey, "@should_be_newly_cached_2:cadence.moe") t.equal(stateKey, "@should_be_newly_cached_2:cadence.moe")
return { return {
@ -3707,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"} {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") t.equal(called, 1, "getStateEvent should be called once")
@ -3722,7 +3501,7 @@ test("event2message: overly long usernames are not treated specially when the ms
}, },
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
origin_server_ts: 1688301929913, 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", sender: "@should_be_newly_cached_2:cadence.moe",
type: "m.room.message", type: "m.room.message",
unsigned: { unsigned: {
@ -3770,7 +3549,7 @@ test("event2message: text attachments work", async t => {
username: "cadence [they]", username: "cadence [they]",
content: "", content: "",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", 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"}] pendingFiles: [{name: "chiki-powerups.txt", mxc: "mxc://cadence.moe/zyThGlYQxvlvBVbVgKDDbiHH"}]
}] }]
} }
@ -3806,14 +3585,14 @@ test("event2message: image attachments work", async t => {
username: "cadence [they]", username: "cadence [they]",
content: "", content: "",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", 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"}] 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( t.deepEqual(
await eventToMessage({ await eventToMessage({
type: "m.room.message", type: "m.room.message",
@ -3840,62 +3619,10 @@ test("event2message: image attachments can have a plaintext caption", async t =>
messagesToEdit: [], messagesToEdit: [],
messagesToSend: [{ messagesToSend: [{
username: "cadence [they]", username: "cadence [they]",
content: "Cat emoji surrounded by pink hearts", content: "",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
attachments: [{id: "0", filename: "cool cat.png"}], attachments: [{id: "0", description: "Cat emoji surrounded by pink hearts", filename: "cool cat.png"}],
pendingFiles: [{name: "cool cat.png", mxc: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}], 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 <code>formatting</code>",
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"]
}
}] }]
} }
) )
@ -3944,7 +3671,7 @@ test("event2message: encrypted image attachments work", async t => {
username: "cadence [they]", username: "cadence [they]",
content: "", content: "",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", 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: [{ pendingFiles: [{
name: "image.png", name: "image.png",
mxc: "mxc://heyquark.com/LOGkUTlVFrqfiExlGZNgCJJX", mxc: "mxc://heyquark.com/LOGkUTlVFrqfiExlGZNgCJJX",
@ -3956,91 +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: stickers work", async t => { test("event2message: stickers work", async t => {
t.deepEqual( t.deepEqual(
await eventToMessage({ await eventToMessage({
@ -4622,42 +4264,6 @@ 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<table><thead><tr><th>Col 1</th><th>Col 2</th><th>Col 3</th></tr></thead><tbody><tr><th>Apple</th><td>Banana</td><td>Cherry</td></tr><tr><th>Aardvark</th><td>Bee</td><td>Crocodile</td></tr><tr><td>Argon</td><td>Boron</td><td>Carbon</td></tr></tbody></table>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 => { slow()("event2message: unknown emoji at the end is reuploaded as a sprite sheet", async t => {
const messages = await eventToMessage({ const messages = await eventToMessage({
type: "m.room.message", type: "m.room.message",
@ -4744,7 +4350,7 @@ slow()("event2message: all unknown chess emojis are reuploaded as a sprite sheet
formatted_body: "testing <img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/lHfmJpzgoNyNtYHdAmBHxXix\" title=\":chess_good_move:\" alt=\":chess_good_move:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/MtRdXixoKjKKOyHJGWLsWLNU\" title=\":chess_incorrect:\" alt=\":chess_incorrect:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/HXfFuougamkURPPMflTJRxGc\" title=\":chess_blund:\" alt=\":chess_blund:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/ikYKbkhGhMERAuPPbsnQzZiX\" title=\":chess_brilliant_move:\" alt=\":chess_brilliant_move:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/AYPpqXzVJvZdzMQJGjioIQBZ\" title=\":chess_blundest:\" alt=\":chess_blundest:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/UVuzvpVUhqjiueMxYXJiFEAj\" title=\":chess_draw_black:\" alt=\":chess_draw_black:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/lHfmJpzgoNyNtYHdAmBHxXix\" title=\":chess_good_move:\" alt=\":chess_good_move:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/MtRdXixoKjKKOyHJGWLsWLNU\" title=\":chess_incorrect:\" alt=\":chess_incorrect:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/HXfFuougamkURPPMflTJRxGc\" title=\":chess_blund:\" alt=\":chess_blund:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/ikYKbkhGhMERAuPPbsnQzZiX\" title=\":chess_brilliant_move:\" alt=\":chess_brilliant_move:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/AYPpqXzVJvZdzMQJGjioIQBZ\" title=\":chess_blundest:\" alt=\":chess_blundest:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/UVuzvpVUhqjiueMxYXJiFEAj\" title=\":chess_draw_black:\" alt=\":chess_draw_black:\">" formatted_body: "testing <img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/lHfmJpzgoNyNtYHdAmBHxXix\" title=\":chess_good_move:\" alt=\":chess_good_move:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/MtRdXixoKjKKOyHJGWLsWLNU\" title=\":chess_incorrect:\" alt=\":chess_incorrect:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/HXfFuougamkURPPMflTJRxGc\" title=\":chess_blund:\" alt=\":chess_blund:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/ikYKbkhGhMERAuPPbsnQzZiX\" title=\":chess_brilliant_move:\" alt=\":chess_brilliant_move:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/AYPpqXzVJvZdzMQJGjioIQBZ\" title=\":chess_blundest:\" alt=\":chess_blundest:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/UVuzvpVUhqjiueMxYXJiFEAj\" title=\":chess_draw_black:\" alt=\":chess_draw_black:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/lHfmJpzgoNyNtYHdAmBHxXix\" title=\":chess_good_move:\" alt=\":chess_good_move:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/MtRdXixoKjKKOyHJGWLsWLNU\" title=\":chess_incorrect:\" alt=\":chess_incorrect:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/HXfFuougamkURPPMflTJRxGc\" title=\":chess_blund:\" alt=\":chess_blund:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/ikYKbkhGhMERAuPPbsnQzZiX\" title=\":chess_brilliant_move:\" alt=\":chess_brilliant_move:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/AYPpqXzVJvZdzMQJGjioIQBZ\" title=\":chess_blundest:\" alt=\":chess_blundest:\"><img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/UVuzvpVUhqjiueMxYXJiFEAj\" title=\":chess_draw_black:\" alt=\":chess_draw_black:\">"
}, },
event_id: "$Me6iE8C8CZyrDEOYYrXKSYRuuh_25Jj9kZaNrf7LKr4", event_id: "$Me6iE8C8CZyrDEOYYrXKSYRuuh_25Jj9kZaNrf7LKr4",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" room_id: "!maggESguZBqGBZtSnr:cadence.moe"
}, {}, {mxcDownloader: mockGetAndConvertEmoji}) }, {}, {mxcDownloader: mockGetAndConvertEmoji})
const testResult = { const testResult = {
content: messages.messagesToSend[0].content, content: messages.messagesToSend[0].content,

View file

@ -30,7 +30,9 @@ const NEWLINE_ELEMENTS = BLOCK_ELEMENTS.concat(["BR"])
*/ */
function eventSenderIsFromDiscord(sender) { function eventSenderIsFromDiscord(sender) {
// If it's from a user in the bridge's namespace, then it originated from discord // 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 // 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))) { if (userRegex.some(x => sender.match(x))) {
return true return true
} }
@ -217,12 +219,12 @@ async function getViaServersQuery(roomID, api) {
* @see https://matrix.org/blog/2024/06/20/matrix-v1.11-release/ implementation details * @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 * @see https://www.sqlite.org/fileformat2.html#record_format SQLite integer field size
* @param {string} mxc * @param {string} mxc
* @returns {string | undefined} * @returns {string?}
*/ */
function getPublicUrlForMxc(mxc) { function getPublicUrlForMxc(mxc) {
assert(hasher, "xxhash is not ready yet") assert(hasher, "xxhash is not ready yet")
const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/) const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
if (!mediaParts) return undefined if (!mediaParts) return null
const serverAndMediaID = `${mediaParts[1]}/${mediaParts[2]}` const serverAndMediaID = `${mediaParts[1]}/${mediaParts[2]}`
const unsignedHash = hasher.h64(serverAndMediaID) const unsignedHash = hasher.h64(serverAndMediaID)

View file

@ -14,140 +14,49 @@ const sendEvent = sync.require("./actions/send-event")
const addReaction = sync.require("./actions/add-reaction") const addReaction = sync.require("./actions/add-reaction")
/** @type {import("./actions/redact")} */ /** @type {import("./actions/redact")} */
const redact = sync.require("./actions/redact") const redact = sync.require("./actions/redact")
/** @type {import("./actions/update-pins")}) */
const updatePins = sync.require("./actions/update-pins")
/** @type {import("../matrix/matrix-command-handler")} */ /** @type {import("../matrix/matrix-command-handler")} */
const matrixCommandHandler = sync.require("../matrix/matrix-command-handler") const matrixCommandHandler = sync.require("../matrix/matrix-command-handler")
/** @type {import("./converters/utils")} */ /** @type {import("./converters/utils")} */
const utils = sync.require("./converters/utils") const utils = sync.require("./converters/utils")
/** @type {import("../matrix/api")}) */ /** @type {import("../matrix/api")}) */
const api = sync.require("../matrix/api") const api = sync.require("../matrix/api")
/** @type {import("../d2m/actions/create-room")} */
const createRoom = sync.require("../d2m/actions/create-room")
const {reg} = require("../matrix/read-registration") const {reg} = require("../matrix/read-registration")
let lastReportedEvent = 0 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;
}
/**
* @param {string} roomID
* @param {"Discord" | "Matrix"} source
* @param {any} type
* @param {any} e
* @param {any} payload
*/
async function sendError(roomID, source, type, e, payload) {
console.error(`Error while processing a ${type} ${source} event:`)
console.error(e)
console.dir(payload, {depth: null})
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(/<!DOCTYPE html>.*?<title>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}`, `<details><summary>Error trace</summary><pre>${stack}</pre></details>`)
// How
builder.addLine("", `<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) { function guard(type, fn) {
return async function(event, ...args) { return async function(event, ...args) {
try { try {
return await fn(event, ...args) return await fn(event, ...args)
} catch (e) { } 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"]
}
})
} }
} }
} }
@ -179,7 +88,7 @@ async function onRetryReactionAdd(reactionEvent) {
} }
// Redact the error to stop people from executing multiple retries // Redact the error to stop people from executing multiple retries
await api.redactEvent(roomID, event.event_id) api.redactEvent(roomID, event.event_id)
} }
sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message", sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message",
@ -189,12 +98,10 @@ sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message",
async event => { async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return if (utils.eventSenderIsFromDiscord(event.sender)) return
const messageResponses = await sendEvent.sendEvent(event) const messageResponses = await sendEvent.sendEvent(event)
if (!messageResponses.length) return
if (event.type === "m.room.message" && event.content.msgtype === "m.text") { if (event.type === "m.room.message" && event.content.msgtype === "m.text") {
// @ts-ignore // @ts-ignore
await matrixCommandHandler.execute(event) await matrixCommandHandler.execute(event)
} }
await api.ackEvent(event)
})) }))
sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker", sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker",
@ -204,7 +111,6 @@ sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker",
async event => { async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return if (utils.eventSenderIsFromDiscord(event.sender)) return
const messageResponses = await sendEvent.sendEvent(event) const messageResponses = await sendEvent.sendEvent(event)
await api.ackEvent(event)
})) }))
sync.addTemporaryListener(as, "type:m.reaction", guard("m.reaction", sync.addTemporaryListener(as, "type:m.reaction", guard("m.reaction",
@ -229,7 +135,6 @@ sync.addTemporaryListener(as, "type:m.room.redaction", guard("m.room.redaction",
async event => { async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return if (utils.eventSenderIsFromDiscord(event.sender)) return
await redact.handle(event) await redact.handle(event)
await api.ackEvent(event)
})) }))
sync.addTemporaryListener(as, "type:m.room.avatar", guard("m.room.avatar", sync.addTemporaryListener(as, "type:m.room.avatar", guard("m.room.avatar",
@ -254,117 +159,25 @@ async event => {
db.prepare("UPDATE channel_room SET nick = ? WHERE room_id = ?").run(name, event.room_id) 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)
}))
function getFromInviteRoomState(inviteRoomState, nskey, key) {
if (!Array.isArray(inviteRoomState)) return null
for (const event of inviteRoomState) {
if (event.type === nskey && event.state_key === "") {
return event.content[key]
}
}
return null
}
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
await api.joinRoom(event.state_key).catch(() => {}) // try to join if able, it's okay if it doesn't want, bot will still respond to invites
}
}))
sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member", sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member",
/** /**
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Member>} event * @param {Ty.Event.StateOuter<Ty.Event.M_Room_Member>} event
*/ */
async event => { async event => {
if (event.state_key[0] !== "@") return if (event.state_key[0] !== "@") return
if (event.content.membership === "invite" && event.state_key === `@${reg.sender_localpart}:${reg.ooye.server_name}`) {
// We were invited to a room. We should join, and register the invite details for future reference in web.
const name = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.name", "name")
const topic = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.topic", "topic")
const avatar = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.avatar", "url")
const creationType = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.create", "type")
if (!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("INSERT OR IGNORE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, creationType, name, topic, avatar)
if (avatar) utils.getPublicUrlForMxc(avatar) // make sure it's available in the media_proxy allowed URLs
}
if (utils.eventSenderIsFromDiscord(event.state_key)) return if (utils.eventSenderIsFromDiscord(event.state_key)) return
if (event.content.membership === "leave" || event.content.membership === "ban") { if (event.content.membership === "leave" || event.content.membership === "ban") {
// Member is gone // Member is gone
db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key) db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key)
} else {
// Unregister room's use as a direct chat if the bot itself left
const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}`
if (event.state_key === bot) {
db.prepare("DELETE FROM direct WHERE room_id = ?").run(event.room_id)
}
}
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 // Member is here
let powerLevel = 0 db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?")
try { .run(
/** @type {Ty.Event.M_Power_Levels} */
const powerLevelsEvent = await api.getStateEvent(event.room_id, "m.room.power_levels", "")
powerLevel = powerLevelsEvent.users?.[event.state_key] ?? powerLevelsEvent.users_default ?? 0
} catch (e) {}
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 = ?").run(
event.room_id, event.state_key, event.room_id, event.state_key,
displayname, avatar_url, powerLevel, event.content.displayname || null, event.content.avatar_url || null,
displayname, avatar_url, powerLevel event.content.displayname || null, event.content.avatar_url || null
) )
}
})) }))
sync.addTemporaryListener(as, "type:m.room.power_levels", guard("m.room.power_levels", sync.addTemporaryListener(as, "type:m.room.power_levels", guard("m.room.power_levels",
@ -379,6 +192,3 @@ async event => {
db.prepare("UPDATE member_cache SET power_level = ? WHERE room_id = ? AND mxid = ?").run(newPower[mxid] || 0, event.room_id, mxid) db.prepare("UPDATE member_cache SET power_level = ? WHERE room_id = ? AND mxid = ?").run(newPower[mxid] || 0, event.room_id, mxid)
} }
})) }))
module.exports.stringifyErrorStack = stringifyErrorStack
module.exports.sendError = sendError

View file

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

View file

@ -2,10 +2,11 @@
const Ty = require("../types") const Ty = require("../types")
const assert = require("assert").strict const assert = require("assert").strict
const streamWeb = require("stream/web")
const fetch = require("node-fetch").default
const passthrough = require("../passthrough") const passthrough = require("../passthrough")
const {sync} = passthrough const { discord, sync, db } = passthrough
/** @type {import("./mreq")} */ /** @type {import("./mreq")} */
const mreq = sync.require("./mreq") const mreq = sync.require("./mreq")
/** @type {import("./txnid")} */ /** @type {import("./txnid")} */
@ -34,21 +35,14 @@ function path(p, mxid, otherParams = {}) {
/** /**
* @param {string} username * @param {string} username
* @returns {Promise<Ty.R.Registered>}
*/ */
async function register(username) { function register(username) {
console.log(`[api] register: ${username}`) console.log(`[api] register: ${username}`)
try { return mreq.mreq("POST", "/client/v3/register", {
await mreq.mreq("POST", "/client/v3/register", {
type: "m.login.application_service", type: "m.login.application_service",
username 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
}
}
} }
/** /**
@ -66,7 +60,7 @@ async function createRoom(content) {
*/ */
async function joinRoom(roomIDOrAlias, mxid) { async function joinRoom(roomIDOrAlias, mxid) {
/** @type {Ty.R.RoomJoined} */ /** @type {Ty.R.RoomJoined} */
const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid), {}) const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid))
return root.room_id return root.room_id
} }
@ -81,16 +75,6 @@ async function leaveRoom(roomID, mxid) {
await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, 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} roomID
* @param {string} eventID * @param {string} eventID
@ -139,17 +123,6 @@ function getJoinedMembers(roomID) {
return mreq.mreq("GET", `/client/v3/rooms/${roomID}/joined_members`) 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 {string} roomID
* @param {{from?: string, limit?: any}} pagination * @param {{from?: string, limit?: any}} pagination
@ -181,23 +154,6 @@ async function getFullHierarchy(roomID) {
return rooms 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} roomID
* @param {string} eventID * @param {string} eventID
@ -308,46 +264,33 @@ async function profileSetAvatarUrl(mxid, avatar_url) {
* Set a user's power level within a room. * Set a user's power level within a room.
* @param {string} roomID * @param {string} roomID
* @param {string} mxid * @param {string} mxid
* @param {number} newPower * @param {number} power
*/ */
async function setUserPower(roomID, mxid, newPower) { async function setUserPower(roomID, mxid, power) {
assert(roomID[0] === "!") assert(roomID[0] === "!")
assert(mxid[0] === "@") assert(mxid[0] === "@")
// Yes there's no shortcut https://github.com/matrix-org/matrix-appservice-bridge/blob/2334b0bae28a285a767fe7244dad59f5a5963037/src/components/intent.ts#L352 // Yes there's no shortcut https://github.com/matrix-org/matrix-appservice-bridge/blob/2334b0bae28a285a767fe7244dad59f5a5963037/src/components/intent.ts#L352
const power = await getStateEvent(roomID, "m.room.power_levels", "") const powerLevels = await getStateEvent(roomID, "m.room.power_levels", "")
power.users = power.users || {} powerLevels.users = powerLevels.users || {}
if (power != null) {
// Check if it has really changed to avoid sending a useless state event powerLevels.users[mxid] = power
// (Can't diff kstate here because of (a) circular imports (b) kstate has special behaviour diffing power levels)
const oldPowerLevel = power.users?.[mxid] ?? power.users_default ?? 0
if (oldPowerLevel === newPower) return
// Bridge bot can't demote equal power users, so need to decide which user will send the event
const botPowerLevel = power.users?.[`@${reg.sender_localpart}:${reg.ooye.server_name}`] ?? power.users_default ?? 0
const eventSender = oldPowerLevel >= botPowerLevel ? mxid : undefined
// Update the event content
if (newPower == null || newPower === (power.users_default ?? 0)) {
delete power.users[mxid]
} else { } else {
power.users[mxid] = newPower delete powerLevels.users[mxid]
} }
await sendState(roomID, "m.room.power_levels", "", powerLevels)
await sendState(roomID, "m.room.power_levels", "", power, eventSender) return powerLevels
return power
} }
/** /**
* Set a user's power level for a whole room hierarchy. * Set a user's power level for a whole room hierarchy.
* @param {string} spaceID * @param {string} roomID
* @param {string} mxid * @param {string} mxid
* @param {number} power * @param {number} power
*/ */
async function setUserPowerCascade(spaceID, mxid, power) { async function setUserPowerCascade(roomID, mxid, power) {
assert(spaceID[0] === "!") assert(roomID[0] === "!")
assert(mxid[0] === "@") assert(mxid[0] === "@")
const rooms = await getFullHierarchy(spaceID) const rooms = await getFullHierarchy(roomID)
await setUserPower(spaceID, mxid, power)
for (const room of rooms) { for (const room of rooms) {
await setUserPower(room.room_id, mxid, power) await setUserPower(room.room_id, mxid, power)
} }
@ -372,88 +315,17 @@ async function ping() {
/** /**
* @param {string} mxc * @param {string} mxc
* @param {RequestInit} [init] * @param {fetch.RequestInit} [init]
* @return {Promise<Response & {body: streamWeb.ReadableStream<Uint8Array>}>}
*/ */
async function getMedia(mxc, init = {}) { function getMedia(mxc, init = {}) {
const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/) const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
assert(mediaParts) assert(mediaParts)
const res = await fetch(`${mreq.baseUrl}/client/v1/media/download/${mediaParts[1]}/${mediaParts[2]}`, { return fetch(`${mreq.baseUrl}/client/v1/media/download/${mediaParts[1]}/${mediaParts[2]}`, {
headers: { headers: {
Authorization: `Bearer ${reg.as_token}` Authorization: `Bearer ${reg.as_token}`
}, },
...init ...init
}) })
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}`)
} }
module.exports.path = path module.exports.path = path
@ -462,16 +334,13 @@ module.exports.createRoom = createRoom
module.exports.joinRoom = joinRoom module.exports.joinRoom = joinRoom
module.exports.inviteToRoom = inviteToRoom module.exports.inviteToRoom = inviteToRoom
module.exports.leaveRoom = leaveRoom module.exports.leaveRoom = leaveRoom
module.exports.leaveRoomWithReason = leaveRoomWithReason
module.exports.getEvent = getEvent module.exports.getEvent = getEvent
module.exports.getEventForTimestamp = getEventForTimestamp module.exports.getEventForTimestamp = getEventForTimestamp
module.exports.getAllState = getAllState module.exports.getAllState = getAllState
module.exports.getStateEvent = getStateEvent module.exports.getStateEvent = getStateEvent
module.exports.getJoinedMembers = getJoinedMembers module.exports.getJoinedMembers = getJoinedMembers
module.exports.getMembers = getMembers
module.exports.getHierarchy = getHierarchy module.exports.getHierarchy = getHierarchy
module.exports.getFullHierarchy = getFullHierarchy module.exports.getFullHierarchy = getFullHierarchy
module.exports.generateFullHierarchy = generateFullHierarchy
module.exports.getRelations = getRelations module.exports.getRelations = getRelations
module.exports.getFullRelations = getFullRelations module.exports.getFullRelations = getFullRelations
module.exports.sendState = sendState module.exports.sendState = sendState
@ -484,10 +353,3 @@ module.exports.setUserPower = setUserPower
module.exports.setUserPowerCascade = setUserPowerCascade module.exports.setUserPowerCascade = setUserPowerCascade
module.exports.ping = ping module.exports.ping = ping
module.exports.getMedia = getMedia 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

View file

@ -1,9 +1,8 @@
// @ts-check // @ts-check
const passthrough = require("../passthrough") const fetch = require("node-fetch").default
const {reg, writeRegistration} = require("./read-registration.js")
const Ty = require("../types")
const passthrough = require("../passthrough")
const {sync, db, select} = passthrough const {sync, db, select} = passthrough
/** @type {import("./mreq")} */ /** @type {import("./mreq")} */
const mreq = sync.require("./mreq") const mreq = sync.require("./mreq")
@ -47,8 +46,11 @@ async function uploadDiscordFileToMxc(path) {
return existingFromDb return existingFromDb
} }
// Download from Discord and upload to Matrix // Download from Discord
const promise = module.exports._actuallyUploadDiscordFileToMxc(url).then(root => { 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 // Store relationship in database
db.prepare("INSERT INTO file (discord_url, mxc_url) VALUES (?, ?)").run(urlNoExpiry, root.content_uri) db.prepare("INSERT INTO file (discord_url, mxc_url) VALUES (?, ?)").run(urlNoExpiry, root.content_uri)
inflight.delete(urlNoExpiry) inflight.delete(urlNoExpiry)
@ -60,33 +62,15 @@ async function uploadDiscordFileToMxc(path) {
return promise return promise
} }
/** async function _actuallyUploadDiscordFileToMxc(url, res) {
* @param {string} url const body = res.body
* @returns {Promise<Ty.R.FileUploaded>} /** @type {import("../types").R.FileUploaded} */
*/ const root = await mreq.mreq("POST", "/media/v3/upload", body, {
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: { headers: {
"Content-Type": res.headers.get("content-type") "Content-Type": res.headers.get("content-type")
} }
}) })
return root 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
}
throw e
}
} }
function guildIcon(guild) { function guildIcon(guild) {
@ -98,8 +82,8 @@ function userAvatar(user) {
} }
function memberAvatar(guildID, user, member) { function memberAvatar(guildID, user, member) {
if (!member?.avatar) return userAvatar(user) if (!member.avatar) return userAvatar(user)
return `/guilds/${guildID}/users/${user.id}/avatars/${member?.avatar}.png?size=${IMAGE_SIZE}` return `/guilds/${guildID}/users/${user.id}/avatars/${member.avatar}.png?size=${IMAGE_SIZE}`
} }
function emoji(emojiID, animated) { function emoji(emojiID, animated) {
@ -109,17 +93,18 @@ function emoji(emojiID, animated) {
} }
const stickerFormat = new Map([ const stickerFormat = new Map([
[1, {label: "PNG", ext: "png", mime: "image/png", endpoint: "/stickers/"}], [1, {label: "PNG", ext: "png", mime: "image/png"}],
[2, {label: "APNG", ext: "png", mime: "image/apng", endpoint: "/stickers/"}], [2, {label: "APNG", ext: "png", mime: "image/apng"}],
[3, {label: "LOTTIE", ext: "json", mime: "lottie", endpoint: "/stickers/"}], [3, {label: "LOTTIE", ext: "json", mime: "lottie"}],
[4, {label: "GIF", ext: "gif", mime: "image/gif", endpoint: "https://media.discordapp.net/stickers/"}] [4, {label: "GIF", ext: "gif", mime: "image/gif"}]
]) ])
/** @param {{id: string, format_type: number}} sticker */ /** @param {{id: string, format_type: number}} sticker */
function sticker(sticker) { function sticker(sticker) {
const format = stickerFormat.get(sticker.format_type) const format = stickerFormat.get(sticker.format_type)
if (!format) throw new Error(`No such format ${sticker.format_type} for sticker ${JSON.stringify(sticker)}`) 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 module.exports.DISCORD_IMAGES_BASE = DISCORD_IMAGES_BASE

View file

@ -8,8 +8,6 @@ const passthrough = require("../passthrough")
const {sync} = passthrough const {sync} = passthrough
/** @type {import("./file")} */ /** @type {import("./file")} */
const file = sync.require("./file") const file = sync.require("./file")
/** @type {import("./api")} */
const api = sync.require("./api")
/** Mutates the input. Not recursive - can only include or exclude entire state events. */ /** Mutates the input. Not recursive - can only include or exclude entire state events. */
function kstateStripConditionals(kstate) { function kstateStripConditionals(kstate) {
@ -87,12 +85,6 @@ function diffKState(actual, target) {
diff[key] = temp diff[key] = temp
} }
} else if (key === "chat.schildi.hide_ui/read_receipts") {
// Special handling: don't add this key if it's new. Do overwrite if already present.
if (key in actual) {
diff[key] = target[key]
}
} else if (key in actual) { } else if (key in actual) {
// diff // diff
if (!isDeepStrictEqual(actual[key], target[key])) { if (!isDeepStrictEqual(actual[key], target[key])) {
@ -110,32 +102,8 @@ function diffKState(actual, target) {
return diff return diff
} }
/* c8 ignore start */
/**
* Async because it gets all room state from the homeserver.
* @param {string} roomID
*/
async function roomToKState(roomID) {
const root = await api.getAllState(roomID)
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.kstateStripConditionals = kstateStripConditionals
module.exports.kstateUploadMxc = kstateUploadMxc module.exports.kstateUploadMxc = kstateUploadMxc
module.exports.kstateToState = kstateToState module.exports.kstateToState = kstateToState
module.exports.stateToKState = stateToKState module.exports.stateToKState = stateToKState
module.exports.diffKState = diffKState module.exports.diffKState = diffKState
module.exports.roomToKState = roomToKState
module.exports.applyKStateDiffToRoom = applyKStateDiffToRoom

View file

@ -234,31 +234,3 @@ test("diffKState: kstate keys must contain a slash separator", t => {
, /does not contain a slash separator/) , /does not contain a slash separator/)
t.pass() t.pass()
}) })
test("diffKState: don't add hide_ui when not present", t => {
test("diffKState: detects new properties", t => {
t.deepEqual(
diffKState({
}, {
"chat.schildi.hide_ui/read_receipts/": {}
}),
{
}
)
})
})
test("diffKState: overwriten hide_ui when present", t => {
test("diffKState: detects new properties", t => {
t.deepEqual(
diffKState({
"chat.schildi.hide_ui/read_receipts/": {hidden: true}
}, {
"chat.schildi.hide_ui/read_receipts/": {}
}),
{
"chat.schildi.hide_ui/read_receipts/": {}
}
)
})
})

View file

@ -224,7 +224,7 @@ const commands = [{
.png() .png()
.toBuffer({resolveWithObject: true}) .toBuffer({resolveWithObject: true})
console.log(`uploading emoji ${resizeOutput.data.length} bytes to :${e.name}:`) 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", { api.sendEvent(event.room_id, "m.room.message", {
...ctx, ...ctx,

View file

@ -1,11 +1,12 @@
// @ts-check // @ts-check
const stream = require("stream") const fetch = require("node-fetch").default
const streamWeb = require("stream/web")
const {buffer} = require("stream/consumers")
const mixin = require("@cloudrac3r/mixin-deep") const mixin = require("@cloudrac3r/mixin-deep")
const stream = require("stream")
const getStream = require("get-stream")
const {reg} = require("./read-registration.js") const {reg} = require("./read-registration.js")
const baseUrl = `${reg.ooye.server_origin}/_matrix` const baseUrl = `${reg.ooye.server_origin}/_matrix`
class MatrixServerError extends Error { class MatrixServerError extends Error {
@ -18,49 +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 {string} method * @param {string} method
* @param {string} url * @param {string} url
* @param {string | object | streamWeb.ReadableStream | stream.Readable} [bodyIn] * @param {any} [body]
* @param {any} [extra] * @param {any} [extra]
*/ */
async function mreq(method, url, bodyIn, extra = {}) { async function mreq(method, url, body, extra = {}) {
const body = await _convertBody(bodyIn) 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({ const opts = mixin({
method, method,
body, body,
headers: { headers: {
Authorization: `Bearer ${reg.as_token}` Authorization: `Bearer ${reg.as_token}`
}, }
...(body && {duplex: "half"}), // https://github.com/octokit/request.js/pull/571/files
}, extra) }, extra)
// console.log(baseUrl + url, opts)
const res = await fetch(baseUrl + url, opts) const res = await fetch(baseUrl + url, opts)
const root = await res.json() const root = await res.json()
if (!res.ok || root.errcode) { 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}) throw new MatrixServerError(root, {baseUrl, url, ...opts})
} }
return root return root
@ -88,4 +81,3 @@ module.exports.MatrixServerError = MatrixServerError
module.exports.baseUrl = baseUrl module.exports.baseUrl = baseUrl
module.exports.mreq = mreq module.exports.mreq = mreq
module.exports.withAccessToken = withAccessToken module.exports.withAccessToken = withAccessToken
module.exports._convertBody = _convertBody

View file

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

View file

@ -3,6 +3,7 @@
const {db, from} = require("../passthrough") const {db, from} = require("../passthrough")
const {reg} = require("./read-registration") const {reg} = require("./read-registration")
const ks = require("./kstate") 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. */ /** 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() { function _getAffectedRooms() {
@ -22,9 +23,9 @@ async function applyPower() {
const rows = _getAffectedRooms() const rows = _getAffectedRooms()
for (const row of rows) { 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}}}) 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, // 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. // 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) db.prepare("UPDATE member_cache SET power_level = ? WHERE room_id = ? AND mxid = ?").run(row.power_level, row.room_id, row.mxid)

12
src/matrix/power.test.js Normal file
View file

@ -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",
}])
})

Some files were not shown because too many files have changed in this diff Show more