Compare commits

..

121 commits

Author SHA1 Message Date
8e0950ded9 Fix client-side MXID validation 2025-02-25 15:43:46 +13:00
2a45b5f9aa Replace hash icon 2025-02-25 15:43:39 +13:00
fca3267e8d Explain how to update 2025-02-25 15:01:54 +13:00
3b034dd6e5 Optional password protection for the web server 2025-02-25 14:36:49 +13:00
3bc37857bb Fix link in readme 2025-02-24 16:41:52 +13:00
d5cbb8ff8a Rearrange controls on guild web page 2025-02-24 15:32:43 +13:00
2f3a10c93f Mark version 3 2025-02-24 02:31:14 +13:00
e2df174bbe Fix #42
Don't force private badge when the base role can't view channels.
2025-02-24 02:25:21 +13:00
20dabf4ad5 Only offer to link channels the bridge can access 2025-02-24 02:14:46 +13:00
5631b7e956 Remove unused API calls 2025-02-24 01:43:03 +13:00
6df8d9a079 Display limited replies to unbridged messages 2025-02-24 01:41:32 +13:00
fb38db5d23 Revert to Stacks 2.5.4
2.5.5 has a regression where the a.s-btn:visited style looks bad.
I can update again when they fix it.
https://github.com/StackExchange/Stacks/pull/1879
2025-02-22 12:35:04 +13:00
31cf91d778 Partially revert fbc1b6f
Since that commit, setup has not worked, because setup loads client
which loads packets which prepares a statement for a table that does not
exist yet, as database migrations have not been run.

Alteratively, we could do migrations earlier in setup, but those would
send a lot of log lines to the terminal in the middle of interactive
prompting.
2025-02-22 12:26:38 +13:00
2e13538ca6 Split out readme into pages 2025-02-21 17:45:20 +13:00
62be5f7091 Code coverage for web settings 2025-02-21 16:41:43 +13:00
21c7b35136 Put QR code behind reveal button 2025-02-21 16:38:00 +13:00
46bd2cbb2b Fix web invite button duplicating the page 2025-02-21 12:12:49 +13:00
bb73341a89 code quality: get htmx from npm 2025-02-21 12:12:35 +13:00
fbc1b6f419 Code quality 2025-02-20 11:36:51 +13:00
6eed4ec54a Allow anyone to react in read-only rooms 2025-02-19 13:56:01 +13:00
6e00066048 REPL code quality 2025-02-19 13:52:20 +13:00
902dfa7e7c Validate mxid format in web login 2025-02-18 15:59:32 +13:00
ea7aec5e66 Ignore error when maximum reactions reached 2025-02-18 14:14:17 +13:00
ed417e029f Preserve name/avatar/topic of linked rooms 2025-02-18 01:46:54 +13:00
438714b67e Try to join space rooms in link flow 2025-02-18 01:30:28 +13:00
6b919d2a82 Add setting for d->m URL previews 2025-02-18 01:11:26 +13:00
efd7cb3fd4 Stay on guild self service page after logging in 2025-02-18 00:46:54 +13:00
331f2f6a38 Update dependencies 2025-02-18 00:08:46 +13:00
98ff9b0e8f Remove async from presence update 2025-02-17 19:35:37 +13:00
506e8a8269 Fix types for combining channels with threads 2025-02-17 16:01:47 +13:00
a5fd30d535 Qualify kind of stream 2025-02-16 17:44:17 +13:00
0d0ca98e13 Don't add unwanted key to initial room creation 2025-02-16 17:32:59 +13:00
a05868f418 Fix m->d encrypted files 2025-02-16 17:15:01 +13:00
d0ac5b58a7 Let anybody run tests 2025-02-16 09:37:21 +13:00
ac40c54e40 Hot-reload Presence instances 2025-02-13 01:16:52 +13:00
b0ded8e289 Fix d->m file uploads 2025-02-13 01:14:29 +13:00
cfaada6797 Rewrite presence code 2025-02-12 16:50:45 +13:00
b6e12044a8 Update dependencies 2025-02-11 13:24:30 +13:00
8e0c15476c Update Discord dependencies 2025-02-11 13:04:40 +13:00
984d4362a5 Remove node-fetch 2025-02-11 12:51:58 +13:00
7782c120bf Only show "no servers available" when logged in 2025-02-11 01:52:41 +13:00
381861ee8e Sadly, the presence API is worse than I hoped 2025-02-11 01:37:23 +13:00
15826dcb3f Make Cloudflare errors less obnoxious
Co-authored-by: Wonder Collective <>
2025-02-10 20:44:49 +13:00
fc6cb8e0d5 Web UI improvements 2025-02-10 16:54:40 +13:00
0f435e930e Per-guild presence sync settings
On by default for existing and small guilds.
Off for new large guilds.
Can be toggled either way.
2025-02-10 16:54:30 +13:00
69e3d64905 Handle replies to state events with no body 2025-02-10 16:44:22 +13:00
8ad0117fd2 d->m: Presence 2025-02-10 15:23:55 +13:00
f98c30cac3 Refactor web access control 2025-02-10 14:10:39 +13:00
4ae8da84e0 Don't add hide_ui when not present 2025-02-10 14:09:41 +13:00
23d473a56b caps 2025-02-10 00:43:04 +13:00
6a1be91071 Fix scanning forwarded messages for mentions 2025-02-10 00:24:59 +13:00
b2078620be Code coverage for matrix log in & guild settings 2025-02-08 16:05:35 +13:00
a90d3b9055 Code coverage for link/unlink endpoints 2025-02-07 16:56:10 +13:00
a29d019d17 Make read-only Discord channels read-only on Matrix 2025-02-05 16:57:45 +13:00
f7e2c89e65 Test setup for new web pages 2025-02-05 14:57:05 +13:00
fa8ce28f88 Fix reaction emoji processing on Windows 2025-02-04 17:07:25 +13:00
be06caf2b4 Clean up log message 2025-02-04 17:01:06 +13:00
d45a0bdc10 UI for linking existing space 2025-02-04 02:45:38 +13:00
3d0609f8f1 Fix duplicate guilds in list 2025-02-03 23:30:32 +13:00
978eb40e1d Store invites in database 2025-02-03 16:37:56 +13:00
f9be1e39a1 Improve dropdown button 2025-02-03 15:48:16 +13:00
443618b974 Log in with Matrix 2025-02-02 01:23:36 +13:00
63cc089bdb Reset room topic immediately if it is cleared 2025-02-01 23:26:24 +13:00
ad51079448 Don't overwrite room custom topics 2025-02-01 23:12:50 +13:00
eec8b0f15b Add loading indicator to invite screens 2025-02-01 22:27:27 +13:00
17ea92a8c2 Fix unlinking left rooms 2025-02-01 22:11:32 +13:00
ae57fa2801 Only announce if they can reasonably type here 2025-02-01 22:03:41 +13:00
5b21344a65 Add room list debugger 2025-02-01 01:40:59 +13:00
cf8867f945 Fix test 2025-01-31 16:50:48 +13:00
eb4aa615be Fix web loading indicators 2025-01-31 16:42:48 +13:00
a459ee1d1c Use htmx.js instead of htmx.min.js
This wastes 30 kB gzipped, which I think is acceptable in exchange for
having method names in the debugger.
2025-01-31 16:42:15 +13:00
b1b9124052 Fully support unlinking channels 2025-01-31 15:09:01 +13:00
5c0e830658 Display XHR errors 2025-01-31 15:07:48 +13:00
d4a50cb8aa Do not run as root 2025-01-30 22:25:25 +13:00
6fe8c60f11 Add analyze of new data 2025-01-30 15:34:29 +13:00
a579b509d3 Catch PK API network errors 2025-01-28 16:08:43 +13:00
eadefef6a3 Clean up member_cache when unbridging 2025-01-21 15:08:12 +13:00
5b06d5984a Do cache space members in member_cache 2025-01-20 02:33:24 +13:00
f42eb6495f New unicode emoji processor 2025-01-17 18:05:34 +13:00
14574b4e2c Support alternate Discord hosts 2025-01-17 11:40:34 +13:00
8ad299b04c Add foreign keys to database 2025-01-17 11:33:29 +13:00
931cacea6a Don't add channels/threads to the public directory 2025-01-16 08:44:13 +13:00
6bb31deeaf Ignore missed messages if channel was just added 2025-01-16 08:40:26 +13:00
1e4952f1b8 Add anti-timeout system to reactions interaction 2025-01-12 14:31:32 +13:00
f3b0d01400 Fix fish reaction 2025-01-12 13:51:57 +13:00
de57bdaf3c Await syncRoom after linking 2025-01-12 13:32:39 +13:00
85269ea153 Hopefully prevent checkMissed errors from crashing 2025-01-12 13:11:51 +13:00
a3e94a215a Hide error if sendTyping request fails 2025-01-12 13:05:16 +13:00
c6708d4dbd Fix channel linking form URL 2025-01-12 12:50:32 +13:00
551dbd0c42 Add dependency justification 2025-01-08 14:51:32 +13:00
ad1aa2c0f6 Resolve Matrix room aliases to Discord channels 2025-01-08 13:56:59 +13:00
6e55061760 Use kstate for d->m pins updates 2025-01-08 11:31:43 +13:00
0c1b4c5e8e Remove unhelpful guard preventing d->m pin syncing 2025-01-08 09:37:30 +13:00
fb18c0fe0b Ensure 1 pin = 1 pin even when message is split 2025-01-08 02:05:28 +13:00
7e6548eb90 Ack bridged Matrix events
May provide reassurance that the bridge is currently working.
Half-Shot's bridge has always done this.
2025-01-08 01:31:31 +13:00
93cacba283 Make sure client hint change applies
Will eventually remove it fully in v4.
2025-01-08 01:25:17 +13:00
3e5034cff5 Remove read receipts visibility client hint
This is a failed experiment that is long past its time. It needs to go.
2025-01-08 01:07:46 +13:00
2009e23689 Docs: Why does the bridge have a website? 2025-01-08 01:01:43 +13:00
dcb7dda6f1 Setup now checks for privileged intents 2025-01-07 15:04:43 +13:00
06b6a63ee3 Sync pins back from Matrix to Discord 2025-01-07 12:24:03 +13:00
4c62124cee Improve invite QR generation 2025-01-06 21:53:48 +13:00
84d61a1118 Use relative path for post-oauth redirect 2025-01-06 21:12:05 +13:00
16ac99781c Better feedback after interrupting/resuming setup 2025-01-06 17:19:33 +13:00
6411279efd Use relative paths on web 2025-01-06 15:31:34 +13:00
97043d90cc await it a bit further up the chain 2025-01-05 19:27:40 +13:00
d7063916a5 During setup, echo any unrecognised requests 2025-01-05 19:02:55 +13:00
0fe02dce22 Fix web page exploding for unlinked guilds
Now it should at least show something, though features
like invite won't work correctly. More work needed.
2024-12-28 20:01:27 +13:00
20b575c5f7 Mention PluralKit support on the readme 2024-12-24 01:25:09 +13:00
8a6b8ee32a Allow creating admins from interaction menu 2024-12-24 01:20:42 +13:00
75140a5b58 Allow creating admins on web 2024-12-24 01:16:02 +13:00
c599dff590 Tests and coverage for web 2024-12-24 01:06:19 +13:00
53379a962d This has never actually occurred 2024-12-07 19:20:01 +13:00
bf01db13d6 Check server before checking well-known 2024-12-03 01:11:53 +13:00
4f040e40d6 Autocreate space if autocreating the room 2024-12-02 16:33:18 +13:00
e00ce22aad Replace into guild_active from homepage
Allow user to change their mind about auto-create by redoing oauth flow.
2024-12-02 15:42:32 +13:00
88a232fb4a Cope if the username is already registered 2024-12-02 15:06:10 +13:00
a35860cb15 Handle more guild page situations 2024-12-02 12:43:00 +13:00
bded9296af Fix guild page being broken when unlinked 2024-12-02 12:29:16 +13:00
559d9329f2 Fix voice messages not being delivered 2024-11-30 22:56:22 +13:00
7ff2a38cdb Move room linking logic out of template 2024-11-26 12:17:31 +13:00
a63d173a9a Remove redundant/invalid checks from setup 2024-11-25 16:30:10 +13:00
107 changed files with 9398 additions and 1356 deletions

View file

@ -0,0 +1,131 @@
# 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: 139
### <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.
* (10) 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) 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.
* (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.
* (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.

98
docs/foreign-keys.md Normal file
View file

@ -0,0 +1,98 @@
# 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.*

108
docs/get-started.md Normal file
View file

@ -0,0 +1,108 @@
# 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 20 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
Out Of Your Element regularly releases new versions. 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

@ -61,8 +61,6 @@ So there will be 3 states of whether a guild is self-service or not. At first, i
Pressing buttons on web or using the /invite command on a guild will insert a row into guild_active, allowing it to be bridged. Pressing buttons on web or using the /invite command on a guild will insert a row into guild_active, allowing it to be bridged.
One more thing. Before v3, when a Matrix room was autocreated it would autocreate the space as well, if it needed to. But now, since nothing will be created until the user takes an action, the guild will always be created directly in response to a request. So room creation can now trust that the guild exists already.
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.
@ -70,5 +68,10 @@ So here's all the technical changes needed to support self-service in v3:
- 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 0.
- 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 can trust that the space exists because we check that in a calling function.
- createRoom will only create other dependencies if the guild is autocreate. - 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

@ -0,0 +1,59 @@
# 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. See the end of this document for more information.
You are likely already using a reverse proxy for running your homeserver, so this should just be a configuration change.
## Example configurations
[See the Get Started document for examples.](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/get-started.md).

340
package-lock.json generated
View file

@ -14,28 +14,30 @@
"@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.0.0", "@cloudrac3r/in-your-element": "^1.0.0",
"@cloudrac3r/mixin-deep": "^3.0.0", "@cloudrac3r/mixin-deep": "^3.0.1",
"@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.7", "@stackoverflow/stacks": "^2.5.4",
"@stackoverflow/stacks-icons": "^6.0.2", "@stackoverflow/stacks-icons": "^6.0.2",
"ansi-colors": "^4.1.3", "ansi-colors": "^4.1.3",
"better-sqlite3": "^11.1.2", "better-sqlite3": "^11.1.2",
"chunk-text": "^2.0.1", "chunk-text": "^2.0.1",
"cloudstorm": "^0.10.10", "cloudstorm": "^0.11.2",
"discord-api-types": "^0.37.119",
"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", "get-stream": "^6.0.1",
"h3": "^1.12.0", "h3": "^1.12.0",
"heatsync": "^2.5.5", "heatsync": "^2.7.2",
"htmx.org": "^2.0.4",
"lru-cache": "^10.4.3", "lru-cache": "^10.4.3",
"minimist": "^1.2.8", "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.10.5", "snowtransfer": "^0.12.0",
"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", "uqr": "^0.1.2",
@ -45,10 +47,8 @@
"devDependencies": { "devDependencies": {
"@cloudrac3r/tap-dot": "^2.0.3", "@cloudrac3r/tap-dot": "^2.0.3",
"@types/node": "^18.16.0", "@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",
"discord-api-types": "^0.37.60",
"supertape": "^10.4.0" "supertape": "^10.4.0"
}, },
"engines": { "engines": {
@ -135,10 +135,13 @@
} }
}, },
"node_modules/@bcoe/v8-coverage": { "node_modules/@bcoe/v8-coverage": {
"version": "0.2.3", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true "dev": true,
"engines": {
"node": ">=18"
}
}, },
"node_modules/@chriscdn/promise-semaphore": { "node_modules/@chriscdn/promise-semaphore": {
"version": "2.0.9", "version": "2.0.9",
@ -280,9 +283,10 @@
} }
}, },
"node_modules/@cloudrac3r/mixin-deep": { "node_modules/@cloudrac3r/mixin-deep": {
"version": "3.0.0", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/@cloudrac3r/mixin-deep/-/mixin-deep-3.0.0.tgz", "resolved": "https://registry.npmjs.org/@cloudrac3r/mixin-deep/-/mixin-deep-3.0.1.tgz",
"integrity": "sha512-yQz1wHSZbHfbKaGSjrV3wIG0e9MnElKlmekMKJPRdTn2jhF2Mt8wfMPX8U7v6rTyzR/7BTrX8CCUcrJMLgoQqw==", "integrity": "sha512-awxfIraHjJ/URNlZ0ROc78Tdjtfk/fM/Gnj1embfrSN08h/HpRtLmPc3xlG3T2vFAy1AkONaebd52u7o6kDaYw==",
"license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@ -922,18 +926,19 @@
"dev": true "dev": true
}, },
"node_modules/@stackoverflow/stacks": { "node_modules/@stackoverflow/stacks": {
"version": "2.5.7", "version": "2.5.4",
"resolved": "https://registry.npmjs.org/@stackoverflow/stacks/-/stacks-2.5.7.tgz", "resolved": "https://registry.npmjs.org/@stackoverflow/stacks/-/stacks-2.5.4.tgz",
"integrity": "sha512-1ipTt7jqUszyd78Gn9TADT22PL0yXe14iEfgZyvJlDvrNrmyJLoGsFMRMwcduPol6/C/zkFt2dmfph/5vFDcYA==", "integrity": "sha512-k11SesaE+bZXNoa7IDinkpmu1BgVq7xIV1Gl1fZ5SM00hPi2S/vCyjIupLvJbSt1PAJTlnpBoUIM1ubb0Y7qFg==",
"license": "MIT",
"dependencies": { "dependencies": {
"@hotwired/stimulus": "^3.2.2", "@hotwired/stimulus": "^3.2.2",
"@popperjs/core": "^2.11.8" "@popperjs/core": "^2.11.8"
} }
}, },
"node_modules/@stackoverflow/stacks-icons": { "node_modules/@stackoverflow/stacks-icons": {
"version": "6.0.2", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/@stackoverflow/stacks-icons/-/stacks-icons-6.0.2.tgz", "resolved": "https://registry.npmjs.org/@stackoverflow/stacks-icons/-/stacks-icons-6.1.0.tgz",
"integrity": "sha512-NDXV/0w6on9fJBfaLrBtPSXTbGyitD+mBTmIpLmDWbVgZo3EJgZBdPdElO0nO7K0WR2Yee1nKhC8euRhd9nicg==" "integrity": "sha512-l5M+gcBeJBAJaxX4ByWhT/eeWhFusdalP/sq0z0uiUM+pwMfDU/pEm2u4DWVYSvBKaskbH3K0oILWaeDvEfGmA=="
}, },
"node_modules/@supertape/engine-loader": { "node_modules/@supertape/engine-loader": {
"version": "2.0.0", "version": "2.0.0",
@ -1071,25 +1076,15 @@
"dev": true "dev": true
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "18.19.46", "version": "18.19.76",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.46.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.76.tgz",
"integrity": "sha512-vnRgMS7W6cKa1/0G3/DTtQYpVrZ8c0Xm6UkLaVFrb9jtcVC3okokW09Ki1Qdrj9ISokszD69nY4WDLRlvHlhAA==", "integrity": "sha512-yvR7Q9LdPz2vGpmpJX5LolrgRdWvB67MJKDPSgIIzpFbaf9a1j/f5DnLp5VDyHGMR0QZHlTr1afsD87QCXFHKw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~5.26.4" "undici-types": "~5.26.4"
} }
}, },
"node_modules/@types/node-fetch": {
"version": "2.6.11",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.11.tgz",
"integrity": "sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==",
"dev": true,
"dependencies": {
"@types/node": "*",
"form-data": "^4.0.0"
}
},
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
"version": "15.7.11", "version": "15.7.11",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz",
@ -1170,12 +1165,6 @@
"resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.3.0.tgz", "resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.3.0.tgz",
"integrity": "sha512-9Z3vxQ+berkL/JJo0dK+EY3Lp0s3NtSnP3VCLsh5HDcZPrh0M+KQRK5sWhUeyPPH+/RCxZqOxLMR+YC6vlviEQ==" "integrity": "sha512-9Z3vxQ+berkL/JJo0dK+EY3Lp0s3NtSnP3VCLsh5HDcZPrh0M+KQRK5sWhUeyPPH+/RCxZqOxLMR+YC6vlviEQ=="
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true
},
"node_modules/babel-walk": { "node_modules/babel-walk": {
"version": "3.0.0-canary-5", "version": "3.0.0-canary-5",
"resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz", "resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz",
@ -1190,7 +1179,8 @@
"node_modules/backtracker": { "node_modules/backtracker": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/backtracker/-/backtracker-4.0.0.tgz", "resolved": "https://registry.npmjs.org/backtracker/-/backtracker-4.0.0.tgz",
"integrity": "sha512-XG2ldN+WDRq9niJMnoZDjLLUnhDOQGhFZc6qZQotN59xj8oOa4KXSCu6YyZQawPqi6gG3HilGFt91zT6Hbdh1w==" "integrity": "sha512-XG2ldN+WDRq9niJMnoZDjLLUnhDOQGhFZc6qZQotN59xj8oOa4KXSCu6YyZQawPqi6gG3HilGFt91zT6Hbdh1w==",
"license": "MIT"
}, },
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
@ -1218,11 +1208,10 @@
] ]
}, },
"node_modules/better-sqlite3": { "node_modules/better-sqlite3": {
"version": "11.3.0", "version": "11.8.1",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.3.0.tgz", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.8.1.tgz",
"integrity": "sha512-iHt9j8NPYF3oKCNOO5ZI4JwThjt3Z6J6XrcwG85VNMVzv1ByqrHWv5VILEbCMFWDsoHhXvQ7oC8vgRXFAKgl9w==", "integrity": "sha512-9BxNaBkblMjhJW8sMRZxnxVTRgbRmssZW0Oxc1MPBTfiR+WW21e2Mk4qu8CzrcZb1LwPCnFsfDEzq+SNcBU8eg==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT",
"dependencies": { "dependencies": {
"bindings": "^1.5.0", "bindings": "^1.5.0",
"prebuild-install": "^7.1.1" "prebuild-install": "^7.1.1"
@ -1292,12 +1281,12 @@
} }
}, },
"node_modules/c8": { "node_modules/c8": {
"version": "10.1.2", "version": "10.1.3",
"resolved": "https://registry.npmjs.org/c8/-/c8-10.1.2.tgz", "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz",
"integrity": "sha512-Qr6rj76eSshu5CgRYvktW0uM0CFY0yi4Fd5D0duDXO6sYinyopmftUiJVuzBQxQcwQLor7JWDVRP+dUfCmzgJw==", "integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@bcoe/v8-coverage": "^0.2.3", "@bcoe/v8-coverage": "^1.0.1",
"@istanbuljs/schema": "^0.1.3", "@istanbuljs/schema": "^0.1.3",
"find-up": "^5.0.0", "find-up": "^5.0.0",
"foreground-child": "^3.1.1", "foreground-child": "^3.1.1",
@ -1425,16 +1414,16 @@
} }
}, },
"node_modules/cloudstorm": { "node_modules/cloudstorm": {
"version": "0.10.11", "version": "0.11.4",
"resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.10.11.tgz", "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.11.4.tgz",
"integrity": "sha512-A3lN0o404la7ryWIxN73gW2ehC0RO4h0yCA2grtOtPh8rNTd6+R2U4llyJlb61HlyOFrEVJ7AbOoFblVSmkrtw==", "integrity": "sha512-fk0tAyZmUBWrxELyXaKh19s1RJucmhmvTMfB/LrvdRHdUvc20VkD7qCrFaQHSQ/+kzwhSHVY43zNAjtz93pH9A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"discord-api-types": "^0.37.98", "discord-api-types": "^0.37.119",
"snowtransfer": "^0.10.7" "snowtransfer": "^0.12.0"
}, },
"engines": { "engines": {
"node": ">=14.8.0" "node": ">=16.15.0"
} }
}, },
"node_modules/color": { "node_modules/color": {
@ -1474,27 +1463,6 @@
"simple-swizzle": "^0.2.2" "simple-swizzle": "^0.2.2"
} }
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/consola": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.2.3.tgz",
"integrity": "sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==",
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.10.0"
}
},
"node_modules/constantinople": { "node_modules/constantinople": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/constantinople/-/constantinople-4.0.1.tgz", "resolved": "https://registry.npmjs.org/constantinople/-/constantinople-4.0.1.tgz",
@ -1535,9 +1503,9 @@
} }
}, },
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.3", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"path-key": "^3.1.0", "path-key": "^3.1.0",
@ -1549,17 +1517,11 @@
} }
}, },
"node_modules/crossws": { "node_modules/crossws": {
"version": "0.2.4", "version": "0.3.4",
"resolved": "https://registry.npmjs.org/crossws/-/crossws-0.2.4.tgz", "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.4.tgz",
"integrity": "sha512-DAxroI2uSOgUKLz00NX6A8U/8EE3SZHmIND+10jkVSaypvyt57J5JEOxAQOL6lQxyzi/wZbTIwssU1uy69h5Vg==", "integrity": "sha512-uj0O1ETYX1Bh6uSgktfPvwDiPYGQ3aI4qVsaC/LWpkIzGj1nUYm5FK3K+t11oOlpN01lGbprFCH4wBlKdJjVgw==",
"license": "MIT", "dependencies": {
"peerDependencies": { "uncrypto": "^0.1.3"
"uWebSockets.js": "*"
},
"peerDependenciesMeta": {
"uWebSockets.js": {
"optional": true
}
} }
}, },
"node_modules/csstype": { "node_modules/csstype": {
@ -1598,17 +1560,7 @@
"node_modules/defu": { "node_modules/defu": {
"version": "6.1.4", "version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
}, },
"node_modules/destr": { "node_modules/destr": {
"version": "2.0.3", "version": "2.0.3",
@ -1634,10 +1586,9 @@
} }
}, },
"node_modules/discord-api-types": { "node_modules/discord-api-types": {
"version": "0.37.101", "version": "0.37.119",
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.101.tgz", "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.119.tgz",
"integrity": "sha512-2wizd94t7G3A8U5Phr3AiuL4gSvhqistDwWnlk1VLTit8BI1jWUncFqFQNdPbHqS3661+Nx/iEyIwtVjPuBP3w==", "integrity": "sha512-WasbGFXEB+VQWXlo6IpW3oUv73Yuau1Ig4AZF/m13tXcTKnMpc/mHjpztIlz4+BM9FG9BHQkEXiPto3bKduQUg=="
"license": "MIT"
}, },
"node_modules/doctypes": { "node_modules/doctypes": {
"version": "1.1.0", "version": "1.1.0",
@ -1797,20 +1748,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"dev": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fs-constants": { "node_modules/fs-constants": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@ -1843,6 +1780,11 @@
"node": "6.* || 8.* || >= 10.*" "node": "6.* || 8.* || >= 10.*"
} }
}, },
"node_modules/get-relative-path": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/get-relative-path/-/get-relative-path-1.0.2.tgz",
"integrity": "sha512-dGkopYfmB4sXMTcZslq5SojEYakpdCSj/SVSHLhv7D6RBHzvDtd/3Q8lTEOAhVKxPPeAHu/YYkENbbz3PaH+8w=="
},
"node_modules/get-source": { "node_modules/get-source": {
"version": "2.0.12", "version": "2.0.12",
"resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz",
@ -1891,21 +1833,20 @@
} }
}, },
"node_modules/h3": { "node_modules/h3": {
"version": "1.12.0", "version": "1.15.0",
"resolved": "https://registry.npmjs.org/h3/-/h3-1.12.0.tgz", "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.0.tgz",
"integrity": "sha512-Zi/CcNeWBXDrFNlV0hUBJQR9F7a96RjMeAZweW/ZWkR9fuXrMcvKnSA63f/zZ9l0GgQOZDVHGvXivNN9PWOwhA==", "integrity": "sha512-OsjX4JW8J4XGgCgEcad20pepFQWnuKH+OwkCJjogF3C+9AZ1iYdtB4hX6vAb5DskBiu5ljEXqApINjR8CqoCMQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"cookie-es": "^1.1.0", "cookie-es": "^1.2.2",
"crossws": "^0.2.4", "crossws": "^0.3.3",
"defu": "^6.1.4", "defu": "^6.1.4",
"destr": "^2.0.3", "destr": "^2.0.3",
"iron-webcrypto": "^1.1.1", "iron-webcrypto": "^1.2.1",
"ohash": "^1.1.3", "node-mock-http": "^1.0.0",
"ohash": "^1.1.4",
"radix3": "^1.1.2", "radix3": "^1.1.2",
"ufo": "^1.5.3", "ufo": "^1.5.4",
"uncrypto": "^0.1.3", "uncrypto": "^0.1.3"
"unenv": "^1.9.0"
} }
}, },
"node_modules/has-flag": { "node_modules/has-flag": {
@ -1930,11 +1871,15 @@
} }
}, },
"node_modules/heatsync": { "node_modules/heatsync": {
"version": "2.5.5", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/heatsync/-/heatsync-2.5.5.tgz", "resolved": "https://registry.npmjs.org/heatsync/-/heatsync-2.7.2.tgz",
"integrity": "sha512-Sy2/X2a69W2W1xgp7GBY81naHtWXxwV8N6uzPTJLQXgq4oTMJeL6F/AUlGS+fUa/Pt5ioxzi7gvd8THMJ3GpyA==", "integrity": "sha512-1djRg4eufv5q+CRy5SuZSiV3j53KIDSGkDubJB+vXY1OE+AnTkw5HIJxi+0vEjHjX+wbH5syYQunQ/ElAgoEmg==",
"license": "MIT",
"dependencies": { "dependencies": {
"backtracker": "^4.0.0" "backtracker": "^4.0.0"
},
"engines": {
"node": ">=14.6.0"
} }
}, },
"node_modules/html-es6cape": { "node_modules/html-es6cape": {
@ -1948,6 +1893,11 @@
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true "dev": true
}, },
"node_modules/htmx.org": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.4.tgz",
"integrity": "sha512-HLxMCdfXDOJirs3vBZl/ZLoY+c7PfM4Ahr2Ad4YXh6d22T5ltbTXFFkpx9Tgb2vvmWFMbIc3LqN2ToNkZJvyYQ=="
},
"node_modules/ieee754": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@ -2150,18 +2100,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/mime-db": { "node_modules/mime-db": {
"version": "1.52.0", "version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@ -2246,30 +2184,10 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/node-fetch": { "node_modules/node-mock-http": {
"version": "2.7.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "integrity": "sha512-0uGYQ1WQL1M5kKvGRXWQ3uZCHtLTO8hln3oBjIusM75WoesZ909uQJs/Hb946i2SS+Gsrhkaa6iAO17jRIv6DQ=="
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-fetch-native": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.4.tgz",
"integrity": "sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==",
"license": "MIT"
}, },
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
@ -2280,10 +2198,9 @@
} }
}, },
"node_modules/ohash": { "node_modules/ohash": {
"version": "1.1.3", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-1.1.3.tgz", "resolved": "https://registry.npmjs.org/ohash/-/ohash-1.1.4.tgz",
"integrity": "sha512-zuHHiGTYTA1sYJ/wZN+t5HKZaH23i4yI1HMwbuXm24Nid7Dv0KcuRlKoNKS9UNfAVSBlnGLcuQrnOKWOZoEGaw==", "integrity": "sha512-FlDryZAahJmEF3VR3w1KogSEdWX3WhA5GPakFx4J81kEAiHyLMpdLLElS8n8dfNadMgAne/MywcvmogzscVt4g=="
"license": "MIT"
}, },
"node_modules/once": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
@ -2370,12 +2287,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"license": "MIT"
},
"node_modules/peek-readable": { "node_modules/peek-readable": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz",
@ -2776,16 +2687,15 @@
} }
}, },
"node_modules/snowtransfer": { "node_modules/snowtransfer": {
"version": "0.10.7", "version": "0.12.0",
"resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.10.7.tgz", "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.12.0.tgz",
"integrity": "sha512-lXUYp6jOou0DI8uFl3Dh78KD1gVa3dNbUt2TK6RW39mHenAR6XpoPoydUNXCWvdxi6uGU6zQ1yNICZpKjF6wMA==", "integrity": "sha512-EmVTAeSXtA7ZlTqwmZxe5JwRTm4FOXEOqMOzGu8fdVSoqXjcWgQ8IfaIRu/54FamOMjOmcxnpTyNPj5MUqWxpA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"discord-api-types": "^0.37.98", "discord-api-types": "^0.37.119"
"undici": "^6.19.8"
}, },
"engines": { "engines": {
"node": ">=14.18.0" "node": ">=16.15.0"
} }
}, },
"node_modules/source-map": { "node_modules/source-map": {
@ -2987,11 +2897,10 @@
} }
}, },
"node_modules/supertape": { "node_modules/supertape": {
"version": "10.7.3", "version": "10.10.0",
"resolved": "https://registry.npmjs.org/supertape/-/supertape-10.7.3.tgz", "resolved": "https://registry.npmjs.org/supertape/-/supertape-10.10.0.tgz",
"integrity": "sha512-t/zv0sev+5261g9KampNVL7io8UQ7zmouRWt9/UU+Yr7Ap0MqBKlyDFFvkzcfADT+O6bXZMW5x3nzYzSU+LAYg==", "integrity": "sha512-Zxww3DePaNlRJgy4XVukEU98254DWwNbV0Ch1jJcCWZxD0AJM9fIJG1bbFmVXXdYe0G0+YnpfrP12nVM2K+cEg==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@cloudcmd/stub": "^4.0.0", "@cloudcmd/stub": "^4.0.0",
"@putout/cli-keypress": "^2.0.0", "@putout/cli-keypress": "^2.0.0",
@ -3158,11 +3067,6 @@
"url": "https://github.com/sponsors/Borewit" "url": "https://github.com/sponsors/Borewit"
} }
}, },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"node_modules/try-catch": { "node_modules/try-catch": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/try-catch/-/try-catch-3.0.1.tgz", "resolved": "https://registry.npmjs.org/try-catch/-/try-catch-3.0.1.tgz",
@ -3210,34 +3114,12 @@
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/undici": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.19.8.tgz",
"integrity": "sha512-U8uCCl2x9TK3WANvmBavymRzxbfFYG+tAu+fgx3zxQy3qdagQqBLwJVrdyO1TBfUXvfKveMKJZhpvUYoOjM+4g==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "5.26.5", "version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true "dev": true
}, },
"node_modules/unenv": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/unenv/-/unenv-1.10.0.tgz",
"integrity": "sha512-wY5bskBQFL9n3Eca5XnhH6KbUo/tfvkwm9OpcdCvLaeA7piBNbavbOKJySEwQ1V0RH6HvNlSAFRTpvTqgKRQXQ==",
"license": "MIT",
"dependencies": {
"consola": "^3.2.3",
"defu": "^6.1.4",
"mime": "^3.0.0",
"node-fetch-native": "^1.6.4",
"pathe": "^1.1.2"
}
},
"node_modules/uqr": { "node_modules/uqr": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.2.tgz", "resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.2.tgz",
@ -3271,20 +3153,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -3406,9 +3274,9 @@
"dev": true "dev": true
}, },
"node_modules/xxhash-wasm": { "node_modules/xxhash-wasm": {
"version": "1.0.2", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.0.2.tgz", "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz",
"integrity": "sha512-ibF0Or+FivM9lNrg+HGJfVX8WJqgo+kCLDc4vx6xMeTce7Aj+DLttKbxxRR/gNLSAelRc1omAPlJ77N/Jem07A==" "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="
}, },
"node_modules/y18n": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
@ -3459,9 +3327,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "3.23.8", "version": "3.24.2",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",
"integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"

View file

@ -1,6 +1,6 @@
{ {
"name": "out-of-your-element", "name": "out-of-your-element",
"version": "1.1.1", "version": "3.0.0",
"description": "A bridge between Matrix and Discord", "description": "A bridge between Matrix and Discord",
"main": "index.js", "main": "index.js",
"repository": { "repository": {
@ -23,28 +23,30 @@
"@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.0.0", "@cloudrac3r/in-your-element": "^1.0.0",
"@cloudrac3r/mixin-deep": "^3.0.0", "@cloudrac3r/mixin-deep": "^3.0.1",
"@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.7", "@stackoverflow/stacks": "^2.5.4",
"@stackoverflow/stacks-icons": "^6.0.2", "@stackoverflow/stacks-icons": "^6.0.2",
"ansi-colors": "^4.1.3", "ansi-colors": "^4.1.3",
"better-sqlite3": "^11.1.2", "better-sqlite3": "^11.1.2",
"chunk-text": "^2.0.1", "chunk-text": "^2.0.1",
"cloudstorm": "^0.10.10", "cloudstorm": "^0.11.2",
"discord-api-types": "^0.37.119",
"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", "get-stream": "^6.0.1",
"h3": "^1.12.0", "h3": "^1.12.0",
"heatsync": "^2.5.5", "heatsync": "^2.7.2",
"htmx.org": "^2.0.4",
"lru-cache": "^10.4.3", "lru-cache": "^10.4.3",
"minimist": "^1.2.8", "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.10.5", "snowtransfer": "^0.12.0",
"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", "uqr": "^0.1.2",
@ -54,17 +56,15 @@
"devDependencies": { "devDependencies": {
"@cloudrac3r/tap-dot": "^2.0.3", "@cloudrac3r/tap-dot": "^2.0.3",
"@types/node": "^18.16.0", "@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",
"discord-api-types": "^0.37.60",
"supertape": "^10.4.0" "supertape": "^10.4.0"
}, },
"scripts": { "scripts": {
"start": "node start.js", "start": "node --enable-source-maps start.js",
"setup": "node scripts/setup.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 test/test.js | tap-dot", "test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js | tap-dot",
"test-slow": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js -- --slow | tap-dot", "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/matrix/mreq.js -x src/d2m/converters/rlottie-wasm.js -r html -r text supertape --no-check-assertions-count --format fail --no-worker test/test.js -- --slow" "cover": "c8 -o test/coverage --skip-full -x db/migrations -x src/matrix/file.js -x src/matrix/api.js -x src/matrix/mreq.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"
} }

184
readme.md
View file

@ -6,15 +6,11 @@ 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)
## 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. * Efficient: Special attention has been given to memory usage, database indexes, disk footprint, runtime algorithms, and queries to the homeserver. [Efficiency details.](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/developer-orientation.md)
* Reliable: Any errors on either side are notified on Matrix and can be retried. * 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.
* 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.
@ -22,27 +18,17 @@ This readme has the most important info. The rest is [in the docs folder.](https
## What works? ## What works?
Most features you'd expect in both directions, plus a little extra spice: 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.
* Messages 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.
* Edits
* Deletions I've also added some interesting features that I haven't seen in any other bridge:
* Text formatting, including spoilers
* Reactions * Members using the PluralKit bot each get their own persistent accounts
* Mentions * Replies from PluralKit members are restyled into native Matrix replies
* 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)
@ -51,150 +37,6 @@ 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.
## Efficiency details ## Get started!
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. [Read the installation instructions →](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/get-started.md)
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 `npm run setup` 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 run 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 set up prerequisites on the Matrix server:
├── setup.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) 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

@ -0,0 +1,77 @@
// @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

@ -115,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) VALUES (?, ?, ?, ?, ?)").run(channel.id, row.matrix_id, channel.name, preMigrationRow.nick, preMigrationRow.custom_avatar) 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)
console.log(`-- -- Added to database (transferred properties from previous OOYE room)`) console.log(`-- -- Added to database (transferred properties from previous OOYE room)`)
})() })()
} else { } else {

View file

@ -11,10 +11,10 @@ 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"]}) const args = require("minimist")(process.argv.slice(2), {string: ["emoji-guild"]})
@ -68,10 +68,7 @@ async function uploadAutoEmoji(snow, guild, name, filename) {
return emoji return emoji
} }
async function validateHomeserverOrigin(serverUrlPrompt, url) { async function suggestWellKnown(serverUrlPrompt, url, otherwise) {
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(/\/$/, "")
@ -80,23 +77,39 @@ async function validateHomeserverOrigin(serverUrlPrompt, url) {
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 `There is no Matrix server at that URL (${url}/_matrix/client/versions is not JSON)` return suggestWellKnown(serverUrlPrompt, url, `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,13 +134,14 @@ async function validateHomeserverOrigin(serverUrlPrompt, url) {
const serverOrigin = await serverOriginPrompt.run() const serverOrigin = await serverOriginPrompt.run()
const app = createApp() const app = createApp()
app.use(defineEventHandler(() => "Out Of Your Element is listening.\n")) app.use(defineEchoHandler())
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({
@ -141,7 +155,7 @@ async function validateHomeserverOrigin(serverUrlPrompt, url) {
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 !== "Out Of Your Element is listening.\n") return `Server does not point to OOYE` if (!text.startsWith("Out Of Your Element is listening.")) return `Server does not point to OOYE`
return true return true
} catch (e) { } catch (e) {
return e.message return e.message
@ -153,9 +167,10 @@ async function validateHomeserverOrigin(serverUrlPrompt, url) {
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 /** @type {SnowTransfer} */ // @ts-ignore
let snow = null let snow = null
/** @type {{id: string, redirect_uris: string[]}} */ // @ts-ignore /** @type {{id: string, flags: number, redirect_uris: string[]}} */ // @ts-ignore
let client = null let client = null
/** @type {{discord_token: string}} */ /** @type {{discord_token: string}} */
const discordTokenResponse = await prompt({ const discordTokenResponse = await prompt({
@ -166,7 +181,7 @@ async function validateHomeserverOrigin(serverUrlPrompt, url) {
process.stdout.write(magenta(" checking, please wait...")) process.stdout.write(magenta(" checking, please wait..."))
try { try {
snow = new SnowTransfer(token) snow = new SnowTransfer(token)
client = await snow.requestHandler.request(`/applications/@me`, {}, "get") client = await snow.requestHandler.request(`/applications/@me`, {}, "get", "json")
return true return true
} catch (e) { } catch (e) {
return e.message return e.message
@ -174,8 +189,36 @@ async function validateHomeserverOrigin(serverUrlPrompt, url) {
} }
}) })
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("What is your Discord client secret?") console.log("What is your Discord client secret?")
console.log(`You can find it on the application page: https://discord.com/developers/applications/${client.id}/oauth2`) 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",
@ -185,14 +228,14 @@ async function validateHomeserverOrigin(serverUrlPrompt, url) {
const expectedUri = `${bridgeOriginResponse.bridge_origin}/oauth` const expectedUri = `${bridgeOriginResponse.bridge_origin}/oauth`
if (!client.redirect_uris.includes(expectedUri)) { if (!client.redirect_uris.includes(expectedUri)) {
console.log(`On the same application page, go to the Redirects section, and add this URI: ${cyan(expectedUri)}`) console.log(`On that same page, scroll down to Redirects and add this URI: ${cyan(expectedUri)}`)
await prompt({ await prompt({
type: "invisible", type: "invisible",
name: "redirect_uri", name: "redirect_uri",
message: "Press Enter when you've added it", message: "Press Enter when you've added it",
validate: async token => { validate: async token => {
process.stdout.write(magenta("checking, please wait...")) process.stdout.write(magenta("checking, please wait..."))
client = await snow.requestHandler.request(`/applications/@me`, {}, "get") client = await snow.requestHandler.request(`/applications/@me`, {}, "get", "json")
if (client.redirect_uris.includes(expectedUri)) { if (client.redirect_uris.includes(expectedUri)) {
return true return true
} else { } else {
@ -211,17 +254,27 @@ async function validateHomeserverOrigin(serverUrlPrompt, url) {
...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(`Registration file saved as ${registrationFilePath}`) console.log(`Your responses have been saved as ${registrationFilePath}`)
} else { } else {
console.log(`✅ Valid registration file found at ${registrationFilePath}`) try {
checkRegistration(reg)
console.log(`✅ Skipped questions - reusing data from ${registrationFilePath}`)
} catch (e) {
console.log(`❌ Failed to reuse data from ${registrationFilePath}`)
console.log("Consider deleting this file. You can re-run setup to safely make a new one.")
console.log("")
console.log(e.toString().replace(/^ *\n/gm, ""))
process.exit(1)
}
} }
console.log(` In ${cyan("Synapse")}, you need to 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")
@ -230,13 +283,18 @@ async function validateHomeserverOrigin(serverUrlPrompt, url) {
// 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")
console.log("⏳ Waiting until homeserver registration works... (Ctrl+C to cancel)") as.router.use("/**", defineEchoHandler())
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
@ -267,35 +325,17 @@ async function validateHomeserverOrigin(serverUrlPrompt, url) {
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...
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) const client = await discord.snow.user.getSelf()
db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING").run(client.id, client.username, reg.sender_localpart.slice(reg.ooye.namespace_prefix.length), mxid)
console.log("✅ Database is ready...") 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")

View file

@ -38,4 +38,6 @@ 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

@ -6,15 +6,21 @@ 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} = passthrough const {discord, sync, db, select, from} = 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 utils = sync.require("../../discord/utils") const dUtils = sync.require("../../discord/utils")
/** @type {import("../../m2d/converters/utils")} */
const mUtils = sync.require("../../m2d/converters/utils")
/** @type {import("./create-space")} */
const createSpace = sync.require("./create-space")
/** /**
* There are 3 levels of room privacy: * There are 3 levels of room privacy:
@ -24,7 +30,7 @@ const utils = sync.require("../../discord/utils")
*/ */
const PRIVACY_ENUMS = { const PRIVACY_ENUMS = {
PRESET: ["private_chat", "public_chat", "public_chat"], PRESET: ["private_chat", "public_chat", "public_chat"],
VISIBILITY: ["private", "private", "public"], VISIBILITY: ["private", "private", "private"],
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
@ -37,26 +43,6 @@ const DEFAULT_PRIVACY_LEVEL = 0
/** @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
@ -93,11 +79,9 @@ 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)
const guildRow = select("guild_space", ["space_id", "privacy_level"], {guild_id: guild.id}).get()
assert(guildRow)
/** Used for membership/permission checks. */ /** Used for membership/permission checks. */
let guildSpaceID = guildRow.space_id const guildSpaceID = await createSpace.ensureSpace(guild)
/** Used as the literal parent on Matrix, for categorisation. Will be the same as `guildSpaceID` unless it's a forum channel's thread, in which case a different space is used to group those threads. */ /** 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 = guildSpaceID
if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum) { if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum) {
@ -105,9 +89,10 @@ async function channelToKState(channel, guild, di) {
assert(typeof parentSpaceID === "string") assert(typeof parentSpaceID === "string")
} }
const channelRow = select("channel_room", ["nick", "custom_avatar"], {channel_id: channel.id}).get() const channelRow = select("channel_room", ["nick", "custom_avatar", "custom_topic"], {channel_id: channel.id}).get()
const customName = channelRow?.nick const customName = channelRow?.nick
const customAvatar = channelRow?.custom_avatar const customAvatar = channelRow?.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 = {}
@ -117,7 +102,8 @@ async function channelToKState(channel, guild, di) {
avatarEventContent.url = {$url: file.guildIcon(guild)} avatarEventContent.url = {$url: file.guildIcon(guild)}
} }
const privacyLevel = guildRow.privacy_level 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"
@ -133,8 +119,9 @@ 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 = utils.getPermissions([], guild.roles, undefined, channel.permission_overwrites) const everyonePermissions = dUtils.getPermissions([], guild.roles, undefined, channel.permission_overwrites)
const everyoneCanMentionEveryone = utils.hasAllPermissions(everyonePermissions, ["MentionEveryone"]) const everyoneCanSend = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendMessages)
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), {})
@ -143,6 +130,7 @@ 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},
@ -155,14 +143,18 @@ 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 : 50,
events: {
"m.reaction": 0
},
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}`,
@ -183,6 +175,8 @@ async function channelToKState(channel, guild, di) {
} }
} }
if (hasCustomTopic) delete channelKState["m.room.topic/"]
return {spaceID: parentSpaceID, privacyLevel, channelKState} return {spaceID: parentSpaceID, privacyLevel, channelKState}
} }
@ -247,15 +241,16 @@ 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 roomToKState(roomID) const newRoomKState = await ks.roomToKState(roomID)
const newRoomPowerLevelsDiff = ks.diffKState(newRoomKState, {"m.room.power_levels/": powerLevelContent}) const newRoomPowerLevelsDiff = ks.diffKState(newRoomKState, {"m.room.power_levels/": powerLevelContent})
await applyKStateDiffToRoom(roomID, newRoomPowerLevelsDiff) await ks.applyKStateDiffToRoom(roomID, newRoomPowerLevelsDiff)
} }
return roomID return roomID
@ -384,7 +379,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 roomToKState(roomID) const roomKState = await ks.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/
@ -392,7 +387,7 @@ 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 = applyKStateDiffToRoom(roomID, roomDiff) const roomApply = ks.applyKStateDiffToRoom(roomID, roomDiff)
db.prepare("UPDATE channel_room SET name = ? WHERE room_id = ?").run(channel.name, roomID) db.prepare("UPDATE channel_room SET name = ? WHERE room_id = ?").run(channel.name, roomID)
// sync room as space member // sync room as space member
@ -412,7 +407,7 @@ function syncRoom(channelID) {
return _syncRoom(channelID, true) return _syncRoom(channelID, true)
} }
async function _unbridgeRoom(channelID) { async function unbridgeChannel(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)
@ -421,37 +416,86 @@ async function _unbridgeRoom(channelID) {
} }
/** /**
* @param {{id: string, topic?: string?}} channel * @param {{id: string, topic?: string?}} channel channel-ish (just needs an id, topic is optional)
* @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 spaceID = select("guild_space", "space_id", {guild_id: guildID}).pluck().get() const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").get()
assert.ok(spaceID) assert.ok(row)
// remove room from being a space member let botInRoom = true
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
await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channel.id}`, {}) try {
if ("topic" in channel) { await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channel.id}`, {})
} catch (e) {
if (String(e).includes("not in room")) {
botInRoom = false
} else {
throw e
}
}
if (botInRoom && "topic" in channel) {
// 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)
} }
/** /**
@ -462,7 +506,7 @@ async function unbridgeDeletedChannel(channel, guildID) {
* @returns {Promise<string[]>} * @returns {Promise<string[]>}
*/ */
async function _syncSpaceMember(channel, spaceID, roomID) { async function _syncSpaceMember(channel, spaceID, roomID) {
const spaceKState = await roomToKState(spaceID) 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)
@ -475,7 +519,7 @@ async function _syncSpaceMember(channel, spaceID, roomID) {
const spaceDiff = ks.diffKState(spaceKState, { const spaceDiff = ks.diffKState(spaceKState, {
[`m.space.child/${roomID}`]: spaceEventContent [`m.space.child/${roomID}`]: spaceEventContent
}) })
return applyKStateDiffToRoom(spaceID, spaceDiff) return ks.applyKStateDiffToRoom(spaceID, spaceDiff)
} }
async function createAllForGuild(guildID) { async function createAllForGuild(guildID) {
@ -498,11 +542,9 @@ 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._unbridgeRoom = _unbridgeRoom module.exports.unbridgeChannel = unbridgeChannel
module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel
module.exports.existsOrAutocreatable = existsOrAutocreatable module.exports.existsOrAutocreatable = existsOrAutocreatable
module.exports.assertExistsOrAutocreatable = assertExistsOrAutocreatable 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, "!jjWAGMeQdNrVZSSfvz:cadence.moe") t.equal(roomID, "!jjmvBegULiLucuWEHU: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, "!jjWAGMeQdNrVZSSfvz:cadence.moe") t.equal(roomID, "!jjmvBegULiLucuWEHU: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, "!jjWAGMeQdNrVZSSfvz:cadence.moe") t.equal(roomID, "!jjmvBegULiLucuWEHU: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, "!jjWAGMeQdNrVZSSfvz:cadence.moe") t.equal(roomID, "!jjmvBegULiLucuWEHU: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,6 +94,108 @@ 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
},
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,6 +31,8 @@ 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 => {
@ -50,7 +52,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) VALUES (?, ?)").run(guild.id, roomID) db.prepare("INSERT INTO guild_space (guild_id, space_id, presence) VALUES (?, ?, ?)").run(guild.id, roomID, enablePresenceByDefault)
return roomID return roomID
} }
@ -93,7 +95,7 @@ async function _syncSpace(guild, shouldActuallySync) {
if (!row) { if (!row) {
const autocreate = select("guild_active", "autocreate", {guild_id: guild.id}).pluck().get() const autocreate = select("guild_active", "autocreate", {guild_id: guild.id}).pluck().get()
assert.equal(autocreate, 1, `refusing to implicitly create guild ${guild.id}. set the guild_active data first before calling ensureSpace/syncSpace.`) 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
@ -116,9 +118,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 createRoom.roomToKState(spaceID) const spaceKState = await ks.roomToKState(spaceID)
const spaceDiff = ks.diffKState(spaceKState, guildKState) const spaceDiff = ks.diffKState(spaceKState, guildKState)
await createRoom.applyKStateDiffToRoom(spaceID, spaceDiff) await ks.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
@ -183,9 +185,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 createRoom.roomToKState(spaceID) const spaceKState = await ks.roomToKState(spaceID)
const spaceDiff = ks.diffKState(spaceKState, guildKState) const spaceDiff = ks.diffKState(spaceKState, guildKState)
await createRoom.applyKStateDiffToRoom(spaceID, spaceDiff) await ks.applyKStateDiffToRoom(spaceID, spaceDiff)
const childRooms = await api.getFullHierarchy(spaceID) const childRooms = await api.getFullHierarchy(spaceID)
@ -232,11 +234,11 @@ async function syncSpaceExpressions(data, checkBeforeSync) {
} }
if (isDeepStrictEqual(existing, content)) return if (isDeepStrictEqual(existing, content)) return
} }
api.sendState(spaceID, "im.ponies.room_emotes", eventKey, content) await api.sendState(spaceID, "im.ponies.room_emotes", eventKey, content)
} }
update(spaceID, "emojis", "moe.cadence.ooye.pack.emojis", expression.emojisToState) await update(spaceID, "emojis", "moe.cadence.ooye.pack.emojis", expression.emojisToState)
update(spaceID, "stickers", "moe.cadence.ooye.pack.stickers", expression.stickersToState) await update(spaceID, "stickers", "moe.cadence.ooye.pack.stickers", expression.stickersToState)
} }
module.exports.createSpace = createSpace module.exports.createSpace = createSpace

View file

@ -16,7 +16,6 @@ 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)
@ -35,7 +34,6 @@ 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

@ -61,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("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id) db.prepare("INSERT OR IGNORE 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 (readable stream) // Convert to PNG (stream.Readable)
const readablePng = await convertLottie.convert(text) const readablePng = await convertLottie.convert(text)
// Upload to MXC // Upload to MXC

View file

@ -3,10 +3,9 @@
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 {discord, sync, db, select} = passthrough const {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")} */
@ -33,7 +32,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, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(pkMessage.member.uuid, simName, localpart, mxid) db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?)").run(pkMessage.member.uuid, simName, simName, mxid)
// Register matrix user with that name // Register matrix user with that name
try { try {
@ -146,15 +145,20 @@ 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. // Their backend is weird. Sometimes it says "message not found" (code 20006) on the first try, so we make multiple attempts.
let attempts = 0 let attempts = 0
do { do {
var res = await fetch(`https://api.pluralkit.me/v2/messages/${messageID}`) try {
if (res.ok) return res.json() var res = await fetch(`https://api.pluralkit.me/v2/messages/${messageID}`)
if (res.ok) return res.json()
var errorGetter = res.json
} catch (e) {
// Catch any network issues too.
errorGetter = e.toString
}
// I think the backend needs some time to update. // I think the backend needs some time to update.
await new Promise(resolve => setTimeout(resolve, 2000)) await new Promise(resolve => setTimeout(resolve, 1500))
} while (++attempts < 3) } while (++attempts < 3)
const errorMessage = await res.json() throw new Error(`PK API returned an error after ${attempts} tries: ${JSON.stringify(await errorGetter())}`)
throw new Error(`PK API returned an error after ${attempts} tries: ${JSON.stringify(errorMessage)}`)
} }
module.exports._memberToStateContent = memberToStateContent module.exports._memberToStateContent = memberToStateContent

View file

@ -33,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, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(user.id, simName, localpart, mxid) db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?)").run(user.id, user.username, simName, mxid)
// Register matrix user with that name // Register matrix user with that name
try { try {

View file

@ -53,7 +53,7 @@ async function removeReaction(data, reactions) {
*/ */
async function removeEmojiReaction(data, reactions) { async function removeEmojiReaction(data, reactions) {
const key = await emojiToKey.emojiToKey(data.emoji) const key = await emojiToKey.emojiToKey(data.emoji)
const discordPreferredEncoding = emoji.encodeEmoji(key, undefined) const discordPreferredEncoding = await 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

@ -47,8 +47,8 @@ async function sendMessage(message, channel, guild, row) {
const events = await messageToEvent.messageToEvent(message, guild, {}, {api}) const events = await messageToEvent.messageToEvent(message, guild, {}, {api})
const eventIDs = [] const eventIDs = []
if (events.length) { if (events.length) {
db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id) db.prepare("INSERT OR IGNORE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id)
if (senderMxid) api.sendTyping(roomID, false, senderMxid) if (senderMxid) api.sendTyping(roomID, false, senderMxid).catch(() => {})
} }
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

@ -0,0 +1,114 @@
// @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,6 +6,8 @@ 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
@ -23,13 +25,13 @@ function convertTimestamp(timestamp) {
* @param {number?} convertedTimestamp * @param {number?} convertedTimestamp
*/ */
async function updatePins(channelID, roomID, convertedTimestamp) { async function updatePins(channelID, roomID, convertedTimestamp) {
const pins = await discord.snow.channel.getChannelPinnedMessages(channelID) const discordPins = await discord.snow.channel.getChannelPinnedMessages(channelID)
const eventIDs = pinsToList.pinsToList(pins) const pinned = pinsToList.pinsToList(discordPins)
if (pins.length === eventIDs.length || eventIDs.length) {
await api.sendState(roomID, "m.room.pinned_events", "", { const kstate = await ks.roomToKState(roomID)
pinned: eventIDs 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

@ -21,7 +21,7 @@ const Rlottie = (async () => {
/** /**
* @param {string} text * @param {string} text
* @returns {Promise<import("stream").Readable>} * @returns {Promise<stream.Readable>}
*/ */
async function convert(text) { async function convert(text) {
const r = await Rlottie const r = await Rlottie
@ -41,6 +41,7 @@ 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 Ty = require("../../types") const {db} = require("../../passthrough")
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,7 +33,9 @@ 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: "| ## ⏺️ dynastic (@dynastic) https://twitter.com/i/user/719631291747078145" body: "> In reply to an unbridged message:"
+ "\n> PokemonGod: https://twitter.com/dynastic/status/1707484191963648161"
+ "\n\n| ## ⏺️ dynastic (@dynastic) https://twitter.com/i/user/719631291747078145"
+ "\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"
@ -41,7 +43,8 @@ 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><p><strong><a href="https://twitter.com/i/user/719631291747078145">⏺️ dynastic (@dynastic)</a></strong>' 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>'
+ '<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>'
}]) }])
@ -351,3 +354,16 @@ 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

@ -176,7 +176,7 @@ async function attachmentToEvent(mentions, attachment) {
info: { info: {
mimetype: attachment.content_type, mimetype: attachment.content_type,
size: attachment.size, size: attachment.size,
duration: attachment.duration_secs ? attachment.duration_secs * 1000 : undefined duration: attachment.duration_secs ? Math.round(attachment.duration_secs * 1000) : undefined
} }
} }
} else { } else {
@ -199,10 +199,11 @@ async function attachmentToEvent(mentions, attachment) {
/** /**
* @param {DiscordTypes.APIMessage} message * @param {DiscordTypes.APIMessage} message
* @param {DiscordTypes.APIGuild} guild * @param {DiscordTypes.APIGuild} guild
* @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean, alwaysReturnFormattedBody?: boolean}} options default values: * @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean, alwaysReturnFormattedBody?: boolean, scanTextForMentions?: 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. * - alwaysReturnFormattedBody: false - formatted_body will be skipped if it is the same as body because the message is plaintext. if you want the formatted_body to be returned anyway, for example to merge it with another message, then set this to true.
* - scanTextForMentions: true - needs to be set to false when converting forwarded messages etc which may be from a different channel that can't be scanned.
* @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API * @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API
*/ */
async function messageToEvent(message, guild, options = {}, di) { async function messageToEvent(message, guild, options = {}, di) {
@ -261,7 +262,9 @@ 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
@ -277,6 +280,8 @@ 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
@ -450,7 +455,7 @@ async function messageToEvent(message, guild, options = {}, di) {
// 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 && options.includeReplyFallback !== false) { if ((repliedToEventRow || repliedToUnknownEvent) && options.includeReplyFallback !== false) {
let repliedToDisplayName let repliedToDisplayName
let repliedToUserHtml let repliedToUserHtml
if (repliedToEventRow?.source === 0 && repliedToEventSenderMxid) { if (repliedToEventRow?.source === 0 && repliedToEventSenderMxid) {
@ -480,12 +485,33 @@ async function messageToEvent(message, guild, options = {}, di) {
discordOnly: true, discordOnly: true,
escapeHTML: false, escapeHTML: false,
}) })
html = `<mx-reply><blockquote><a href="https://matrix.to/#/${repliedToEventRow.room_id}/${repliedToEventRow.event_id}">In reply to</a> ${repliedToUserHtml}` if (repliedToEventRow) {
+ `<br>${repliedToHtml}</blockquote></mx-reply>` // Generate a reply pointing to the Matrix event we found
+ html html = `<mx-reply><blockquote><a href="https://matrix.to/#/${repliedToEventRow.room_id}/${repliedToEventRow.event_id}">In reply to</a> ${repliedToUserHtml}`
body = (`${repliedToDisplayName}: ` // scenario 1 part B for mentions + `<br>${repliedToHtml}</blockquote></mx-reply>`
+ repliedToBody).split("\n").map(line => "> " + line).join("\n") + html
+ "\n\n" + body body = (`${repliedToDisplayName}: ` // scenario 1 part B for mentions
+ repliedToBody).split("\n").map(line => "> " + line).join("\n")
+ "\n\n" + body
} else { // repliedToUnknownEvent
// This reply can't point to the Matrix event because it isn't bridged, we need to indicate this.
assert(message.referenced_message)
const dateDifference = new Date(message.timestamp).getTime() - new Date(message.referenced_message.timestamp).getTime()
const oneHour = 60 * 60 * 1000
if (dateDifference < oneHour) {
var dateDisplay = "n"
} else if (dateDifference < 25 * oneHour) {
var dateDisplay = ` ${Math.floor(dateDifference / oneHour)}-hour-old`
} else {
var dateDisplay = ` ${Math.round(dateDifference / (24 * oneHour))}-day-old`
}
html = `<blockquote>In reply to a${dateDisplay} unbridged message from ${repliedToDisplayName}:`
+ `<br>${repliedToHtml}</blockquote>`
+ html
body = (`In reply to a${dateDisplay} unbridged message:\n${repliedToDisplayName}: `
+ repliedToBody).split("\n").map(line => "> " + line).join("\n")
+ "\n\n" + body
}
} }
const newTextMessageEvent = { const newTextMessageEvent = {
@ -544,7 +570,7 @@ async function messageToEvent(message, guild, options = {}, di) {
// Forwarded content // Forwarded content
// @ts-ignore // @ts-ignore
const forwardedEvents = await messageToEvent(message.message_snapshots[0].message, guild, {includeReplyFallback: false, includeEditFallbackStar: false, alwaysReturnFormattedBody: true}, di) const forwardedEvents = await messageToEvent(message.message_snapshots[0].message, guild, {includeReplyFallback: false, includeEditFallbackStar: false, alwaysReturnFormattedBody: true, scanTextForMentions: false}, di)
// Indent // Indent
for (const event of forwardedEvents) { for (const event of forwardedEvents) {
@ -570,7 +596,7 @@ async function messageToEvent(message, guild, options = {}, di) {
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 (matches.length && matches.some(m => m[1].match(/[a-z]/i) && m[1] !== "everyone" && m[1] !== "here")) { if (options.scanTextForMentions !== false && matches.length && matches.some(m => m[1].match(/[a-z]/i) && m[1] !== "everyone" && m[1] !== "here")) {
const writtenMentionsText = matches.map(m => m[1].toLowerCase()) const 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)
@ -596,7 +622,12 @@ 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.
} }

View file

@ -575,7 +575,7 @@ test("message2event: voice message", async t => {
external_url: "https://bridge.example.org/download/discordcdn/1099031887500034088/1112476845502365786/voice-message.ogg", external_url: "https://bridge.example.org/download/discordcdn/1099031887500034088/1112476845502365786/voice-message.ogg",
filename: "voice-message.ogg", filename: "voice-message.ogg",
info: { info: {
duration: 3960.0000381469727, duration: 3960,
mimetype: "audio/ogg", mimetype: "audio/ogg",
size: 10584, size: 10584,
}, },
@ -724,6 +724,20 @@ 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: {
@ -1134,3 +1148,20 @@ test("message2event: constructed forwarded text", async t => {
} }
]) ])
}) })
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"
}
])
})

View file

@ -64,7 +64,7 @@ function userToSimName(user) {
} }
// 1. Is sim user already registered? // 1. Is sim user already registered?
const existing = select("sim", "sim_name", {user_id: user.id}).pluck().get() const existing = select("sim", "user_id", {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,10 +1,15 @@
// @ts-check // @ts-check
const { SnowTransfer } = require("snowtransfer") const DiscordTypes = require("discord-api-types/v10")
const { Client: CloudStorm } = require("cloudstorm") const {Endpoints, SnowTransfer} = require("snowtransfer")
const {reg} = require("../matrix/read-registration")
const {Client: CloudStorm} = require("cloudstorm")
// @ts-ignore
Endpoints.BASE_HOST = reg.ooye.discord_origin || "https://discord.com"; Endpoints.CDN_URL = reg.ooye.discord_cdn_origin || "https://cdn.discordapp.com"
const 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")
@ -24,7 +29,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" "MESSAGE_CONTENT", "GUILD_PRESENCES"
], ],
ws: { ws: {
compress: false, compress: false,
@ -32,15 +37,15 @@ class DiscordClient {
} }
}) })
this.ready = false this.ready = false
/** @type {import("discord-api-types/v10").APIUser} */ /** @type {DiscordTypes.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<import("discord-api-types/v10").APIApplication, "id" | "flags">} */ /** @type {Pick<DiscordTypes.APIApplication, "id" | "flags">} */
// @ts-ignore // @ts-ignore
this.application = null this.application = null
/** @type {Map<string, import("discord-api-types/v10").APIChannel>} */ /** @type {Map<string, DiscordTypes.APIChannel>} */
this.channels = new Map() this.channels = new Map()
/** @type {Map<string, import("discord-api-types/v10").APIGuild>} */ /** @type {Map<string, DiscordTypes.APIGuild & {members: DiscordTypes.APIGuildMember[]}>} */ // we get members from the GUILD_CREATE and we do maintain it
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()

View file

@ -32,6 +32,7 @@ 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)
@ -51,9 +52,14 @@ const utils = {
} }
if (listen === "full") { if (listen === "full") {
eventDispatcher.checkMissedExpressions(message.d) try {
eventDispatcher.checkMissedPins(client, message.d) await eventDispatcher.checkMissedExpressions(message.d)
eventDispatcher.checkMissedMessages(client, message.d) await eventDispatcher.checkMissedPins(client, message.d)
await eventDispatcher.checkMissedMessages(client, message.d)
} catch (e) {
console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:")
console.error(e)
}
} }
} else if (message.t === "GUILD_UPDATE") { } else if (message.t === "GUILD_UPDATE") {
@ -96,6 +102,13 @@ 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"]) { if (message.d["guild_id"]) {
@ -166,6 +179,7 @@ const utils = {
await eventDispatcher.onThreadCreate(client, message.d) await eventDispatcher.onThreadCreate(client, message.d)
} else if (message.t === "THREAD_UPDATE") { } else if (message.t === "THREAD_UPDATE") {
// @ts-ignore
await eventDispatcher.onChannelOrThreadUpdate(client, message.d, true) await eventDispatcher.onChannelOrThreadUpdate(client, message.d, true)
} else if (message.t === "MESSAGE_CREATE") { } else if (message.t === "MESSAGE_CREATE") {
@ -191,6 +205,9 @@ const utils = {
} 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 === "PRESENCE_UPDATE") {
eventDispatcher.onPresenceUpdate(client, message.d)
} }
} catch (e) { } catch (e) {

View file

@ -33,6 +33,8 @@ const mxUtils = require("../m2d/converters/utils")
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 {any} */ // @ts-ignore bad types from semaphore /** @type {any} */ // @ts-ignore bad types from semaphore
const Semaphore = require("@chriscdn/promise-semaphore") const Semaphore = require("@chriscdn/promise-semaphore")
@ -103,13 +105,22 @@ 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 prepared = select("event_message", "event_id", {}, "WHERE message_id = ?").pluck() const preparedExists = db.prepare("SELECT channel_id FROM message_channel WHERE channel_id = ? LIMIT 1")
for (const channel of guild.channels.concat(guild.threads)) { const preparedGet = select("event_message", "event_id", {}, "WHERE message_id = ?").pluck()
/** @type {(DiscordTypes.APIChannel & {type: DiscordTypes.GuildChannelType})[]} */
let channels = []
channels = channels.concat(guild.channels, guild.threads)
for (const channel of channels) {
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
@ -131,7 +142,7 @@ module.exports = {
} }
} }
let latestBridgedMessageIndex = messages.findIndex(m => { let latestBridgedMessageIndex = messages.findIndex(m => {
return prepared.get(m.id) return preparedGet.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.
@ -179,7 +190,7 @@ module.exports = {
*/ */
async checkMissedExpressions(guild) { async checkMissedExpressions(guild) {
const data = {guild_id: guild.id, ...guild} const data = {guild_id: guild.id, ...guild}
createSpace.syncSpaceExpressions(data, true) await createSpace.syncSpaceExpressions(data, true)
}, },
/** /**
@ -363,5 +374,15 @@ module.exports = {
*/ */
async onExpressionsUpdate(client, data) { async onExpressionsUpdate(client, data) {
await createSpace.syncSpaceExpressions(data, false) await createSpace.syncSpaceExpressions(data, false)
},
/**
* @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayPresenceUpdateDispatchData} data
*/
onPresenceUpdate(client, data) {
const status = data.status
if (!status) return
setPresence.presenceTracker.incomingPresence(data.user.id, data.guild_id, status)
} }
} }

View file

@ -6,7 +6,8 @@ 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)").run() db.prepare("CREATE TABLE IF NOT EXISTS migration (filename TEXT NOT NULL, PRIMARY KEY (filename)) WITHOUT ROWID").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 = ""
@ -37,6 +38,8 @@ 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

@ -0,0 +1,147 @@
-- /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

@ -0,0 +1,225 @@
-- 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

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

View file

@ -0,0 +1,13 @@
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

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

View file

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

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

@ -10,6 +10,8 @@ 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
} }
event_message: { event_message: {
@ -31,6 +33,8 @@ 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: {
@ -38,6 +42,14 @@ export type Models = {
autocreate: 0 | 1 autocreate: 0 | 1
} }
invite: {
mxid: string
room_id: string
type: string | null
name: string | null
avatar: string | null
}
lottie: { lottie: {
sticker_id: string sticker_id: string
mxc_url: string mxc_url: string

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("!jjWAGMeQdNrVZSSfvz:cadence.moe") const row = select("guild_space", "guild_id", {}, "WHERE space_id = ?").get("!jjmvBegULiLucuWEHU: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("!jjWAGMeQdNrVZSSfvz:cadence.moe") const row = from("guild_space").select("guild_id").and("WHERE space_id = ?").get("!jjmvBegULiLucuWEHU: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("!jjWAGMeQdNrVZSSfvz:cadence.moe") const guildID = select("guild_space", "guild_id", {}, "WHERE space_id = ?").pluck().get("!jjmvBegULiLucuWEHU:cadence.moe")
t.equal(guildID, data.guild.general.id) t.equal(guildID, data.guild.general.id)
}) })
@ -36,7 +36,7 @@ test("orm: select: in array works", t => {
}) })
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("!jjWAGMeQdNrVZSSfvz:cadence.moe") const guildID = from("guild_space").pluck("guild_id").and("WHERE space_id = ?").get("!jjmvBegULiLucuWEHU:cadence.moe")
t.equal(guildID, data.guild.general.id) t.equal(guildID, data.guild.general.id)
}) })

View file

@ -59,7 +59,7 @@ test("invite: checks if guild exists", async t => { // it might not exist if the
}) })
test("invite: checks if channel exists or is autocreatable", async t => { test("invite: checks if channel exists or is autocreatable", async t => {
db.prepare("UPDATE guild_active SET autocreate = 0").run() db.prepare("UPDATE guild_active SET autocreate = 0 WHERE guild_id = '112760669178241024'").run()
const msgs = await fromAsync(_interact({ const msgs = await fromAsync(_interact({
data: { data: {
options: [{ options: [{
@ -72,7 +72,7 @@ test("invite: checks if channel exists or is autocreatable", async t => {
guild_id: "112760669178241024" 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.") 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").run() 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 => { test("invite: checks if user is already invited to space", async t => {
@ -91,7 +91,7 @@ test("invite: checks if user is already invited to space", async t => {
api: { api: {
getStateEvent: async (roomID, type, stateKey) => { getStateEvent: async (roomID, type, stateKey) => {
called++ called++
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID
t.equal(type, "m.room.member") t.equal(type, "m.room.member")
t.equal(stateKey, "@cadence:cadence.moe") t.equal(stateKey, "@cadence:cadence.moe")
return { return {
@ -121,14 +121,14 @@ test("invite: invites if user is not in space", async t => {
api: { api: {
getStateEvent: async (roomID, type, stateKey) => { getStateEvent: async (roomID, type, stateKey) => {
called++ called++
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID
t.equal(type, "m.room.member") t.equal(type, "m.room.member")
t.equal(stateKey, "@cadence:cadence.moe") t.equal(stateKey, "@cadence:cadence.moe")
throw new MatrixServerError("State event doesn't exist or something") throw new MatrixServerError("State event doesn't exist or something")
}, },
inviteToRoom: async (roomID, mxid) => { inviteToRoom: async (roomID, mxid) => {
called++ called++
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID
t.equal(mxid, "@cadence:cadence.moe") t.equal(mxid, "@cadence:cadence.moe")
} }
} }
@ -155,7 +155,7 @@ test("invite: prompts to invite to room (if never joined)", async t => {
called++ called++
t.equal(type, "m.room.member") t.equal(type, "m.room.member")
t.equal(stateKey, "@cadence:cadence.moe") t.equal(stateKey, "@cadence:cadence.moe")
if (roomID === "!jjWAGMeQdNrVZSSfvz:cadence.moe") { // space ID if (roomID === "!jjmvBegULiLucuWEHU:cadence.moe") { // space ID
return { return {
displayname: "cadence", displayname: "cadence",
membership: "join" membership: "join"
@ -188,7 +188,7 @@ test("invite: prompts to invite to room (if left)", async t => {
called++ called++
t.equal(type, "m.room.member") t.equal(type, "m.room.member")
t.equal(stateKey, "@cadence:cadence.moe") t.equal(stateKey, "@cadence:cadence.moe")
if (roomID === "!jjWAGMeQdNrVZSSfvz:cadence.moe") { // space ID if (roomID === "!jjmvBegULiLucuWEHU:cadence.moe") { // space ID
return { return {
displayname: "cadence", displayname: "cadence",
membership: "join" membership: "join"

View file

@ -82,6 +82,10 @@ 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
} }
] ]
} }
@ -103,7 +107,10 @@ async function* _interactEdit({data, guild_id, message}, {api}) {
assert(mxid) assert(mxid)
const permission = data.values[0] const permission = data.values[0]
const power = permission === "moderator" ? 50 : 0 const power =
( permission === "admin" ? 100
: permission === "moderator" ? 50
: 0)
yield {createInteractionResponse: { yield {createInteractionResponse: {
type: DiscordTypes.InteractionResponseType.UpdateMessage, type: DiscordTypes.InteractionResponseType.UpdateMessage,

View file

@ -57,7 +57,7 @@ test("permissions: reports permissions of selected matrix user (implicit default
}, },
async getStateEvent(roomID, type, key) { async getStateEvent(roomID, type, key) {
called++ called++
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID
t.equal(type, "m.room.power_levels") t.equal(type, "m.room.power_levels")
t.equal(key, "") t.equal(key, "")
return { return {
@ -91,7 +91,7 @@ test("permissions: reports permissions of selected matrix user (moderator)", asy
}, },
async getStateEvent(roomID, type, key) { async getStateEvent(roomID, type, key) {
called++ called++
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID
t.equal(type, "m.room.power_levels") t.equal(type, "m.room.power_levels")
t.equal(key, "") t.equal(key, "")
return { return {
@ -127,7 +127,7 @@ test("permissions: reports permissions of selected matrix user (admin)", async t
}, },
async getStateEvent(roomID, type, key) { async getStateEvent(roomID, type, key) {
called++ called++
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID
t.equal(type, "m.room.power_levels") t.equal(type, "m.room.power_levels")
t.equal(key, "") t.equal(key, "")
return { return {
@ -159,7 +159,7 @@ test("permissions: can update user to moderator", async t => {
api: { api: {
async setUserPowerCascade(roomID, mxid, power) { async setUserPowerCascade(roomID, mxid, power) {
called++ called++
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID
t.equal(mxid, "@cadence:cadence.moe") t.equal(mxid, "@cadence:cadence.moe")
t.equal(power, 50) t.equal(power, 50)
} }
@ -186,7 +186,7 @@ test("permissions: can update user to default", async t => {
api: { api: {
async setUserPowerCascade(roomID, mxid, power) { async setUserPowerCascade(roomID, mxid, power) {
called++ called++
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID
t.equal(mxid, "@cadence:cadence.moe") t.equal(mxid, "@cadence:cadence.moe")
t.equal(power, 0) t.equal(power, 0)
} }

View file

@ -2,6 +2,8 @@
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const {discord, sync, select, from} = require("../../passthrough") const {discord, sync, 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")
@ -11,21 +13,28 @@ const utils = sync.require("../../m2d/converters/utils")
/** /**
* @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction * @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction
* @param {{api: typeof api}} di * @param {{api: typeof api}} di
* @returns {Promise<DiscordTypes.APIInteractionResponse>} * @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
*/ */
async function _interact({data}, {api}) { async function* _interact({data}, {api}) {
const row = from("event_message").join("message_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 { return yield {createInteractionResponse: {
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[]>} */
@ -40,29 +49,27 @@ async function _interact({data}, {api}) {
} }
if (inverted.size === 0) { if (inverted.size === 0) {
return { return yield {editOriginalInteractionResponse: {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, content: "Nobody from Matrix reacted to this message.",
data: { }}
content: "Nobody from Matrix reacted to this message.",
flags: DiscordTypes.MessageFlags.Ephemeral
}
}
} }
return { return yield {editOriginalInteractionResponse: {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource, content: [...inverted.entries()].map(([key, value]) => `${key}${value.join(" ⬩ ")}`).join("\n"),
data: { }}
content: [...inverted.entries()].map(([key, value]) => `${key}${value.join(" ⬩ ")}`).join("\n"),
flags: DiscordTypes.MessageFlags.Ephemeral
}
}
} }
/* c8 ignore start */ /* c8 ignore start */
/** @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction */ /** @param {DiscordTypes.APIMessageApplicationCommandGuildInteraction} interaction */
async function interact(interaction) { async function interact(interaction) {
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interact(interaction, {api})) 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

View file

@ -1,17 +1,31 @@
const {test} = require("supertape") const {test} = require("supertape")
const {_interact} = require("./reactions") 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 => { test("reactions: checks if message is bridged", async t => {
const msg = await _interact({ const msgs = await fromAsync(_interact({
data: { data: {
target_id: "0" target_id: "0"
} }
}, {}) }, {}))
t.equal(msg.data.content, "This message hasn't been bridged to Matrix.") 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 => { test("reactions: different response if nobody reacted", async t => {
const msg = await _interact({ const msgs = await fromAsync(_interact({
data: { data: {
target_id: "1126786462646550579" target_id: "1126786462646550579"
} }
@ -23,13 +37,14 @@ test("reactions: different response if nobody reacted", async t => {
return [] return []
} }
} }
}) }))
t.equal(msg.data.content, "Nobody from Matrix reacted to this message.") 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 => { test("reactions: shows reactions if there are some, ignoring discord users", async t => {
let called = 1 let called = 1
const msg = await _interact({ const msgs = await fromAsync(_interact({
data: { data: {
target_id: "1126786462646550579" target_id: "1126786462646550579"
} }
@ -73,9 +88,10 @@ test("reactions: shows reactions if there are some, ignoring discord users", asy
}] }]
} }
} }
}) }))
t.equal(msgs.length, 2)
t.equal( t.equal(
msg.data.content, msgs[1].editOriginalInteractionResponse.content,
"🐈 ⮞ cadence [they] ⬩ @rnl:cadence.moe" "🐈 ⮞ cadence [they] ⬩ @rnl:cadence.moe"
+ "\n🐈⬛ ⮞ cadence [they]" + "\n🐈⬛ ⮞ cadence [they]"
) )

View file

@ -64,7 +64,9 @@ 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

View file

@ -20,10 +20,19 @@ 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 = emoji.encodeEmoji(key, event.content.shortcode) const discordPreferredEncoding = await emoji.encodeEmoji(key, event.content.shortcode)
if (!discordPreferredEncoding) return if (!discordPreferredEncoding) return
await discord.snow.channel.createReaction(channelID, messageID, discordPreferredEncoding) // acting as the discord bot itself try {
await discord.snow.channel.createReaction(channelID, messageID, discordPreferredEncoding) // acting as the discord bot itself
} catch (e) {
if (e.message?.includes("Maximum number of reactions reached")) {
// we'll silence this particular error to avoid spamming the chat
// not adding it to the database otherwise a m->d removal would try calling the API
return
}
throw e
}
db.prepare("REPLACE INTO reaction (hashed_event_id, message_id, encoded_emoji) VALUES (?, ?, ?)").run(utils.getEventIDHash(event.event_id), messageID, discordPreferredEncoding) db.prepare("REPLACE INTO reaction (hashed_event_id, message_id, encoded_emoji) VALUES (?, ?, ?)").run(utils.getEventIDHash(event.event_id), messageID, discordPreferredEncoding)
} }

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 {Readable} = require("stream") const stream = 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 | Readable}[]}} data * @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.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 | Readable}[]}} data * @param {DiscordTypes.RESTPatchAPIWebhookWithTokenMessageJSONBody & {files?: {name: string, file: Buffer | stream.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,9 +1,6 @@
// @ts-check // @ts-check
const assert = require("assert") const stream = require("stream")
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")} */
@ -18,16 +15,15 @@ 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.
// @ts-ignore the signal is slightly different from the type it wants (still works fine) const res = await api.getMedia(mxc, {signal: abortController.signal})
const res = await api.getMedia(mxc, {agent: false, signal: abortController.signal}) const readable = stream.Readable.fromWeb(res.body)
return emojiSheetConverter.convertImageStream(res.body, () => { return emojiSheetConverter.convertImageStream(readable, () => {
abortController.abort() abortController.abort()
res.body.pause() readable.emit("end")
res.body.emit("end") readable.on("error", () => {}) // DOMException [AbortError]: This operation was aborted
}) })
} }

View file

@ -13,7 +13,6 @@ 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.redacts)
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,10 +2,9 @@
const Ty = require("../../types") const Ty = require("../../types")
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const {Readable} = require("stream") const stream = 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
@ -23,8 +22,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 | Readable}[], pendingFiles?: ({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer | Readable})[]}} message * @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[], pendingFiles?: ({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer | stream.Readable})[]}} message
* @returns {Promise<DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]}>} * @returns {Promise<DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]}>}
*/ */
async function resolvePendingFiles(message) { async function resolvePendingFiles(message) {
if (!message.pendingFiles) return message if (!message.pendingFiles) return message
@ -38,16 +37,14 @@ 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"))
// @ts-ignore await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb(res.body).pipe(d))
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
/** @type {Readable} */ // @ts-ignore const body = await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb(res.body))
const body = await api.getMedia(p.mxc).then(res => res.body)
return { return {
name: p.name, name: p.name,
file: body file: body
@ -102,14 +99,13 @@ 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("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(messageResponse.id, threadID || channelID) db.prepare("INSERT 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

@ -0,0 +1,25 @@
// @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

@ -0,0 +1,17 @@
// @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

@ -0,0 +1,11 @@
// @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,6 +1,7 @@
// @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")
@ -48,7 +49,7 @@ async function compositeMatrixEmojis(mxcs, mxcDownloader) {
} }
/** /**
* @param {import("node-fetch").Response["body"]} streamIn * @param {stream.Readable} streamIn
* @param {() => any} stopStream * @param {() => any} stopStream
* @returns {Promise<Buffer | undefined>} Uncompressed PNG image * @returns {Promise<Buffer | undefined>} Uncompressed PNG image
*/ */

View file

@ -1,57 +1,98 @@
// @ts-check // @ts-check
const assert = require("assert").strict const fsp = require("fs").promises
const Ty = require("../../types") const {join} = require("path")
const emojisp = fsp.readFile(join(__dirname, "emojis.txt"), "utf8").then(content => content.split("\n"))
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {sync, select} = passthrough const {select} = passthrough
/** /**
* @param {string} input * @param {string} input
* @param {string | null | undefined} shortcode * @param {string | null | undefined} shortcode
* @returns {string?} * @returns {string?}
*/ */
function encodeEmoji(input, shortcode) { function encodeCustomEmoji(input, shortcode) {
let discordPreferredEncoding // Custom emoji
if (input.startsWith("mxc://")) { let row = select("emoji", ["emoji_id", "name"], {mxc_url: input}).get()
// Custom emoji if (!row && shortcode) {
let row = select("emoji", ["emoji_id", "name"], {mxc_url: input}).get() // Use the name to try to find a known emoji with the same name.
if (!row && shortcode) { const name = shortcode.replace(/^:|:$/g, "")
// Use the name to try to find a known emoji with the same name. row = select("emoji", ["emoji_id", "name"], {name: name}).get()
const name = shortcode.replace(/^:|:$/g, "") }
row = select("emoji", ["emoji_id", "name"], {name: name}).get() if (!row) {
} // We don't have this emoji and there's no realistic way to just-in-time upload a new emoji somewhere. Sucks!
if (!row) { return null
// We don't have this emoji and there's no realistic way to just-in-time upload a new emoji somewhere. }
// Sucks! return encodeURIComponent(`${row.name}:${row.emoji_id}`)
return null }
}
// Cool, we got an exact or a candidate emoji. /**
discordPreferredEncoding = encodeURIComponent(`${row.name}:${row.emoji_id}`) * @param {string} input
} else { * @returns {Promise<string?>} URL encoded!
// Default emoji */
// https://github.com/discord/discord-api-docs/issues/2723#issuecomment-807022205 ???????????? async function encodeDefaultEmoji(input) {
const encoded = encodeURIComponent(input) // Default emoji
const encodedTrimmed = encoded.replace(/%EF%B8%8F/g, "")
// Shortcut: If there are ASCII letters then it's not an emoji, it's a freeform Matrix text reaction.
const forceTrimmedList = [ // (Regional indicator letters are not ASCII. ASCII digits might be part of an emoji.)
"%F0%9F%91%8D", // 👍 if (input.match(/[A-Za-z]/)) return null
"%F0%9F%91%8E", // 👎️
"%E2%AD%90", // ⭐ // Check against the dataset
"%F0%9F%90%88", // 🐈 const emojis = await emojisp
"%E2%9D%93", // ❓ const encoded = encodeURIComponent(input)
"%F0%9F%8F%86", // 🏆️
"%F0%9F%93%9A", // 📚️ // Best case scenario: they reacted with an exact replica of a valid emoji.
] if (emojis.includes(input)) return encoded
discordPreferredEncoding = // Maybe it has some extraneous \ufe0f or \ufe0e (at the end or in the middle), and it'll be valid if they're removed.
( forceTrimmedList.includes(encodedTrimmed) ? encodedTrimmed const trimmed = input.replace(/\ufe0e|\ufe0f/g, "")
: encodedTrimmed !== encoded && [...input].length === 2 ? encoded const trimmedEncoded = encodeURIComponent(trimmed)
: encodedTrimmed) if (trimmed !== input) {
if (emojis.includes(trimmed)) return trimmedEncoded
console.log("add reaction from matrix:", input, encoded, encodedTrimmed, "chosen:", discordPreferredEncoding) }
// 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 {
return encodeDefaultEmoji(input)
} }
return discordPreferredEncoding
} }
module.exports.encodeEmoji = encodeEmoji module.exports.encodeEmoji = encodeEmoji

View file

@ -0,0 +1,52 @@
// @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 {Readable} = require("stream") const stream = 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")
@ -258,7 +258,18 @@ 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 => {
db.prepare("REPLACE INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?)").run(roomID, mxid, event?.displayname || null, event?.avatar_url || null) const room = select("channel_room", "room_id", {room_id: roomID}).get()
if (room) {
// save the member to the cache so we don't have to check with the homeserver next time
// the cache will be kept in sync by the `m.room.member` event listener
const displayname = event?.displayname || null
const avatar_url = event?.avatar_url || null
db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?").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}
@ -330,9 +341,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\/#\/(![^"/, ?)]+)(?:\/(\$[^"/ ?)]+))?(?:\?[^",:!? )]*?)?)(">|[,<\n )]|$)/g)]) { for (const match of [...input.matchAll(/("?https:\/\/matrix.to\/#\/((?:#|%23|!)[^"/, ?)]+)(?:\/(\$[^"/ ?)]+))?(?:\?[^",:!? )]*?)?)(">|[,<\n )]|$)/g)]) {
assert(typeof match.index === "number") assert(typeof match.index === "number")
const [_, attributeValue, roomID, eventID, endMarker] = match let [_, attributeValue, roomID, eventID, endMarker] = match
let result let result
const resultType = endMarker === '">' ? "html" : "plain" const resultType = endMarker === '">' ? "html" : "plain"
@ -350,6 +361,16 @@ 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) {
@ -455,7 +476,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) || undefined if (member.avatar_url) avatarURL = mxUtils.getPublicUrlForMxc(member.avatar_url)
// 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)
@ -491,8 +512,7 @@ async function eventToMessage(event, guild, di) {
// 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)
if (!originalEvent) return const repliedToEventId = originalEvent?.content?.["m.relates_to"]?.["m.in_reply_to"]?.event_id
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.
@ -555,34 +575,27 @@ async function eventToMessage(event, guild, di) {
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"]
} }
let contentPreview /** @type {string} */
let repliedToContent = repliedToEvent.content.formatted_body || repliedToEvent.content.body
const fileReplyContentAlternative = attachmentEmojis.get(repliedToEvent.content.msgtype) const fileReplyContentAlternative = attachmentEmojis.get(repliedToEvent.content.msgtype)
let contentPreview
if (fileReplyContentAlternative) { if (fileReplyContentAlternative) {
contentPreview = " " + fileReplyContentAlternative contentPreview = " " + fileReplyContentAlternative
} else if (repliedToEvent.unsigned?.redacted_because) { } else if (repliedToEvent.unsigned?.redacted_because) {
contentPreview = " (in reply to a deleted message)" contentPreview = " (in reply to a deleted message)"
} else if (typeof repliedToContent !== "string") {
// 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
@ -600,10 +613,18 @@ 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`
})() })()
@ -799,7 +820,7 @@ async function eventToMessage(event, guild, di) {
// 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 | Readable}[]})[]} */ /** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.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": "!BpMdOUkWWhFxmTrENV:cadence.moe" "room_id": "!kLRqKKUQXcibIMtOpl: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: "!BpMdOUkWWhFxmTrENV:cadence.moe" room_id: "!kLRqKKUQXcibIMtOpl: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: "!BpMdOUkWWhFxmTrENV:cadence.moe" room_id: "!kLRqKKUQXcibIMtOpl: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: "!BpMdOUkWWhFxmTrENV:cadence.moe" room_id: "!kLRqKKUQXcibIMtOpl: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: "!BpMdOUkWWhFxmTrENV:cadence.moe" room_id: "!kLRqKKUQXcibIMtOpl: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: "!BpMdOUkWWhFxmTrENV:cadence.moe" room_id: "!kLRqKKUQXcibIMtOpl: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: "!BpMdOUkWWhFxmTrENV:cadence.moe" room_id: "!kLRqKKUQXcibIMtOpl: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: '!cBxtVRxDlZvSVhJXVK:cadence.moe', room_id: '!TqlyQmifxGUggEmdBN: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: '!cBxtVRxDlZvSVhJXVK:cadence.moe', room_id: '!TqlyQmifxGUggEmdBN: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/#/!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)", 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)",
"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/#/!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?", "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?",
"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": "!cBxtVRxDlZvSVhJXVK:cadence.moe" "room_id": "!TqlyQmifxGUggEmdBN:cadence.moe"
}) })
}, },
snow: { snow: {
@ -2625,6 +2625,52 @@ 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({
@ -2957,6 +3003,133 @@ 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({
@ -3358,7 +3531,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: "!should_be_newly_cached:cadence.moe", room_id: "!qzDBLKlildpzrrOnFZ: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: {
@ -3368,7 +3541,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, "!should_be_newly_cached:cadence.moe") t.equal(roomID, "!qzDBLKlildpzrrOnFZ: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 {
@ -3392,12 +3565,60 @@ test("event2message: caches the member if the member is not known", async t => {
} }
) )
t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!should_be_newly_cached:cadence.moe"}).all(), [ t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!qzDBLKlildpzrrOnFZ: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(
@ -3453,7 +3674,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: "!should_be_newly_cached_2:cadence.moe", room_id: "!cqeGDbPiMFAhLsqqqq: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: {
@ -3463,7 +3684,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, "!should_be_newly_cached_2:cadence.moe") t.equal(roomID, "!cqeGDbPiMFAhLsqqqq: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 {
@ -3486,7 +3707,7 @@ test("event2message: overly long usernames are shifted into the message content"
}] }]
} }
) )
t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!should_be_newly_cached_2:cadence.moe"}).all(), [ t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!cqeGDbPiMFAhLsqqqq: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")
@ -3501,7 +3722,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: "!should_be_newly_cached_2:cadence.moe", room_id: "!cqeGDbPiMFAhLsqqqq: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: {
@ -4350,7 +4571,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: "!maggESguZBqGBZtSnr:cadence.moe" room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}, {}, {mxcDownloader: mockGetAndConvertEmoji}) }, {}, {mxcDownloader: mockGetAndConvertEmoji})
const testResult = { const testResult = {
content: messages.messagesToSend[0].content, content: messages.messagesToSend[0].content,

View file

@ -30,9 +30,7 @@ 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 includes messages sent by the appservice's bot user, because that is what's used for webhooks // This could include 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
} }
@ -219,12 +217,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?} * @returns {string | undefined}
*/ */
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 null if (!mediaParts) return undefined
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,12 +14,16 @@ 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
@ -29,14 +33,26 @@ function guard(type, fn) {
try { try {
return await fn(event, ...args) return await fn(event, ...args)
} catch (e) { } catch (e) {
console.error("hit event-dispatcher's error handler with this exception:") console.error(`Exception while processing a ${type} Matrix event:`)
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}) console.dir(event, {depth: null})
if (Date.now() - lastReportedEvent < 5000) return if (Date.now() - lastReportedEvent < 5000) return
lastReportedEvent = Date.now() lastReportedEvent = Date.now()
const cloudflareErrorTitle = e.toString().match(/<!DOCTYPE html>.*?<title>discord\.com \| ([^<]*)<\/title>/s)?.[1]
if (cloudflareErrorTitle) {
return api.sendEvent(event.room_id, "m.room.message", {
msgtype: "m.text",
body: `\u26a0 Matrix event not delivered to Discord. Cloudflare error: ${cloudflareErrorTitle}.`,
format: "org.matrix.custom.html",
formatted_body: `\u26a0 <strong>Matrix event not delivered to Discord</strong><br>Cloudflare error: ${cloudflareErrorTitle}`,
"moe.cadence.ooye.error": {
source: "matrix",
payload: event
}
})
}
let stackLines = e.stack.split("\n") let stackLines = e.stack.split("\n")
api.sendEvent(event.room_id, "m.room.message", { api.sendEvent(event.room_id, "m.room.message", {
msgtype: "m.text", msgtype: "m.text",
@ -102,6 +118,7 @@ async event => {
// @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",
@ -111,6 +128,7 @@ sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker",
async event => { async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return if (utils.eventSenderIsFromDiscord(event.sender)) return
const messageResponses = await sendEvent.sendEvent(event) const messageResponses = await sendEvent.sendEvent(event)
await api.ackEvent(event)
})) }))
sync.addTemporaryListener(as, "type:m.reaction", guard("m.reaction", sync.addTemporaryListener(as, "type:m.reaction", guard("m.reaction",
@ -135,6 +153,7 @@ 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",
@ -159,25 +178,111 @@ 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) return db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key)
} else {
// Member is here
db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?")
.run(
event.room_id, event.state_key,
event.content.displayname || null, event.content.avatar_url || null,
event.content.displayname || null, event.content.avatar_url || null
)
} }
const exists = select("channel_room", "room_id", {room_id: event.room_id}) ?? select("guild_space", "space_id", {space_id: event.room_id})
if (!exists) return // don't cache members in unbridged rooms
// Member is here
let powerLevel = 0
try {
/** @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,
displayname, avatar_url, powerLevel,
displayname, avatar_url, powerLevel
)
})) }))
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",

View file

@ -2,11 +2,10 @@
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 { discord, sync, db } = passthrough const {sync} = passthrough
/** @type {import("./mreq")} */ /** @type {import("./mreq")} */
const mreq = sync.require("./mreq") const mreq = sync.require("./mreq")
/** @type {import("./txnid")} */ /** @type {import("./txnid")} */
@ -35,14 +34,21 @@ function path(p, mxid, otherParams = {}) {
/** /**
* @param {string} username * @param {string} username
* @returns {Promise<Ty.R.Registered>}
*/ */
function register(username) { async function register(username) {
console.log(`[api] register: ${username}`) console.log(`[api] register: ${username}`)
return mreq.mreq("POST", "/client/v3/register", { try {
type: "m.login.application_service", await mreq.mreq("POST", "/client/v3/register", {
username type: "m.login.application_service",
}) 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
}
}
} }
/** /**
@ -75,6 +81,16 @@ 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
@ -294,14 +310,15 @@ async function setUserPower(roomID, mxid, 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} roomID * @param {string} spaceID
* @param {string} mxid * @param {string} mxid
* @param {number} power * @param {number} power
*/ */
async function setUserPowerCascade(roomID, mxid, power) { async function setUserPowerCascade(spaceID, mxid, power) {
assert(roomID[0] === "!") assert(spaceID[0] === "!")
assert(mxid[0] === "@") assert(mxid[0] === "@")
const rooms = await getFullHierarchy(roomID) const rooms = await getFullHierarchy(spaceID)
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)
} }
@ -326,17 +343,80 @@ async function ping() {
/** /**
* @param {string} mxc * @param {string} mxc
* @param {fetch.RequestInit} [init] * @param {RequestInit} [init]
* @return {Promise<Response & {body: streamWeb.ReadableStream<Uint8Array>}>}
*/ */
function getMedia(mxc, init = {}) { async function getMedia(mxc, init = {}) {
const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/) const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
assert(mediaParts) assert(mediaParts)
return fetch(`${mreq.baseUrl}/client/v1/media/download/${mediaParts[1]}/${mediaParts[2]}`, { const res = await 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)
} }
module.exports.path = path module.exports.path = path
@ -345,6 +425,7 @@ 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
@ -365,3 +446,9 @@ 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

View file

@ -1,7 +1,5 @@
// @ts-check // @ts-check
const fetch = require("node-fetch").default
const passthrough = require("../passthrough") const passthrough = require("../passthrough")
const {sync, db, select} = passthrough const {sync, db, select} = passthrough
/** @type {import("./mreq")} */ /** @type {import("./mreq")} */
@ -47,7 +45,7 @@ async function uploadDiscordFileToMxc(path) {
} }
// Download from Discord // Download from Discord
const promise = fetch(url, {}).then(/** @param {import("node-fetch").Response} res */ async res => { const promise = fetch(url, {}).then(async res => {
// Upload to Matrix // Upload to Matrix
const root = await module.exports._actuallyUploadDiscordFileToMxc(urlNoExpiry, res) const root = await module.exports._actuallyUploadDiscordFileToMxc(urlNoExpiry, res)
@ -62,6 +60,10 @@ async function uploadDiscordFileToMxc(path) {
return promise return promise
} }
/**
* @param {string} url
* @param {Response} res
*/
async function _actuallyUploadDiscordFileToMxc(url, res) { async function _actuallyUploadDiscordFileToMxc(url, res) {
const body = res.body const body = res.body
/** @type {import("../types").R.FileUploaded} */ /** @type {import("../types").R.FileUploaded} */

View file

@ -8,6 +8,8 @@ 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) {
@ -85,6 +87,12 @@ 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])) {
@ -102,8 +110,32 @@ 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,3 +234,31 @@ 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

@ -1,8 +1,8 @@
// @ts-check // @ts-check
const fetch = require("node-fetch").default
const mixin = require("@cloudrac3r/mixin-deep") const mixin = require("@cloudrac3r/mixin-deep")
const stream = require("stream") const stream = require("stream")
const streamWeb = require("stream/web")
const getStream = require("get-stream") const getStream = require("get-stream")
const {reg, writeRegistration} = require("./read-registration.js") const {reg, writeRegistration} = require("./read-registration.js")
@ -22,7 +22,7 @@ class MatrixServerError extends Error {
/** /**
* @param {string} method * @param {string} method
* @param {string} url * @param {string} url
* @param {any} [body] * @param {string | object | streamWeb.ReadableStream | stream.Readable} [body]
* @param {any} [extra] * @param {any} [extra]
*/ */
async function mreq(method, url, body, extra = {}) { async function mreq(method, url, body, extra = {}) {
@ -30,16 +30,19 @@ async function mreq(method, url, body, extra = {}) {
body = JSON.stringify(body) body = JSON.stringify(body)
} else if (body instanceof stream.Readable && reg.ooye.content_length_workaround) { } else if (body instanceof stream.Readable && reg.ooye.content_length_workaround) {
body = await getStream.buffer(body) body = await getStream.buffer(body)
} else if (body instanceof streamWeb.ReadableStream && reg.ooye.content_length_workaround) {
body = await stream.consumers.buffer(stream.Readable.fromWeb(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) // 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()
@ -55,7 +58,7 @@ async function mreq(method, url, body, extra = {}) {
writeRegistration(reg) writeRegistration(reg)
return root return root
} }
delete opts.headers.Authorization delete opts.headers?.["Authorization"]
throw new MatrixServerError(root, {baseUrl, url, ...opts}) throw new MatrixServerError(root, {baseUrl, url, ...opts})
} }
return root return root

View file

@ -3,7 +3,6 @@
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() {
@ -23,9 +22,9 @@ async function applyPower() {
const rows = _getAffectedRooms() const rows = _getAffectedRooms()
for (const row of rows) { for (const row of rows) {
const kstate = await roomToKState(row.room_id) const kstate = await ks.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 applyKStateDiffToRoom(row.room_id, diff) await ks.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)

View file

@ -4,7 +4,7 @@
* @typedef {Object} Passthrough * @typedef {Object} Passthrough
* @property {import("repl").REPLServer} repl * @property {import("repl").REPLServer} repl
* @property {import("./d2m/discord-client")} discord * @property {import("./d2m/discord-client")} discord
* @property {import("heatsync").default} sync * @property {import("heatsync")} sync
* @property {import("better-sqlite3/lib/database")} db * @property {import("better-sqlite3/lib/database")} db
* @property {import("@cloudrac3r/in-your-element").AppService} as * @property {import("@cloudrac3r/in-your-element").AppService} as
* @property {import("./db/orm").from} from * @property {import("./db/orm").from} from

View file

@ -5,7 +5,7 @@ const util = require("util")
const {addbot} = require("../addbot") const {addbot} = require("../addbot")
const passthrough = require("./passthrough") const passthrough = require("./passthrough")
const {discord, sync, db} = passthrough const {discord, sync, db, select, from, as} = passthrough
const data = sync.require("../test/data") const data = sync.require("../test/data")
const createSpace = sync.require("./d2m/actions/create-space") const createSpace = sync.require("./d2m/actions/create-space")
@ -19,19 +19,18 @@ const eventDispatcher = sync.require("./d2m/event-dispatcher")
const updatePins = sync.require("./d2m/actions/update-pins") const updatePins = sync.require("./d2m/actions/update-pins")
const speedbump = sync.require("./d2m/actions/speedbump") const speedbump = sync.require("./d2m/actions/speedbump")
const ks = sync.require("./matrix/kstate") const ks = sync.require("./matrix/kstate")
const setPresence = sync.require("./d2m/actions/set-presence")
const channelWebhook = sync.require("./m2d/actions/channel-webhook")
const guildID = "112760669178241024" const guildID = "112760669178241024"
const extraContext = {}
if (process.stdin.isTTY) { if (process.stdin.isTTY) {
setImmediate(() => { // assign after since old extraContext data will get removed setImmediate(() => {
if (!passthrough.repl) { if (!passthrough.repl) {
const cli = repl.start({ prompt: "", eval: customEval, writer: s => s }) const cli = repl.start({ prompt: "", eval: customEval, writer: s => s })
Object.assign(cli.context, extraContext, passthrough) Object.assign(cli.context, passthrough)
passthrough.repl = cli passthrough.repl = cli
} else {
Object.assign(passthrough.repl.context, extraContext)
} }
// @ts-ignore
sync.addTemporaryListener(passthrough.repl, "exit", () => process.exit()) sync.addTemporaryListener(passthrough.repl, "exit", () => process.exit())
}) })
} }
@ -60,9 +59,3 @@ async function customEval(input, _context, _filename, callback) {
return callback(null, util.inspect(e, false, 100, true)) return callback(null, util.inspect(e, false, 100, true))
} }
} }
sync.events.once(__filename, () => {
for (const key in extraContext) {
delete passthrough.repl.context[key]
}
})

25
src/types.d.ts vendored
View file

@ -28,6 +28,9 @@ export type AppServiceRegistrationConfig = {
content_length_workaround: boolean content_length_workaround: boolean
include_user_id_in_mxid: boolean include_user_id_in_mxid: boolean
invite: string[] invite: string[]
discord_origin?: string
discord_cdn_origin?: string,
web_password: string
} }
old_bridge?: { old_bridge?: {
as_token: string as_token: string
@ -114,7 +117,7 @@ export namespace Event {
sender: string sender: string
content: T content: T
origin_server_ts: number origin_server_ts: number
unsigned: any unsigned?: any
event_id: string event_id: string
} }
@ -137,7 +140,7 @@ export namespace Event {
content: any content: any
state_key: string state_key: string
origin_server_ts: number origin_server_ts: number
unsigned: any unsigned?: any
event_id: string event_id: string
user_id: string user_id: string
age: number age: number
@ -241,6 +244,14 @@ export namespace Event {
name?: string name?: string
} }
export type M_Room_Topic = {
topic?: string
}
export type M_Room_PinnedEvents = {
pinned: string[]
}
export type M_Power_Levels = { export type M_Power_Levels = {
/** The level required to ban a user. Defaults to 50 if unspecified. */ /** The level required to ban a user. Defaults to 50 if unspecified. */
ban?: number, ban?: number,
@ -271,6 +282,11 @@ export namespace Event {
users_default?: number users_default?: number
} }
export type M_Space_Child = {
via?: string[]
suggested?: boolean
}
export type M_Reaction = { export type M_Reaction = {
"m.relates_to": { "m.relates_to": {
rel_type: "m.annotation" rel_type: "m.annotation"
@ -332,6 +348,11 @@ export namespace R {
room_id: string room_id: string
room_type?: string room_type?: string
} }
export type ResolvedRoom = {
room_id: string
servers: string[]
}
} }
export type Pagination<T> = { export type Pagination<T> = {

33
src/web/auth.js Normal file
View file

@ -0,0 +1,33 @@
// @ts-check
const h3 = require("h3")
const {db} = require("../passthrough")
const {reg} = require("../matrix/read-registration")
/**
* Combined guilds managed by Discord account + Matrix account.
* @param {h3.H3Event} event
* @returns {Promise<Set<string>>} guild IDs
*/
async function getManagedGuilds(event) {
const session = await useSession(event)
const managed = new Set(session.data.managedGuilds || [])
if (session.data.mxid) {
const matrixGuilds = db.prepare("SELECT guild_id FROM guild_space INNER JOIN member_cache ON space_id = room_id WHERE mxid = ? AND power_level >= 50").pluck().all(session.data.mxid)
for (const id of matrixGuilds) {
managed.add(id)
}
}
return managed
}
/**
* @param {h3.H3Event} event
* @returns {ReturnType<typeof h3.useSession<{userID?: string, mxid?: string, managedGuilds?: string[], state?: string, selfService?: boolean, password?: string}>>}
*/
function useSession(event) {
return h3.useSession(event, {password: reg.as_token})
}
module.exports.getManagedGuilds = getManagedGuilds
module.exports.useSession = useSession

View file

@ -3,12 +3,15 @@
const assert = require("assert/strict") const assert = require("assert/strict")
const fs = require("fs") const fs = require("fs")
const {join} = require("path") const {join} = require("path")
const getRelativePath = require("get-relative-path")
const h3 = require("h3") const h3 = require("h3")
const {defineEventHandler, defaultContentType, setResponseStatus, useSession, getQuery} = h3 const {defineEventHandler, defaultContentType, setResponseStatus, getQuery} = h3
const {compileFile} = require("@cloudrac3r/pug") const {compileFile} = require("@cloudrac3r/pug")
const pretty = process.argv.join(" ").includes("test")
const {as} = require("../passthrough") const {sync} = require("../passthrough")
const {reg} = require("../matrix/read-registration") /** @type {import("./auth")} */
const auth = sync.require("./auth")
// Pug // Pug
@ -31,17 +34,27 @@ function render(event, filename, locals) {
function compile() { function compile() {
try { try {
const template = compileFile(path, {}) const template = compileFile(path, {pretty})
pugCache.set(path, async (event, locals) => { pugCache.set(path, async (event, locals) => {
defaultContentType(event, "text/html; charset=utf-8") defaultContentType(event, "text/html; charset=utf-8")
const session = await useSession(event, {password: reg.as_token}) const session = await auth.useSession(event)
const managed = await auth.getManagedGuilds(event)
const rel = (to, paramsObject) => {
let result = getRelativePath(event.path, to)
if (paramsObject) {
const params = new URLSearchParams(paramsObject)
result += "?" + params.toString()
}
return result
}
return template(Object.assign({}, return template(Object.assign({},
getQuery(event), // Query parameters can be easily accessed on the top level but don't allow them to overwrite anything getQuery(event), // Query parameters can be easily accessed on the top level but don't allow them to overwrite anything
globals, // Globals globals, // Globals
locals, // Explicit locals overwrite globals in case we need to DI something locals, // Explicit locals overwrite globals in case we need to DI something
{session} // Session is always session because it has to be trusted {session, event, rel, managed} // These are assigned last so they overwrite everything else. It would be catastrophically bad if they can't be trusted.
)) ))
}) })
/* c8 ignore start */
} catch (e) { } catch (e) {
pugCache.set(path, async (event) => { pugCache.set(path, async (event) => {
setResponseStatus(event, 500, "Internal Template Error") setResponseStatus(event, 500, "Internal Template Error")
@ -49,6 +62,7 @@ function render(event, filename, locals) {
return e.toString() return e.toString()
}) })
} }
/* c8 ignore stop */
} }
if (!pugCache.has(path)) { if (!pugCache.has(path)) {

View file

@ -11,7 +11,9 @@ mixin badge-private
| Private | Private
mixin discord(channel, radio=false) mixin discord(channel, radio=false)
- let permissions = dUtils.getPermissions([], discord.guilds.get(channel.guild_id).roles, "", channel.permission_overwrites) //- Previously, we passed guild.roles as the second parameter, but this doesn't quite match Discord's behaviour. See issue #42 for why this was changed.
//- Basically we just want to assign badges based on the channel overwrites, without considering the guild's base permissions. /shrug
- let permissions = dUtils.getPermissions([], [{id: guild_id, name: "@everyone", permissions: 1<<10 | 1<<11}], null, channel.permission_overwrites)
.s-user-card.s-user-card__small .s-user-card.s-user-card__small
if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel) if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel)
!= icons.Icons.IconLock != icons.Icons.IconLock
@ -49,132 +51,50 @@ mixin matrix(row, radio=false, badge="")
+badge-private +badge-private
block body block body
if !guild_id && session.data.managedGuilds .s-page-title.mb24
.s-empty-state.wmx4.p48 h1.s-page-title--header= guild.name
!= icons.Spots.SpotEmptyXL
p Select a server from the top right corner to continue.
p If the server you're looking for isn't there, try #[a(href="/oauth?action=add") logging in again.]
else if !session.data.managedGuilds .d-flex.g16(class="sm:fw-wrap")
.s-empty-state.wmx4.p48 .fl-grow1
!= icons.Spots.SpotEmptyXL h2.fs-headline1 Invite a Matrix user
p You need to log in to manage your servers.
a.s-btn.s-btn__icon.s-btn__filled(href="/oauth")
!= icons.Icons.IconDiscord
= ` Log in with Discord`
else if !discord.guilds.has(guild_id) || !session.data.managedGuilds || !session.data.managedGuilds.includes(guild_id) form.d-grid.g-af-column.gy4.gx8.jc-start(method="post" action="/api/invite" hx-post="/api/invite" hx-trigger="submit" hx-swap="none" hx-on::after-request="if (event.detail.successful) this.reset()" hx-disabled-elt="input, button" hx-indicator="#invite-button")
.s-empty-state.wmx4.p48 label.s-label(for="mxid") Matrix ID
!= icons.Spots.SpotAlertXL input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org" pattern="@([^:]+):([a-z0-9:\\-]+\\.[a-z0-9.:\\-]+)")
p Either the selected server doesn't exist, or you don't have the Manage Server permission on Discord. label.s-label(for="permissions") Permissions
p If you've checked your permissions, try #[a(href="/oauth") logging in again.] .s-select
select#permissions(name="permissions")
else option(value="default") Default
- let guild = discord.guilds.get(guild_id) option(value="moderator") Moderator
option(value="admin") Admin
.s-page-title.mb24
h1.s-page-title--header= guild.name
.d-flex.g16
.fl-grow1
h2.fs-headline1 Invite a Matrix user
form.d-grid.g-af-column.gy4.gx8.jc-start(method="post" action="/api/invite" style="grid-template-rows: repeat(2, auto)")
label.s-label(for="mxid") Matrix ID
input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org")
label.s-label(for="permissions") Permissions
.s-select
select#permissions(name="permissions")
option(value="default") Default
option(value="moderator") Moderator
input(type="hidden" name="guild_id" value=guild_id)
.grid--row-start2
button.s-btn.s-btn__filled.htmx-indicator Invite
div
-
let size = 105
let p = new URLSearchParams()
p.set("data", `https://bridge.cadence.moe/invite?nonce=${nonce}`)
img(width=size height=size src=`/qr?${p}`)
h2.mt48.fs-headline1 Moderation
h2.mt48.fs-headline1 Matrix setup
h3.mt32.fs-category Linked channels
-
function getPosition(channel) {
let position = 0
let looking = channel
while (looking.parent_id) {
looking = discord.channels.get(looking.parent_id)
position = looking.position * 1000
}
if (channel.position) position += channel.position
return position
}
let channelIDs = discord.guildChannelMap.get(guild_id)
let linkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {channel_id: channelIDs}).all()
let linkedChannelsWithDetails = linkedChannels.map(c => ({channel: discord.channels.get(c.channel_id), ...c})).filter(c => c.channel)
let linkedChannelIDs = linkedChannelsWithDetails.map(c => c.channel_id)
linkedChannelsWithDetails.sort((a, b) => getPosition(a.channel) - getPosition(b.channel))
let unlinkedChannelIDs = channelIDs.filter(c => !linkedChannelIDs.includes(c))
let unlinkedChannels = unlinkedChannelIDs.map(c => discord.channels.get(c)).filter(c => [0, 5].includes(c.type))
unlinkedChannels.sort((a, b) => getPosition(a) - getPosition(b))
let linkedRoomIDs = linkedChannels.map(c => c.room_id)
let unlinkedRooms = rooms.filter(r => !linkedRoomIDs.includes(r.room_id) && !r.room_type)
// https://discord.com/developers/docs/topics/threads#active-archived-threads
// need to filter out linked archived threads from unlinkedRooms, will just do that by comparing against the name
unlinkedRooms = unlinkedRooms.filter(r => !r.name.match(/^\[(🔒)?⛓️\]/))
.s-card.bs-sm.p0
.s-table-container
table.s-table.s-table__bx-simple
each row in linkedChannelsWithDetails
tr
td.w40: +discord(row.channel)
td.p2: button.s-btn.s-btn__muted.s-btn__xs!= icons.Icons.IconLinkSm
td: +matrix(row)
else
tr
td(colspan="3")
.s-empty-state No channels linked between Discord and Matrix yet...
h3.mt32.fs-category Auto-create
.s-card
form.d-flex.ai-center.g8
label.s-label.fl-grow1(for="autocreate")
| Create new Matrix rooms automatically
p.s-description If you want, OOYE can automatically create new Matrix rooms and link them when an unlinked Discord channel is spoken in.
- let value = !!select("guild_active", "autocreate", {guild_id}).pluck().get()
input(type="hidden" name="guild_id" value=guild_id) input(type="hidden" name="guild_id" value=guild_id)
input.s-toggle-switch.order-last#autocreate(name="autocreate" type="checkbox" hx-post="/api/autocreate" hx-indicator="#autocreate-loading" hx-disabled-elt="this" checked=value) .grid--row-start2
.is-loading#autocreate-loading button.s-btn.s-btn__filled#invite-button Invite
div
.s-card.d-flex.ai-center.jc-center(style="min-width: 132px; min-height: 132px;")
button.s-btn(class=space_id ? "s-btn__muted" : "s-btn__filled" hx-get=`/qr?guild_id=${guild_id}` hx-indicator="closest button" hx-swap="outerHTML" hx-disabled-elt="this") Show QR
if space_id
h3.mt32.fs-category Privacy level h3.mt32.fs-category Privacy level
span#privacy-level-loading
.s-card .s-card
form(hx-post="/api/privacy-level" hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="this") form(hx-post="/api/privacy-level" hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="input")
input(type="hidden" name="guild_id" value=guild_id) input(type="hidden" name="guild_id" value=guild_id)
.d-flex.ai-center.mb4
label.s-label.fl-grow1
| How people can join on Matrix
span.is-loading#privacy-level-loading
.s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental.d-grid.gx16.ai-center(style="grid-template-columns: auto 1fr") .s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental.d-grid.gx16.ai-center(style="grid-template-columns: auto 1fr")
input(type="radio" name="level" value="directory" id="privacy-level-directory" checked=(privacy_level === 2)) input(type="radio" name="privacy_level" value="directory" id="privacy-level-directory" checked=(privacy_level === 2))
label.d-flex.gx8.jc-center.grid--row-start3(for="privacy-level-directory") label.d-flex.gx8.jc-center.grid--row-start3(for="privacy-level-directory")
!= icons.Icons.IconPlusSm != icons.Icons.IconPlusSm
!= icons.Icons.IconInternationalSm != icons.Icons.IconInternationalSm
.fl-grow1 Directory .fl-grow1 Directory
input(type="radio" name="level" value="link" id="privacy-level-link" checked=(privacy_level === 1)) input(type="radio" name="privacy_level" value="link" id="privacy-level-link" checked=(privacy_level === 1))
label.d-flex.gx8.jc-center.grid--row-start2(for="privacy-level-link") label.d-flex.gx8.jc-center.grid--row-start2(for="privacy-level-link")
!= icons.Icons.IconPlusSm != icons.Icons.IconPlusSm
!= icons.Icons.IconLinkSm != icons.Icons.IconLinkSm
.fl-grow1 Link .fl-grow1 Link
input(type="radio" name="level" value="invite" id="privacy-level-invite" checked=(privacy_level === 0)) input(type="radio" name="privacy_level" value="invite" id="privacy-level-invite" checked=(privacy_level === 0))
label.d-flex.gx8.jc-center.grid--row-start1(for="privacy-level-invite") label.d-flex.gx8.jc-center.grid--row-start1(for="privacy-level-invite")
svg.svg-icon(width="14" height="14" viewBox="0 0 14 14") svg.svg-icon(width="14" height="14" viewBox="0 0 14 14")
!= icons.Icons.IconLockSm != icons.Icons.IconLockSm
@ -184,44 +104,116 @@ block body
p.s-description.m0 Shareable invite links, like Discord p.s-description.m0 Shareable invite links, like Discord
p.s-description.m0 Publicly listed in directory, like Discord server discovery p.s-description.m0 Publicly listed in directory, like Discord server discovery
h2.mt48.fs-headline1 Features
.s-card.d-grid.px0.g16
form.d-flex.ai-center.g16
#url-preview-loading.p8
- let value = !!select("guild_space", "url_preview", {guild_id}).pluck().get()
input(type="hidden" name="guild_id" value=guild_id)
input.s-toggle-switch#url-preview(name="url_preview" type="checkbox" hx-post="/api/url-preview" hx-indicator="#url-preview-loading" hx-disabled-elt="this" checked=value autocomplete="off")
label.s-label.fl-grow1(for="url-preview")
| Show Discord's URL previews on Matrix
p.s-description Shows info about links posted to chat. Discord's previews are generally better quality than Synapse's, especially for social media and videos.
//- form.d-flex.ai-center.g16
fieldset.s-check-group #presence-loading.p8
legend.s-label How people can join on Matrix - value = !!select("guild_space", "presence", {guild_id}).pluck().get()
.s-check-control input(type="hidden" name="guild_id" value=guild_id)
input.s-radio(type="radio" name="privacy-level" id="privacy-level-invite" value="invite" checked) input.s-toggle-switch#presence(name="presence" type="checkbox" hx-post="/api/presence" hx-indicator="#presence-loading" hx-disabled-elt="this" checked=value autocomplete="off")
label.s-label(for="privacy-level-invite") label.s-label(for="presence")
| Invite | Show online statuses on Matrix
p.s-description In-app direct invite on Matrix; invite command on Discord; invite form on web p.s-description This might cause lag on really big Discord servers.
.s-check-control
input.s-radio(type="radio" name="privacy-level" id="privacy-level-link" value="link")
label.s-label(for="privacy-level-link")
| Link
p.s-description All of the above, and shareable invite links (like Discord)
.s-check-control
input.s-radio(type="radio" name="privacy-level" id="privacy-level-directory" value="directory")
label.s-label(for="privacy-level-directory")
| Public
p.s-description All of the above, and publicly visible in the Matrix space directory (like Server Discovery)
if space_id
h2.mt48.fs-headline1 Channel setup
h3.mt32.fs-category Linked channels
.s-card.bs-sm.p0
form.s-table-container(method="post" action="/api/unlink" hx-confirm="Do you want to unlink these channels?\nIt may take a moment to clean up Matrix resources.")
input(type="hidden" name="guild_id" value=guild_id)
table.s-table.s-table__bx-simple
each row in linkedChannelsWithDetails
tr
td.w40: +discord(row.channel)
td.p2: button.s-btn.s-btn__muted.s-btn__xs(name="channel_id" value=row.channel.id hx-post="/api/unlink" hx-trigger="click" hx-disabled-elt="this")!= icons.Icons.IconLinkSm
td: +matrix(row)
else
tr
td(colspan="3")
.s-empty-state No channels linked between Discord and Matrix yet...
h3.fs-category.mt32 Auto-create
.s-card.d-grid.px0
form.d-flex.ai-center.g16
#autocreate-loading.p8
- let value = !!select("guild_active", "autocreate", {guild_id}).pluck().get()
input(type="hidden" name="guild_id" value=guild_id)
input.s-toggle-switch#autocreate(name="autocreate" type="checkbox" hx-post="/api/autocreate" hx-indicator="#autocreate-loading" hx-disabled-elt="this" checked=value autocomplete="off")
label.s-label.fl-grow1(for="autocreate")
| Create new Matrix rooms automatically
p.s-description If you want, OOYE can automatically create new Matrix rooms and link them when an unlinked Discord channel is spoken in.
if space_id
h3.mt32.fs-category Manually link channels h3.mt32.fs-category Manually link channels
form.d-flex.g16.ai-start(hx-post="/api/privacy-level" hx-trigger="submit" hx-disabled-elt="this") form.d-flex.g16.ai-start(hx-post="/api/link" hx-trigger="submit" hx-disabled-elt="input, button" hx-indicator="#link-button")
.fl-grow2.s-btn-group.fd-column.w40 .fl-grow2.s-btn-group.fd-column.w40
each channel in unlinkedChannels each channel in unlinkedChannels
input.s-btn--radio(type="radio" name="discord" id=channel.id value=channel.id) input.s-btn--radio(type="radio" name="discord" required id=channel.id value=channel.id)
label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id) label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id)
+discord(channel, true, "Announcement") +discord(channel, true, "Announcement")
else else
.s-empty-state.p8 All Discord channels are linked. .s-empty-state.p8 All Discord channels are linked.
.fl-grow1.s-btn-group.fd-column.w30 .fl-grow1.s-btn-group.fd-column.w30
each room in unlinkedRooms each room in unlinkedRooms
input.s-btn--radio(type="radio" name="matrix" id=room.room_id value=room.room_id) input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id)
label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id) label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id)
+matrix(room, true) +matrix(room, true)
else else
.s-empty-state.p8 All Matrix rooms are linked. .s-empty-state.p8 All Matrix rooms are linked.
input(type="hidden" name="guild_id" value=guild_id) input(type="hidden" name="guild_id" value=guild_id)
div div
button.s-btn.s-btn__icon.s-btn__filled.htmx-indicator button.s-btn.s-btn__icon.s-btn__filled#link-button
!= icons.Icons.IconMerge != icons.Icons.IconMerge
= ` Link` = ` Link`
details.mt48
summary Debug room list
.d-grid.grid__2.gx24
div
h3.mt24 Channels
p Channels are read from the channel_room table and then merged with the discord.channels memory cache to make the merged list. Anything in memory cache that's not in channel_room is considered unlinked.
div
h3.mt24 Rooms
p Rooms use the same merged list as channels, based on augmented channel_room data. Then, rooms are read from the space. Anything in the space that's not merged is considered unlinked.
div
h3.mt24 Unavailable channels: Deleted from Discord
.s-card.p0
ul.my8.ml24
each row in removedUncachedChannels
li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`)= row.nick || row.name
h3.mt24 Unavailable channels: Wrong type
.s-card.p0
ul.my8.ml24
each row in removedWrongTypeChannels
li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name}
h3.mt24 Unavailable channels: Bridge can't access
.s-card.p0
ul.my8.ml24
each row in removedPrivateChannels
li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`)= row.name
div- // Rooms
h3.mt24 Unavailable rooms: Already linked
.s-card.p0
ul.my8.ml24
each row in removedLinkedRooms
li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name
h3.mt24 Unavailable rooms: Wrong type
.s-card.p0
ul.my8.ml24
each row in removedWrongTypeRooms
li: a(href=`https://matrix.to/#/${row.room_id}`) (#{row.room_type}) #{row.name}
h3.mt24 Unavailable rooms: Archived thread
.s-card.p0
ul.my8.ml24
each row in removedArchivedThreadRooms
li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name

View file

@ -0,0 +1,36 @@
extends includes/template.pug
block body
if !session.data.userID
.s-empty-state.wmx4.p48
!= icons.Spots.SpotEmptyXL
p You need to log in to manage your servers.
.d-flex.jc-center.g8
a.s-btn.s-btn__icon.s-btn__blurple.s-btn__filled(href=rel("/oauth"))
!= icons.Icons.IconDiscord
= ` Log in with Discord`
a.s-btn.s-btn__icon.s-btn__matrix.s-btn__filled(href=rel("/log-in-with-matrix"))
!= icons.Icons.IconSpeechBubble
= ` Log in with Matrix`
else if !guild_id
.s-empty-state.wmx4.p48
!= icons.Spots.SpotEmptyXL
p Select a server from the top right corner to continue.
p If the server you're looking for isn't there, try #[a(href=rel("/oauth?action=add")) logging in again.]
else if !discord.guilds.has(guild_id) || !managed.has(guild_id)
.s-empty-state.wmx4.p48
!= icons.Spots.SpotAlertXL
p Either the selected server doesn't exist, or you don't have the Manage Server permission on Discord.
p If you've checked your permissions, try #[a(href=rel("/oauth")) logging in again.]
else if !row
.s-empty-state.wmx4.p48
!= icons.Spots.SpotAlertXL
p Please add the bot to your server using the buttons on the home page.
else
.s-empty-state.wmx4.p48
!= icons.Spots.SpotAlertXL
p Access denied.

View file

@ -0,0 +1,53 @@
extends includes/template.pug
mixin space(space)
.s-user-card.flex__1
span.s-avatar.s-avatar__32.s-user-card--avatar
if space.avatar
img.s-avatar--image(src=mUtils.getPublicUrlForMxc(space.avatar))
else
.s-avatar--letter.bg-silver-400.bar-md(aria-hidden="true")= space.name[0]
.s-user-card--info.ai-start
strong= space.name
if space.topic
ul.s-user-card--awards
li= space.topic
block body
.s-notice.s-notice__info.d-flex.g16
div
!= icons.Icons.IconInfo
div
- const self = `@${reg.sender_localpart}:${reg.ooye.server_name}`
strong You picked self-service mode
.mt4 To complete setup, you need to manually choose a Matrix space to link with #[strong= guild.name].
.mt4 On Matrix, invite #[code.s-code-block: a.fc-black.s-link(href=`https://matrix.to/#/${self}` target="_blank")= self] to a space. Then you can pick the space on this page.
h3.mt32.fs-category Choose a space
form.s-card.bs-sm.p0.s-table-container.bar-md(method="post" action="/api/link-space")
input(type="hidden" name="guild_id" value=guild_id)
table.s-table.s-table__bx-simple
each space in spaces
tr
td.p0: +space(space)
td: button.s-btn(name="space_id" value=space.room_id hx-post="/api/link-space" hx-trigger="click" hx-disabled-elt="this") Link with this space
else
if session.data.mxid
tr
td.p16 Invite the bridge to a space, and the space will show up here.
else
tr
td.d-flex.ai-center.pl16.g16
| You need to log in with Matrix first.
a.s-btn.s-btn__matrix.s-btn__outlined(href=rel(`/log-in-with-matrix`, {next: `./guild?guild_id=${guild_id}`})) Log in with Matrix
h3.mt48.fs-category Auto-create
.s-card
form.d-flex.ai-center.g8(method="post" action="/api/autocreate" hx-post="/api/autocreate" hx-indicator="#easy-mode-button")
input(type="hidden" name="guild_id" value=guild_id)
input(type="hidden" name="autocreate" value="true")
label.s-label.fl-grow1
| Changed your mind?
p.s-description If you want, OOYE can create and manage the Matrix space so you don't have to.
button.s-btn.s-btn__outlined#easy-mode-button Use easy mode

View file

@ -1,24 +1,56 @@
extends includes/template.pug extends includes/template.pug
block body block body
.s-page-title.mb24 - let locked = reg.ooye.web_password && reg.ooye.web_password !== session.data.password
h1.s-page-title--header Bridge a Discord server
.d-grid.g24.grid__2(class="sm:grid__1") if locked
.s-card.bs-md.d-flex.fd-column aside.s-notice.s-notice__warning.p8
h2 Easy mode .d-flex.flex__center.jc-space-between.s-banner--container.g8(class="md:fw-wrap")
p Add the bot to your Discord server. .d-flex.ai-center.g8
p It will automatically create new Matrix rooms for you. .flex--item!= icons.Icons.IconLock
.fl-grow1 p.m0 <strong>Private instance.</strong> You need the password to use this instance of Out Of Your Element.
a.s-btn.s-btn__filled.s-btn__icon(href="/oauth?action=add") form(method="post" action="/api/password")
!= icons.Icons.IconPlus input.s-input(placeholder="Enter password" name="password")
= ` Add to server`
.s-card.bs-md.d-flex.fd-column .h32
h2 Self-service
p OOYE will link an existing Discord server and Matrix space together. .s-page-title.mb24
p Choose this option if you already have a community set up on Matrix. h1.s-page-title--header Out Of Your Element
p Or, choose this if you're migrating from a different bridge.
.fl-grow1 else
a.s-btn.s-btn__outlined.s-btn__icon(href="/oauth?action=add-self-service") .s-page-title.mb24
!= icons.Icons.IconUnorderedList h1.s-page-title--header Bridge a Discord server
= ` Set up self-service`
.d-grid.g24.grid__2.mb24(class="sm:grid__1")
.s-card.bs-md.d-flex.fd-column
h2 Easy mode
p Add the bot to your Discord server.
p It will automatically create new Matrix rooms for you.
.fl-grow1
a.s-btn.s-btn__filled.s-btn__icon(href=rel("/oauth?action=add"))
!= icons.Icons.IconPlus
= ` Add to server`
.s-card.bs-md.d-flex.fd-column
h2 Self-service
p OOYE will link an existing Discord server and Matrix space together.
p Choose this option if you already have a community set up on Matrix.
p Or, choose this if you're migrating from a different bridge.
.fl-grow1
a.s-btn.s-btn__outlined.s-btn__icon(href=rel("/oauth?action=add-self-service"))
!= icons.Icons.IconUnorderedList
= ` Set up self-service`
.s-prose
h2 What is this?
p #[a(href="https://gitdab.com/cadence/out-of-your-element") Out Of Your Element] is a bridge between the Discord and Matrix chat apps. It lets people on both platforms chat with each other without needing to get everyone on the same app.
p Just chat like usual, and the bridge will forward messages back and forth between the two platforms, so everyone sees the whole conversation.
p All kinds of content are supported, including pictures, threads, emojis, and @mentions.
p It's really easy to set up, even if you only have Discord. Just add the bot to your server, and it'll make everything available on Matrix automatically.
if locked
h2 This is a private instance
p Anybody can run their own instance of the Out Of Your Element software. The person running this instance has made it private, so you can't add it to your server just yet. If you know the people in charge of #{reg.ooye.server_name}, ask them for the password.
h2 Run your own instance
p You can still use Out Of Your Element by running your own copy of the software, but this requires some technical skill.
p To get started, #[a(href="https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/get-started.md") check the installation instructions.]

View file

@ -1,46 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <svg fill="none" viewBox="0 0 16 16" height="16" width="16"><path stroke="currentcolor" stroke-width="2" d="m6.75 1-2.5 14m7.5-14-2.5 14M14 10.25H1m14-4.5H2"></path></svg>
<svg
aria-hidden="true"
class="svg-icon iconItalic"
width="18"
height="18"
viewBox="0 0 18 18"
version="1.1"
id="svg1"
sodipodi:docname="hash.svg"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<path
style="opacity:1;stroke-width:24.2222;stroke-linecap:square;paint-order:stroke fill markers"
d="m 13.949463,2.0417087 c 0,0 0.664304,0.00704 0.854464,0.00134 0.19016,-0.0057 0.924873,0.2384962 0.57664,0.9863413 -0.288846,0.6203095 -5.045042,11.035358 -5.4783833,11.984378 -0.4333415,0.949021 -0.7881247,0.945761 -1.3553087,0.945761 -0.567184,0 -0.3175392,0 -0.734375,0 -0.4168358,0 -0.7985231,-0.467356 -0.5770328,-0.951217 C 7.4569576,14.524452 12.479729,3.5512928 12.725807,3.0070042 13.022379,2.3510304 13.336114,2.0361844 13.949463,2.0417087 Z"
id="path4"
sodipodi:nodetypes="czszzzzsc" />
<rect
style="opacity:1;stroke-width:27.7591;stroke-linecap:square;paint-order:stroke fill markers"
id="rect4"
width="11.987322"
height="2"
x="2.002677"
y="11.007812"
rx="1"
ry="1" />
<rect
style="opacity:1;stroke-width:27.7591;stroke-linecap:square;paint-order:stroke fill markers"
id="rect5"
width="11.987322"
height="2"
x="4.0100012"
y="5.007813"
rx="1"
ry="1" />
<path
style="opacity:1;stroke-width:24.2222;stroke-linecap:square;paint-order:stroke fill markers"
d="m 9.1764922,2.0417087 c 0,0 0.664304,0.00704 0.8544638,0.00134 0.19016,-0.0057 0.924873,0.2384962 0.57664,0.9863413 -0.288846,0.6203095 -5.0450418,11.035358 -5.4783831,11.984378 -0.4333415,0.949021 -0.7881247,0.945761 -1.3553087,0.945761 -0.567184,0 -0.3175392,0 -0.734375,0 -0.4168358,0 -0.7985231,-0.467356 -0.5770328,-0.951217 C 2.6839868,14.524452 7.7067582,3.5512928 7.9528362,3.0070042 8.2494082,2.3510304 8.5631432,2.0361844 9.1764922,2.0417087 Z"
id="path1"
sodipodi:nodetypes="czszzzzsc" />
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 172 B

View file

@ -1,71 +1,150 @@
mixin guild(guild) mixin guild(guild)
span.s-avatar.s-avatar__32.s-user-card--avatar span.s-avatar.s-avatar__32.s-user-card--avatar
if guild.icon if guild.icon
img.s-avatar--image(src=`https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=32`) img.s-avatar--image(src=`https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=32`)
else else
.s-avatar--letter.bg-silver-400.bar-md(aria-hidden="true")= guild.name[0] .s-avatar--letter.bg-silver-400.bar-md(aria-hidden="true")= guild.name[0]
.s-user-card--info.ai-start .s-user-card--info.ai-start
strong= guild.name strong= guild.name
ul.s-user-card--awards ul.s-user-card--awards
li #{discord.guildChannelMap.get(guild.id).filter(c => [0, 5, 15, 16].includes(discord.channels.get(c).type)).length} channels li #{discord.guildChannelMap.get(guild.id).filter(c => [0, 5, 15, 16].includes(discord.channels.get(c).type)).length} channels
doctype html mixin define-theme(name, h, s, l)
html(lang="en") style.
head :root {
title Out Of Your Element --#{name}-h: #{h};
<meta name="viewport" content="width=device-width, initial-scale=1"> --#{name}-s: #{s};
link(rel="stylesheet" type="text/css" href="/static/stacks.min.css") --#{name}-l: #{l};
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 80%22><text y=%22.83em%22 font-size=%2283%22>💬</text></svg>"> --#{name}: var(--#{name}-400);
meta(name="htmx-config" content='{"indicatorClass":"is-loading"}') --#{name}-100: hsl(var(--#{name}-h), calc(var(--#{name}-s) + 0 * 1%), clamp(70%, calc(var(--#{name}-l) + 50 * 1%), 95%));
style. --#{name}-200: hsl(var(--#{name}-h), calc(var(--#{name}-s) + 0 * 1%), clamp(55%, calc(var(--#{name}-l) + 35 * 1%), 90%));
.themed { --#{name}-300: hsl(var(--#{name}-h), calc(var(--#{name}-s) + 0 * 1%), clamp(35%, calc(var(--#{name}-l) + 15 * 1%), 75%));
--theme-base-primary-color-h: 266; --#{name}-400: hsl(var(--#{name}-h), calc(var(--#{name}-s) + 0 * 1%), clamp(20%, calc(var(--#{name}-l) + 0 * 1%), 60%));
--theme-base-primary-color-s: 53%; --#{name}-500: hsl(var(--#{name}-h), calc(var(--#{name}-s) + 0 * 1%), clamp(15%, calc(var(--#{name}-l) + -14 * 1%), 45%));
--theme-base-primary-color-l: 63%; --#{name}-600: hsl(var(--#{name}-h), calc(var(--#{name}-s) + 0 * 1%), clamp(5%, calc(var(--#{name}-l) + -26 * 1%), 30%));
--theme-dark-primary-color-h: 266; }
--theme-dark-primary-color-s: 53%;
--theme-dark-primary-color-l: 63%; mixin define-themed-button(name, theme)
} style.
.s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental input[type="radio"]:checked ~ label:not(.s-toggle-switch--label-off) { .s-btn.s-btn__#{name} {
--_ts-multiple-bg: var(--green-400); --_bu-bg-active: var(--#{theme}-300);
--_ts-multiple-fc: var(--white); --_bu-bg-hover: var(--#{theme}-200);
} --_bu-bg-selected: var(--#{theme}-300);
body.themed.theme-system --_bu-fc: var(--#{theme}-500);
header.s-topbar --_bu-fc-active: var(--_bu-fc);
.s-topbar--skip-link(href="#content") Skip to main content --_bu-fc-hover: var(--#{theme}-500);
.s-topbar--container.wmx9 --_bu-fc-selected: var(--#{theme}-600);
a.s-topbar--logo(href="/") --_bu-filled-bc: transparent;
img.s-avatar.s-avatar__32(src="/icon.png") --_bu-filled-bc-selected: var(--_bu-filled-bc);
nav.s-topbar--navigation --_bu-filled-bg: var(--#{theme}-400);
ul.s-topbar--content --_bu-filled-bg-active: var(--#{theme}-500);
li.ps-relative --_bu-filled-bg-hover: var(--#{theme}-500);
if !session.data.managedGuilds || session.data.managedGuilds.length === 0 --_bu-filled-bg-selected: var(--#{theme}-600);
a.s-btn.s-btn__icon.as-center(href="/oauth") --_bu-filled-fc: var(--white);
!= icons.Icons.IconDiscord --_bu-filled-fc-active: var(--_bu-filled-fc);
= ` Log in` --_bu-filled-fc-hover: var(--_bu-filled-fc);
else if guild_id && session.data.managedGuilds.includes(guild_id) && discord.guilds.has(guild_id) --_bu-filled-fc-selected: var(--_bu-filled-fc);
button.s-topbar--item.s-btn.s-btn__muted.s-user-card(popovertarget="guilds") --_bu-outlined-bc: var(--#{theme}-400);
+guild(discord.guilds.get(guild_id)) --_bu-outlined-bc-selected: var(--#{theme}-500);
else if session.data.managedGuilds --_bu-outlined-bg-selected: var(--_bu-bg-selected);
button.s-topbar--item.s-btn.s-btn__muted.s-btn__dropdown.pr24.s-user-card.s-label(popovertarget="guilds") --_bu-outlined-fc-selected: var(--_bu-fc-selected);
| Your servers --_bu-number-fc: var(--white);
#guilds(popover data-popper-placement="bottom" style="display: revert; width: revert;").s-popover.overflow-visible --_bu-number-fc-filled: var(--#{theme});
.s-popover--arrow.s-popover--arrow__tc }
.s-popover--content.overflow-y-auto.overflow-x-hidden
ul.s-menu(role="menu") doctype html
each guild in (session.data.managedGuilds || []).map(id => discord.guilds.get(id)).filter(g => g) html(lang="en")
li(role="menuitem") head
a.s-topbar--item.s-user-card.d-flex.p4(href=`/guild?guild_id=${guild.id}`) title Out Of Your Element
+guild(guild) <meta name="viewport" content="width=device-width, initial-scale=1">
.mx-auto.w100.wmx9.py24.px8.fs-body1#content link(rel="stylesheet" type="text/css" href=rel("/static/stacks.min.css"))
block body <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 80%22><text y=%22.83em%22 font-size=%2283%22>💬</text></svg>">
script. meta(name="htmx-config" content='{"requestClass":"is-loading"}')
document.querySelectorAll("[popovertarget]").forEach(e => { style.
e.addEventListener("click", () => { .s-prose a {
const rect = e.getBoundingClientRect() text-decoration: underline;
const t = `:popover-open { position: absolute; top: ${Math.floor(rect.bottom)}px; left: ${Math.floor(rect.left + rect.width / 2)}px; width: ${Math.floor(rect.width)}px; transform: translateX(-50%); box-sizing: content-box; margin: 0 }` }
// console.log(t) .themed {
document.styleSheets[0].insertRule(t) --theme-base-primary-color-h: 266;
}) --theme-base-primary-color-s: 53%;
}) --theme-base-primary-color-l: 63%;
script(src="/static/htmx.min.js") --theme-dark-primary-color-h: 266;
--theme-dark-primary-color-s: 53%;
--theme-dark-primary-color-l: 63%;
}
.s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental input[type="radio"]:checked ~ label:not(.s-toggle-switch--label-off) {
--_ts-multiple-bg: var(--green-400);
--_ts-multiple-fc: var(--white);
}
.s-btn__dropdown:has(+ :popover-open) {
background-color: var(--theme-topbar-item-background-hover, var(--black-200)) !important;
}
+define-theme("blurple", "236", "84%", "64%")
+define-themed-button("blurple", "blurple")
+define-themed-button("matrix", "black")
body.themed.theme-system
header.s-topbar
.s-topbar--skip-link(href="#content") Skip to main content
.s-topbar--container.wmx9
a.s-topbar--logo(href=rel("/"))
img.s-avatar.s-avatar__32(src=rel("/icon.png"))
nav.s-topbar--navigation
ul.s-topbar--content
li.ps-relative.g8
if !session.data.mxid
a.s-btn.s-btn__icon.s-btn__matrix.s-btn__outlined.as-center(href=rel("/log-in-with-matrix"))
!= icons.Icons.IconSpeechBubble
= ` Log in`
span(class="sm:d-none")= ` with Matrix`
if !session.data.userID
a.s-btn.s-btn__icon.s-btn__blurple.s-btn__outlined.as-center(href=rel("/oauth"))
!= icons.Icons.IconDiscord
= ` Log in`
span(class="sm:d-none")= ` with Discord`
if guild_id && managed.has(guild_id) && discord.guilds.has(guild_id)
button.s-topbar--item.s-btn.s-btn__muted.s-btn__dropdown.pr32.bar0.s-user-card(popovertarget="guilds")
+guild(discord.guilds.get(guild_id))
else if managed.size
button.s-topbar--item.s-btn.s-btn__muted.s-btn__dropdown.pr24.s-user-card.bar0.fc-black(popovertarget="guilds")
| Your servers
else if session.data.mxid || session.data.userID
.d-flex.ai-center
.s-badge.s-badge__bot.py6.px16.bar-md
| No servers available
#guilds(popover data-popper-placement="bottom" style="display: revert; width: revert;").s-popover.overflow-visible
.s-popover--arrow.s-popover--arrow__tc
.s-popover--content.overflow-y-auto.overflow-x-hidden
ul.s-menu(role="menu")
each guild in [...managed].map(id => discord.guilds.get(id)).filter(g => g).sort((a, b) => a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1)
li(role="menuitem")
a.s-topbar--item.s-user-card.d-flex.p4(href=rel(`/guild?guild_id=${guild.id}`))
+guild(guild)
//- Body
.mx-auto.w100.wmx9.py24.px8.fs-body1#content
block body
//- Guild list popover
script.
document.querySelectorAll("[popovertarget]").forEach(e => {
e.addEventListener("click", () => {
const rect = e.getBoundingClientRect()
const t = `:popover-open { position: absolute; top: ${Math.floor(rect.bottom)}px; left: ${Math.floor(rect.left + rect.width / 2)}px; width: ${Math.floor(rect.width)}px; transform: translateX(-50%); box-sizing: content-box; margin: 0 }`
document.styleSheets[0].insertRule(t, document.styleSheets[0].cssRules.length)
})
})
script(src=rel("/static/htmx.js"))
//- Error dialog
aside.s-modal#server-error(aria-hidden="true")
.s-modal--dialog
h1.s-modal--header Server error
pre.overflow-auto#server-error-content
button.s-modal--close.s-btn.s-btn__muted(aria-label="Close" type="button" onclick="hideError()")!= icons.Icons.IconClearSm
.s-modal--footer
button.s-btn.s-btn__outlined.s-btn__muted(type="button" onclick="hideError()") OK
script.
function hideError() {
document.getElementById("server-error").setAttribute("aria-hidden", "true")
}
document.body.addEventListener("htmx:responseError", event => {
document.getElementById("server-error").setAttribute("aria-hidden", "false")
document.getElementById("server-error-content").textContent = event.detail.xhr.responseText
})

View file

@ -13,11 +13,11 @@ block body
.s-page-title.mb24 .s-page-title.mb24
h1.s-page-title--header= guild.name h1.s-page-title--header= guild.name
.d-flex.g16 .d-flex.g16#form-container
.fl-grow1 .fl-grow1
h2.fs-headline1 Invite a Matrix user h2.fs-headline1 Invite a Matrix user
form.d-flex.gy16.fd-column(method="post" action="/api/invite" style="grid-template-rows: repeat(2, auto)") form.d-flex.gy16.fd-column(method="post" action="/api/invite" hx-post="/api/invite" hx-indicator="#invite-button" hx-select="#ok" hx-target="#form-container")
.d-flex.gy4.fd-column .d-flex.gy4.fd-column
label.s-label(for="mxid") Matrix ID label.s-label(for="mxid") Matrix ID
input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org") input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org")
@ -27,6 +27,7 @@ block body
select#permissions(name="permissions") select#permissions(name="permissions")
option(value="default") Default option(value="default") Default
option(value="moderator") Moderator option(value="moderator") Moderator
option(value="admin") Admin
input(type="hidden" name="nonce" value=nonce) input(type="hidden" name="nonce" value=nonce)
div div
button.s-btn.s-btn__filled.htmx-indicator Invite button.s-btn.s-btn__filled#invite-button Invite

View file

@ -0,0 +1,16 @@
extends includes/template.pug
block body
.s-page-title.mb24
h1.s-page-title--header Log in with Matrix
.d-flex.g16#form-container
.fl-grow1
form.d-flex.gy16.fd-column(method="post" action="/api/log-in-with-matrix" hx-post="/api/log-in-with-matrix" hx-indicator="#log-in-button" hx-select="#ok" hx-target="#form-container")
if next
input(type="hidden" name="next" value=next)
.d-flex.gy4.fd-column
label.s-label(for="mxid") Your Matrix ID
input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org" pattern="@([^:]+):([a-z0-9:\\-]+\\.[a-z0-9.:\\-]+)")
div
button.s-btn.s-btn__github#log-in-button Continue with Matrix

View file

@ -1,6 +1,6 @@
extends includes/template.pug extends includes/template.pug
block body block body
.ta-center.wmx5.p48.mx-auto .ta-center.wmx5.p48.mx-auto#ok
!= icons.Spots.SpotApproveXL != spot ? icons.Spots[spot] : icons.Spots.SpotApproveXL
p.mt24.fs-body2= msg p.mt24.fs-body2= msg

View file

@ -1,7 +1,7 @@
// @ts-check // @ts-check
const assert = require("assert/strict") const assert = require("assert/strict")
const {defineEventHandler, getValidatedRouterParams, sendRedirect, createError} = require("h3") const {defineEventHandler, getValidatedRouterParams, sendRedirect, createError, H3Event} = require("h3")
const {z} = require("zod") const {z} = require("zod")
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore /** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
@ -19,6 +19,15 @@ const schema = {
}) })
} }
/**
* @param {H3Event} event
* @returns {import("snowtransfer").SnowTransfer}
*/
function getSnow(event) {
/* c8 ignore next */
return event.context.snow || discord.snow
}
/** @type {Map<string, Promise<string>>} */ /** @type {Map<string, Promise<string>>} */
const cache = new Map() const cache = new Map()
@ -56,7 +65,8 @@ function defineMediaProxyHandler(domain) {
if (!timeUntilExpiry(refreshed)) promise = undefined if (!timeUntilExpiry(refreshed)) promise = undefined
} }
if (!promise) { if (!promise) {
promise = discord.snow.channel.refreshAttachmentURLs([url]).then(x => x.refreshed_urls[0].refreshed) const snow = getSnow(event)
promise = snow.channel.refreshAttachmentURLs([url]).then(x => x.refreshed_urls[0].refreshed)
cache.set(url, promise) cache.set(url, promise)
refreshed = await promise refreshed = await promise
const time = timeUntilExpiry(refreshed) const time = timeUntilExpiry(refreshed)

View file

@ -0,0 +1,49 @@
// @ts-check
const tryToCatch = require("try-to-catch")
const {test} = require("supertape")
const {router} = require("../../../test/web")
const {MatrixServerError} = require("../../matrix/mreq")
const snow = {
channel: {
async refreshAttachmentURLs(attachments) {
if (typeof attachments === "string") attachments = [attachments]
return {
refreshed_urls: attachments.map(a => ({
original: a,
refreshed: a + `?ex=${Math.floor(Date.now() / 1000 + 3600).toString(16)}`
}))
}
}
}
}
test("web download discord: access denied if not a known attachment", async t => {
const [error] = await tryToCatch(() =>
router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name", {
params: {
channel_id: "1",
attachment_id: "2",
file_name: "image.png"
},
snow
})
)
t.ok(error)
})
test("web download discord: works if a known attachment", async t => {
const event = {}
await router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name", {
params: {
channel_id: "655216173696286746",
attachment_id: "1314358913482621010",
file_name: "image.png"
},
event,
snow
})
t.equal(event.node.res.statusCode, 302)
t.match(event.node.res.getHeader("location"), /https:\/\/cdn.discordapp.com\/attachments\/655216173696286746\/1314358913482621010\/image\.png\?ex=/)
})

View file

@ -1,7 +1,7 @@
// @ts-check // @ts-check
const assert = require("assert/strict") const assert = require("assert/strict")
const {defineEventHandler, getValidatedRouterParams, setResponseStatus, setResponseHeader, sendStream, createError} = require("h3") const {defineEventHandler, getValidatedRouterParams, setResponseStatus, setResponseHeader, sendStream, createError, H3Event} = require("h3")
const {z} = require("zod") const {z} = require("zod")
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore /** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
@ -11,9 +11,6 @@ require("xxhash-wasm")().then(h => hasher = h)
const {sync, as, select} = require("../../passthrough") const {sync, as, select} = require("../../passthrough")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
const schema = { const schema = {
params: z.object({ params: z.object({
server_name: z.string(), server_name: z.string(),
@ -21,6 +18,15 @@ const schema = {
}) })
} }
/**
* @param {H3Event} event
* @returns {import("../../matrix/api")}
*/
function getAPI(event) {
/* c8 ignore next */
return event.context.api || sync.require("../../matrix/api")
}
as.router.get(`/download/matrix/:server_name/:media_id`, defineEventHandler(async event => { as.router.get(`/download/matrix/:server_name/:media_id`, defineEventHandler(async event => {
const params = await getValidatedRouterParams(event, schema.params.parse) const params = await getValidatedRouterParams(event, schema.params.parse)
@ -36,6 +42,7 @@ as.router.get(`/download/matrix/:server_name/:media_id`, defineEventHandler(asyn
}) })
} }
const api = getAPI(event)
const res = await api.getMedia(`mxc://${params.server_name}/${params.media_id}`) const res = await api.getMedia(`mxc://${params.server_name}/${params.media_id}`)
const contentType = res.headers.get("content-type") const contentType = res.headers.get("content-type")

View file

@ -0,0 +1,35 @@
// @ts-check
const tryToCatch = require("try-to-catch")
const {test} = require("supertape")
const {router} = require("../../../test/web")
test("web download matrix: access denied if not a known attachment", async t => {
const [error] = await tryToCatch(() =>
router.test("get", "/download/matrix/:server_name/:media_id", {
params: {
server_name: "cadence.moe",
media_id: "1"
}
})
)
t.ok(error)
})
test("web download matrix: works if a known attachment", async t => {
const event = {}
await router.test("get", "/download/matrix/:server_name/:media_id", {
params: {
server_name: "cadence.moe",
media_id: "KrwlqopRyMxnEBcWDgpJZPxh",
},
event,
api: {
async getMedia(mxc, init) {
return new Response("", {status: 200, headers: {"content-type": "image/png"}})
}
}
})
t.equal(event.node.res.statusCode, 200)
t.equal(event.node.res.getHeader("content-type"), "image/png")
})

View file

@ -2,44 +2,93 @@
const assert = require("assert/strict") const assert = require("assert/strict")
const {z} = require("zod") const {z} = require("zod")
const {defineEventHandler, sendRedirect, useSession, createError, readValidatedBody} = require("h3") const {defineEventHandler, createError, readValidatedBody, getRequestHeader, setResponseHeader, sendRedirect, H3Event} = require("h3")
const {as, db, sync} = require("../../passthrough") const {as, db, sync, select} = require("../../passthrough")
const {reg} = require("../../matrix/read-registration")
/** @type {import("../../d2m/actions/create-space")} */ /** @type {import("../auth")} */
const createSpace = sync.require("../../d2m/actions/create-space") const auth = sync.require("../auth")
/** @type {import("../../d2m/actions/set-presence")} */
const setPresence = sync.require("../../d2m/actions/set-presence")
/** @type {["invite", "link", "directory"]} */ /**
const levels = ["invite", "link", "directory"] * @param {H3Event} event
const schema = { * @returns {import("../../d2m/actions/create-space")}
autocreate: z.object({ */
guild_id: z.string(), function getCreateSpace(event) {
autocreate: z.string().optional() /* c8 ignore next */
}), return event.context.createSpace || sync.require("../../d2m/actions/create-space")
privacyLevel: z.object({ }
guild_id: z.string(),
level: z.enum(levels) /**
* @typedef Options
* @prop {(value: string?) => number} transform
* @prop {(event: H3Event, guildID: string) => any} [after]
* @prop {keyof import("../../db/orm-defs").Models} table
*/
/**
* @template {string} T
* @param {T} key
* @param {Partial<Options>} [inputOptions]
*/
function defineToggle(key, inputOptions) {
/** @type {Options} */
const options = {
transform: x => +!!x, // convert toggle to 0 or 1
table: "guild_space"
}
Object.assign(options, inputOptions)
return defineEventHandler(async event => {
const bodySchema = z.object({
guild_id: z.string(),
[key]: z.string().optional()
})
/** @type {Record<T, string?> & Record<"guild_id", string> & Record<string, unknown>} */ // @ts-ignore
const parsedBody = await readValidatedBody(event, bodySchema.parse)
const managed = await auth.getManagedGuilds(event)
if (!managed.has(parsedBody.guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"})
const value = options.transform(parsedBody[key])
assert(typeof value === "number")
db.prepare(`UPDATE ${options.table} SET ${key} = ? WHERE guild_id = ?`).run(value, parsedBody.guild_id)
return (options.after && await options.after(event, parsedBody.guild_id)) || null
}) })
} }
as.router.post("/api/autocreate", defineEventHandler(async event => { as.router.post("/api/autocreate", defineToggle("autocreate", {
const parsedBody = await readValidatedBody(event, schema.autocreate.parse) table: "guild_active",
const session = await useSession(event, {password: reg.as_token}) after(event, guild_id) {
if (!(session.data.managedGuilds || []).includes(parsedBody.guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"}) // If showing a partial page due to incomplete setup, need to refresh the whole page to show the alternate version
const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get()
db.prepare("UPDATE guild_active SET autocreate = ? WHERE guild_id = ?").run(+!!parsedBody.autocreate, parsedBody.guild_id) if (!spaceID) {
return null // 204 if (getRequestHeader(event, "HX-Request")) {
setResponseHeader(event, "HX-Refresh", "true")
} else {
return sendRedirect(event, "", 302)
}
}
}
})) }))
as.router.post("/api/privacy-level", defineEventHandler(async event => { as.router.post("/api/url-preview", defineToggle("url_preview"))
const parsedBody = await readValidatedBody(event, schema.privacyLevel.parse)
const session = await useSession(event, {password: reg.as_token})
if (!(session.data.managedGuilds || []).includes(parsedBody.guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"})
const i = levels.indexOf(parsedBody.level) as.router.post("/api/presence", defineToggle("presence", {
assert.notEqual(i, -1) after() {
db.prepare("UPDATE guild_space SET privacy_level = ? WHERE guild_id = ?").run(i, parsedBody.guild_id) setPresence.guildPresenceSetting.update()
await createSpace.syncSpaceFully(parsedBody.guild_id) // this is inefficient but OK to call infrequently on user request }
return null // 204 }))
as.router.post("/api/privacy-level", defineToggle("privacy_level", {
transform(value) {
assert(value)
const i = ["invite", "link", "directory"].indexOf(value)
assert.notEqual(i, -1)
return i
},
async after(event, guildID) {
const createSpace = getCreateSpace(event)
await createSpace.syncSpaceFully(guildID) // this is inefficient but OK to call infrequently on user request
}
})) }))

View file

@ -0,0 +1,96 @@
// @ts-check
const tryToCatch = require("try-to-catch")
const {router, test} = require("../../../test/web")
const {select} = require("../../passthrough")
const {MatrixServerError} = require("../../matrix/mreq")
test("web autocreate: checks permissions", async t => {
const [error] = await tryToCatch(() => router.test("post", "/api/autocreate", {
body: {
guild_id: "66192955777486848"
}
}))
t.equal(error.data, "Can't change settings for a guild you don't have Manage Server permissions in")
})
test("web autocreate: turns off autocreate and does htmx page refresh when guild not linked", async t => {
const event = {}
await router.test("post", "/api/autocreate", {
sessionData: {
managedGuilds: ["66192955777486848"]
},
body: {
guild_id: "66192955777486848",
// autocreate is false
},
headers: {
"hx-request": "true"
},
event
})
t.equal(event.node.res.getHeader("hx-refresh"), "true")
t.equal(select("guild_active", "autocreate", {guild_id: "66192955777486848"}).pluck().get(), 0)
})
test("web autocreate: turns on autocreate and issues 302 when not using htmx", async t => {
const event = {}
await router.test("post", "/api/autocreate", {
sessionData: {
managedGuilds: ["66192955777486848"]
},
body: {
guild_id: "66192955777486848",
autocreate: "yes"
},
event
})
t.equal(event.node.res.getHeader("location"), "")
t.equal(select("guild_active", "autocreate", {guild_id: "66192955777486848"}).pluck().get(), 1)
})
test("web privacy level: checks permissions", async t => {
const [error] = await tryToCatch(() => router.test("post", "/api/privacy-level", {
body: {
guild_id: "112760669178241024",
privacy_level: "directory"
}
}))
t.equal(error.data, "Can't change settings for a guild you don't have Manage Server permissions in")
})
test("web privacy level: updates privacy level", async t => {
let called = 0
await router.test("post", "/api/privacy-level", {
sessionData: {
managedGuilds: ["112760669178241024"]
},
body: {
guild_id: "112760669178241024",
privacy_level: "directory"
},
createSpace: {
async syncSpaceFully(guildID) {
called++
t.equal(guildID, "112760669178241024")
return ""
}
}
})
t.equal(called, 1)
t.equal(select("guild_space", "privacy_level", {guild_id: "112760669178241024"}).pluck().get(), 2) // directory = 2
})
test("web presence: updates presence", async t => {
await router.test("post", "/api/presence", {
sessionData: {
managedGuilds: ["112760669178241024"]
},
body: {
guild_id: "112760669178241024"
// presence is on by default - turn it off
}
})
t.equal(select("guild_space", "presence", {guild_id: "112760669178241024"}).pluck().get(), 0)
})

239
src/web/routes/guild.js Normal file
View file

@ -0,0 +1,239 @@
// @ts-check
const DiscordTypes = require("discord-api-types/v10")
const assert = require("assert/strict")
const {z} = require("zod")
const {H3Event, defineEventHandler, sendRedirect, createError, getValidatedQuery, readValidatedBody, setResponseHeader} = require("h3")
const {randomUUID} = require("crypto")
const {LRUCache} = require("lru-cache")
const Ty = require("../../types")
const uqr = require("uqr")
const {id: botID} = require("../../../addbot")
const {discord, as, sync, select, from, db} = require("../../passthrough")
/** @type {import("../pug-sync")} */
const pugSync = sync.require("../pug-sync")
/** @type {import("../../d2m/actions/create-space")} */
const createSpace = sync.require("../../d2m/actions/create-space")
/** @type {import("../auth")} */
const auth = require("../auth")
/** @type {import("../../discord/utils")} */
const utils = sync.require("../../discord/utils")
const {reg} = require("../../matrix/read-registration")
const schema = {
guild: z.object({
guild_id: z.string().optional()
}),
qr: z.object({
guild_id: z.string().optional()
}),
invite: z.object({
mxid: z.string().regex(/@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)/),
permissions: z.enum(["default", "moderator", "admin"]),
guild_id: z.string().optional(),
nonce: z.string().optional()
}),
inviteNonce: z.object({
nonce: z.string()
})
}
/**
* @param {H3Event} event
* @returns {import("../../matrix/api")}
*/
function getAPI(event) {
/* c8 ignore next */
return event.context.api || sync.require("../../matrix/api")
}
/** @type {LRUCache<string, string>} nonce to guild id */
const validNonce = new LRUCache({max: 200})
/**
* Modifies the input, removing items that don't pass the filter. Returns the items that didn't pass.
* @param {T[]} xs
* @param {(x: T, i?: number) => any} fn
* @template T
* @returns T[]
*/
function filterTo(xs, fn) {
/** @type {T[]} */
const filtered = []
for (let i = xs.length-1; i >= 0; i--) {
const x = xs[i]
if (!fn(x, i)) {
filtered.unshift(x)
xs.splice(i, 1)
}
}
return filtered
}
/**
* @param {DiscordTypes.APIGuild} guild
* @param {Ty.R.Hierarchy[]} rooms
* @param {string[]} roles
*/
function getChannelRoomsLinks(guild, rooms, roles) {
function getPosition(channel) {
let position = 0
let looking = channel
while (looking.parent_id) {
looking = discord.channels.get(looking.parent_id)
position = looking.position * 1000
}
if (channel.position) position += channel.position
return position
}
let channelIDs = discord.guildChannelMap.get(guild.id)
assert(channelIDs)
let linkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {channel_id: channelIDs}).all()
let linkedChannelsWithDetails = linkedChannels.map(c => ({channel: discord.channels.get(c.channel_id), ...c}))
let removedUncachedChannels = filterTo(linkedChannelsWithDetails, c => c.channel)
let linkedChannelIDs = linkedChannelsWithDetails.map(c => c.channel_id)
linkedChannelsWithDetails.sort((a, b) => getPosition(a.channel) - getPosition(b.channel))
let unlinkedChannelIDs = channelIDs.filter(c => !linkedChannelIDs.includes(c))
/** @type {DiscordTypes.APIGuildChannel[]} */ // @ts-ignore
let unlinkedChannels = unlinkedChannelIDs.map(c => discord.channels.get(c))
let removedWrongTypeChannels = filterTo(unlinkedChannels, c => c && [0, 5].includes(c.type))
let removedPrivateChannels = filterTo(unlinkedChannels, c => {
const permissions = utils.getPermissions(roles, guild.roles, botID, c["permission_overwrites"])
return utils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel)
})
unlinkedChannels.sort((a, b) => getPosition(a) - getPosition(b))
let linkedRoomIDs = linkedChannels.map(c => c.room_id)
let unlinkedRooms = [...rooms]
let removedLinkedRooms = filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id))
let removedWrongTypeRooms = filterTo(unlinkedRooms, r => !r.room_type)
// https://discord.com/developers/docs/topics/threads#active-archived-threads
// need to filter out linked archived threads from unlinkedRooms, will just do that by comparing against the name
let removedArchivedThreadRooms = filterTo(unlinkedRooms, r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/))
return {
linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms,
removedUncachedChannels, removedWrongTypeChannels, removedPrivateChannels, removedLinkedRooms, removedWrongTypeRooms, removedArchivedThreadRooms
}
}
as.router.get("/guild", defineEventHandler(async event => {
const {guild_id} = await getValidatedQuery(event, schema.guild.parse)
const session = await auth.useSession(event)
const managed = await auth.getManagedGuilds(event)
const row = from("guild_active").join("guild_space", "guild_id", "left").select("space_id", "privacy_level", "autocreate").where({guild_id}).get()
// @ts-ignore
const guild = discord.guilds.get(guild_id)
// Permission problems
if (!guild_id || !guild || !managed.has(guild_id) || !row) {
return pugSync.render(event, "guild_access_denied.pug", {guild_id, row})
}
// Self-service guild that hasn't been linked yet - needs a special page encouraging the link flow
if (!row.space_id && row.autocreate === 0) {
const spaces = db.prepare("SELECT room_id, type, name, topic, avatar FROM invite LEFT JOIN guild_space ON invite.room_id = guild_space.space_id WHERE mxid = ? AND space_id IS NULL AND type = 'm.space'").all(session.data.mxid)
return pugSync.render(event, "guild_not_linked.pug", {guild, guild_id, spaces})
}
const roles = guild.members?.find(m => m.user.id === botID)?.roles || []
// Easy mode guild that hasn't been linked yet - need to remove elements that would require an existing space
if (!row.space_id) {
const links = getChannelRoomsLinks(guild, [], roles)
return pugSync.render(event, "guild.pug", {guild, guild_id, ...links, ...row})
}
// Linked guild
const api = getAPI(event)
const rooms = await api.getFullHierarchy(row.space_id)
const links = getChannelRoomsLinks(guild, rooms, roles)
return pugSync.render(event, "guild.pug", {guild, guild_id, ...links, ...row})
}))
as.router.get("/qr", defineEventHandler(async event => {
const {guild_id} = await getValidatedQuery(event, schema.qr.parse)
const managed = await auth.getManagedGuilds(event)
const row = from("guild_active").join("guild_space", "guild_id", "left").select("space_id", "privacy_level", "autocreate").where({guild_id}).get()
// @ts-ignore
const guild = discord.guilds.get(guild_id)
// Permission problems
if (!guild_id || !guild || !managed.has(guild_id) || !row) {
return pugSync.render(event, "guild_access_denied.pug", {guild_id, row})
}
const nonce = randomUUID()
validNonce.set(nonce, guild_id)
const data = `${reg.ooye.bridge_origin}/invite?nonce=${nonce}`
// necessary to scale the svg pixel-perfectly on the page
// https://github.com/unjs/uqr/blob/244952a8ba2d417f938071b61e11fb1ff95d6e75/src/svg.ts#L24
const generatedSvg = uqr.renderSVG(data, {pixelSize: 3})
const svg = generatedSvg.replace(/viewBox="0 0 ([0-9]+) ([0-9]+)"/, `data-nonce="${nonce}" width="$1" height="$2" $&`)
assert.notEqual(svg, generatedSvg)
return svg
}))
as.router.get("/invite", defineEventHandler(async event => {
const {nonce} = await getValidatedQuery(event, schema.inviteNonce.parse)
const isValid = validNonce.has(nonce)
const guild_id = validNonce.get(nonce)
const guild = discord.guilds.get(guild_id || "")
return pugSync.render(event, "invite.pug", {isValid, nonce, guild_id, guild})
}))
as.router.post("/api/invite", defineEventHandler(async event => {
const parsedBody = await readValidatedBody(event, schema.invite.parse)
const managed = await auth.getManagedGuilds(event)
const api = getAPI(event)
// Check guild ID or nonce
if (parsedBody.guild_id) {
var guild_id = parsedBody.guild_id
if (!managed.has(guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't invite users to a guild you don't have Manage Server permissions in"})
} else if (parsedBody.nonce) {
if (!validNonce.has(parsedBody.nonce)) throw createError({status: 403, message: "Nonce expired", data: "Nonce means number-used-once, and, well, you tried to use it twice..."})
let ok = validNonce.get(parsedBody.nonce)
assert(ok)
var guild_id = ok
validNonce.delete(parsedBody.nonce)
} else {
throw createError({status: 400, message: "Missing guild ID", data: "Passing a guild ID or a nonce is required."})
}
// Check guild is bridged
const guild = discord.guilds.get(guild_id)
assert(guild)
const spaceID = await createSpace.ensureSpace(guild)
// Check for existing invite to the space
let spaceMember
try {
spaceMember = await api.getStateEvent(spaceID, "m.room.member", parsedBody.mxid)
} catch (e) {}
if (!spaceMember || !["invite", "join"].includes(spaceMember.membership)) {
// Invite
await api.inviteToRoom(spaceID, parsedBody.mxid)
}
// Permissions
const powerLevel =
( parsedBody.permissions === "admin" ? 100
: parsedBody.permissions === "moderator" ? 50
: 0)
if (powerLevel) await api.setUserPowerCascade(spaceID, parsedBody.mxid, powerLevel)
if (parsedBody.guild_id) {
setResponseHeader(event, "HX-Refresh", true)
return sendRedirect(event, `/guild?guild_id=${guild_id}`, 302)
} else {
return sendRedirect(event, "/ok?msg=User has been invited.", 302)
}
}))

View file

@ -0,0 +1,277 @@
// @ts-check
const tryToCatch = require("try-to-catch")
const {router, test} = require("../../../test/web")
const {MatrixServerError} = require("../../matrix/mreq")
let nonce
test("web guild: access denied when not logged in", async t => {
const html = await router.test("get", "/guild?guild_id=112760669178241024", {
sessionData: {
},
})
t.has(html, "You need to log in to manage your servers.")
})
test("web guild: asks to select guild if not selected", async t => {
const html = await router.test("get", "/guild", {
sessionData: {
userID: "1",
managedGuilds: []
},
})
t.has(html, "Select a server from the top right corner to continue.")
})
test("web guild: access denied when guild id messed up", async t => {
const html = await router.test("get", "/guild?guild_id=1", {
sessionData: {
userID: "1",
managedGuilds: []
},
})
t.has(html, "the selected server doesn't exist")
})
test("web qr: access denied when guild id messed up", async t => {
const html = await router.test("get", "/qr?guild_id=1", {
sessionData: {
userID: "1",
managedGuilds: []
},
})
t.has(html, "the selected server doesn't exist")
})
test("web invite: access denied with invalid nonce", async t => {
const html = await router.test("get", "/invite?nonce=1")
t.match(html, /This QR code has expired./)
})
test("web guild: can view unbridged guild", async t => {
const html = await router.test("get", "/guild?guild_id=66192955777486848", {
sessionData: {
managedGuilds: ["66192955777486848"]
}
})
t.has(html, `<h1 class="s-page-title--header">Function &amp; Arg</h1>`)
})
test("web guild: unbridged self-service guild prompts log in to matrix", async t => {
const html = await router.test("get", "/guild?guild_id=665289423482519565", {
sessionData: {
managedGuilds: ["665289423482519565"]
}
})
t.has(html, `You picked self-service mode`)
t.has(html, `You need to log in with Matrix first`)
})
test("web guild: unbridged self-service guild asks to be invited", async t => {
const html = await router.test("get", "/guild?guild_id=665289423482519565", {
sessionData: {
mxid: "@user:example.org",
managedGuilds: ["665289423482519565"]
}
})
t.has(html, `On Matrix, invite <`)
})
test("web guild: unbridged self-service guild shows available spaces", async t => {
const html = await router.test("get", "/guild?guild_id=665289423482519565", {
sessionData: {
mxid: "@cadence:cadence.moe",
managedGuilds: ["665289423482519565"]
}
})
t.has(html, `<strong>Data Horde</strong>`)
t.has(html, `<li>here is the space topic</li>`)
t.has(html, `<img class="s-avatar--image" src="https://bridge.example.org/download/matrix/cadence.moe/TLqQOsTSrZkVKwBSWYTZNTrw">`)
t.notMatch(html, /<strong>some room<\/strong>/)
t.notMatch(html, /<strong>somebody else's space<\/strong>/)
})
test("web guild: can view bridged guild when logged in with discord", async t => {
const html = await router.test("get", "/guild?guild_id=112760669178241024", {
sessionData: {
managedGuilds: ["112760669178241024"]
},
api: {
async getStateEvent(roomID, type, key) {
return {}
},
async getMembers(roomID, membership) {
return {chunk: []}
},
async getFullHierarchy(roomID) {
return []
}
}
})
t.has(html, `<h1 class="s-page-title--header">Psychonauts 3</h1>`)
})
test("web guild: can view bridged guild when logged in with matrix", async t => {
const html = await router.test("get", "/guild?guild_id=112760669178241024", {
sessionData: {
mxid: "@cadence:cadence.moe"
},
api: {
async getStateEvent(roomID, type, key) {
return {}
},
async getMembers(roomID, membership) {
return {chunk: []}
},
async getFullHierarchy(roomID) {
return []
}
}
})
t.has(html, `<h1 class="s-page-title--header">Psychonauts 3</h1>`)
})
test("web qr: generates nonce", async t => {
const html = await router.test("get", "/qr?guild_id=112760669178241024", {
sessionData: {
managedGuilds: ["112760669178241024"]
}
})
nonce = html.match(/data-nonce="([a-f0-9-]+)"/)?.[1]
t.ok(nonce)
})
test("web invite: page loads with valid nonce", async t => {
const html = await router.test("get", `/invite?nonce=${nonce}`)
t.has(html, "Invite a Matrix user")
})
test("api invite: access denied with nothing", async t => {
const [error] = await tryToCatch(() =>
router.test("post", `/api/invite`, {
body: {
mxid: "@cadence:cadence.moe",
permissions: "moderator"
}
})
)
t.equal(error.message, "Missing guild ID")
})
test("api invite: access denied when not in guild", async t => {
const [error] = await tryToCatch(() =>
router.test("post", `/api/invite`, {
body: {
mxid: "@cadence:cadence.moe",
permissions: "moderator",
guild_id: "112760669178241024"
}
})
)
t.equal(error.message, "Forbidden")
})
test("api invite: can invite with valid nonce", async t => {
let called = 0
const [error] = await tryToCatch(() =>
router.test("post", `/api/invite`, {
body: {
mxid: "@cadence:cadence.moe",
permissions: "moderator",
nonce
},
api: {
async getStateEvent(roomID, type, key) {
called++
return {membership: "leave"}
},
async inviteToRoom(roomID, mxidToInvite, mxid) {
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe")
called++
},
async setUserPowerCascade(roomID, mxid, power) {
t.equal(power, 50) // moderator
called++
}
}
})
)
t.notOk(error)
t.equal(called, 3)
})
test("api invite: access denied when nonce has been used", async t => {
const [error] = await tryToCatch(() =>
router.test("post", `/api/invite`, {
body: {
mxid: "@cadence:cadence.moe",
permissions: "moderator",
nonce
}
})
)
t.equal(error.message, "Nonce expired")
})
test("api invite: can invite to a moderated guild", async t => {
let called = 0
const [error] = await tryToCatch(() =>
router.test("post", `/api/invite`, {
body: {
mxid: "@cadence:cadence.moe",
permissions: "admin",
guild_id: "112760669178241024"
},
sessionData: {
managedGuilds: ["112760669178241024"]
},
api: {
async getStateEvent(roomID, type, key) {
called++
throw new MatrixServerError({errcode: "M_NOT_FOUND", error: "Event not found or something"})
},
async inviteToRoom(roomID, mxidToInvite, mxid) {
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe")
called++
},
async setUserPowerCascade(roomID, mxid, power) {
t.equal(power, 100) // moderator
called++
}
}
})
)
t.notOk(error)
t.equal(called, 3)
})
test("api invite: does not reinvite joined users", async t => {
let called = 0
const [error] = await tryToCatch(() =>
router.test("post", `/api/invite`, {
body: {
mxid: "@cadence:cadence.moe",
permissions: "default",
guild_id: "112760669178241024"
},
sessionData: {
managedGuilds: ["112760669178241024"]
},
api: {
async getStateEvent(roomID, type, key) {
called++
return {membership: "join"}
}
}
})
)
t.notOk(error)
t.equal(called, 1)
})

View file

@ -1,105 +0,0 @@
// @ts-check
const assert = require("assert/strict")
const {z} = require("zod")
const {defineEventHandler, sendRedirect, useSession, createError, getValidatedQuery, readValidatedBody} = require("h3")
const {randomUUID} = require("crypto")
const {LRUCache} = require("lru-cache")
const {discord, as, sync, select} = require("../../passthrough")
/** @type {import("../pug-sync")} */
const pugSync = sync.require("../pug-sync")
/** @type {import("../../d2m/actions/create-space")} */
const createSpace = sync.require("../../d2m/actions/create-space")
const {reg} = require("../../matrix/read-registration")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
const schema = {
guild: z.object({
guild_id: z.string().optional()
}),
invite: z.object({
mxid: z.string().regex(/@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)/),
permissions: z.enum(["default", "moderator"]),
guild_id: z.string().optional(),
nonce: z.string().optional()
}),
inviteNonce: z.object({
nonce: z.string()
})
}
/** @type {LRUCache<string, string>} nonce to guild id */
const validNonce = new LRUCache({max: 200})
as.router.get("/guild", defineEventHandler(async event => {
const {guild_id} = await getValidatedQuery(event, schema.guild.parse)
const session = await useSession(event, {password: reg.as_token})
const row = select("guild_space", ["space_id", "privacy_level"], {guild_id}).get()
if (!guild_id || !row || !discord.guilds.has(guild_id) || !session.data.managedGuilds || !session.data.managedGuilds.includes(guild_id)) {
return pugSync.render(event, "guild.pug", {guild_id})
}
const nonce = randomUUID()
validNonce.set(nonce, guild_id)
const mods = await api.getStateEvent(row.space_id, "m.room.power_levels", "")
const banned = await api.getMembers(row.space_id, "ban")
const rooms = await api.getFullHierarchy(row.space_id)
return pugSync.render(event, "guild.pug", {guild_id, nonce, mods, banned, rooms, ...row})
}))
as.router.get("/invite", defineEventHandler(async event => {
const {nonce} = await getValidatedQuery(event, schema.inviteNonce.parse)
const isValid = validNonce.has(nonce)
const guild_id = validNonce.get(nonce)
const guild = discord.guilds.get(guild_id || "")
return pugSync.render(event, "invite.pug", {isValid, nonce, guild_id, guild})
}))
as.router.post("/api/invite", defineEventHandler(async event => {
const parsedBody = await readValidatedBody(event, schema.invite.parse)
const session = await useSession(event, {password: reg.as_token})
// Check guild ID or nonce
if (parsedBody.guild_id) {
var guild_id = parsedBody.guild_id
if (!(session.data.managedGuilds || []).includes(guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't invite users to a guild you don't have Manage Server permissions in"})
} else if (parsedBody.nonce) {
if (!validNonce.has(parsedBody.nonce)) throw createError({status: 403, message: "Nonce expired", data: "Nonce means number-used-once, and, well, you tried to use it twice..."})
let ok = validNonce.get(parsedBody.nonce)
assert(ok)
var guild_id = ok
validNonce.delete(parsedBody.nonce)
} else {
throw createError({status: 400, message: "Missing guild ID", data: "Passing a guild ID or a nonce is required."})
}
// Check guild is bridged
const guild = discord.guilds.get(guild_id)
assert(guild)
const spaceID = await createSpace.ensureSpace(guild)
// Check for existing invite to the space
let spaceMember
try {
spaceMember = await api.getStateEvent(spaceID, "m.room.member", parsedBody.mxid)
} catch (e) {}
if (!spaceMember || spaceMember.membership !== "invite" || spaceMember.membership !== "join") {
// Invite
await api.inviteToRoom(spaceID, parsedBody.mxid)
}
// Permissions
if (parsedBody.permissions === "moderator") {
await api.setUserPowerCascade(spaceID, parsedBody.mxid, 50)
}
if (parsedBody.guild_id) {
return sendRedirect(event, `/guild?guild_id=${guild_id}`, 302)
} else {
return sendRedirect(event, "/ok?msg=User has been invited.", 302)
}
}))

View file

@ -1,34 +1,121 @@
// @ts-check // @ts-check
const {z} = require("zod") const {z} = require("zod")
const {defineEventHandler, useSession, createError, readValidatedBody} = require("h3") const {defineEventHandler, createError, readValidatedBody, setResponseHeader, H3Event} = require("h3")
const Ty = require("../../types") const Ty = require("../../types")
const DiscordTypes = require("discord-api-types/v10")
const {discord, db, as, sync, select, from} = require("../../passthrough") const {discord, db, as, sync, select, from} = require("../../passthrough")
/** @type {import("../../d2m/actions/create-space")} */ /** @type {import("../auth")} */
const createSpace = sync.require("../../d2m/actions/create-space") const auth = sync.require("../auth")
/** @type {import("../../d2m/actions/create-room")} */ /** @type {import("../../matrix/mreq")} */
const createRoom = sync.require("../../d2m/actions/create-room") const mreq = sync.require("../../matrix/mreq")
const {reg} = require("../../matrix/read-registration") const {reg} = require("../../matrix/read-registration")
/** @type {import("../../matrix/api")} */ /**
const api = sync.require("../../matrix/api") * @param {H3Event} event
* @returns {import("../../matrix/api")}
*/
function getAPI(event) {
/* c8 ignore next */
return event.context.api || sync.require("../../matrix/api")
}
/**
* @param {H3Event} event
* @returns {import("../../d2m/actions/create-room")}
*/
function getCreateRoom(event) {
/* c8 ignore next */
return event.context.createRoom || sync.require("../../d2m/actions/create-room")
}
/**
* @param {H3Event} event
* @returns {import("../../d2m/actions/create-space")}
*/
function getCreateSpace(event) {
/* c8 ignore next */
return event.context.createSpace || sync.require("../../d2m/actions/create-space")
}
const schema = { const schema = {
linkSpace: z.object({
guild_id: z.string(),
space_id: z.string()
}),
link: z.object({ link: z.object({
guild_id: z.string(), guild_id: z.string(),
matrix: z.string(), matrix: z.string(),
discord: z.string() discord: z.string()
}),
unlink: z.object({
guild_id: z.string(),
channel_id: z.string()
}) })
} }
as.router.post("/api/link-space", defineEventHandler(async event => {
const parsedBody = await readValidatedBody(event, schema.linkSpace.parse)
const session = await auth.useSession(event)
const managed = await auth.getManagedGuilds(event)
const api = getAPI(event)
// Check guild ID
const guildID = parsedBody.guild_id
if (!managed.has(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"})
// Check space ID
if (!session.data.mxid) throw createError({status: 403, message: "Forbidden", data: "Can't link with your Matrix space if you aren't logged in to Matrix"})
const spaceID = parsedBody.space_id
const inviteType = select("invite", "type", {mxid: session.data.mxid, room_id: spaceID}).pluck().get()
if (inviteType !== "m.space") throw createError({status: 403, message: "Forbidden", data: "You personally must invite OOYE to that space on Matrix"})
// Check they are not already bridged
const existing = select("guild_space", "guild_id", {}, "WHERE guild_id = ? OR space_id = ?").get(guildID, spaceID)
if (existing) throw createError({status: 400, message: "Bad Request", data: `Guild ID ${guildID} or space ID ${spaceID} are already bridged and cannot be reused`})
// Check space exists and bridge is joined
try {
await api.joinRoom(parsedBody.space_id)
} catch (e) {
throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`})
}
// Check bridge has PL 100
const me = `@${reg.sender_localpart}:${reg.ooye.server_name}`
/** @type {Ty.Event.M_Power_Levels?} */
let powerLevelsStateContent = null
try {
powerLevelsStateContent = await api.getStateEvent(spaceID, "m.room.power_levels", "")
} catch (e) {}
const selfPowerLevel = powerLevelsStateContent?.users?.[me] || powerLevelsStateContent?.users_default || 0
if (selfPowerLevel < (powerLevelsStateContent?.state_default || 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix space"})
// Check inviting user is a moderator in the space
const invitingPowerLevel = powerLevelsStateContent?.users?.[session.data.mxid] || powerLevelsStateContent?.users_default || 0
if (invitingPowerLevel < (powerLevelsStateContent?.state_default || 50)) throw createError({status: 403, message: "Forbidden", data: `You need to be at least power level 50 (moderator) in the target Matrix space to set up OOYE, but you are currently power level ${invitingPowerLevel}.`})
// Insert database entry
db.transaction(() => {
db.prepare("INSERT INTO guild_space (guild_id, space_id) VALUES (?, ?)").run(guildID, spaceID)
db.prepare("DELETE FROM invite WHERE room_id = ?").run(spaceID)
})()
setResponseHeader(event, "HX-Refresh", "true")
return null // 204
}))
as.router.post("/api/link", defineEventHandler(async event => { as.router.post("/api/link", defineEventHandler(async event => {
const parsedBody = await readValidatedBody(event, schema.link.parse) const parsedBody = await readValidatedBody(event, schema.link.parse)
const session = await useSession(event, {password: reg.as_token}) const managed = await auth.getManagedGuilds(event)
const api = getAPI(event)
const createRoom = getCreateRoom(event)
const createSpace = getCreateSpace(event)
// Check guild ID or nonce // Check guild ID or nonce
const guildID = parsedBody.guild_id const guildID = parsedBody.guild_id
if (!(session.data.managedGuilds || []).includes(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"}) if (!managed.has(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"})
// Check guild is bridged // Check guild is bridged
const guild = discord.guilds.get(guildID) const guild = discord.guilds.get(guildID)
@ -39,25 +126,89 @@ as.router.post("/api/link", defineEventHandler(async event => {
const channel = discord.channels.get(parsedBody.discord) const channel = discord.channels.get(parsedBody.discord)
if (!channel) throw createError({status: 400, message: "Bad Request", data: "Discord channel does not exist"}) if (!channel) throw createError({status: 400, message: "Bad Request", data: "Discord channel does not exist"})
// Check channel is part of the guild
if (!("guild_id" in channel) || channel.guild_id !== guildID) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel.id} is not part of guild ${guildID}`})
// Check channel and room are not already bridged // Check channel and room are not already bridged
const row = from("channel_room").select("channel_id", "room_id").and("WHERE channel_id = ? OR room_id = ?").get(parsedBody.discord, parsedBody.matrix) const row = from("channel_room").select("channel_id", "room_id").and("WHERE channel_id = ? OR room_id = ?").get(channel.id, parsedBody.matrix)
if (row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${row.channel_id} and room ID ${row.room_id} are already bridged and cannot be reused`}) if (row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${row.channel_id} or room ID ${parsedBody.matrix} are already bridged and cannot be reused`})
// Check room exists and bridge is joined and bridge has PL 100 // Check room is part of the guild's space
const self = `@${reg.sender_localpart}:${reg.ooye.server_name}` /** @type {Ty.Event.M_Space_Child?} */
/** @type {Ty.Event.M_Room_Member} */ let spaceChildEvent = null
const memberEvent = await api.getStateEvent(parsedBody.matrix, "m.room.member", self) try {
if (memberEvent.membership !== "join") throw createError({status: 400, message: "Bad Request", data: "Matrix room does not exist"}) spaceChildEvent = await api.getStateEvent(spaceID, "m.space.child", parsedBody.matrix)
/** @type {Ty.Event.M_Power_Levels} */ } catch (e) {}
const powerLevelsStateContent = await api.getStateEvent(parsedBody.matrix, "m.room.power_levels", "") if (!Array.isArray(spaceChildEvent?.via)) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged space"})
const selfPowerLevel = powerLevelsStateContent.users?.[self] || powerLevelsStateContent.users_default || 0
if (selfPowerLevel < (powerLevelsStateContent.state_default || 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix room"})
// Insert database entry // Check room exists and bridge is joined
db.prepare("INSERT INTO channel_room (channel_id, room_id, name, guild_id) VALUES (?, ?, ?, ?)").run(parsedBody.discord, parsedBody.matrix, channel.name, guildID) try {
await api.joinRoom(parsedBody.matrix)
} catch (e) {
throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`})
}
// Check bridge has PL 100
const me = `@${reg.sender_localpart}:${reg.ooye.server_name}`
/** @type {Ty.Event.M_Power_Levels?} */
let powerLevelsStateContent = null
try {
powerLevelsStateContent = await api.getStateEvent(parsedBody.matrix, "m.room.power_levels", "")
} catch (e) {}
const selfPowerLevel = powerLevelsStateContent?.users?.[me] || powerLevelsStateContent?.users_default || 0
if (selfPowerLevel < (powerLevelsStateContent?.state_default || 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix room"})
// Insert database entry, but keep the room's existing properties if they are set
const nick = await api.getStateEvent(parsedBody.matrix, "m.room.name", "").then(content => content.name || null).catch(() => null)
const avatar = await api.getStateEvent(parsedBody.matrix, "m.room.avatar", "").then(content => content.url || null).catch(() => null)
const topic = await api.getStateEvent(parsedBody.matrix, "m.room.topic", "").then(content => content.topic || null).catch(() => null)
db.prepare("INSERT INTO channel_room (channel_id, room_id, name, guild_id, nick, custom_avatar, custom_topic) VALUES (?, ?, ?, ?, ?, ?, ?)").run(channel.id, parsedBody.matrix, channel.name, guildID, nick, avatar, topic)
// Sync room data and space child // Sync room data and space child
createRoom.syncRoom(parsedBody.discord) await createRoom.syncRoom(parsedBody.discord)
// Send a notification in the room
if (channel.type === DiscordTypes.ChannelType.GuildText) {
await api.sendEvent(parsedBody.matrix, "m.room.message", {
msgtype: "m.notice",
body: "👋 This room is now bridged with Discord. Say hi!"
})
}
setResponseHeader(event, "HX-Refresh", "true")
return null // 204
}))
as.router.post("/api/unlink", defineEventHandler(async event => {
const {channel_id, guild_id} = await readValidatedBody(event, schema.unlink.parse)
const managed = await auth.getManagedGuilds(event)
const createRoom = getCreateRoom(event)
// Check guild ID or nonce
if (!managed.has(guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"})
// Check guild exists
const guild = discord.guilds.get(guild_id)
if (!guild) throw createError({status: 400, message: "Bad Request", data: "Discord guild does not exist or bot has not joined it"})
// Check that the channel (if it exists) is part of this guild
/** @type {any} */
let channel = discord.channels.get(channel_id)
if (channel) {
if (!("guild_id" in channel) || channel.guild_id !== guild_id) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel_id} is not part of guild ${guild_id}`})
} else {
// Otherwise, if the channel isn't cached, it must have been deleted.
// There's no other authentication here - it's okay for anyone to unlink a deleted channel just by knowing its ID.
channel = {id: channel_id}
}
// Check channel is currently bridged
const row = select("channel_room", "channel_id", {channel_id: channel_id}).get()
if (!row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel_id} is not currently bridged`})
// Do it
await createRoom.unbridgeDeletedChannel(channel, guild_id)
setResponseHeader(event, "HX-Refresh", "true")
return null // 204 return null // 204
})) }))

644
src/web/routes/link.test.js Normal file
View file

@ -0,0 +1,644 @@
// @ts-check
const tryToCatch = require("try-to-catch")
const {router, test} = require("../../../test/web")
const {MatrixServerError} = require("../../matrix/mreq")
const {select, db} = require("../../passthrough")
const assert = require("assert").strict
test("web link space: access denied when not logged in to Discord", async t => {
const [error] = await tryToCatch(() => router.test("post", "/api/link-space", {
sessionData: {
},
body: {
space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe",
guild_id: "665289423482519565"
}
}))
t.equal(error.data, "Can't edit a guild you don't have Manage Server permissions in")
})
test("web link space: access denied when not logged in to Matrix", async t => {
const [error] = await tryToCatch(() => router.test("post", "/api/link-space", {
sessionData: {
managedGuilds: ["665289423482519565"]
},
body: {
space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe",
guild_id: "665289423482519565"
}
}))
t.equal(error.data, "Can't link with your Matrix space if you aren't logged in to Matrix")
})
test("web link space: access denied when bot was invited by different user", async t => {
const [error] = await tryToCatch(() => router.test("post", "/api/link-space", {
sessionData: {
managedGuilds: ["665289423482519565"],
mxid: "@user:example.org"
},
body: {
space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe",
guild_id: "665289423482519565"
}
}))
t.equal(error.data, "You personally must invite OOYE to that space on Matrix")
})
test("web link space: access denied when guild is already in use", async t => {
const [error] = await tryToCatch(() => router.test("post", "/api/link-space", {
sessionData: {
managedGuilds: ["112760669178241024"],
mxid: "@cadence:cadence.moe"
},
body: {
space_id: "!jjmvBegULiLucuWEHU:cadence.moe",
guild_id: "112760669178241024"
}
}))
t.equal(error.data, "Guild ID 112760669178241024 or space ID !jjmvBegULiLucuWEHU:cadence.moe are already bridged and cannot be reused")
})
test("web link space: check that OOYE is joined", async t => {
let called = 0
const [error] = await tryToCatch(() => router.test("post", "/api/link-space", {
sessionData: {
managedGuilds: ["665289423482519565"],
mxid: "@cadence:cadence.moe"
},
body: {
space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe",
guild_id: "665289423482519565"
},
api: {
async joinRoom(roomID) {
called++
throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"})
}
}
}))
t.equal(error.data, "M_FORBIDDEN - not allowed to join I guess")
t.equal(called, 1)
})
test("web link space: check that OOYE has PL 100 (not missing)", async t => {
let called = 0
const [error] = await tryToCatch(() => router.test("post", "/api/link-space", {
sessionData: {
managedGuilds: ["665289423482519565"],
mxid: "@cadence:cadence.moe"
},
body: {
space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe",
guild_id: "665289423482519565"
},
api: {
async joinRoom(roomID) {
called++
return roomID
},
async getStateEvent(roomID, type, key) {
called++
t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
t.equal(type, "m.room.power_levels")
throw new MatrixServerError({errcode: "M_NOT_FOUND", error: "what if I told you that power levels never existed"})
}
}
}))
t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix space")
t.equal(called, 2)
})
test("web link space: check that OOYE has PL 100 (not users_default)", async t => {
let called = 0
const [error] = await tryToCatch(() => router.test("post", "/api/link-space", {
sessionData: {
managedGuilds: ["665289423482519565"],
mxid: "@cadence:cadence.moe"
},
body: {
space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe",
guild_id: "665289423482519565"
},
api: {
async joinRoom(roomID) {
called++
return roomID
},
async getStateEvent(roomID, type, key) {
called++
t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {}
}
}
}))
t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix space")
t.equal(called, 2)
})
test("web link space: check that OOYE has PL 100 (not 50)", async t => {
let called = 0
const [error] = await tryToCatch(() => router.test("post", "/api/link-space", {
sessionData: {
managedGuilds: ["665289423482519565"],
mxid: "@cadence:cadence.moe"
},
body: {
space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe",
guild_id: "665289423482519565"
},
api: {
async joinRoom(roomID) {
called++
return roomID
},
async getStateEvent(roomID, type, key) {
called++
t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {users: {"@_ooye_bot:cadence.moe": 50}}
}
}
}))
t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix space")
t.equal(called, 2)
})
test("web link space: check that inviting user has PL 50", async t => {
let called = 0
const [error] = await tryToCatch(() => router.test("post", "/api/link-space", {
sessionData: {
managedGuilds: ["665289423482519565"],
mxid: "@cadence:cadence.moe"
},
body: {
space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe",
guild_id: "665289423482519565"
},
api: {
async joinRoom(roomID) {
called++
return roomID
},
async getStateEvent(roomID, type, key) {
called++
t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {users: {"@_ooye_bot:cadence.moe": 100}}
}
}
}))
t.equal(error.data, "You need to be at least power level 50 (moderator) in the target Matrix space to set up OOYE, but you are currently power level 0.")
t.equal(called, 2)
})
test("web link space: successfully adds entry to database and loads page", async t => {
let called = 0
await router.test("post", "/api/link-space", {
sessionData: {
managedGuilds: ["665289423482519565"],
mxid: "@cadence:cadence.moe"
},
body: {
space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe",
guild_id: "665289423482519565"
},
api: {
async joinRoom(roomID) {
called++
return roomID
},
async getStateEvent(roomID, type, key) {
called++
t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {users: {"@_ooye_bot:cadence.moe": 100, "@cadence:cadence.moe": 50}}
}
}
})
t.equal(called, 2)
// check that the entry was added to the database
t.equal(select("guild_space", "privacy_level", {guild_id: "665289423482519565", space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe"}).pluck().get(), 0)
// check that the guild info page now loads
const html = await router.test("get", "/guild?guild_id=665289423482519565", {
sessionData: {
managedGuilds: ["665289423482519565"],
mxid: "@cadence:cadence.moe"
},
api: {
async getStateEvent(roomID, type, key) {
return {}
},
async getMembers(roomID, membership) {
return {chunk: []}
},
async getFullHierarchy(roomID) {
return []
}
}
})
t.has(html, `<h1 class="s-page-title--header">Data Horde</h1>`)
})
// *****
test("web link room: access denied when not logged in to Discord", async t => {
const [error] = await tryToCatch(() => router.test("post", "/api/link", {
sessionData: {
},
body: {
discord: "665310973967597573",
matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
guild_id: "665289423482519565"
}
}))
t.equal(error.data, "Can't edit a guild you don't have Manage Server permissions in")
})
test("web link room: check that guild exists", async t => {
const [error] = await tryToCatch(() => router.test("post", "/api/link", {
sessionData: {
managedGuilds: ["1"]
},
body: {
discord: "665310973967597573",
matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
guild_id: "1"
}
}))
t.equal(error.data, "Discord guild does not exist or bot has not joined it")
})
test("web link room: check that channel exists", async t => {
const [error] = await tryToCatch(() => router.test("post", "/api/link", {
sessionData: {
managedGuilds: ["665289423482519565"]
},
body: {
discord: "1",
matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
guild_id: "665289423482519565"
}
}))
t.equal(error.data, "Discord channel does not exist")
})
test("web link room: check that channel is part of guild", async t => {
const [error] = await tryToCatch(() => router.test("post", "/api/link", {
sessionData: {
managedGuilds: ["665289423482519565"]
},
body: {
discord: "112760669178241024",
matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
guild_id: "665289423482519565"
}
}))
t.equal(error.data, "Channel ID 112760669178241024 is not part of guild 665289423482519565")
})
test("web link room: check that channel is not already linked", async t => {
const [error] = await tryToCatch(() => router.test("post", "/api/link", {
sessionData: {
managedGuilds: ["112760669178241024"]
},
body: {
discord: "112760669178241024",
matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
guild_id: "112760669178241024"
}
}))
t.equal(error.data, "Channel ID 112760669178241024 or room ID !NDbIqNpJyPvfKRnNcr:cadence.moe are already bridged and cannot be reused")
})
test("web link room: checks the autocreate setting if the space doesn't exist yet", async t => {
let called = 0
const [error] = await tryToCatch(() => router.test("post", "/api/link", {
sessionData: {
managedGuilds: ["665289423482519565"]
},
body: {
discord: "665310973967597573",
matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
guild_id: "665289423482519565"
},
createSpace: {
async ensureSpace(guild) {
called++
t.equal(guild.id, "665289423482519565")
// simulate what ensureSpace is intended to check
const autocreate = 0
assert.equal(autocreate, 1, "refusing to implicitly create a space for guild 665289423482519565. set the guild_active data first before calling ensureSpace/syncSpace.")
return ""
}
}
}))
t.match(error.message, /refusing to implicitly create a space/)
t.equal(called, 1)
})
test("web link room: check that room is part of space (event missing)", async t => {
let called = 0
const [error] = await tryToCatch(() => router.test("post", "/api/link", {
sessionData: {
managedGuilds: ["665289423482519565"]
},
body: {
discord: "665310973967597573",
matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
guild_id: "665289423482519565"
},
api: {
async getStateEvent(roomID, type, key) {
called++
t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
t.equal(type, "m.space.child")
t.equal(key, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
throw new MatrixServerError({errcode: "M_NOT_FOUND", error: "what if I told you there was no such thing as a space"})
}
}
}))
t.equal(error.data, "Matrix room needs to be part of the bridged space")
t.equal(called, 1)
})
test("web link room: check that room is part of space (event empty)", async t => {
let called = 0
const [error] = await tryToCatch(() => router.test("post", "/api/link", {
sessionData: {
managedGuilds: ["665289423482519565"]
},
body: {
discord: "665310973967597573",
matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
guild_id: "665289423482519565"
},
api: {
async getStateEvent(roomID, type, key) {
called++
t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
t.equal(type, "m.space.child")
t.equal(key, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
return {}
}
}
}))
t.equal(error.data, "Matrix room needs to be part of the bridged space")
t.equal(called, 1)
})
test("web link room: check that bridge can join room", async t => {
let called = 0
const [error] = await tryToCatch(() => router.test("post", "/api/link", {
sessionData: {
managedGuilds: ["665289423482519565"]
},
body: {
discord: "665310973967597573",
matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
guild_id: "665289423482519565"
},
api: {
async joinRoom(roomID) {
called++
throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"})
},
async getStateEvent(roomID, type, key) {
called++
t.equal(type, "m.space.child")
t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
t.equal(key, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
return {via: ["cadence.moe"]}
}
}
}))
t.equal(error.data, "M_FORBIDDEN - not allowed to join I guess")
t.equal(called, 2)
})
test("web link room: check that bridge has PL 100 in target room (event missing)", async t => {
let called = 0
const [error] = await tryToCatch(() => router.test("post", "/api/link", {
sessionData: {
managedGuilds: ["665289423482519565"]
},
body: {
discord: "665310973967597573",
matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
guild_id: "665289423482519565"
},
api: {
async joinRoom(roomID) {
called++
return roomID
},
async getStateEvent(roomID, type, key) {
called++
if (type === "m.space.child") {
t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
t.equal(key, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
return {via: ["cadence.moe"]}
} else if (type === "m.room.power_levels") {
t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
t.equal(key, "")
throw new MatrixServerError({errcode: "M_NOT_FOUND", error: "what if I told you there's no such thing as power levels"})
}
}
}
}))
t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix room")
t.equal(called, 3)
})
test("web link room: check that bridge has PL 100 in target room (users default)", async t => {
let called = 0
const [error] = await tryToCatch(() => router.test("post", "/api/link", {
sessionData: {
managedGuilds: ["665289423482519565"]
},
body: {
discord: "665310973967597573",
matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
guild_id: "665289423482519565"
},
api: {
async joinRoom(roomID) {
called++
return roomID
},
async getStateEvent(roomID, type, key) {
called++
if (type === "m.space.child") {
t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
t.equal(key, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
return {via: ["cadence.moe"]}
} else if (type === "m.room.power_levels") {
t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
t.equal(key, "")
return {users_default: 50}
}
}
}
}))
t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix room")
t.equal(called, 3)
})
test("web link room: successfully calls createRoom", async t => {
let called = 0
await router.test("post", "/api/link", {
sessionData: {
managedGuilds: ["665289423482519565"]
},
body: {
discord: "665310973967597573",
matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
guild_id: "665289423482519565"
},
api: {
async joinRoom(roomID) {
called++
return roomID
},
async getStateEvent(roomID, type, key) {
if (type === "m.room.power_levels") {
called++
t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
t.equal(key, "")
return {users: {"@_ooye_bot:cadence.moe": 100}}
} else if (type === "m.space.child") {
called++
t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
t.equal(key, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
return {via: ["cadence.moe"]}
} else if (type === "m.room.name") {
called++
t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
return {}
} else if (type === "m.room.avatar") {
called++
t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
return {}
} else if (type === "m.room.topic") {
called++
t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
return {}
}
},
async sendEvent(roomID, type, content) {
called++
t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
t.equal(type, "m.room.message")
t.match(content.body, /👋/)
return ""
}
},
createRoom: {
async syncRoom(channelID) {
called++
t.equal(channelID, "665310973967597573")
return "!NDbIqNpJyPvfKRnNcr:cadence.moe"
}
}
})
t.equal(called, 8)
})
// *****
test("web unlink room: access denied if not logged in to Discord", async t => {
const [error] = await tryToCatch(() => router.test("post", "/api/unlink", {
body: {
channel_id: "665310973967597573",
guild_id: "665289423482519565"
}
}))
t.equal(error.data, "Can't edit a guild you don't have Manage Server permissions in")
})
test("web unlink room: checks that guild exists", async t => {
const [error] = await tryToCatch(() => router.test("post", "/api/unlink", {
sessionData: {
managedGuilds: ["2"]
},
body: {
channel_id: "665310973967597573",
guild_id: "2"
}
}))
t.equal(error.data, "Discord guild does not exist or bot has not joined it")
})
test("web unlink room: checks that the channel is part of the guild", async t => {
const [error] = await tryToCatch(() => router.test("post", "/api/unlink", {
sessionData: {
managedGuilds: ["665289423482519565"]
},
body: {
channel_id: "112760669178241024",
guild_id: "665289423482519565"
}
}))
t.equal(error.data, "Channel ID 112760669178241024 is not part of guild 665289423482519565")
})
test("web unlink room: successfully calls unbridgeDeletedChannel when the channel does exist", async t => {
let called = 0
await router.test("post", "/api/unlink", {
sessionData: {
managedGuilds: ["665289423482519565"]
},
body: {
channel_id: "665310973967597573",
guild_id: "665289423482519565"
},
createRoom: {
async unbridgeDeletedChannel(channel) {
called++
t.equal(channel.id, "665310973967597573")
}
}
})
t.equal(called, 1)
})
test("web unlink room: successfully calls unbridgeDeletedChannel when the channel does not exist", async t => {
let called = 0
await router.test("post", "/api/unlink", {
sessionData: {
managedGuilds: ["112760669178241024"]
},
body: {
channel_id: "489237891895768942",
guild_id: "112760669178241024"
},
createRoom: {
async unbridgeDeletedChannel(channel) {
called++
t.equal(channel.id, "489237891895768942")
}
}
})
t.equal(called, 1)
})
test("web unlink room: checks that the channel is bridged", async t => {
db.prepare("DELETE FROM channel_room WHERE channel_id = '665310973967597573'").run()
const [error] = await tryToCatch(() => router.test("post", "/api/unlink", {
sessionData: {
managedGuilds: ["665289423482519565"]
},
body: {
channel_id: "665310973967597573",
guild_id: "665289423482519565"
}
}))
t.equal(error.data, "Channel ID 665310973967597573 is not currently bridged")
})

View file

@ -0,0 +1,133 @@
// @ts-check
const {z} = require("zod")
const {randomUUID} = require("crypto")
const {defineEventHandler, getValidatedQuery, sendRedirect, readValidatedBody, createError, getRequestHeader, H3Event} = require("h3")
const {LRUCache} = require("lru-cache")
const {as} = require("../../passthrough")
const {reg} = require("../../matrix/read-registration")
const {sync} = require("../../passthrough")
const assert = require("assert").strict
/** @type {import("../pug-sync")} */
const pugSync = sync.require("../pug-sync")
/** @type {import("../auth")} */
const auth = sync.require("../auth")
const schema = {
form: z.object({
mxid: z.string().regex(/^@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)$/),
next: z.string().optional()
}),
token: z.object({
token: z.string().optional(),
next: z.string().optional()
})
}
/**
* @param {H3Event} event
* @returns {import("../../matrix/api")}
*/
function getAPI(event) {
/* c8 ignore next */
return event.context.api || sync.require("../../matrix/api")
}
/** @type {LRUCache<string, string>} token to mxid */
const validToken = new LRUCache({max: 200})
/*
1st request, GET, they clicked the button, need to input their mxid
2nd request, POST, they input their mxid and we need to send a link
3rd request, GET, they clicked the link and we need to set the session data (just their mxid)
*/
as.router.get("/log-in-with-matrix", defineEventHandler(async event => {
let {token, next} = await getValidatedQuery(event, schema.token.parse)
if (!token) {
// We are in the first request and need to tell them to input their mxid
return pugSync.render(event, "log-in-with-matrix.pug", {next})
}
const userAgent = getRequestHeader(event, "User-Agent")
if (userAgent?.match(/bot/)) throw createError({status: 400, data: "Sorry URL previewer, you can't have this URL."})
if (!validToken.has(token)) return sendRedirect(event, `${reg.ooye.bridge_origin}/log-in-with-matrix`, 302)
const session = await auth.useSession(event)
const mxid = validToken.get(token)
assert(mxid)
validToken.delete(token)
await session.update({mxid})
if (!next) next = "./" // open to homepage where they can see they're logged in
return sendRedirect(event, next, 302)
}))
as.router.post("/api/log-in-with-matrix", defineEventHandler(async event => {
const api = getAPI(event)
const {mxid, next} = await readValidatedBody(event, schema.form.parse)
let roomID = null
// Don't extend a duplicate invite for the same user
for (const alreadyInvited of validToken.values()) {
if (mxid === alreadyInvited) {
return sendRedirect(event, "../ok?msg=We already sent you a link on Matrix. Please click it!", 302)
}
}
// See if we can reuse an existing room from account data
let directData = {}
try {
directData = await api.getAccountData("m.direct")
} catch (e) {}
const rooms = directData[mxid] || []
for (const candidate of rooms) {
// Check that the person is/still in the room
let member
try {
member = await api.getStateEvent(candidate, "m.room.member", mxid)
} catch (e) {}
if (!member || member.membership === "leave") {
// We can reinvite them back to the same room!
await api.inviteToRoom(candidate, mxid)
roomID = candidate
} else {
// Member is in this room
roomID = candidate
}
if (roomID) break // no need to check other candidates
}
// No candidates available, create a new room and invite
if (!roomID) {
roomID = await api.createRoom({
invite: [mxid],
is_direct: true,
preset: "trusted_private_chat"
})
// Store the newly created room in account data (Matrix doesn't do this for us automatically, sigh...)
;(directData[mxid] ??= []).push(roomID)
await api.setAccountData("m.direct", directData)
}
const token = randomUUID()
validToken.set(token, mxid)
console.log(`web log in requested for ${mxid}`)
const paramsObject = {token}
if (next) paramsObject.next = next
const params = new URLSearchParams(paramsObject)
let link = `${reg.ooye.bridge_origin}/log-in-with-matrix?${params.toString()}`
const body = `Hi, this is Out Of Your Element! You just clicked the "log in" button on the website.\nOpen this link to finish: ${link}\nThe link can be used once.`
await api.sendEvent(roomID, "m.room.message", {
msgtype: "m.text",
body
})
return sendRedirect(event, "../ok?msg=Please check your inbox on Matrix!&spot=SpotMailXL", 302)
}))

View file

@ -0,0 +1,189 @@
// @ts-check
const tryToCatch = require("try-to-catch")
const {router, test} = require("../../../test/web")
const {MatrixServerError} = require("../../matrix/mreq")
// ***** first request *****
test("log in with matrix: shows web page with form on first request", async t => {
const html = await router.test("get", "/log-in-with-matrix", {
})
t.has(html, `hx-post="/api/log-in-with-matrix"`)
})
// ***** second request *****
let token
test("log in with matrix: checks if mxid format looks valid", async t => {
const [error] = await tryToCatch(() => router.test("post", "/api/log-in-with-matrix", {
body: {
mxid: "x@cadence:cadence.moe"
}
}))
t.equal(error.data.issues[0].validation, "regex")
})
test("log in with matrix: checks if mxid domain format looks valid", async t => {
const [error] = await tryToCatch(() => router.test("post", "/api/log-in-with-matrix", {
body: {
mxid: "@cadence:cadence."
}
}))
t.equal(error.data.issues[0].validation, "regex")
})
test("log in with matrix: sends message when there is no m.direct data", async t => {
const event = {}
let called = 0
await router.test("post", "/api/log-in-with-matrix", {
body: {
mxid: "@cadence:cadence.moe"
},
api: {
async getAccountData(type) {
called++
t.equal(type, "m.direct")
throw new MatrixServerError({errcode: "M_NOT_FOUND"})
},
async createRoom() {
called++
return "!created:cadence.moe"
},
async setAccountData(type, content) {
called++
t.equal(type, "m.direct")
t.deepEqual(content, {"@cadence:cadence.moe": ["!created:cadence.moe"]})
},
async sendEvent(roomID, type, content) {
called++
t.equal(roomID, "!created:cadence.moe")
t.equal(type, "m.room.message")
token = content.body.match(/log-in-with-matrix\?token=([a-f0-9-]+)/)[1]
t.ok(token, "log in token not issued")
return ""
}
},
event
})
t.match(event.node.res.getHeader("location"), /Please check your inbox on Matrix/)
t.equal(called, 4)
})
test("log in with matrix: does not send another message when a log in is in progress", async t => {
const event = {}
await router.test("post", "/api/log-in-with-matrix", {
body: {
mxid: "@cadence:cadence.moe"
},
event
})
t.match(event.node.res.getHeader("location"), /We already sent you a link on Matrix/)
})
test("log in with matrix: reuses room from m.direct", async t => {
const event = {}
let called = 0
await router.test("post", "/api/log-in-with-matrix", {
body: {
mxid: "@user1:example.org"
},
api: {
async getAccountData(type) {
called++
t.equal(type, "m.direct")
return {"@user1:example.org": ["!existing:cadence.moe"]}
},
async getStateEvent(roomID, type, key) {
called++
t.equal(roomID, "!existing:cadence.moe")
t.equal(type, "m.room.member")
t.equal(key, "@user1:example.org")
return {membership: "join"}
},
async sendEvent(roomID) {
called++
t.equal(roomID, "!existing:cadence.moe")
return ""
}
},
event
})
t.match(event.node.res.getHeader("location"), /Please check your inbox on Matrix/)
t.equal(called, 3)
})
test("log in with matrix: reuses room from m.direct, reinviting if user has left", async t => {
const event = {}
let called = 0
await router.test("post", "/api/log-in-with-matrix", {
body: {
mxid: "@user2:example.org"
},
api: {
async getAccountData(type) {
called++
t.equal(type, "m.direct")
return {"@user2:example.org": ["!existing:cadence.moe"]}
},
async getStateEvent(roomID, type, key) {
called++
t.equal(roomID, "!existing:cadence.moe")
t.equal(type, "m.room.member")
t.equal(key, "@user2:example.org")
throw new MatrixServerError({errcode: "M_NOT_FOUND"})
},
async inviteToRoom(roomID, mxid) {
called++
t.equal(roomID, "!existing:cadence.moe")
t.equal(mxid, "@user2:example.org")
},
async sendEvent(roomID) {
called++
t.equal(roomID, "!existing:cadence.moe")
return ""
}
},
event
})
t.match(event.node.res.getHeader("location"), /Please check your inbox on Matrix/)
t.equal(called, 4)
})
// ***** third request *****
test("log in with matrix: does not use up token when requested by Synapse URL previewer", async t => {
const event = {}
const [error] = await tryToCatch(() => router.test("get", `/log-in-with-matrix?token=${token}`, {
headers: {
"user-agent": "Synapse (bot; +https://github.com/matrix-org/synapse)"
},
event
}))
t.equal(error.data, "Sorry URL previewer, you can't have this URL.")
})
test("log in with matrix: does not use up token when requested by Discord URL previewer", async t => {
const event = {}
const [error] = await tryToCatch(() => router.test("get", `/log-in-with-matrix?token=${token}`, {
headers: {
"user-agent": "Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)"
},
event
}))
t.equal(error.data, "Sorry URL previewer, you can't have this URL.")
})
test("log in with matrix: successful request when using valid token", async t => {
const event = {}
await router.test("get", `/log-in-with-matrix?token=${token}`, {event})
t.equal(event.node.res.getHeader("location"), "./")
})
test("log in with matrix: won't log in again if token has been used", async t => {
const event = {}
await router.test("get", `/log-in-with-matrix?token=${token}`, {event})
t.equal(event.node.res.getHeader("location"), "https://bridge.example.org/log-in-with-matrix")
})

View file

@ -2,13 +2,15 @@
const {z} = require("zod") const {z} = require("zod")
const {randomUUID} = require("crypto") const {randomUUID} = require("crypto")
const {defineEventHandler, getValidatedQuery, sendRedirect, getQuery, useSession, createError} = require("h3") const {defineEventHandler, getValidatedQuery, sendRedirect, createError} = require("h3")
const {SnowTransfer} = require("snowtransfer") const {SnowTransfer, tokenless} = require("snowtransfer")
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const fetch = require("node-fetch") const getRelativePath = require("get-relative-path")
const {as, db} = require("../../passthrough") const {discord, as, db, sync} = require("../../passthrough")
const {id} = require("../../../addbot") const {id} = require("../../../addbot")
/** @type {import("../auth")} */
const auth = sync.require("../auth")
const {reg} = require("../../matrix/read-registration") const {reg} = require("../../matrix/read-registration")
const redirect_uri = `${reg.ooye.bridge_origin}/oauth` const redirect_uri = `${reg.ooye.bridge_origin}/oauth`
@ -32,16 +34,18 @@ const schema = {
} }
as.router.get("/oauth", defineEventHandler(async event => { as.router.get("/oauth", defineEventHandler(async event => {
const session = await useSession(event, {password: reg.as_token}) const session = await auth.useSession(event)
let scope = "guilds" let scope = "guilds"
const parsedFirstQuery = await getValidatedQuery(event, schema.first.safeParse) if (!reg.ooye.web_password || reg.ooye.web_password === session.data.password) {
if (parsedFirstQuery.data?.action === "add") { const parsedFirstQuery = await getValidatedQuery(event, schema.first.safeParse)
scope = "bot+guilds" if (parsedFirstQuery.data?.action === "add") {
await session.update({selfService: false}) scope = "bot+guilds"
} else if (parsedFirstQuery.data?.action === "add-self-service") { await session.update({selfService: false})
scope = "bot+guilds" } else if (parsedFirstQuery.data?.action === "add-self-service") {
await session.update({selfService: true}) scope = "bot+guilds"
await session.update({selfService: true})
}
} }
async function tryAgain() { async function tryAgain() {
@ -57,28 +61,18 @@ as.router.get("/oauth", defineEventHandler(async event => {
if (!savedState) throw createError({status: 400, message: "Missing state", data: "Missing saved state parameter. Please try again, and make sure you have cookies enabled."}) if (!savedState) throw createError({status: 400, message: "Missing state", data: "Missing saved state parameter. Please try again, and make sure you have cookies enabled."})
if (savedState != parsedQuery.data.state) return tryAgain() if (savedState != parsedQuery.data.state) return tryAgain()
const res = await fetch("https://discord.com/api/oauth2/token", { const oauthResult = await tokenless.getOauth2Token(id, redirect_uri, reg.ooye.discord_client_secret, parsedQuery.data.code)
method: "post", const parsedToken = schema.token.safeParse(oauthResult)
body: new URLSearchParams({ if (!parsedToken.success) {
grant_type: "authorization_code", throw createError({status: 502, message: "Invalid token response", data: `Discord completed OAuth, but returned this instead of an OAuth access token: ${JSON.stringify(oauthResult)}`})
client_id: id,
client_secret: reg.ooye.discord_client_secret,
redirect_uri,
code: parsedQuery.data.code
})
})
const root = await res.json()
const parsedToken = schema.token.safeParse(root)
if (!res.ok || !parsedToken.success) {
throw createError({status: 502, message: "Invalid token response", data: `Discord completed OAuth, but returned this instead of an OAuth access token: ${JSON.stringify(root)}`})
} }
const userID = Buffer.from(parsedToken.data.access_token.split(".")[0], "base64").toString()
const client = new SnowTransfer(`Bearer ${parsedToken.data.access_token}`) const client = new SnowTransfer(`Bearer ${parsedToken.data.access_token}`)
try { try {
const guilds = await client.user.getGuilds() const guilds = await client.user.getGuilds()
var managedGuilds = guilds.filter(g => BigInt(g.permissions) & DiscordTypes.PermissionFlagsBits.ManageGuild).map(g => g.id) var managedGuilds = guilds.filter(g => BigInt(g.permissions) & DiscordTypes.PermissionFlagsBits.ManageGuild).map(g => g.id)
await session.update({managedGuilds}) await session.update({managedGuilds, userID, state: undefined})
} catch (e) { } catch (e) {
throw createError({status: 502, message: "API call failed", data: e.message}) throw createError({status: 502, message: "API call failed", data: e.message})
} }
@ -86,12 +80,13 @@ as.router.get("/oauth", defineEventHandler(async event => {
// Set auto-create for the guild // Set auto-create for the guild
// @ts-ignore // @ts-ignore
if (managedGuilds.includes(parsedQuery.data.guild_id)) { if (managedGuilds.includes(parsedQuery.data.guild_id)) {
db.prepare("INSERT OR IGNORE INTO guild_active (guild_id, autocreate) VALUES (?, ?)").run(parsedQuery.data.guild_id, +!session.data.selfService) const autocreateInteger = +!session.data.selfService
db.prepare("INSERT INTO guild_active (guild_id, autocreate) VALUES (?, ?) ON CONFLICT DO UPDATE SET autocreate = ?").run(parsedQuery.data.guild_id, autocreateInteger, autocreateInteger)
} }
if (parsedQuery.data.guild_id) { if (parsedQuery.data.guild_id) {
return sendRedirect(event, `/guild?guild_id=${parsedQuery.data.guild_id}`, 302) return sendRedirect(event, getRelativePath(event.path, `/guild?guild_id=${parsedQuery.data.guild_id}`), 302)
} }
return sendRedirect(event, "/", 302) return sendRedirect(event, getRelativePath(event.path, "/"), 302)
})) }))

View file

@ -0,0 +1,21 @@
// @ts-check
const {z} = require("zod")
const {defineEventHandler, readValidatedBody, sendRedirect} = require("h3")
const {as, sync} = require("../../passthrough")
/** @type {import("../auth")} */
const auth = sync.require("../auth")
const schema = {
password: z.object({
password: z.string()
})
}
as.router.post("/api/password", defineEventHandler(async event => {
const {password} = await readValidatedBody(event, schema.password.parse)
const session = await auth.useSession(event)
await session.update({password})
return sendRedirect(event, "../")
}))

View file

@ -1,19 +0,0 @@
// @ts-check
const {z} = require("zod")
const {defineEventHandler, getValidatedQuery} = require("h3")
const {as} = require("../../passthrough")
const uqr = require("uqr")
const schema = {
qr: z.object({
data: z.string().max(128)
})
}
as.router.get("/qr", defineEventHandler(async event => {
const {data} = await getValidatedQuery(event, schema.qr.parse)
return new Response(uqr.renderSVG(data, {pixelSize: 3}), {headers: {"content-type": "image/svg+xml"}})
}))

View file

@ -1,21 +1,25 @@
// @ts-check // @ts-check
const assert = require("assert")
const fs = require("fs") const fs = require("fs")
const {join} = require("path") const {join} = require("path")
const h3 = require("h3") const h3 = require("h3")
const {defineEventHandler, defaultContentType, getRequestHeader, setResponseHeader, setResponseStatus, useSession, getQuery, handleCacheHeaders} = h3 const {defineEventHandler, defaultContentType, getRequestHeader, setResponseHeader, handleCacheHeaders} = h3
const icons = require("@stackoverflow/stacks-icons") const icons = require("@stackoverflow/stacks-icons")
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const dUtils = require("../discord/utils") const dUtils = require("../discord/utils")
const reg = require("../matrix/read-registration")
const {sync, discord, as, select} = require("../passthrough") const {sync, discord, as, select} = require("../passthrough")
/** @type {import("./pug-sync")} */ /** @type {import("./pug-sync")} */
const pugSync = sync.require("./pug-sync") const pugSync = sync.require("./pug-sync")
/** @type {import("../m2d/converters/utils")} */
const mUtils = sync.require("../m2d/converters/utils")
const {id} = require("../../addbot") const {id} = require("../../addbot")
// Pug // Pug
pugSync.addGlobals({id, h3, discord, select, DiscordTypes, dUtils, icons}) pugSync.addGlobals({id, h3, discord, select, DiscordTypes, dUtils, mUtils, icons, reg: reg.reg})
pugSync.createRoute(as.router, "/", "home.pug") pugSync.createRoute(as.router, "/", "home.pug")
pugSync.createRoute(as.router, "/ok", "ok.pug") pugSync.createRoute(as.router, "/ok", "ok.pug")
@ -24,18 +28,20 @@ pugSync.createRoute(as.router, "/ok", "ok.pug")
sync.require("./routes/download-matrix") sync.require("./routes/download-matrix")
sync.require("./routes/download-discord") sync.require("./routes/download-discord")
sync.require("./routes/guild-settings") sync.require("./routes/guild-settings")
sync.require("./routes/invite") sync.require("./routes/guild")
sync.require("./routes/link") sync.require("./routes/link")
sync.require("./routes/log-in-with-matrix")
sync.require("./routes/oauth") sync.require("./routes/oauth")
sync.require("./routes/qr") sync.require("./routes/password")
// Files // Files
function compressResponse(event, response) { function compressResponse(event, response) {
if (!getRequestHeader(event, "accept-encoding")?.includes("gzip")) return if (!getRequestHeader(event, "accept-encoding")?.includes("gzip")) return
/* c8 ignore next */
if (typeof response.body !== "string") return if (typeof response.body !== "string") return
/** @type {ReadableStream} */ // @ts-ignore
const stream = new Response(response.body).body const stream = new Response(response.body).body
assert(stream)
setResponseHeader(event, "content-encoding", "gzip") setResponseHeader(event, "content-encoding", "gzip")
response.body = stream.pipeThrough(new CompressionStream("gzip")) response.body = stream.pipeThrough(new CompressionStream("gzip"))
} }
@ -49,12 +55,12 @@ as.router.get("/static/stacks.min.css", defineEventHandler({
} }
})) }))
as.router.get("/static/htmx.min.js", defineEventHandler({ as.router.get("/static/htmx.js", defineEventHandler({
onBeforeResponse: compressResponse, onBeforeResponse: compressResponse,
handler: async event => { handler: async event => {
handleCacheHeaders(event, {maxAge: 86400}) handleCacheHeaders(event, {maxAge: 86400})
defaultContentType(event, "text/javascript") defaultContentType(event, "text/javascript")
return fs.promises.readFile(join(__dirname, "static", "htmx.min.js"), "utf-8") return fs.promises.readFile(require.resolve("htmx.org/dist/htmx.js"), "utf-8")
} }
})) }))

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