Compare commits

..

220 commits
v2.2 ... main

Author SHA1 Message Date
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
07d6eb3c12 Fix existingPartZero assertion error 2024-11-02 20:35:52 +13:00
15e5b17b0d When inviting bot, check it has bot scope 2024-11-02 19:22:30 +13:00
14115c0e06 Attempt retrigger after speedbump 2024-11-01 17:25:11 +13:00
0d8b9d5705 Forwarded messages code coverage and plaintext fix 2024-11-01 16:50:28 +13:00
1b539cfa64 Forwarding text messages 2024-11-01 16:39:56 +13:00
b23b818192 Use attachment proxy for external_url 2024-10-31 17:34:50 +13:00
49948ae2c1 Support forwarded messages 2024-10-31 17:34:25 +13:00
ac165845d7 Remove unused parameter 2024-10-31 14:42:15 +13:00
cce432aeee Compatibility: send {} with room joins
Now compatible with the spec and with condu(wu)it.
2024-10-31 11:55:54 +13:00
e5f7c7fdcb Proxy discord attachment links within embeds 2024-10-31 11:53:34 +13:00
4167a01ed1 Add test template for forwarded message 2024-10-25 16:51:20 +13:00
c127923f4d Make the link button do something 2024-10-18 16:35:47 +13:00
da5525a542 Make invite interaction async
Fix potential lag issues
2024-10-14 13:09:40 +13:00
6f7ed829b8 Create and populate guild_id column 2024-10-05 02:23:58 +13:00
5a86c07eb9 Host QR codes locally 2024-10-04 02:21:57 +13:00
4287a329f5 Display list of unlinked rooms 2024-10-03 17:21:42 +13:00
086e8cdc25 Add privacy level controls on web 2024-10-03 03:26:49 +13:00
9f9d1f615e Test coverage for all interactions 2024-09-30 23:35:09 +13:00
3662ee5db6 Fix interaction updates 2024-09-30 22:50:19 +13:00
d72b162fe7 Mobile design 2024-09-30 17:24:26 +13:00
b79b010568 Update heatsync dependency 2024-09-30 16:46:20 +13:00
f77602afa6 Add tests for privacy interaction 2024-09-30 16:26:12 +13:00
f5853ccf95 Fix check for enabling content_length_workaround 2024-09-30 01:45:38 +13:00
33915a595d Add tests for reactions interaction 2024-09-30 01:42:12 +13:00
61803c3838 Add tests for matrix info interaction 2024-09-30 01:19:03 +13:00
bad8c5b8c2 Test invite interaction & code coverage 2024-09-30 00:51:55 +13:00
65170c1282 Registration YAML sample no longer needed 2024-09-30 00:37:10 +13:00
5dbd79cf39 Prompt to add redirect URI in setup 2024-09-29 04:40:38 +13:00
cf756cb0af Create space as needed in oauth flow
I have manually tested this with both web flows, the link flow, the
/invite command, and the toggle switch, and they all work.
2024-09-29 03:58:51 +13:00
034f8d6b55 Code coverage reporting 2024-09-29 03:27:40 +13:00
0e6e5e61e4 Merge branch 'v3.0-beta3' 2024-09-29 03:13:10 +13:00
bac2deb32f Check existsOrAutocreatable before dispatching 2024-09-29 03:11:59 +13:00
d629e666db Fis messages being double-redacted 2024-09-29 00:21:48 +12:00
69c93ca9d9 Automatically set up content_length_workaround 2024-09-26 09:59:13 +12:00
8743910c35 Rename seed.js to setup; add npm script for it 2024-09-26 09:47:53 +12:00
d6de57f0c3 Factor out namespace_prefix 2024-09-26 02:16:50 +12:00
734c9a5838 Remove hardcoded cadence.moe 2024-09-25 13:37:54 +12:00
b0a0e62a86 Adapt createRoom/space/invite to self-service 2024-09-25 01:58:26 +12:00
312ea69d73 Fix page duplicating when clicking toggle switch 2024-09-25 01:54:01 +12:00
3af31385f0 Use $url resolver in channelToKState 2024-09-25 01:08:29 +12:00
8915e8d96c Make invite INSERT OR IGNORE autocreate entry 2024-09-24 17:21:55 +12:00
af68657ec4 Make ensureRoom/syncRoom check if autocreatable 2024-09-24 17:21:35 +12:00
dbbb8281e6 Create guild_active table instead of column 2024-09-24 16:31:47 +12:00
06f502dd89 Store Discord media proxy hashes in the database 2024-09-22 15:53:58 +12:00
f3eb1fbeb3 Fix not matching all errors in catch block 2024-09-22 15:43:02 +12:00
b6c23c30fb Upload web code 2024-09-22 15:42:15 +12:00
1d2daf2504 Add web dependencies 2024-09-20 16:50:14 +12:00
f011cac3c5 Fix timestamp resolution bug 2024-09-20 10:01:33 +12:00
8277cc4a17 npm start works too 2024-09-19 11:25:24 +12:00
50bd9fd9d7 Remove colorette dependency transitively 2024-09-16 00:54:31 +12:00
15472b4a46 Move test files out of matrix CDN
matrix.org LOVES her! learn this one simple trick
2024-09-15 22:56:55 +12:00
1ebf9e193c Use getMedia API for media proxy 2024-09-15 22:54:04 +12:00
a61ad30908 Annotate the specific step that is slow 2024-09-15 22:21:21 +12:00
76caaa1468 Force registration to be re-created
Removing YAML support makes current registration files invalid, and
seed.js will prompt to set up a new one. This is good, because old ones
are incompatible and new ones had to be set up anyway.
2024-09-15 22:19:35 +12:00
6a15f4fc0f Check for Matrix v1.11 in setup 2024-09-15 22:17:00 +12:00
f35c5266b6 Tidy up repo 2024-09-15 02:11:43 +12:00
d5ec013648 Mention that links don't expire 2024-09-15 01:13:31 +12:00
01af302796 Move ooye.db to current working dir 2024-09-15 01:09:47 +12:00
b8962780ae Fix type errors in scripts and make executable 2024-09-15 00:55:54 +12:00
256196cc54 start.js as an executable script 2024-09-15 00:50:00 +12:00
b8a992453b addbot.js as an executable script 2024-09-15 00:49:38 +12:00
ae9acbcc52 Generate public urls for media.discordapp.net too 2024-09-15 00:34:53 +12:00
6bc3eaf866 Migrate all legacy commands to interactions 2024-09-15 00:33:37 +12:00
b3daa6b84c Fix discord URL cache purge 2024-09-14 19:11:23 +12:00
a6a1b7fb28 Update dev overview in readme 2024-09-14 02:17:13 +12:00
adf07ad736 Generate public urls for attachments in message 2024-09-14 01:58:17 +12:00
c6175e09f8 Generate public url for linked discord attachments 2024-09-14 01:45:08 +12:00
d6dc5cb88f Type fixes 2024-09-14 00:58:21 +12:00
555d7cb126 Authmedia bug fixes 2024-09-13 23:48:07 +12:00
cc7831a313 Remove token from config file 2024-09-13 23:47:11 +12:00
c0d92ea66d Move calls from getPublicUrlForMxc to getMedia 2024-09-13 17:19:42 +12:00
b45d0f3038 Barebones matrix media proxy 2024-09-13 03:16:03 +12:00
eaa3b87670 Fix sticker names being accumulated 2024-09-12 17:18:15 +12:00
915c34646a Fix references from moving all those files 2024-09-12 17:16:59 +12:00
4247a3114a Move everything to src folder... it had to happen 2024-09-12 17:05:13 +12:00
decc32f7e6 Change getPublicUrlForMxc function for authmedia 2024-09-12 16:59:48 +12:00
96fd046530 Fix type warning in emoji sprite sheet 2024-09-10 12:40:17 +12:00
23a7394ce7 Catch bridge->hs connection errors 2024-09-09 10:31:10 +12:00
7d42a530e7 Wait for ping to work during setup 2024-09-05 16:48:53 +12:00
37f3a59d8e Interactive initial setup 2024-09-05 15:36:43 +12:00
e0bb19bfab /bridge: search substrings rather than startswith 2024-09-02 00:15:00 +12:00
1037fe0276 Only apply power to bridged rooms 2024-09-01 15:32:41 +12:00
c689ec00b7 Fix trying to access database before migrations ran 2024-09-01 15:26:25 +12:00
a218774c13 Only moderators can change room avatar 2024-09-01 15:14:26 +12:00
62edffbae0 Add reaction viewer to right click menu 2024-08-31 17:44:56 +12:00
a2e3f06e8e Unbridge rooms when their channel is deleted 2024-08-31 17:02:46 +12:00
7afe3e7591 Copy power levels from spaces to new rooms 2024-08-31 16:41:06 +12:00
ee266f844a Move reaction_part to new events when possible 2024-08-28 17:18:50 +12:00
65de8aaf98 Code coverage 2024-08-28 13:01:59 +12:00
9e9f4819c7 Make sure url starts with http:// or https:// 2024-08-28 12:51:28 +12:00
a5518e562f Fix reading property that was always missing 2024-08-28 12:10:59 +12:00
5af7d6bf42 Add test for a generated embed on a reply 2024-08-28 11:51:02 +12:00
5d91f999f2 Make power migration testable 2024-08-28 02:53:27 +12:00
2c27879afb Add another async/await, just to be safe 2024-08-28 02:09:27 +12:00
71c553a9cf Test cases for bridging author command metadata 2024-08-28 02:05:51 +12:00
607fd3808a Fix bigint/number type in orm WHERE 2024-08-28 01:47:47 +12:00
818311bcb4 Use kstate $url feature for channel icons 2024-08-28 01:35:53 +12:00
a6c961984d An emoji can be a single character 2024-08-28 01:31:57 +12:00
42bfd034cf Bridge command author metadata to Matrix 2024-08-28 00:50:48 +12:00
4b7593d630 Make invite command more testable 2024-08-28 00:18:06 +12:00
78a17b2de9 Update formatting of matrix info command 2024-08-28 00:17:54 +12:00
ddb211f8f3 Make permissions command apply recursively 2024-08-28 00:17:34 +12:00
3a84658e8b Make a helper for the room hierarchy 2024-08-27 19:10:38 +12:00
89696fd161 Update dependencies 2024-08-27 19:02:53 +12:00
be405d3eed Highly experimental message interactions 2024-08-27 02:17:10 +12:00
b8793dae0f Sync new power system to newly created rooms 2024-08-26 01:42:56 +12:00
df1296e579 Store invite in database and sync power on startup 2024-08-26 01:34:46 +12:00
74632c671c Update dependencies 2024-08-20 03:18:33 +12:00
2a080b737c Parsing HTML with regular expressions (GONE WRONG) 2024-08-20 03:15:33 +12:00
2c93ff1e6d Match old and new formats of reply rep 2024-08-16 12:04:09 +12:00
0cf1d26764 Update dependency justification 2024-08-16 10:25:38 +12:00
5c48645334 m->d: Suppress embeds for links in reply preview 2024-08-16 10:25:37 +12:00
283cf66906 Move reply previews to one line, fix android small text 2024-08-16 01:08:01 +12:00
5e4d539807 Fix potential abuse of error retry system 2024-08-15 23:51:34 +12:00
7e9005216d Switch to new application service library 2024-08-03 22:46:09 +12:00
87cad6c613 Update and unminify rlottie 2024-08-03 22:45:34 +12:00
2c3b653b54 Consistently add @ to m->d matrix mentions 2024-07-31 00:06:32 +12:00
f9eb043c10 Update dependencies 2024-07-31 00:00:48 +12:00
742d34f15e Fork turndown for Discord-like link escaping 2024-07-30 23:45:44 +12:00
a6251e850c Use small text for m->d replies 2024-07-30 23:45:26 +12:00
185 changed files with 22973 additions and 2742 deletions

13
.gitignore vendored
View file

@ -1,7 +1,16 @@
node_modules # Secrets
config.js config.js
registration.yaml registration.yaml
ooye.db*
events.db*
# Automatically generated
node_modules
coverage coverage
db/ooye.db*
test/res/* test/res/*
!test/res/lottie* !test/res/lottie*
icon.svg
*~
.#*
\#*#
launch.json

9
addbot.js Normal file → Executable file
View file

@ -1,15 +1,18 @@
#!/usr/bin/env node
// @ts-check // @ts-check
const config = require("./config") const {reg} = require("./src/matrix/read-registration")
const token = reg.ooye.discord_token
const id = Buffer.from(token.split(".")[0], "base64").toString()
function addbot() { function addbot() {
const token = config.discordToken
const id = Buffer.from(token.split(".")[0], "base64")
return `Open this link to add the bot to a Discord server:\nhttps://discord.com/oauth2/authorize?client_id=${id}&scope=bot&permissions=1610883072 ` return `Open this link to add the bot to a Discord server:\nhttps://discord.com/oauth2/authorize?client_id=${id}&scope=bot&permissions=1610883072 `
} }
/* c8 ignore next 3 */
if (process.argv.find(a => a.endsWith("addbot") || a.endsWith("addbot.js"))) { if (process.argv.find(a => a.endsWith("addbot") || a.endsWith("addbot.js"))) {
console.log(addbot()) console.log(addbot())
} }
module.exports.id = id
module.exports.addbot = addbot module.exports.addbot = addbot

View file

@ -1,3 +1,3 @@
#!/usr/bin/env sh #!/usr/bin/env sh
echo "Open this link to add the bot to a Discord server:" echo "Open this link to add the bot to a Discord server:"
echo "https://discord.com/oauth2/authorize?client_id=$(grep discordToken config.js | sed -E 's!.*: ["'\'']([A-Za-z0-9+=/_-]*).*!\1!g' | base64 -d)&scope=bot&permissions=1610883072" echo "https://discord.com/oauth2/authorize?client_id=$(grep discord_token registration.yaml | sed -E 's!.*: ["'\'']([A-Za-z0-9+=/_-]*).*!\1!g' | base64 -d)&scope=bot&permissions=1610883072"

View file

@ -1,3 +0,0 @@
module.exports = {
discordToken: "yes"
}

View file

@ -1,100 +0,0 @@
// @ts-check
const mixin = require("@cloudrac3r/mixin-deep")
const {channelToKState, _convertNameAndTopic} = require("./create-room")
const {kstateStripConditionals} = require("../../matrix/kstate")
const {test} = require("supertape")
const testData = require("../../test/data")
const passthrough = require("../../passthrough")
const {db} = passthrough
test("channel2room: discoverable privacy room", async t => {
db.prepare("UPDATE guild_space SET privacy_level = 2").run()
t.deepEqual(
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general).then(x => x.channelKState)),
Object.assign({}, testData.room.general, {
"m.room.guest_access/": {guest_access: "forbidden"},
"m.room.join_rules/": {join_rule: "public"},
"m.room.history_visibility/": {history_visibility: "world_readable"}
})
)
})
test("channel2room: linkable privacy room", async t => {
db.prepare("UPDATE guild_space SET privacy_level = 1").run()
t.deepEqual(
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general).then(x => x.channelKState)),
Object.assign({}, testData.room.general, {
"m.room.guest_access/": {guest_access: "forbidden"},
"m.room.join_rules/": {join_rule: "public"}
})
)
})
test("channel2room: invite-only privacy room", async t => {
db.prepare("UPDATE guild_space SET privacy_level = 0").run()
t.deepEqual(
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general).then(x => x.channelKState)),
testData.room.general
)
})
test("channel2room: room where limited people can mention everyone", async t => {
const limitedGuild = mixin({}, testData.guild.general)
limitedGuild.roles[0].permissions = (BigInt(limitedGuild.roles[0].permissions) - 131072n).toString()
const limitedRoom = mixin({}, testData.room.general, {"m.room.power_levels/": {notifications: {room: 20}}})
t.deepEqual(
kstateStripConditionals(await channelToKState(testData.channel.general, limitedGuild).then(x => x.channelKState)),
limitedRoom
)
})
test("convertNameAndTopic: custom name and topic", t => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, "hauntings"),
["hauntings", "#the-twilight-zone | Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"]
)
})
test("convertNameAndTopic: custom name, no topic", t => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", type: 0}, {id: "456"}, "hauntings"),
["hauntings", "#the-twilight-zone\n\nChannel ID: 123\nGuild ID: 456"]
)
})
test("convertNameAndTopic: original name and topic", t => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, null),
["the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"]
)
})
test("convertNameAndTopic: original name, no topic", t => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", type: 0}, {id: "456"}, null),
["the-twilight-zone", "Channel ID: 123\nGuild ID: 456"]
)
})
test("convertNameAndTopic: public thread icon", t => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 11}, {id: "456"}, null),
["[⛓️] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"]
)
})
test("convertNameAndTopic: private thread icon", t => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 12}, {id: "456"}, null),
["[🔒⛓️] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"]
)
})
test("convertNameAndTopic: voice channel icon", t => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 2}, {id: "456"}, null),
["[🔊] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"]
)
})

File diff suppressed because one or more lines are too long

View file

@ -1,273 +0,0 @@
// @ts-check
const assert = require("assert").strict
const util = require("util")
const DiscordTypes = require("discord-api-types/v10")
const reg = require("../matrix/read-registration")
const {addbot} = require("../addbot")
const {discord, sync, db, select} = require("../passthrough")
/** @type {import("../matrix/api")}) */
const api = sync.require("../matrix/api")
/** @type {import("../matrix/file")} */
const file = sync.require("../matrix/file")
/** @type {import("../d2m/actions/create-space")} */
const createSpace = sync.require("../d2m/actions/create-space")
/** @type {import("./utils")} */
const utils = sync.require("./utils")
const PREFIX = "//"
let buttons = []
/**
* @param {string} channelID where to add the button
* @param {string} messageID where to add the button
* @param {string} emoji emoji to add as a button
* @param {string} userID only listen for responses from this user
* @returns {Promise<import("discord-api-types/v10").GatewayMessageReactionAddDispatchData>}
*/
async function addButton(channelID, messageID, emoji, userID) {
await discord.snow.channel.createReaction(channelID, messageID, emoji)
return new Promise(resolve => {
buttons.push({channelID, messageID, userID, resolve, created: Date.now()})
})
}
// Clear out old buttons every so often to free memory
setInterval(() => {
const now = Date.now()
buttons = buttons.filter(b => now - b.created < 2*60*60*1000)
}, 10*60*1000)
/** @param {import("discord-api-types/v10").GatewayMessageReactionAddDispatchData} data */
function onReactionAdd(data) {
const button = buttons.find(b => b.channelID === data.channel_id && b.messageID === data.message_id && b.userID === data.user_id)
if (button) {
buttons = buttons.filter(b => b !== button) // remove button data so it can't be clicked again
button.resolve(data)
}
}
/**
* @callback CommandExecute
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message
* @param {DiscordTypes.APIGuildTextChannel} channel
* @param {DiscordTypes.APIGuild} guild
* @param {Partial<DiscordTypes.RESTPostAPIChannelMessageJSONBody>} [ctx]
*/
/**
* @typedef Command
* @property {string[]} aliases
* @property {(message: DiscordTypes.GatewayMessageCreateDispatchData, channel: DiscordTypes.APIGuildTextChannel, guild: DiscordTypes.APIGuild) => Promise<any>} execute
*/
/** @param {CommandExecute} execute */
function replyctx(execute) {
/** @type {CommandExecute} */
return function(message, channel, guild, ctx = {}) {
ctx.message_reference = {
message_id: message.id,
channel_id: channel.id,
guild_id: guild.id,
fail_if_not_exists: false
}
return execute(message, channel, guild, ctx)
}
}
/** @type {Command[]} */
const commands = [{
aliases: ["icon", "avatar", "roomicon", "roomavatar", "channelicon", "channelavatar"],
execute: replyctx(
async (message, channel, guild, ctx) => {
// Guard
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
if (!roomID) return discord.snow.channel.createMessage(channel.id, {
...ctx,
content: "This channel isn't bridged to the other side."
})
// Current avatar
const avatarEvent = await api.getStateEvent(roomID, "m.room.avatar", "")
const avatarURLParts = avatarEvent?.url.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
let currentAvatarMessage =
( avatarURLParts ? `Current room-specific avatar: ${reg.ooye.server_origin}/_matrix/media/r0/download/${avatarURLParts[1]}/${avatarURLParts[2]}`
: "No avatar. Now's your time to strike. Use `//icon` again with a link or upload to set the room-specific avatar.")
// Next potential avatar
const nextAvatarURL = message.attachments.find(a => a.content_type?.startsWith("image/"))?.url || message.content.match(/https?:\/\/[^ ]+\.[^ ]+\.(?:png|jpg|jpeg|webp)\b/)?.[0]
let nextAvatarMessage =
( nextAvatarURL ? `\nYou want to set it to: ${nextAvatarURL}\nHit ✅ to make it happen.`
: "")
const sent = await discord.snow.channel.createMessage(channel.id, {
...ctx,
content: currentAvatarMessage + nextAvatarMessage
})
if (nextAvatarURL) {
addButton(channel.id, sent.id, "✅", message.author.id).then(async data => {
const mxcUrl = await file.uploadDiscordFileToMxc(nextAvatarURL)
await api.sendState(roomID, "m.room.avatar", "", {
url: mxcUrl
})
db.prepare("UPDATE channel_room SET custom_avatar = ? WHERE channel_id = ?").run(mxcUrl, channel.id)
await discord.snow.channel.createMessage(channel.id, {
...ctx,
content: "Your creation is unleashed. Any complaints will be redirected to Grelbo."
})
})
}
}
)
}, {
aliases: ["invite"],
execute: replyctx(
async (message, channel, guild, ctx) => {
// Check guild is bridged
const spaceID = select("guild_space", "space_id", {guild_id: guild.id}).pluck().get()
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
if (!spaceID || !roomID) return discord.snow.channel.createMessage(channel.id, {
...ctx,
content: "This server isn't bridged to Matrix, so you can't invite Matrix users."
})
// Check CREATE_INSTANT_INVITE permission
assert(message.member)
const guildPermissions = utils.getPermissions(message.member.roles, guild.roles)
if (!(guildPermissions & DiscordTypes.PermissionFlagsBits.CreateInstantInvite)) {
return discord.snow.channel.createMessage(channel.id, {
...ctx,
content: "You don't have permission to invite people to this Discord server."
})
}
// Guard against accidental mentions instead of the MXID
if (message.content.match(/<[@#:].*>/)) return discord.snow.channel.createMessage(channel.id, {
...ctx,
content: "You have to say the Matrix ID of the person you want to invite, but you mentioned a Discord user in your message.\nOne way to fix this is by writing `` ` `` backticks `` ` `` around the Matrix ID."
})
// Get named MXID
const mxid = message.content.match(/@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)/)?.[0]
if (!mxid) return discord.snow.channel.createMessage(channel.id, {
...ctx,
content: "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`"
})
// Check for existing invite to the space
let spaceMember
try {
spaceMember = await api.getStateEvent(spaceID, "m.room.member", mxid)
} catch (e) {}
if (spaceMember && spaceMember.membership === "invite") {
return discord.snow.channel.createMessage(channel.id, {
...ctx,
content: `\`${mxid}\` already has an invite, which they haven't accepted yet.`
})
}
// Invite Matrix user if not in space
if (!spaceMember || spaceMember.membership !== "join") {
await api.inviteToRoom(spaceID, mxid)
return discord.snow.channel.createMessage(channel.id, {
...ctx,
content: `You invited \`${mxid}\` to the server.`
})
}
// The Matrix user *is* in the space, maybe we want to invite them to this channel?
let roomMember
try {
roomMember = await api.getStateEvent(roomID, "m.room.member", mxid)
} catch (e) {}
if (!roomMember || (roomMember.membership !== "join" && roomMember.membership !== "invite")) {
const sent = await discord.snow.channel.createMessage(channel.id, {
...ctx,
content: `\`${mxid}\` is already in this server. Would you like to additionally invite them to this specific channel?\nHit ✅ to make it happen.`
})
return addButton(channel.id, sent.id, "✅", message.author.id).then(async data => {
await api.inviteToRoom(roomID, mxid)
await discord.snow.channel.createMessage(channel.id, {
...ctx,
content: `You invited \`${mxid}\` to the channel.`
})
})
}
// The Matrix user *is* in the space and in the channel.
await discord.snow.channel.createMessage(channel.id, {
...ctx,
content: `\`${mxid}\` is already in this server and this channel.`
})
}
)
}, {
aliases: ["addbot"],
execute: replyctx(
async (message, channel, guild, ctx) => {
return discord.snow.channel.createMessage(channel.id, {
...ctx,
content: addbot()
})
}
)
}, {
aliases: ["privacy", "discoverable", "publish", "published"],
execute: replyctx(
async (message, channel, guild, ctx) => {
const current = select("guild_space", "privacy_level", {guild_id: guild.id}).pluck().get()
if (current == null) {
return discord.snow.channel.createMessage(channel.id, {
...ctx,
content: "This server isn't bridged to the other side."
})
}
const levels = ["invite", "link", "directory"]
const level = levels.findIndex(x => message.content.includes(x))
if (level === -1) {
return discord.snow.channel.createMessage(channel.id, {
...ctx,
content: "**Usage: `//privacy <level>`**. This will set who can join the space on Matrix-side. There are three levels:"
+ "\n`invite`: Can only join with a direct in-app invite from another Matrix user, or the //invite command."
+ "\n`link`: Matrix links can be created and shared like Discord's invite links. `invite` features also work."
+ "\n`directory`: Publishes to the Matrix in-app directory, like Server Discovery. Preview enabled. `invite` and `link` also work."
+ `\n**Current privacy level: \`${levels[current]}\`**`
})
}
assert(message.member)
const guildPermissions = utils.getPermissions(message.member.roles, guild.roles)
if (guild.owner_id !== message.author.id && !(guildPermissions & BigInt(0x28))) { // MANAGE_GUILD | ADMINISTRATOR
return discord.snow.channel.createMessage(channel.id, {
...ctx,
content: "You don't have permission to change the privacy level. You need Manage Server or Administrator."
})
}
db.prepare("UPDATE guild_space SET privacy_level = ? WHERE guild_id = ?").run(level, guild.id)
discord.snow.channel.createMessage(channel.id, {
...ctx,
content: `Privacy level updated to \`${levels[level]}\`. Changes will apply shortly.`
})
await createSpace.syncSpaceFully(guild.id)
}
)
}]
/** @type {CommandExecute} */
async function execute(message, channel, guild) {
if (!message.content.startsWith(PREFIX)) return
const words = message.content.slice(PREFIX.length).split(" ")
const commandName = words[0]
const command = commands.find(c => c.aliases.includes(commandName))
if (!command) return
await command.execute(message, channel, guild)
}
module.exports.execute = execute
module.exports.onReactionAdd = onReactionAdd

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

View file

@ -0,0 +1,77 @@
# Self-service room creation rules
Before version 3 of Out Of Your Element, new Matrix rooms would be created on-demand when a Discord channel is spoken in for the first time. This has worked pretty well.
This is done through functions like ensureRoom and ensureSpace in actions:
```js
async function sendMessage(message, channel, guild, row) {
const roomID = await createRoom.ensureRoom(message.channel_id)
...
}
/**
* Ensures the room exists. If it doesn't, creates the room with an accurate initial state.
* @param {string} channelID
* @returns {Promise<string>} Matrix room ID
*/
function ensureRoom(channelID) {
return _syncRoom(channelID, /* shouldActuallySync */ false) /* calls ensureSpace */
}
/**
* Ensures the space exists. If it doesn't, creates the space with an accurate initial state.
* @param {DiscordTypes.APIGuild} guild
* @returns {Promise<string>} Matrix space ID
*/
function ensureSpace(guild) {
return _syncSpace(guild, /* shouldActuallySync */ false)
}
```
With the introduction of self-service mode, we still want to retain this as a possible mode of operation, since some people prefer to have OOYE handle this administrative work. However, other people prefer to manage the links between channels and rooms themselves, and have control over what new rooms get linked up to.
Visibly, this is managed through the web interface. The web interface lets moderators enable/disable auto-creation of new rooms, as well as set which channels and rooms are linked together.
There is a small complication. Not only are Matrix rooms created automatically, their Matrix spaces are also created automatically during room sync: ensureRoom calls ensureSpace. If a user opts in to self-service mode by clicking the specific button in the web portal, we must ensure the _space is not created automatically either,_ because the Matrix user will provide a space to link to.
To solve this, we need a way to suppress specific guilds from having auto-created spaces. The natural way to represent this is a column on guild_space, but that doesn't work, because each guild_space row requires a guild and space to be linked, and we _don't want_ them to be linked.
So, internally, OOYE keeps track of this through a new table:
```sql
CREATE TABLE "guild_active" (
"guild_id" TEXT NOT NULL, -- only guilds that are bridged are present in this table
"autocreate" INTEGER NOT NULL, -- 0 or 1
PRIMARY KEY("guild_id")
) WITHOUT ROWID;
```
There is one more complication. When adding a Discord bot through web oauth with a redirect_uri, Discord adds the bot to the server normally, _then_ redirects back to OOYE, and only then does OOYE know which guild the bot was just added to. So, for a short time between the bot being added and the user being redirected, OOYE might receive Discord events in the server before it has the chance to create the guild_active database row.
So to prevent this, self-service behaviour needs to be an implicit default, and users must firmly choose one system or another to begin using OOYE. It is important for me to design this in a way that doesn't force users to do any extra work or make a choice they don't understand to keep the pre-v3 behaviour.
So there will be 3 states of whether a guild is self-service or not. At first, it could be absent from the table, in which case events for it will be dropped. Or it could be in the table with autocomplete = 0, in which case only rooms that already exist in channel_room will have messages bridged. Or it could have autocomplete = 1, in which case Matrix rooms will be created as needed, as per the pre-v3 behaviour.
| Auto-create | Meaning |
| -- | ------------ |
| 😶‍🌫️ | Unbridged - waiting |
| ❌ | Bridged - self-service |
| ✅ | Bridged - auto-create |
Pressing buttons on web or using the /invite command on a guild will insert a row into guild_active, allowing it to be bridged.
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.
- When /invite command is used, INSERT OR IGNORE INTO state 1 and ensureRoom + ensureSpace.
- When bot is added through "easy mode" web button, REPLACE INTO state 1 and ensureSpace.
- When bot is added through "self-service" web button, REPLACE INTO state 0.
- Event dispatcher will only ensureRoom if the guild_active state is 1.
- createRoom will only create other dependencies if the guild is autocreate.
## Enough with your theory. How do rooms actually get bridged now?
After clicking the easy mode button on web and adding the bot to a server, it will create new Matrix rooms on-demand when any invite features are used (web or command) OR just when any message is sent on Discord.
Alternatively, pressing the self-service mode button and adding the bot to a server will prompt the web user to link it with a space. After doing so, they'll be on the standard guild management page where they can invite to the space and manually link rooms. Nothing will be autocreated.

View file

@ -0,0 +1,103 @@
# 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 configuration 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; preload" always;
proxy_pass http://127.0.0.1:6693;
}
}
```
## Example configuration 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 configuration 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

@ -1,57 +0,0 @@
// @ts-check
const assert = require("assert").strict
const Ty = require("../../types")
const passthrough = require("../../passthrough")
const {sync, select} = passthrough
/**
* @param {string} input
* @param {string | null | undefined} shortcode
* @returns {string?}
*/
function encodeEmoji(input, shortcode) {
let discordPreferredEncoding
if (input.startsWith("mxc://")) {
// Custom emoji
let row = select("emoji", ["emoji_id", "name"], {mxc_url: input}).get()
if (!row && shortcode) {
// Use the name to try to find a known emoji with the same name.
const name = shortcode.replace(/^:|:$/g, "")
row = select("emoji", ["emoji_id", "name"], {name: name}).get()
}
if (!row) {
// We don't have this emoji and there's no realistic way to just-in-time upload a new emoji somewhere.
// Sucks!
return null
}
// Cool, we got an exact or a candidate emoji.
discordPreferredEncoding = encodeURIComponent(`${row.name}:${row.emoji_id}`)
} else {
// Default emoji
// https://github.com/discord/discord-api-docs/issues/2723#issuecomment-807022205 ????????????
const encoded = encodeURIComponent(input)
const encodedTrimmed = encoded.replace(/%EF%B8%8F/g, "")
const forceTrimmedList = [
"%F0%9F%91%8D", // 👍
"%F0%9F%91%8E", // 👎️
"%E2%AD%90", // ⭐
"%F0%9F%90%88", // 🐈
"%E2%9D%93", // ❓
"%F0%9F%8F%86", // 🏆️
"%F0%9F%93%9A", // 📚️
]
discordPreferredEncoding =
( forceTrimmedList.includes(encodedTrimmed) ? encodedTrimmed
: encodedTrimmed !== encoded && [...input].length === 2 ? encoded
: encodedTrimmed)
console.log("add reaction from matrix:", input, encoded, encodedTrimmed, "chosen:", discordPreferredEncoding)
}
return discordPreferredEncoding
}
module.exports.encodeEmoji = encodeEmoji

View file

@ -1,152 +0,0 @@
// @ts-check
/*
* Grab Matrix events we care about, check them, and bridge them.
*/
const util = require("util")
const Ty = require("../types")
const {discord, db, sync, as} = require("../passthrough")
/** @type {import("./actions/send-event")} */
const sendEvent = sync.require("./actions/send-event")
/** @type {import("./actions/add-reaction")} */
const addReaction = sync.require("./actions/add-reaction")
/** @type {import("./actions/redact")} */
const redact = sync.require("./actions/redact")
/** @type {import("../matrix/matrix-command-handler")} */
const matrixCommandHandler = sync.require("../matrix/matrix-command-handler")
/** @type {import("./converters/utils")} */
const utils = sync.require("./converters/utils")
/** @type {import("../matrix/api")}) */
const api = sync.require("../matrix/api")
/** @type {import("../matrix/read-registration")}) */
const reg = sync.require("../matrix/read-registration")
let lastReportedEvent = 0
function guard(type, fn) {
return async function(event, ...args) {
try {
return await fn(event, ...args)
} catch (e) {
console.error("hit event-dispatcher's error handler with this exception:")
console.error(e) // TODO: also log errors into a file or into the database, maybe use a library for this? or just wing it?
console.error(`while handling this ${type} gateway event:`)
console.dir(event, {depth: null})
if (Date.now() - lastReportedEvent < 5000) return
lastReportedEvent = Date.now()
let stackLines = e.stack.split("\n")
api.sendEvent(event.room_id, "m.room.message", {
msgtype: "m.text",
body: "\u26a0 Matrix event not delivered to Discord. See formatted content for full details.",
format: "org.matrix.custom.html",
formatted_body: "\u26a0 <strong>Matrix event not delivered to Discord</strong>"
+ `<br>Event type: ${type}`
+ `<br>${e.toString()}`
+ `<br><details><summary>Error trace</summary>`
+ `<pre>${stackLines.join("\n")}</pre></details>`
+ `<details><summary>Original payload</summary>`
+ `<pre>${util.inspect(event, false, 4, false)}</pre></details>`,
"moe.cadence.ooye.error": {
source: "matrix",
payload: event
},
"m.mentions": {
user_ids: ["@cadence:cadence.moe"]
}
})
}
}
}
async function retry(roomID, eventID) {
const event = await api.getEvent(roomID, eventID)
const error = event.content["moe.cadence.ooye.error"]
if (event.sender !== `@${reg.sender_localpart}:${reg.ooye.server_name}` || !error) return
if (error.source === "matrix") {
as.emit("type:" + error.payload.type, error.payload)
} else if (error.source === "discord") {
discord.cloud.emit("event", error.payload)
}
}
sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message",
/**
* @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} event it is a m.room.message because that's what this listener is filtering for
*/
async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return
const messageResponses = await sendEvent.sendEvent(event)
if (event.type === "m.room.message" && event.content.msgtype === "m.text") {
// @ts-ignore
await matrixCommandHandler.execute(event)
}
}))
sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker",
/**
* @param {Ty.Event.Outer_M_Sticker} event it is a m.sticker because that's what this listener is filtering for
*/
async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return
const messageResponses = await sendEvent.sendEvent(event)
}))
sync.addTemporaryListener(as, "type:m.reaction", guard("m.reaction",
/**
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>} event it is a m.reaction because that's what this listener is filtering for
*/
async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return
if (event.content["m.relates_to"].key === "🔁") {
// Try to bridge a failed event again?
await retry(event.room_id, event.content["m.relates_to"].event_id)
} else {
matrixCommandHandler.onReactionAdd(event)
await addReaction.addReaction(event)
}
}))
sync.addTemporaryListener(as, "type:m.room.redaction", guard("m.room.redaction",
/**
* @param {Ty.Event.Outer_M_Room_Redaction} event it is a m.room.redaction because that's what this listener is filtering for
*/
async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return
await redact.handle(event)
}))
sync.addTemporaryListener(as, "type:m.room.avatar", guard("m.room.avatar",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Avatar>} event
*/
async event => {
if (event.state_key !== "") return
if (utils.eventSenderIsFromDiscord(event.sender)) return
const url = event.content.url || null
db.prepare("UPDATE channel_room SET custom_avatar = ? WHERE room_id = ?").run(url, event.room_id)
}))
sync.addTemporaryListener(as, "type:m.room.name", guard("m.room.name",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Name>} event
*/
async event => {
if (event.state_key !== "") return
if (utils.eventSenderIsFromDiscord(event.sender)) return
const name = event.content.name || null
db.prepare("UPDATE channel_room SET nick = ? WHERE room_id = ?").run(name, event.room_id)
}))
sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Member>} event
*/
async event => {
if (event.state_key[0] !== "@") return
if (utils.eventSenderIsFromDiscord(event.state_key)) return
db.prepare("REPLACE INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?)").run(event.room_id, event.state_key, event.content.displayname || null, event.content.avatar_url || null)
}))

View file

@ -1,8 +0,0 @@
const reg = require("../matrix/read-registration")
const AppService = require("matrix-appservice").AppService
const as = new AppService({
homeserverToken: reg.hs_token
})
as.listen(+(new URL(reg.url).port))
module.exports = as

View file

@ -1,14 +0,0 @@
// @ts-check
const fs = require("fs")
const assert = require("assert").strict
const yaml = require("js-yaml")
/** @ts-ignore @type {import("../types").AppServiceRegistrationConfig} */
const reg = yaml.load(fs.readFileSync("registration.yaml", "utf8"))
reg["ooye"].invite = (reg.ooye.invite || []).filter(mxid => mxid.endsWith(`:${reg.ooye.server_name}`)) // one day I will understand why typescript disagrees with dot notation on this line
assert(reg.ooye.max_file_size)
assert(reg.ooye.namespace_prefix)
assert(reg.ooye.server_name)
module.exports = reg

View file

@ -1,10 +0,0 @@
const {test} = require("supertape")
const reg = require("./read-registration")
test("reg: has necessary parameters", t => {
const propertiesToCheck = ["sender_localpart", "id", "as_token", "ooye"]
t.deepEqual(
propertiesToCheck.filter(p => p in reg),
propertiesToCheck
)
})

2281
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -14,45 +14,57 @@
], ],
"author": "Cadence, PapiOphidian", "author": "Cadence, PapiOphidian",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"engines": {
"node": ">=20"
},
"dependencies": { "dependencies": {
"@chriscdn/promise-semaphore": "^2.0.1", "@chriscdn/promise-semaphore": "^2.0.1",
"@cloudrac3r/discord-markdown": "^2.6.2", "@cloudrac3r/discord-markdown": "^2.6.3",
"@cloudrac3r/giframe": "^0.4.3", "@cloudrac3r/giframe": "^0.4.3",
"@cloudrac3r/html-template-tag": "^5.0.1", "@cloudrac3r/html-template-tag": "^5.0.1",
"@cloudrac3r/mixin-deep": "^3.0.0", "@cloudrac3r/in-your-element": "^1.0.0",
"@cloudrac3r/mixin-deep": "^3.0.1",
"@cloudrac3r/pngjs": "^7.0.3", "@cloudrac3r/pngjs": "^7.0.3",
"better-sqlite3": "^9.0.0", "@cloudrac3r/pug": "^4.0.4",
"@cloudrac3r/turndown": "^7.1.4",
"@stackoverflow/stacks": "^2.5.7",
"@stackoverflow/stacks-icons": "^6.0.2",
"ansi-colors": "^4.1.3",
"better-sqlite3": "^11.1.2",
"chunk-text": "^2.0.1", "chunk-text": "^2.0.1",
"cloudstorm": "^0.10.8", "cloudstorm": "^0.11.2",
"entities": "^4.5.0", "discord-api-types": "^0.37.119",
"domino": "^2.1.6",
"enquirer": "^2.4.1",
"entities": "^5.0.0",
"get-relative-path": "^1.0.2",
"get-stream": "^6.0.1", "get-stream": "^6.0.1",
"heatsync": "^2.5.3", "h3": "^1.12.0",
"js-yaml": "^4.1.0", "heatsync": "^2.7.2",
"matrix-appservice": "^2.0.0", "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.32.6", "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",
"turndown": "^7.1.2", "uqr": "^0.1.2",
"xxhash-wasm": "^1.0.2" "xxhash-wasm": "^1.0.2",
"zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@cloudrac3r/tap-dot": "^2.0.2", "@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": "^8.0.1",
"colorette": "^1.4.0",
"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 --enable-source-maps start.js",
"setup": "node --enable-source-maps scripts/setup.js",
"addbot": "node addbot.js", "addbot": "node addbot.js",
"test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap 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 --skip-full -x db/migrations -x matrix/file.js -x matrix/api.js -x matrix/mreq.js -x d2m/converters/rlottie-wasm.js -r html -r text supertape --no-check-assertions-count --format fail --no-worker test/test.js -- --slow" "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"
} }
} }

109
readme.md
View file

@ -36,12 +36,14 @@ Most features you'd expect in both directions, plus a little extra spice:
* Attachments * Attachments
* Spoiler attachments * Spoiler attachments
* Embeds * Embeds
* Presence
* Guild-Space details syncing * Guild-Space details syncing
* Channel-Room details syncing * Channel-Room details syncing
* Custom emoji list syncing * Custom emoji list syncing
* Custom emojis in messages * Custom emojis in messages
* Custom room names/avatars can be applied on Matrix-side * Custom room names/avatars can be applied on Matrix-side
* Larger files from Discord are linked instead of reuploaded to Matrix * PluralKit members have persistent user accounts
* 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
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)
@ -55,13 +57,13 @@ For more information about features, [see the user guide.](https://gitdab.com/ca
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. 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 are actually stored as xxhash integers to reduce storage requirements even more. On my personal instance of OOYE, every 100,000 messages require 16.1 MB of storage space in the SQLite database. The bridge uses a small SQLite database to store relationships like which Discord messages correspond to which Matrix messages. This is so the bridge knows what to edit when some message is edited on Discord. Using `without rowid` on the database tables stores the index and the data in the same B-tree. Since Matrix and Discord's internal IDs are quite long, this vastly reduces storage space because those IDs do not have to be stored twice separately. Some event IDs and URLs are actually stored as xxhash integers to reduce storage requirements even more. On my personal instance of OOYE, every 300,000 messages (representing a year of conversations) requires 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. 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. 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`. 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 # Setup
@ -71,55 +73,56 @@ You'll need:
* Administrative access to a homeserver * Administrative access to a homeserver
* Discord bot * 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)
Follow these steps: Follow these steps:
1. [Get Node.js version 18 or later](https://nodejs.org/en/download/releases) (the version is required by the better-sqlite3 and matrix-appservice dependencies) 1. [Get Node.js version 20 or later](https://nodejs.org/en/download/prebuilt-installer)
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.) 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). * 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 --save-dev` (omit --save-dev if you will not run the automated tests) 1. Install dependencies: `npm install`
1. Copy `config.example.js` to `config.js` and fill in Discord token. 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. Copy `registration.example.yaml` to `registration.yaml` and fill in bracketed values. You could generate each hex string with `dd if=/dev/urandom bs=32 count=1 2> /dev/null | basenc --base16 | dd conv=lcase 2> /dev/null`. Register the registration in Synapse's `homeserver.yaml` through the usual appservice installation process, then restart Synapse. 1. Start the bridge: `npm run start`
1. Run `node scripts/seed.js` to check your setup and set the bot's initial state. You only need to run this once ever.
1. Make sure the tests work by running `npm t`
1. Start the bridge: `node start.js`
1. Add the bot to a server - use any *one* of the following commands for an invite link: 1. Add the bot to a server - use any *one* of the following commands for an invite link:
* (in the REPL) `addbot` * (in the REPL) `addbot`
* (in a chat) `//addbot`
* $ `node addbot.js` * $ `node addbot.js`
* $ `npm run addbot` * $ `npm run addbot`
* $ `./addbot.sh` * $ `./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. 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, either add yourself to `invite` in `registration.yaml`, or use the `//invite [your mxid here]` command on Discord. To get into the rooms on your Matrix account, use the `/invite [your mxid here]` command on Discord.
# Development setup # Development setup
* Be sure to install dependencies with `--save-dev` so you can run the tests. * Install development dependencies with `npm install --save-dev` so you can run the tests.
* Any files you change will automatically be reloaded, except for `stdin.js` and `d2m/discord-*.js`. * 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` * 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. * 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 ## Repository structure
. .
* Run this to start the bridge:
├── start.js
* Runtime configuration, like tokens and user info: * Runtime configuration, like tokens and user info:
├── config.js
├── registration.yaml ├── registration.yaml
* You are here! :)
├── readme.md
* The bridge's SQLite database is stored here: * The bridge's SQLite database is stored here:
├── ooye.db*
* Source code
└── src
* Database schema:
├── db ├── db
│   ├── *.sql, *.db │   ├── orm.js, orm-defs.d.ts
│   * Migrations change the database schema when you update to a newer version of OOYE: │   * Migrations change the database schema when you update to a newer version of OOYE:
│   ├── migrate.js
│   └── migrations │   └── migrations
│       └── *.sql, *.js │       └── *.sql, *.js
* Discord-to-Matrix bridging: * Discord-to-Matrix bridging:
@ -134,7 +137,10 @@ To get into the rooms on your Matrix account, either add yourself to `invite` in
│   ├── discord-*.js │   ├── discord-*.js
│   * Listening to events from Discord and dispatching them to the correct `action`: │   * Listening to events from Discord and dispatching them to the correct `action`:
│   └── event-dispatcher.js │   └── event-dispatcher.js
* Discord bot commands and menus:
├── discord ├── discord
│   ├── interactions
│   │   └── *.js
│   └── discord-command-handler.js │   └── discord-command-handler.js
* Matrix-to-Discord bridging: * Matrix-to-Discord bridging:
├── m2d ├── m2d
@ -150,36 +156,51 @@ To get into the rooms on your Matrix account, either add yourself to `invite` in
├── matrix ├── matrix
│   └── *.js │   └── *.js
* Various files you can run once if you need them. * Various files you can run once if you need them.
├── scripts └── scripts
│   * First time running a new bridge? Run this file to plant a seed, which will flourish into state for the bridge: * First time running a new bridge? Run this file to set up prerequisites on the Matrix server:
│   ├── seed.js ├── setup.js
│   * Hopefully you won't need the rest of these. Code quality varies wildly. * Hopefully you won't need the rest of these. Code quality varies wildly.
│   └── *.js └── *.js
* You are here! :)
└── readme.md
## Dependency justification ## Dependency justification
(deduped transitive dependency count) dependency name: explanation Total transitive production dependencies: 139
* (0) @chriscdn/promise-semaphore: It does what I want! I like it! ### <font size="+2">🦕</font>
* (42) better-sqlite3: SQLite3 is the best database, and this is the best library for it. Really! I love it.
* (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. * (1) chunk-text: It does what I want.
* (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust. * (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust.
* (8) snowtransfer: Discord API library with bring-your-own-caching that I trust. * (0) discord-api-types: Bitfields needed at runtime and types needed for development.
* (1) discord-markdown: This is my fork! * (0) domino: DOM implementation that's already pulled in by turndown.
* (0) get-stream: Only needed if content_length_workaround is true. * (1) enquirer: Interactive prompting for the initial setup rather than forcing users to edit YAML non-interactively.
* (0) giframe: This is my fork!
* (1) heatsync: Module hot-reloader that I trust.
* (0) entities: Looks fine. No dependencies. * (0) entities: Looks fine. No dependencies.
* (1) html-template-tag: This is my fork! * (0) get-relative-path: Looks fine. No dependencies.
* (1) js-yaml: It seems to do what I want, and it's already pulled in by matrix-appservice. * (0) get-stream: Only needed if content_length_workaround is true.
* (70) matrix-appservice: I wish it didn't pull in express :( * (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) minimist: It's already pulled in by better-sqlite3->prebuild-install.
* (0) mixin-deep: This is my fork! (It fixes a bug in regular mixin-deep.)
* (3) node-fetch@2: I like it and it does what I want.
* (0) pngjs: Lottie stickers are converted to bitmaps with the vendored Rlottie WASM build, then the bitmaps are converted to PNG with pngjs.
* (0) prettier-bytes: It does what I want and has no dependencies. * (0) prettier-bytes: It does what I want and has no dependencies.
* (51) sharp: Jimp has fewer dependencies, but sharp is faster. * (0) snowtransfer: Discord API library with bring-your-own-caching that I trust.
* (0) try-to-catch: Not strictly necessary, but it does what I want and has no dependencies. * (0) try-to-catch: Not strictly necessary, but it's already pulled in by supertape, so I may as well.
* (1) turndown: I need an HTML-to-Markdown converter and this one looked suitable enough. It has some bugs that I've worked around, so I might switch away from it later. * (0) uqr: QR code SVG generator. Used on the website to scan in an invite link.
* (0) xxhash-wasm: Used where cryptographically secure hashing is not required.
* (0) zod: Input validation for the web server. It's popular and easy to use.

View file

@ -1,25 +0,0 @@
id: de8c56117637cb5d9f4ac216f612dc2adb1de4c09ae8d13553f28c33a28147c7
hs_token: [a unique 64 character hex string]
as_token: [a unique 64 character hex string]
url: http://localhost:6693
sender_localpart: _ooye_bot
protocols:
- discord
namespaces:
users:
- exclusive: true
regex: '@_ooye_.*'
aliases:
- exclusive: true
regex: '#_ooye_.*'
rate_limited: false
ooye:
namespace_prefix: _ooye_
max_file_size: 5000000
server_name: [the part after the colon in your matrix id, like cadence.moe]
server_origin: [the full protocol and domain of your actual matrix server's location, with no trailing slash, like https://matrix.cadence.moe]
content_length_workaround: false
include_user_id_in_mxid: false
invite:
# uncomment this to auto-invite the named user to newly created spaces and mark them as admin (PL 100) everywhere
# - '@cadence:cadence.moe'

11
scripts/capture-message-update-events.js Normal file → Executable file
View file

@ -1,3 +1,4 @@
#!/usr/bin/env node
// @ts-check // @ts-check
// **** // ****
@ -16,16 +17,16 @@ function fieldToPresenceValue(field) {
const sqlite = require("better-sqlite3") const sqlite = require("better-sqlite3")
const HeatSync = require("heatsync") const HeatSync = require("heatsync")
const config = require("../config") const {reg} = require("../src/matrix/read-registration")
const passthrough = require("../passthrough") const passthrough = require("../src/passthrough")
const sync = new HeatSync({watchFS: false}) const sync = new HeatSync({watchFS: false})
Object.assign(passthrough, {config, sync}) Object.assign(passthrough, {sync})
const DiscordClient = require("../d2m/discord-client") const DiscordClient = require("../src/d2m/discord-client")
const discord = new DiscordClient(config.discordToken, "no") const discord = new DiscordClient(reg.ooye.discord_token, "no")
passthrough.discord = discord passthrough.discord = discord
;(async () => { ;(async () => {

10
scripts/check-migrate.js Normal file → Executable file
View file

@ -1,3 +1,4 @@
#!/usr/bin/env node
// @ts-check // @ts-check
// Trigger the database migration flow and exit after committing. // Trigger the database migration flow and exit after committing.
@ -5,11 +6,10 @@
const sqlite = require("better-sqlite3") const sqlite = require("better-sqlite3")
const config = require("../config") const passthrough = require("../src/passthrough")
const passthrough = require("../passthrough") const db = new sqlite("ooye.db")
const db = new sqlite("db/ooye.db") const migrate = require("../src/db/migrate")
const migrate = require("../db/migrate")
Object.assign(passthrough, {config, db }) Object.assign(passthrough, {db})
migrate.migrate(db) migrate.migrate(db)

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

23
scripts/migrate-from-old-bridge.js Normal file → Executable file
View file

@ -1,3 +1,4 @@
#!/usr/bin/env node
// @ts-check // @ts-check
const assert = require("assert").strict const assert = require("assert").strict
@ -6,19 +7,17 @@ const Semaphore = require("@chriscdn/promise-semaphore")
const sqlite = require("better-sqlite3") const sqlite = require("better-sqlite3")
const HeatSync = require("heatsync") const HeatSync = require("heatsync")
const config = require("../config") const passthrough = require("../src/passthrough")
const passthrough = require("../passthrough")
const sync = new HeatSync({watchFS: false}) const sync = new HeatSync({watchFS: false})
/** @type {import("../matrix/read-registration")} */ const {reg} = require("../src/matrix/read-registration")
const reg = sync.require("../matrix/read-registration")
assert(reg.old_bridge) assert(reg.old_bridge)
const oldAT = reg.old_bridge.as_token const oldAT = reg.old_bridge.as_token
const newAT = reg.as_token const newAT = reg.as_token
const oldDB = new sqlite(reg.old_bridge.database) const oldDB = new sqlite(reg.old_bridge.database)
const db = new sqlite("db/ooye.db") const db = new sqlite("ooye.db")
db.exec(`CREATE TABLE IF NOT EXISTS half_shot_migration ( db.exec(`CREATE TABLE IF NOT EXISTS half_shot_migration (
discord_channel TEXT NOT NULL, discord_channel TEXT NOT NULL,
@ -26,19 +25,19 @@ db.exec(`CREATE TABLE IF NOT EXISTS half_shot_migration (
PRIMARY KEY("discord_channel") PRIMARY KEY("discord_channel")
) WITHOUT ROWID;`) ) WITHOUT ROWID;`)
Object.assign(passthrough, {config, sync, db}) Object.assign(passthrough, {sync, db})
const DiscordClient = require("../d2m/discord-client") const DiscordClient = require("../src/d2m/discord-client")
const discord = new DiscordClient(config.discordToken, "half") const discord = new DiscordClient(reg.ooye.discord_token, "half")
passthrough.discord = discord passthrough.discord = discord
/** @type {import("../d2m/actions/create-space")} */ /** @type {import("../src/d2m/actions/create-space")} */
const createSpace = sync.require("../d2m/actions/create-space") const createSpace = sync.require("../d2m/actions/create-space")
/** @type {import("../d2m/actions/create-room")} */ /** @type {import("../src/d2m/actions/create-room")} */
const createRoom = sync.require("../d2m/actions/create-room") const createRoom = sync.require("../d2m/actions/create-room")
/** @type {import("../matrix/mreq")} */ /** @type {import("../src/matrix/mreq")} */
const mreq = sync.require("../matrix/mreq") const mreq = sync.require("../matrix/mreq")
/** @type {import("../matrix/api")} */ /** @type {import("../src/matrix/api")} */
const api = sync.require("../matrix/api") const api = sync.require("../matrix/api")
const sema = new Semaphore() const sema = new Semaphore()

View file

@ -1,22 +0,0 @@
// @ts-check
const { AppServiceRegistration } = require("matrix-appservice");
let id = AppServiceRegistration.generateToken()
try {
const reg = require("../matrix/read-registration")
if (reg.id) id = reg.id
} catch (e) {}
// creating registration files
const newReg = new AppServiceRegistration(null);
newReg.setAppServiceUrl("http://localhost:6693");
newReg.setId(id);
newReg.setHomeserverToken(AppServiceRegistration.generateToken());
newReg.setAppServiceToken(AppServiceRegistration.generateToken());
newReg.setSenderLocalpart("_ooye_bot");
newReg.addRegexPattern("users", "@_ooye_.*", true);
newReg.addRegexPattern("aliases", "#_ooye_.*", true);
newReg.setProtocols(["discord"]); // For 3PID lookups
newReg.setRateLimited(false);
newReg.outputAsYaml("registration.yaml");

View file

@ -0,0 +1,39 @@
// @ts-check
const HeatSync = require("heatsync")
const sync = new HeatSync({watchFS: false})
const sqlite = require("better-sqlite3")
const db = new sqlite("db/ooye.db")
const passthrough = require("../src/passthrough")
Object.assign(passthrough, {db, sync})
const api = require("../src/matrix/api")
const mreq = require("../src/matrix/mreq")
const rooms = db.prepare("select room_id from channel_room").pluck().all()
;(async () => {
// Step 5: Kick users starting with @_discord_
await mreq.withAccessToken("baby", async () => {
for (const roomID of rooms) {
try {
const members = await api.getJoinedMembers(roomID)
for (const mxid of Object.keys(members.joined)) {
if (mxid.startsWith("@_discord_") && !mxid.startsWith("@_discord_bot")) {
await api.leaveRoom(roomID, mxid)
}
}
await api.setUserPower(roomID, "@_discord_bot:cadence.moe", 0)
await api.leaveRoom(roomID)
} catch (e) {
if (e.message.includes("Appservice not in room")) {
// ok
} else {
throw e
}
}
}
})
})()

13
scripts/save-channel-names-to-db.js Normal file → Executable file
View file

@ -1,19 +1,20 @@
#!/usr/bin/env node
// @ts-check // @ts-check
const sqlite = require("better-sqlite3") const sqlite = require("better-sqlite3")
const HeatSync = require("heatsync") const HeatSync = require("heatsync")
const config = require("../config") const {reg} = require("../src/matrix/read-registration")
const passthrough = require("../passthrough") const passthrough = require("../src/passthrough")
const db = new sqlite("db/ooye.db") const db = new sqlite("ooye.db")
const sync = new HeatSync({watchFS: false}) const sync = new HeatSync({watchFS: false})
Object.assign(passthrough, {config, sync, db}) Object.assign(passthrough, {sync, db})
const DiscordClient = require("../d2m/discord-client") const DiscordClient = require("../src/d2m/discord-client")
const discord = new DiscordClient(config.discordToken, "no") const discord = new DiscordClient(reg.ooye.discord_token, "no")
passthrough.discord = discord passthrough.discord = discord
;(async () => { ;(async () => {

7
scripts/save-event-types-to-db.js Normal file → Executable file
View file

@ -1,16 +1,17 @@
#!/usr/bin/env node
// @ts-check // @ts-check
const sqlite = require("better-sqlite3") const sqlite = require("better-sqlite3")
const HeatSync = require("heatsync") const HeatSync = require("heatsync")
const passthrough = require("../passthrough") const passthrough = require("../src/passthrough")
const db = new sqlite("db/ooye.db") const db = new sqlite("ooye.db")
const sync = new HeatSync({watchFS: false}) const sync = new HeatSync({watchFS: false})
Object.assign(passthrough, {sync, db}) Object.assign(passthrough, {sync, db})
const api = require("../matrix/api") const api = require("../src/matrix/api")
/** @type {{event_id: string, room_id: string, event_type: string}[]} */ // @ts-ignore /** @type {{event_id: string, room_id: string, event_type: string}[]} */ // @ts-ignore
const rows = db.prepare("SELECT event_id, room_id, event_type FROM event_message INNER JOIN message_channel USING (message_id) INNER JOIN channel_room USING (channel_id)").all() const rows = db.prepare("SELECT event_id, room_id, event_type FROM event_message INNER JOIN message_channel USING (message_id) INNER JOIN channel_room USING (channel_id)").all()

View file

@ -1,138 +0,0 @@
// @ts-check
console.log("This could take up to 30 seconds. Please be patient.")
const assert = require("assert").strict
const fs = require("fs")
const sqlite = require("better-sqlite3")
const HeatSync = require("heatsync")
const args = require("minimist")(process.argv.slice(2), {string: ["emoji-guild"]})
const config = require("../config")
const passthrough = require("../passthrough")
const db = new sqlite("db/ooye.db")
const migrate = require("../db/migrate")
const sync = new HeatSync({watchFS: false})
Object.assign(passthrough, { sync, config, db })
const orm = sync.require("../db/orm")
passthrough.from = orm.from
passthrough.select = orm.select
const DiscordClient = require("../d2m/discord-client")
const discord = new DiscordClient(config.discordToken, "no")
passthrough.discord = discord
const api = require("../matrix/api")
const file = require("../matrix/file")
const reg = require("../matrix/read-registration")
const utils = require("../m2d/converters/utils")
function die(message) {
console.error(message)
process.exit(1)
}
async function uploadAutoEmoji(guild, name, filename) {
let emoji = guild.emojis.find(e => e.name === name)
if (!emoji) {
console.log(` Uploading ${name}...`)
const data = fs.readFileSync(filename, null)
emoji = await discord.snow.guildAssets.createEmoji(guild.id, {name, image: "data:image/png;base64," + data.toString("base64")})
} else {
console.log(` Reusing ${name}...`)
}
db.prepare("REPLACE INTO auto_emoji (name, emoji_id, guild_id) VALUES (?, ?, ?)").run(emoji.name, emoji.id, guild.id)
return emoji
}
;(async () => {
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(config.discordToken.split(".")[0], "base64").toString()
assert(botID.match(/^[0-9]{10,}$/), "discord token must follow the correct format")
console.log("✅ Configuration looks good...")
// database ddl...
await migrate.migrate(db)
// add initial rows to database, like adding the bot to sim...
db.prepare("INSERT OR IGNORE INTO sim (user_id, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(botID, reg.sender_localpart.slice(reg.ooye.namespace_prefix.length), reg.sender_localpart, mxid)
console.log("✅ Database is ready...")
// ensure appservice bot user is registered...
try {
await api.register(reg.sender_localpart)
} catch (e) {
if (e.errcode === "M_USER_IN_USE" || e.data?.error === "Internal server error") {
// "Internal server error" is the only OK error because older versions of Synapse say this if you try to register the same username twice.
} else {
throw e
}
}
// upload initial images...
const avatarUrl = await file.uploadDiscordFileToMxc("https://cadence.moe/friends/out_of_your_element.png")
console.log("✅ Matrix appservice login works...")
// upload the L1 L2 emojis to some guild
const emojis = db.prepare("SELECT name FROM auto_emoji WHERE name = 'L1' OR name = 'L2'").pluck().all()
if (emojis.length !== 2) {
// If an argument was supplied, always use that one
let guild = null
if (args["emoji-guild"]) {
if (typeof args["emoji-guild"] === "string") {
guild = await discord.snow.guild.getGuild(args["emoji-guild"])
}
if (!guild) return die(`Error: You asked emojis to be uploaded to guild ID ${args["emoji-guild"]}, but the bot isn't in that guild.`)
}
// Otherwise, check if we have already registered an auto emoji guild
if (!guild) {
const guildID = passthrough.select("auto_emoji", "guild_id", {name: "_"}).pluck().get()
if (guildID) {
guild = await discord.snow.guild.getGuild(guildID, false)
}
}
// Otherwise, check if we should create a new guild
if (!guild) {
const guilds = await discord.snow.user.getGuilds({limit: 11, with_counts: false})
if (guilds.length < 10) {
console.log(" Creating a guild for emojis...")
guild = await discord.snow.guild.createGuild({name: "OOYE Emojis"})
}
}
// Otherwise, it's the user's problem
if (!guild) {
return die(`Error: The bot needs to upload some emojis. Please say where to upload them to. Run seed.js again with --emoji-guild=GUILD_ID`)
}
// Upload those emojis to the chosen location
db.prepare("REPLACE INTO auto_emoji (name, emoji_id, guild_id) VALUES ('_', '_', ?)").run(guild.id)
await uploadAutoEmoji(guild, "L1", "docs/img/L1.png")
await uploadAutoEmoji(guild, "L2", "docs/img/L2.png")
}
console.log("✅ Emojis are ready...")
// set profile data on discord...
const avatarImageBuffer = await fetch("https://cadence.moe/friends/out_of_your_element.png").then(res => res.arrayBuffer())
await discord.snow.user.updateSelf({avatar: "data:image/png;base64," + Buffer.from(avatarImageBuffer).toString("base64")})
await discord.snow.requestHandler.request(`/applications/@me`, {}, "patch", "json", {description: "Powered by **Out Of Your Element**\nhttps://gitdab.com/cadence/out-of-your-element"})
console.log("✅ Discord profile updated...")
// set profile data on homeserver...
await api.profileSetDisplayname(mxid, "Out Of Your Element")
await api.profileSetAvatarUrl(mxid, avatarUrl)
console.log("✅ Matrix profile updated...")
console.log("Good to go. I hope you enjoy Out Of Your Element.")
process.exit()
})()

385
scripts/setup.js Normal file
View file

@ -0,0 +1,385 @@
#!/usr/bin/env node
// @ts-check
const assert = require("assert").strict
const fs = require("fs")
const sqlite = require("better-sqlite3")
const {scheduler} = require("timers/promises")
const {isDeepStrictEqual} = require("util")
const {createServer} = require("http")
const {join} = require("path")
const {prompt} = require("enquirer")
const Input = require("enquirer/lib/prompts/input")
const {magenta, bold, cyan} = require("ansi-colors")
const HeatSync = require("heatsync")
const {SnowTransfer} = require("snowtransfer")
const DiscordTypes = require("discord-api-types/v10")
const {createApp, defineEventHandler, toNodeListener} = require("h3")
const args = require("minimist")(process.argv.slice(2), {string: ["emoji-guild"]})
// Move database file if it's still in the old location
if (fs.existsSync("db")) {
if (fs.existsSync("db/ooye.db")) {
fs.renameSync("db/ooye.db", "ooye.db")
}
const files = fs.readdirSync("db")
if (files.length) {
console.error("The db folder is deprecated and must be removed. Your ooye.db database file has already been moved to the root of the repo. You must manually move or delete the remaining files:")
for (const file of files) {
console.error(file)
}
process.exit(1)
}
fs.rmSync("db", {recursive: true})
}
const passthrough = require("../src/passthrough")
const db = new sqlite("ooye.db")
const migrate = require("../src/db/migrate")
const sync = new HeatSync({watchFS: false})
Object.assign(passthrough, {sync, db})
const orm = sync.require("../src/db/orm")
passthrough.from = orm.from
passthrough.select = orm.select
let registration = require("../src/matrix/read-registration")
let {reg, getTemplateRegistration, writeRegistration, readRegistration, checkRegistration, registrationFilePath} = registration
function die(message) {
console.error(message)
process.exit(1)
}
async function uploadAutoEmoji(snow, guild, name, filename) {
let emoji = guild.emojis.find(e => e.name === name)
if (!emoji) {
console.log(` Uploading ${name}...`)
const data = fs.readFileSync(filename, null)
emoji = await snow.guildAssets.createEmoji(guild.id, {name, image: "data:image/png;base64," + data.toString("base64")})
} else {
console.log(` Reusing ${name}...`)
}
db.prepare("REPLACE INTO auto_emoji (name, emoji_id, guild_id) VALUES (?, ?, ?)").run(emoji.name, emoji.id, guild.id)
return emoji
}
async function suggestWellKnown(serverUrlPrompt, url, otherwise) {
try {
var json = await fetch(`${url}/.well-known/matrix/client`).then(res => res.json())
let baseURL = json["m.homeserver"].base_url.replace(/\/$/, "")
if (baseURL && baseURL !== url) {
serverUrlPrompt.initial = baseURL
return `Did you mean: ${bold(baseURL)}? (Enter to accept)`
}
} catch (e) {}
return otherwise
}
async function validateHomeserverOrigin(serverUrlPrompt, url) {
if (!url.match(/^https?:\/\//)) return "Must be a URL"
if (url.match(/\/$/)) return "Must not end with a slash"
process.stdout.write(magenta(" checking, please wait..."))
try {
var res = await fetch(`${url}/_matrix/client/versions`)
if (res.status !== 200) {
return suggestWellKnown(serverUrlPrompt, url, `There is no Matrix server at that URL (${url}/_matrix/client/versions returned ${res.status})`)
}
} catch (e) {
return e.message
}
try {
var json = await res.json()
if (!Array.isArray(json?.versions) || !json.versions.includes("v1.11")) {
return `OOYE needs Matrix version v1.11, but ${url} doesn't support this`
}
} catch (e) {
return suggestWellKnown(serverUrlPrompt, url, `There is no Matrix server at that URL (${url}/_matrix/client/versions is not JSON)`)
}
return true
}
function defineEchoHandler() {
return defineEventHandler(event => {
return "Out Of Your Element is listening.\n" +
`Received a ${event.method} request on path ${event.path}\n`
})
}
;(async () => {
// create registration file with prompts...
if (!reg) {
console.log("What is the name of your homeserver? This is the part after : in your username.")
/** @type {{server_name: string}} */
const serverNameResponse = await prompt({
type: "input",
name: "server_name",
message: "Homeserver name",
validate: serverName => !!serverName.match(/[a-z][a-z.]+[a-z]/)
})
console.log("What is the URL of your homeserver?")
const serverOriginPrompt = new Input({
type: "input",
name: "server_origin",
message: "Homeserver URL",
initial: () => `https://${serverNameResponse.server_name}`,
validate: url => validateHomeserverOrigin(serverOriginPrompt, url)
})
/** @type {string} */ // @ts-ignore
const serverOrigin = await serverOriginPrompt.run()
const app = createApp()
app.use(defineEchoHandler())
const server = createServer(toNodeListener(app))
await server.listen(6693)
console.log("OOYE has its own web server. It needs to be accessible on the public internet.")
console.log("You need to enter a public URL where you will be able to host this web server.")
console.log("OOYE listens on localhost:6693, so you will probably have to set up a reverse proxy.")
console.log("Now listening on port 6693. Feel free to send some test requests.")
/** @type {{bridge_origin: string}} */
const bridgeOriginResponse = await prompt({
type: "input",
name: "bridge_origin",
message: "URL to reach OOYE",
initial: () => `https://bridge.${serverNameResponse.server_name}`,
validate: async url => {
process.stdout.write(magenta(" checking, please wait..."))
try {
const res = await fetch(url)
if (res.status !== 200) return `Server returned status code ${res.status}`
const text = await res.text()
if (!text.startsWith("Out Of Your Element is listening.")) return `Server does not point to OOYE`
return true
} catch (e) {
return e.message
}
}
})
bridgeOriginResponse.bridge_origin = bridgeOriginResponse.bridge_origin.replace(/\/+$/, "") // remove trailing slash
await server.close()
console.log("What is your Discord bot token?")
console.log("Go to https://discord.com/developers, create or pick an app, go to the Bot section, and reset the token.")
/** @type {SnowTransfer} */ // @ts-ignore
let snow = null
/** @type {{id: string, flags: number, redirect_uris: string[]}} */ // @ts-ignore
let client = null
/** @type {{discord_token: string}} */
const discordTokenResponse = await prompt({
type: "input",
name: "discord_token",
message: "Bot token",
validate: async token => {
process.stdout.write(magenta(" checking, please wait..."))
try {
snow = new SnowTransfer(token)
client = await snow.requestHandler.request(`/applications/@me`, {}, "get", "json")
return true
} catch (e) {
return e.message
}
}
})
const 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("What is your Discord client secret?")
console.log(`You can find it in the application's OAuth2 section: https://discord.com/developers/applications/${client.id}/oauth2`)
/** @type {{discord_client_secret: string}} */
const clientSecretResponse = await prompt({
type: "input",
name: "discord_client_secret",
message: "Client secret"
})
const expectedUri = `${bridgeOriginResponse.bridge_origin}/oauth`
if (!client.redirect_uris.includes(expectedUri)) {
console.log(`On that same page, scroll down to Redirects and add this URI: ${cyan(expectedUri)}`)
await prompt({
type: "invisible",
name: "redirect_uri",
message: "Press Enter when you've added it",
validate: async token => {
process.stdout.write(magenta("checking, please wait..."))
client = await snow.requestHandler.request(`/applications/@me`, {}, "get", "json")
if (client.redirect_uris.includes(expectedUri)) {
return true
} else {
return "Redirect URI has not been added yet"
}
}
})
}
const template = getTemplateRegistration(serverNameResponse.server_name)
reg = {
...template,
url: bridgeOriginResponse.bridge_origin,
ooye: {
...template.ooye,
...bridgeOriginResponse,
server_origin: serverOrigin,
...discordTokenResponse,
...clientSecretResponse
}
}
registration.reg = reg
checkRegistration(reg)
writeRegistration(reg)
console.log(`✅ Your responses have been saved as ${registrationFilePath}`)
} else {
try {
checkRegistration(reg)
console.log(`✅ Skipped questions - reusing data from ${registrationFilePath}`)
} catch (e) {
console.log(`❌ Failed to reuse data from ${registrationFilePath}`)
console.log("Consider deleting this file. You can re-run setup to safely make a new one.")
console.log("")
console.log(e.toString().replace(/^ *\n/gm, ""))
process.exit(1)
}
}
console.log(` In ${cyan("Synapse")}, you need to reference that file in your homeserver.yaml and ${cyan("restart Synapse")}.`)
console.log(" https://element-hq.github.io/synapse/latest/application_services.html")
console.log(` In ${cyan("Conduit")}, you need to send the file contents to the #admins room.`)
console.log(" https://docs.conduit.rs/appservices.html")
console.log()
// Done with user prompts, reg is now guaranteed to be valid
const api = require("../src/matrix/api")
const file = require("../src/matrix/file")
const DiscordClient = require("../src/d2m/discord-client")
const discord = new DiscordClient(reg.ooye.discord_token, "no")
passthrough.discord = discord
const {as} = require("../src/matrix/appservice")
as.router.use("/**", defineEchoHandler())
console.log("⏳ Waiting for you to register the file with your homeserver... (Ctrl+C to cancel)")
process.once("SIGINT", () => {
console.log("(Ctrl+C) Quit early. Please re-run setup later and allow it to complete.")
process.exit(1)
})
let itWorks = false
let lastError = null
do {
const result = await api.ping().catch(e => ({ok: false, status: "net", root: e.message}))
// If it didn't work, log details and retry after some time
itWorks = result.ok
if (!itWorks) {
// Log the full error data if the error is different to last time
if (!isDeepStrictEqual(lastError, result.root)) {
if (typeof result.root === "string") {
console.log(`\nCannot reach homeserver: ${result.root}`)
} else if (result.root.error) {
console.log(`\nHomeserver said: [${result.status}] ${result.root.error}`)
} else {
console.log(`\nHomeserver said: [${result.status}] ${JSON.stringify(result.root)}`)
}
lastError = result.root
} else {
process.stderr.write(".")
}
await scheduler.wait(5000)
}
} while (!itWorks)
console.log("")
as.close().catch(() => {})
const mxid = `@${reg.sender_localpart}:${reg.ooye.server_name}`
// database ddl...
await migrate.migrate(db)
// add initial rows to database, like adding the bot to sim...
const client = await discord.snow.user.getSelf()
db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING").run(client.id, client.username, reg.sender_localpart.slice(reg.ooye.namespace_prefix.length), mxid)
console.log("✅ Database is ready...")
// ensure appservice bot user is registered...
await api.register(reg.sender_localpart)
// upload initial images...
const avatarUrl = await file.uploadDiscordFileToMxc("https://cadence.moe/friends/out_of_your_element.png")
console.log("✅ Matrix appservice login works...")
// upload the L1 L2 emojis to some guild
const emojis = db.prepare("SELECT name FROM auto_emoji WHERE name = 'L1' OR name = 'L2'").pluck().all()
if (emojis.length !== 2) {
// If an argument was supplied, always use that one
let guild = null
if (args["emoji-guild"]) {
if (typeof args["emoji-guild"] === "string") {
guild = await discord.snow.guild.getGuild(args["emoji-guild"])
}
if (!guild) return die(`Error: You asked emojis to be uploaded to guild ID ${args["emoji-guild"]}, but the bot isn't in that guild.`)
}
// Otherwise, check if we have already registered an auto emoji guild
if (!guild) {
const guildID = passthrough.select("auto_emoji", "guild_id", {name: "_"}).pluck().get()
if (guildID) {
guild = await discord.snow.guild.getGuild(guildID, false)
}
}
// Otherwise, check if we should create a new guild
if (!guild) {
const guilds = await discord.snow.user.getGuilds({limit: 11, with_counts: false})
if (guilds.length < 10) {
console.log(" Creating a guild for emojis...")
guild = await discord.snow.guild.createGuild({name: "OOYE Emojis"})
}
}
// Otherwise, it's the user's problem
if (!guild) {
return die(`Error: The bot needs to upload some emojis. Please say where to upload them to. Run setup again with --emoji-guild=GUILD_ID`)
}
// Upload those emojis to the chosen location
db.prepare("REPLACE INTO auto_emoji (name, emoji_id, guild_id) VALUES ('_', '_', ?)").run(guild.id)
await uploadAutoEmoji(discord.snow, guild, "L1", join(__dirname, "../docs/img/L1.png"))
await uploadAutoEmoji(discord.snow, guild, "L2", join(__dirname, "../docs/img/L2.png"))
}
console.log("✅ Emojis are ready...")
// set profile data on discord...
const avatarImageBuffer = await fetch("https://cadence.moe/friends/out_of_your_element.png").then(res => res.arrayBuffer())
await discord.snow.user.updateSelf({avatar: "data:image/png;base64," + Buffer.from(avatarImageBuffer).toString("base64")})
await discord.snow.requestHandler.request(`/applications/@me`, {}, "patch", "json", {description: "Powered by **Out Of Your Element**\nhttps://gitdab.com/cadence/out-of-your-element"})
console.log("✅ Discord profile updated...")
// set profile data on homeserver...
console.log("⏩ Updating Matrix profile... (If you've joined lots of rooms, this is slow. Please allow at least 30 seconds.)")
await api.profileSetDisplayname(mxid, "Out Of Your Element")
await api.profileSetAvatarUrl(mxid, avatarUrl)
console.log("✅ Matrix profile updated...")
console.log("Good to go. I hope you enjoy Out Of Your Element.")
process.exit()
})()

43
scripts/start-server.js Executable file
View file

@ -0,0 +1,43 @@
#!/usr/bin/env node
// @ts-check
const {createServer} = require("http")
const EventEmitter = require("events")
const {createApp, createRouter, toNodeListener} = require("h3")
const sqlite = require("better-sqlite3")
const migrate = require("../src/db/migrate")
const HeatSync = require("heatsync")
const {reg} = require("../src/matrix/read-registration")
const passthrough = require("../src/passthrough")
const db = new sqlite("ooye.db")
const sync = new HeatSync()
Object.assign(passthrough, {sync, db})
const DiscordClient = require("../src/d2m/discord-client")
const discord = new DiscordClient(reg.ooye.discord_token, "half")
passthrough.discord = discord
const app = createApp()
const router = createRouter()
app.use(router)
const server = createServer(toNodeListener(app))
server.listen(reg.socket || new URL(reg.url).port)
const as = Object.assign(new EventEmitter(), {app, router, server}) // @ts-ignore
passthrough.as = as
const orm = sync.require("../src/db/orm")
passthrough.from = orm.from
passthrough.select = orm.select
;(async () => {
await migrate.migrate(db)
await discord.cloud.connect()
console.log("Discord gateway started")
sync.require("../src/web/server")
require("../src/stdin")
})()

3
scripts/wal.js Normal file → Executable file
View file

@ -1,6 +1,7 @@
#!/usr/bin/env node
// @ts-check // @ts-check
const sqlite = require("better-sqlite3") const sqlite = require("better-sqlite3")
const db = new sqlite("db/ooye.db", {fileMustExist: true}) const db = new sqlite("ooye.db", {fileMustExist: true})
db.pragma("journal_mode = wal") db.pragma("journal_mode = wal")
db.close() db.close()

View file

@ -2,20 +2,25 @@
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 reg = require("../../matrix/read-registration") const Ty = require("../../types")
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("./create-space")}) */ /** @type {import("../../m2d/converters/utils")} */
const createSpace = sync.require("./create-space") // watch out for the require loop 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:
@ -25,7 +30,7 @@ const createSpace = sync.require("./create-space") // watch out for the require
*/ */
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
@ -38,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
@ -89,41 +74,36 @@ function convertNameAndTopic(channel, guild, customName) {
* Async because it may create the guild and/or upload the guild icon to mxc. * Async because it may create the guild and/or upload the guild icon to mxc.
* @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel * @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel
* @param {DiscordTypes.APIGuild} guild * @param {DiscordTypes.APIGuild} guild
* @param {{api: {getStateEvent: typeof api.getStateEvent}}} di simple-as-nails dependency injection for the matrix API
*/ */
async function channelToKState(channel, guild) { async function channelToKState(channel, guild, di) {
// @ts-ignore // @ts-ignore
const parentChannel = discord.channels.get(channel.parent_id) const parentChannel = discord.channels.get(channel.parent_id)
/** Used for membership/permission checks. */
let guildSpaceID
/** Used as the literal parent on Matrix, for categorisation. Will be the same as `guildSpaceID` unless it's a forum channel's thread, in which case a different space is used to group those threads. */
let parentSpaceID
let privacyLevel
if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum) { // it's a forum channel's thread, so use a different space to group those threads
guildSpaceID = await createSpace.ensureSpace(guild)
parentSpaceID = await ensureRoom(channel.parent_id)
privacyLevel = select("guild_space", "privacy_level", {space_id: guildSpaceID}).pluck().get()
} else { // otherwise use the guild's space like usual
parentSpaceID = await createSpace.ensureSpace(guild)
guildSpaceID = parentSpaceID
privacyLevel = select("guild_space", "privacy_level", {space_id: parentSpaceID}).pluck().get()
}
assert(typeof parentSpaceID === "string")
assert(typeof guildSpaceID === "string")
assert(typeof privacyLevel === "number")
const row = select("channel_room", ["nick", "custom_avatar"], {channel_id: channel.id}).get() /** Used for membership/permission checks. */
const customName = row?.nick const guildSpaceID = await createSpace.ensureSpace(guild)
const customAvatar = row?.custom_avatar /** Used as the literal parent on Matrix, for categorisation. Will be the same as `guildSpaceID` unless it's a forum channel's thread, in which case a different space is used to group those threads. */
let parentSpaceID = guildSpaceID
if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum) {
parentSpaceID = await ensureRoom(channel.parent_id)
assert(typeof parentSpaceID === "string")
}
const channelRow = select("channel_room", ["nick", "custom_avatar", "custom_topic"], {channel_id: channel.id}).get()
const customName = channelRow?.nick
const customAvatar = channelRow?.custom_avatar
const hasCustomTopic = channelRow?.custom_topic
const [convertedName, convertedTopic] = convertNameAndTopic(channel, guild, customName) const [convertedName, convertedTopic] = convertNameAndTopic(channel, guild, customName)
const avatarEventContent = {} const avatarEventContent = {}
if (customAvatar) { if (customAvatar) {
avatarEventContent.url = customAvatar avatarEventContent.url = customAvatar
} else if (guild.icon) { } else if (guild.icon) {
avatarEventContent.discord_path = file.guildIcon(guild) avatarEventContent.url = {$url: file.guildIcon(guild)}
avatarEventContent.url = await file.uploadDiscordFileToMxc(avatarEventContent.discord_path) // TODO: somehow represent future values in kstate (callbacks?), while still allowing for diffing, so test cases don't need to touch the media API
} }
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"
@ -139,9 +119,18 @@ async function channelToKState(channel, guild) {
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 globalAdminPower = globalAdmins.reduce((a, c) => (a[c.mxid] = c.power_level, a), {})
/** @type {Ty.Event.M_Power_Levels} */
const spacePowerEvent = await di.api.getStateEvent(guildSpaceID, "m.room.power_levels", "")
const spacePower = spacePowerEvent.users
/** @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},
@ -154,17 +143,15 @@ async function channelToKState(channel, guild) {
}, },
/** @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: { events_default: everyoneCanSend ? 0 : 50,
"m.room.avatar": 0
},
notifications: { notifications: {
room: everyoneCanMentionEveryone ? 0 : 20 room: everyoneCanMentionEveryone ? 0 : 20
}, },
users: reg.ooye.invite.reduce((a, c) => (a[c] = 100, a), {}) 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}`,
@ -175,7 +162,7 @@ async function channelToKState(channel, guild) {
network: { network: {
id: guild.id, id: guild.id,
displayname: guild.name, displayname: guild.name,
avatar_url: await file.uploadDiscordFileToMxc(file.guildIcon(guild)) avatar_url: {$url: file.guildIcon(guild)}
}, },
channel: { channel: {
id: channel.id, id: channel.id,
@ -185,6 +172,8 @@ async function channelToKState(channel, guild) {
} }
} }
if (hasCustomTopic) delete channelKState["m.room.topic/"]
return {spaceID: parentSpaceID, privacyLevel, channelKState} return {spaceID: parentSpaceID, privacyLevel, channelKState}
} }
@ -249,15 +238,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
@ -274,6 +264,61 @@ function channelToGuild(channel) {
return guild return guild
} }
/**
* This function handles whether it's allowed to bridge messages in this channel, and if so, where to.
* This has to account for whether self-service is enabled for the guild or not.
* This also has to account for different channel types, like forum channels (which need the
* parent forum to already exist, and ignore the self-service setting), or thread channels (which
* need the parent channel to already exist, and ignore the self-service setting).
* @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel text channel or thread
* @param {string} guildID
* @returns obj if bridged; 1 if autocreatable; null/undefined if guild is not bridged; 0 if self-service and not autocreatable thread
*/
function existsOrAutocreatable(channel, guildID) {
// 1. If the channel is already linked somewhere, it's always okay to bridge to that destination, no matter what. Yippee!
const existing = select("channel_room", ["room_id", "thread_parent"], {channel_id: channel.id}).get()
if (existing) return existing
// 2. If the guild is an autocreate guild, it's always okay to bridge to that destination, and
// we'll need to create any dependent resources recursively.
const autocreate = select("guild_active", "autocreate", {guild_id: guildID}).pluck().get()
if (autocreate === 1) return autocreate
// 3. If the guild is not approved for bridging yet, we can't bridge there.
// They need to decide one way or another whether it's self-service before we can continue.
if (autocreate == null) return autocreate
// 4. If we got here, the guild is in self-service mode.
// New channels won't be able to create new rooms. But forum threads or channel threads could be fine.
if ([DiscordTypes.ChannelType.PublicThread, DiscordTypes.ChannelType.PrivateThread, DiscordTypes.ChannelType.AnnouncementThread].includes(channel.type)) {
// In self-service mode, threads rely on the parent resource already existing.
/** @type {DiscordTypes.APIGuildTextChannel} */ // @ts-ignore
const parent = discord.channels.get(channel.parent_id)
assert(parent)
const parentExisting = existsOrAutocreatable(parent, guildID)
if (parentExisting) return 1 // Autocreatable
}
// 5. If we got here, the guild is in self-service mode and the channel is truly not bridged.
return autocreate
}
/**
* @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel text channel or thread
* @param {string} guildID
* @returns obj if bridged; 1 if autocreatable. (throws if not autocreatable)
*/
function assertExistsOrAutocreatable(channel, guildID) {
const existing = existsOrAutocreatable(channel, guildID)
if (existing === 0) {
throw new Error(`Guild ${guildID} is self-service, so won't create a Matrix room for channel ${channel.id}`)
}
if (!existing) {
throw new Error(`Guild ${guildID} is not bridged, so won't create a Matrix room for channel ${channel.id}`)
}
return existing
}
/* /*
Ensure flow: Ensure flow:
1. Get IDs 1. Get IDs
@ -292,6 +337,7 @@ function channelToGuild(channel) {
*/ */
/** /**
* Create room and/or sync room data. Please check that a channel_room entry exists or autocreate = 1 before calling this.
* @param {string} channelID * @param {string} channelID
* @param {boolean} shouldActuallySync false if just need to ensure room exists (which is a quick database check), true if also want to sync room data when it does exist (slow) * @param {boolean} shouldActuallySync false if just need to ensure room exists (which is a quick database check), true if also want to sync room data when it does exist (slow)
* @returns {Promise<string>} room ID * @returns {Promise<string>} room ID
@ -306,11 +352,11 @@ async function _syncRoom(channelID, shouldActuallySync) {
await inflightRoomCreate.get(channelID) // just waiting, and then doing a new db query afterwards, is the simplest way of doing it await inflightRoomCreate.get(channelID) // just waiting, and then doing a new db query afterwards, is the simplest way of doing it
} }
const existing = select("channel_room", ["room_id", "thread_parent"], {channel_id: channelID}).get() const existing = assertExistsOrAutocreatable(channel, guild.id)
if (!existing) { if (existing === 1) {
const creation = (async () => { const creation = (async () => {
const {spaceID, privacyLevel, channelKState} = await channelToKState(channel, guild) const {spaceID, privacyLevel, channelKState} = await channelToKState(channel, guild, {api})
const roomID = await createRoom(channel, guild, spaceID, channelKState, privacyLevel) const roomID = await createRoom(channel, guild, spaceID, channelKState, privacyLevel)
inflightRoomCreate.delete(channelID) // OK to release inflight waiters now. they will read the correct `existing` row inflightRoomCreate.delete(channelID) // OK to release inflight waiters now. they will read the correct `existing` row
return roomID return roomID
@ -327,10 +373,10 @@ async function _syncRoom(channelID, shouldActuallySync) {
console.log(`[room sync] to matrix: ${channel.name}`) console.log(`[room sync] to matrix: ${channel.name}`)
const {spaceID, channelKState} = await channelToKState(channel, guild) // 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/
@ -338,7 +384,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
@ -348,35 +394,76 @@ async function _syncRoom(channelID, shouldActuallySync) {
return roomID return roomID
} }
/** Ensures the room exists. If it doesn't, creates the room with an accurate initial state. */ /** Ensures the room exists. If it doesn't, creates the room with an accurate initial state. Please check that a channel_room entry exists or guild autocreate = 1 before calling this. */
function ensureRoom(channelID) { function ensureRoom(channelID) {
return _syncRoom(channelID, false) return _syncRoom(channelID, false)
} }
/** Actually syncs. Gets all room state from the homeserver in order to diff, and uploads the icon to mxc if it has changed. */ /** Actually syncs. Gets all room state from the homeserver in order to diff, and uploads the icon to mxc if it has changed. Please check that a channel_room entry exists or guild autocreate = 1 before calling this. */
function syncRoom(channelID) { 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)
return unbridgeDeletedChannel(channel.id, channel.guild_id) assert.ok(channel.guild_id)
return unbridgeDeletedChannel(channel, channel.guild_id)
} }
async function unbridgeDeletedChannel(channelID, guildID) { /**
const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get() * @param {{id: string, topic?: string?}} channel channel-ish (just needs an id, topic is optional)
* @param {string} guildID
*/
async function unbridgeDeletedChannel(channel, guildID) {
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}/${channelID}`, {}) try {
await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channel.id}`, {})
} catch (e) {
if (String(e).includes("not in room")) {
botInRoom = false
} else {
throw e
}
}
if (botInRoom && "topic" in channel) {
// previously the Matrix topic would say the channel ID. we should remove that
await api.sendState(roomID, "m.room.topic", "", {topic: channel.topic || ""})
}
// delete webhook on discord
const webhook = select("webhook", ["webhook_id", "webhook_token"], {channel_id: channel.id}).get()
if (webhook) {
await discord.snow.webhook.deleteWebhook(webhook.webhook_id, webhook.webhook_token)
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", {
@ -384,12 +471,28 @@ async function unbridgeDeletedChannel(channelID, guildID) {
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, {})
// leave room // leave room
await api.leaveRoom(roomID) await api.leaveRoom(roomID)
}
// delete room from database // if it is a self-service room, remove sim members
const {changes} = db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channelID) // (the room can be used with less clutter and the member list makes sense if it's bridged somewhere else)
assert.equal(changes, 1) if (row.autocreate === 0) {
// remove sim members
const members = select("sim_member", "mxid", {room_id: roomID}).pluck().all()
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)
}
}
} }
/** /**
@ -400,7 +503,7 @@ async function unbridgeDeletedChannel(channelID, 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)
@ -413,7 +516,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) {
@ -436,9 +539,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.assertExistsOrAutocreatable = assertExistsOrAutocreatable

View file

@ -0,0 +1,243 @@
// @ts-check
const mixin = require("@cloudrac3r/mixin-deep")
const {channelToKState, _convertNameAndTopic} = require("./create-room")
const {kstateStripConditionals} = require("../../matrix/kstate")
const {test} = require("supertape")
const testData = require("../../../test/data")
const passthrough = require("../../passthrough")
const {db} = passthrough
test("channel2room: discoverable privacy room", async t => {
let called = 0
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
called++
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {users: {"@example:matrix.org": 50}}
}
db.prepare("UPDATE guild_space SET privacy_level = 2").run()
t.deepEqual(
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)),
Object.assign({}, testData.room.general, {
"m.room.guest_access/": {guest_access: "forbidden"},
"m.room.join_rules/": {join_rule: "public"},
"m.room.history_visibility/": {history_visibility: "world_readable"},
"m.room.power_levels/": mixin({users: {"@example:matrix.org": 50}}, testData.room.general["m.room.power_levels/"])
})
)
t.equal(called, 1)
})
test("channel2room: linkable privacy room", async t => {
let called = 0
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
called++
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {users: {"@example:matrix.org": 50}}
}
db.prepare("UPDATE guild_space SET privacy_level = 1").run()
t.deepEqual(
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)),
Object.assign({}, testData.room.general, {
"m.room.guest_access/": {guest_access: "forbidden"},
"m.room.join_rules/": {join_rule: "public"},
"m.room.power_levels/": mixin({users: {"@example:matrix.org": 50}}, testData.room.general["m.room.power_levels/"])
})
)
t.equal(called, 1)
})
test("channel2room: invite-only privacy room", async t => {
let called = 0
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
called++
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {users: {"@example:matrix.org": 50}}
}
db.prepare("UPDATE guild_space SET privacy_level = 0").run()
t.deepEqual(
kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)),
Object.assign({}, testData.room.general, {
"m.room.power_levels/": mixin({users: {"@example:matrix.org": 50}}, testData.room.general["m.room.power_levels/"])
})
)
t.equal(called, 1)
})
test("channel2room: room where limited people can mention everyone", async t => {
let called = 0
async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
called++
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {users: {"@example:matrix.org": 50}}
}
const limitedGuild = mixin({}, testData.guild.general)
limitedGuild.roles[0].permissions = (BigInt(limitedGuild.roles[0].permissions) - 131072n).toString()
const limitedRoom = mixin({}, testData.room.general, {"m.room.power_levels/": {
notifications: {room: 20},
users: {"@example:matrix.org": 50}
}})
t.deepEqual(
kstateStripConditionals(await channelToKState(testData.channel.general, limitedGuild, {api: {getStateEvent}}).then(x => x.channelKState)),
limitedRoom
)
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!
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 => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, "hauntings"),
["hauntings", "#the-twilight-zone | Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"]
)
})
test("convertNameAndTopic: custom name, no topic", t => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", type: 0}, {id: "456"}, "hauntings"),
["hauntings", "#the-twilight-zone\n\nChannel ID: 123\nGuild ID: 456"]
)
})
test("convertNameAndTopic: original name and topic", t => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, null),
["the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"]
)
})
test("convertNameAndTopic: original name, no topic", t => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", type: 0}, {id: "456"}, null),
["the-twilight-zone", "Channel ID: 123\nGuild ID: 456"]
)
})
test("convertNameAndTopic: public thread icon", t => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 11}, {id: "456"}, null),
["[⛓️] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"]
)
})
test("convertNameAndTopic: private thread icon", t => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 12}, {id: "456"}, null),
["[🔒⛓️] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"]
)
})
test("convertNameAndTopic: voice channel icon", t => {
t.deepEqual(
_convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 2}, {id: "456"}, null),
["[🔊] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"]
)
})

View file

@ -4,7 +4,7 @@ const assert = require("assert").strict
const {isDeepStrictEqual} = require("util") const {isDeepStrictEqual} = require("util")
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const Ty = require("../../types") 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} = passthrough
@ -31,6 +31,10 @@ 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 roomID = await createRoom.postApplyPowerLevels(kstate, async kstate => { const roomID = await createRoom.postApplyPowerLevels(kstate, async kstate => {
return api.createRoom({ return api.createRoom({
name, name,
@ -40,7 +44,7 @@ async function createSpace(guild, kstate) {
events_default: 100, // space can only be managed by bridge events_default: 100, // space can only be managed by bridge
invite: 0 // any existing member can invite others invite: 0 // any existing member can invite others
}, },
invite: reg.ooye.invite, invite: globalAdmins,
topic, topic,
creation_content: { creation_content: {
type: "m.space" type: "m.space"
@ -48,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
} }
@ -58,17 +62,17 @@ async function createSpace(guild, kstate) {
*/ */
async function guildToKState(guild, privacyLevel) { async function guildToKState(guild, privacyLevel) {
assert.equal(typeof privacyLevel, "number") assert.equal(typeof privacyLevel, "number")
const globalAdmins = select("member_power", ["mxid", "power_level"], {room_id: "*"}).all()
const guildKState = { const guildKState = {
"m.room.name/": {name: guild.name}, "m.room.name/": {name: guild.name},
"m.room.avatar/": { "m.room.avatar/": {
$if: guild.icon, $if: guild.icon,
discord_path: file.guildIcon(guild),
url: {$url: file.guildIcon(guild)} url: {$url: file.guildIcon(guild)}
}, },
"m.room.guest_access/": {guest_access: createRoom.PRIVACY_ENUMS.GUEST_ACCESS[privacyLevel]}, "m.room.guest_access/": {guest_access: createRoom.PRIVACY_ENUMS.GUEST_ACCESS[privacyLevel]},
"m.room.history_visibility/": {history_visibility: createRoom.PRIVACY_ENUMS.SPACE_HISTORY_VISIBILITY[privacyLevel]}, "m.room.history_visibility/": {history_visibility: createRoom.PRIVACY_ENUMS.SPACE_HISTORY_VISIBILITY[privacyLevel]},
"m.room.join_rules/": {join_rule: createRoom.PRIVACY_ENUMS.SPACE_JOIN_RULES[privacyLevel]}, "m.room.join_rules/": {join_rule: createRoom.PRIVACY_ENUMS.SPACE_JOIN_RULES[privacyLevel]},
"m.room.power_levels/": {users: reg.ooye.invite.reduce((a, c) => (a[c] = 100, a), {})} "m.room.power_levels/": {users: globalAdmins.reduce((a, c) => (a[c.mxid] = c.power_level, a), {})} // used in guild initial creation postApplyPowerLevels
} }
return guildKState return guildKState
@ -90,6 +94,9 @@ async function _syncSpace(guild, shouldActuallySync) {
const row = select("guild_space", ["space_id", "privacy_level"], {guild_id: guild.id}).get() const row = select("guild_space", ["space_id", "privacy_level"], {guild_id: guild.id}).get()
if (!row) { if (!row) {
const autocreate = select("guild_active", "autocreate", {guild_id: guild.id}).pluck().get()
assert.equal(autocreate, 1, `refusing to implicitly create a space for guild ${guild.id}. set the guild_active data first before calling ensureSpace/syncSpace.`)
const creation = (async () => { const creation = (async () => {
const guildKState = await guildToKState(guild, createRoom.DEFAULT_PRIVACY_LEVEL) // New spaces will have to use the default privacy level; we obviously can't look up the existing entry const guildKState = await guildToKState(guild, createRoom.DEFAULT_PRIVACY_LEVEL) // New spaces will have to use the default privacy level; we obviously can't look up the existing entry
const spaceID = await createSpace(guild, guildKState) const spaceID = await createSpace(guild, guildKState)
@ -111,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
@ -178,28 +185,19 @@ 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)
/** @type {string[]} room IDs */ const childRooms = await api.getFullHierarchy(spaceID)
let childRooms = []
/** @type {string | undefined} */
let nextBatch = undefined
do {
/** @type {Ty.HierarchyPagination<Ty.R.Hierarchy>} */
const res = await api.getHierarchy(spaceID, {from: nextBatch})
childRooms.push(...res.rooms.map(room => room.room_id))
nextBatch = res.next_batch
} while (nextBatch)
for (const roomID of childRooms) { for (const {room_id} of childRooms) {
const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get() const channelID = select("channel_room", "channel_id", {room_id}).pluck().get()
if (!channelID) continue if (!channelID) continue
if (discord.channels.has(channelID)) { if (discord.channels.has(channelID)) {
await createRoom.syncRoom(channelID) await createRoom.syncRoom(channelID)
} else { } else {
await createRoom.unbridgeDeletedChannel(channelID, guildID) await createRoom.unbridgeDeletedChannel({id: channelID}, guildID)
} }
} }
@ -236,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

@ -4,7 +4,7 @@ const mixin = require("@cloudrac3r/mixin-deep")
const {guildToKState, ensureSpace} = require("./create-space") const {guildToKState, ensureSpace} = require("./create-space")
const {kstateStripConditionals, kstateUploadMxc} = require("../../matrix/kstate") const {kstateStripConditionals, kstateUploadMxc} = require("../../matrix/kstate")
const {test} = require("supertape") const {test} = require("supertape")
const testData = require("../../test/data") const testData = require("../../../test/data")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {db} = passthrough const {db} = passthrough
@ -14,7 +14,6 @@ test("guild2space: can generate kstate for a guild, passing privacy level 0", as
await kstateUploadMxc(kstateStripConditionals(await guildToKState(testData.guild.general, 0))), await kstateUploadMxc(kstateStripConditionals(await guildToKState(testData.guild.general, 0))),
{ {
"m.room.avatar/": { "m.room.avatar/": {
discord_path: "/icons/112760669178241024/a_f83622e09ead74f0c5c527fe241f8f8c.png?size=1024",
url: "mxc://cadence.moe/zKXGZhmImMHuGQZWJEFKJbsF" url: "mxc://cadence.moe/zKXGZhmImMHuGQZWJEFKJbsF"
}, },
"m.room.guest_access/": { "m.room.guest_access/": {

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

@ -53,7 +53,7 @@ async function editMessage(message, guild, row) {
const sendNewEventParts = new Set() const sendNewEventParts = new Set()
for (const promotion of promotions) { for (const promotion of promotions) {
if ("eventID" in promotion) { if ("eventID" in promotion) {
db.prepare(`UPDATE event_message SET ${promotion.column} = 0 WHERE event_id = ?`).run(promotion.eventID) db.prepare(`UPDATE event_message SET ${promotion.column} = ? WHERE event_id = ?`).run(promotion.value ?? 0, promotion.eventID)
} else if ("nextEvent" in promotion) { } else if ("nextEvent" in promotion) {
sendNewEventParts.add(promotion.column) sendNewEventParts.add(promotion.column)
} }
@ -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

@ -30,7 +30,7 @@ async function emojisToState(emojis) {
} }
db.prepare("INSERT OR IGNORE INTO emoji (emoji_id, name, animated, mxc_url) VALUES (?, ?, ?, ?)").run(emoji.id, emoji.name, +!!emoji.animated, url) db.prepare("INSERT OR IGNORE INTO emoji (emoji_id, name, animated, mxc_url) VALUES (?, ?, ?, ?)").run(emoji.id, emoji.name, +!!emoji.animated, url)
}).catch(e => { }).catch(e => {
if (e.data.errcode === "M_TOO_LARGE") { // Very unlikely to happen. Only possible for 3x-series emojis uploaded shortly after animated emojis were introduced, when there was no 256 KB size limit. if (e.data?.errcode === "M_TOO_LARGE") { // Very unlikely to happen. Only possible for 3x-series emojis uploaded shortly after animated emojis were introduced, when there was no 256 KB size limit.
return return
} }
console.error(`Trying to handle emoji ${emoji.name} (${emoji.id}), but...`) console.error(`Trying to handle emoji ${emoji.name} (${emoji.id}), but...`)
@ -66,7 +66,7 @@ async function stickersToState(stickers) {
while (shortcodes.includes(shortcode)) shortcode = shortcode + "~" while (shortcodes.includes(shortcode)) shortcode = shortcode + "~"
shortcodes.push(shortcode) shortcodes.push(shortcode)
result.images[shortcodes] = { result.images[shortcode] = {
info: { info: {
mimetype: file.stickerFormat.get(sticker.format_type)?.mime || "image/png" mimetype: file.stickerFormat.get(sticker.format_type)?.mime || "image/png"
}, },

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

@ -1,12 +1,11 @@
// @ts-check // @ts-check
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 {
try {
var res = await fetch(`https://api.pluralkit.me/v2/messages/${messageID}`) var res = await fetch(`https://api.pluralkit.me/v2/messages/${messageID}`)
if (res.ok) return res.json() 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

@ -1,7 +1,7 @@
// @ts-check // @ts-check
const assert = require("assert").strict const assert = require("assert").strict
const reg = require("../../matrix/read-registration") const {reg} = require("../../matrix/read-registration")
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const mixin = require("@cloudrac3r/mixin-deep") const mixin = require("@cloudrac3r/mixin-deep")
@ -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

@ -1,6 +1,6 @@
const {_memberToStateContent} = require("./register-user") const {_memberToStateContent} = require("./register-user")
const {test} = require("supertape") const {test} = require("supertape")
const testData = require("../../test/data") const testData = require("../../../test/data")
test("member2state: without member nick or avatar", async t => { test("member2state: without member nick or avatar", async t => {
t.deepEqual( t.deepEqual(

View file

@ -23,16 +23,7 @@ async function removeSomeReactions(data) {
const eventIDForMessage = select("event_message", "event_id", {message_id: data.message_id, reaction_part: 0}).pluck().get() const eventIDForMessage = select("event_message", "event_id", {message_id: data.message_id, reaction_part: 0}).pluck().get()
if (!eventIDForMessage) return if (!eventIDForMessage) return
/** @type {Ty.Event.Outer<Ty.Event.M_Reaction>[]} */ const reactions = await api.getFullRelations(roomID, eventIDForMessage, "m.annotation")
let reactions = []
/** @type {string | undefined} */
let nextBatch = undefined
do {
/** @type {Ty.Pagination<Ty.Event.Outer<Ty.Event.M_Reaction>>} */
const res = await api.getRelations(roomID, eventIDForMessage, {from: nextBatch}, "m.annotation")
reactions = reactions.concat(res.chunk)
nextBatch = res.next_batch
} while (nextBatch)
// Run the proper strategy and any strategy-specific database changes // Run the proper strategy and any strategy-specific database changes
const removals = await const removals = await
@ -62,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

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

View file

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

@ -24,14 +24,13 @@ function eventCanBeEdited(ev) {
/** /**
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message * @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
* IMPORTANT: This may not have all the normal fields! The API documentation doesn't provide possible types, just says it's all optional!
* Since I don't have a spec, I will have to capture some real traffic and add it as test cases... I hope they don't change anything later...
* @param {import("discord-api-types/v10").APIGuild} guild * @param {import("discord-api-types/v10").APIGuild} guild
* @param {import("../../matrix/api")} api simple-as-nails dependency injection for the matrix API * @param {import("../../matrix/api")} api simple-as-nails dependency injection for the matrix API
*/ */
async function editToChanges(message, guild, api) { async function editToChanges(message, guild, api) {
// If it is a user edit, allow deleting old messages (e.g. they might have removed text from an image). // If it is a user edit, allow deleting old messages (e.g. they might have removed text from an image).
// If it is the system adding a generated embed to a message, don't delete old messages since the system only sends partial data. // If it is the system adding a generated embed to a message, don't delete old messages since the system only sends partial data.
// Since an update in August 2024, the system always provides the full data of message updates. I'll leave in the old code since it won't cause problems.
const isGeneratedEmbed = !("content" in message) const isGeneratedEmbed = !("content" in message)
@ -123,28 +122,44 @@ async function editToChanges(message, guild, api) {
eventsToReplace = eventsToReplace.filter(eventCanBeEdited) eventsToReplace = eventsToReplace.filter(eventCanBeEdited)
// We want to maintain exactly one part = 0 and one reaction_part = 0 database row at all times. // We want to maintain exactly one part = 0 and one reaction_part = 0 database row at all times.
/** @type {({column: string, eventID: string} | {column: string, nextEvent: true})[]} */ // This would be disrupted if existing events that are (reaction_)part = 0 will be redacted.
// If that is the case, pick a different existing or newly sent event to be (reaction_)part = 0.
/** @type {({column: string, eventID: string, value?: number} | {column: string, nextEvent: true})[]} */
const promotions = [] const promotions = []
for (const column of ["part", "reaction_part"]) { for (const column of ["part", "reaction_part"]) {
const candidatesForParts = unchangedEvents.concat(eventsToReplace) const candidatesForParts = unchangedEvents.concat(eventsToReplace)
// If no events with part = 0 exist (or will exist), we need to do some management. // If no events with part = 0 exist (or will exist), we need to do some management.
if (!candidatesForParts.some(e => e.old[column] === 0)) { if (!candidatesForParts.some(e => e.old[column] === 0)) {
// Try to find an existing event to promote. Bigger order is better.
if (candidatesForParts.length) { if (candidatesForParts.length) {
// We can choose an existing event to promote. Bigger order is better.
const order = e => 2*+(e.event_type === "m.room.message") + 1*+(e.old.event_subtype === "m.text") const order = e => 2*+(e.event_type === "m.room.message") + 1*+(e.old.event_subtype === "m.text")
candidatesForParts.sort((a, b) => order(b) - order(a)) candidatesForParts.sort((a, b) => order(b) - order(a))
if (column === "part") { if (column === "part") {
promotions.push({column, eventID: candidatesForParts[0].old.event_id}) // part should be the first one promotions.push({column, eventID: candidatesForParts[0].old.event_id}) // part should be the first one
} else if (eventsToSend.length) {
promotions.push({column, nextEvent: true}) // reaction_part should be the last one
} else { } else {
promotions.push({column, eventID: candidatesForParts[candidatesForParts.length - 1].old.event_id}) // reaction_part should be the last one promotions.push({column, eventID: candidatesForParts[candidatesForParts.length - 1].old.event_id}) // reaction_part should be the last one
} }
} else { }
// No existing events to promote, but new events are being sent. Whatever gets sent will be the next part = 0. // Or, if there are no existing events to promote and new events will be sent, whatever gets sent will be the next part = 0.
else {
promotions.push({column, nextEvent: true}) promotions.push({column, nextEvent: true})
} }
} }
} }
// If adding events, try to keep reactions attached to the bottom of the group (unless reactions have already been added)
if (eventsToSend.length && !promotions.length) {
const existingReaction = select("reaction", "message_id", {message_id: message.id}).pluck().get()
if (!existingReaction) {
const existingPartZero = unchangedEvents.concat(eventsToReplace).find(p => p.old.reaction_part === 0)
assert(existingPartZero) // will exist because a reaction_part=0 always exists and no events are being removed
promotions.push({column: "reaction_part", eventID: existingPartZero.old.event_id, value: 1}) // update the current reaction_part to 1
promotions.push({column: "reaction_part", nextEvent: true}) // the newly created event will have reaction_part = 0
}
}
// Removing unnecessary properties before returning // Removing unnecessary properties before returning
eventsToRedact = eventsToRedact.map(e => e.old.event_id) eventsToRedact = eventsToRedact.map(e => e.old.event_id)
eventsToReplace = eventsToReplace.map(e => ({oldID: e.old.event_id, newContent: makeReplacementEventContent(e.old.event_id, e.newFallbackContent, e.newInnerContent)})) eventsToReplace = eventsToReplace.map(e => ({oldID: e.old.event_id, newContent: makeReplacementEventContent(e.old.event_id, e.newFallbackContent, e.newInnerContent)}))

View file

@ -1,6 +1,6 @@
const {test} = require("supertape") const {test} = require("supertape")
const {editToChanges} = require("./edit-to-changes") const {editToChanges} = require("./edit-to-changes")
const data = require("../../test/data") const data = require("../../../test/data")
const Ty = require("../../types") const Ty = require("../../types")
test("edit2changes: edit by webhook", async t => { test("edit2changes: edit by webhook", async t => {
@ -99,9 +99,9 @@ test("edit2changes: change file type", async t => {
t.deepEqual(eventsToRedact, ["$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCJ"]) t.deepEqual(eventsToRedact, ["$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCJ"])
t.deepEqual(eventsToSend, [{ t.deepEqual(eventsToSend, [{
$type: "m.room.message", $type: "m.room.message",
body: "📝 Uploaded file: https://cdn.discordapp.com/attachments/112760669178241024/1141501302497615912/gaze_into_my_dark_mind.txt (20 MB)", body: "📝 Uploaded file: https://bridge.example.org/download/discordcdn/112760669178241024/1141501302497615912/gaze_into_my_dark_mind.txt (20 MB)",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: "📝 Uploaded file: <a href=\"https://cdn.discordapp.com/attachments/112760669178241024/1141501302497615912/gaze_into_my_dark_mind.txt\">gaze_into_my_dark_mind.txt</a> (20 MB)", formatted_body: "📝 Uploaded file: <a href=\"https://bridge.example.org/download/discordcdn/112760669178241024/1141501302497615912/gaze_into_my_dark_mind.txt\">gaze_into_my_dark_mind.txt</a> (20 MB)",
"m.mentions": {}, "m.mentions": {},
msgtype: "m.text" msgtype: "m.text"
}]) }])
@ -109,7 +109,7 @@ test("edit2changes: change file type", async t => {
t.deepEqual(promotions, [{column: "part", nextEvent: true}, {column: "reaction_part", nextEvent: true}]) t.deepEqual(promotions, [{column: "part", nextEvent: true}, {column: "reaction_part", nextEvent: true}])
}) })
test("edit2changes: add caption back to that image", async t => { test("edit2changes: add caption back to that image (due to it having a reaction, the reaction_part will not be moved)", async t => {
const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.added_caption_to_image, data.guild.general, {}) const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.added_caption_to_image, data.guild.general, {})
t.deepEqual(eventsToRedact, []) t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToSend, [{ t.deepEqual(eventsToSend, [{
@ -266,7 +266,62 @@ test("edit2changes: generated embed", async t => {
+ `</li><li>Both players present their best five-or-less-card pok...</li></ul></p></blockquote>`, + `</li><li>Both players present their best five-or-less-card pok...</li></ul></p></blockquote>`,
"m.mentions": {} "m.mentions": {}
}]) }])
t.deepEqual(promotions, []) // TODO: it would be ideal to promote this to reaction_part = 0. this is OK to do because the main message won't have had any reactions yet. t.deepEqual(promotions, [{
"column": "reaction_part",
"eventID": "$mPSzglkCu-6cZHbYro0RW2u5mHvbH9aXDjO5FCzosc0",
"value": 1,
}, {
"column": "reaction_part",
"nextEvent": true,
}])
t.equal(senderMxid, "@_ooye_cadence:cadence.moe") t.equal(senderMxid, "@_ooye_cadence:cadence.moe")
t.equal(called, 1) t.equal(called, 1)
}) })
test("edit2changes: generated embed on a reply", async t => {
const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_on_reply, data.guild.general, {})
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToReplace, [{
oldID: "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF",
newContent: {
$type: "m.room.message",
// Unfortunately the edited message doesn't include the message_reference field. Fine. Whatever. It looks normal if you're using a good client.
body: "> a Discord user: [Replied-to message content wasn't provided by Discord]"
+ "\n\n* https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM",
format: "org.matrix.custom.html",
formatted_body: "<mx-reply><blockquote><a href=\"https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM\">In reply to</a> a Discord user<br>[Replied-to message content wasn't provided by Discord]</blockquote></mx-reply>* <a href=\"https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM\">https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM</a>",
"m.mentions": {},
"m.new_content": {
body: "https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM",
format: "org.matrix.custom.html",
formatted_body: "<a href=\"https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM\">https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM</a>",
"m.mentions": {},
msgtype: "m.text",
},
"m.relates_to": {
event_id: "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF",
rel_type: "m.replace",
},
msgtype: "m.text",
},
}])
t.deepEqual(eventsToSend, [{
$type: "m.room.message",
msgtype: "m.notice",
body: "| ## Matrix - Decentralised and secure communication https://matrix.to/"
+ "\n| \n| You're invited to talk on Matrix. If you don't already have a client this link will help you pick one, and join the conversation. If you already have one, this link will help you join the conversation",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><strong><a href="https://matrix.to/">Matrix - Decentralised and secure communication</a></strong>`
+ `</p><p>You're invited to talk on Matrix. If you don't already have a client this link will help you pick one, and join the conversation. If you already have one, this link will help you join the conversation</p></blockquote>`,
"m.mentions": {}
}])
t.deepEqual(promotions, [{
"column": "reaction_part",
"eventID": "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF",
"value": 1,
}, {
"column": "reaction_part",
"nextEvent": true,
}])
t.equal(senderMxid, "@_ooye_cadence:cadence.moe")
})

View file

@ -2,7 +2,7 @@
const {test} = require("supertape") const {test} = require("supertape")
const {emojiToKey} = require("./emoji-to-key") const {emojiToKey} = require("./emoji-to-key")
const data = require("../../test/data") const data = require("../../../test/data")
const Ty = require("../../types") const Ty = require("../../types")
test("emoji2key: unicode emoji works", async t => { test("emoji2key: unicode emoji works", async t => {

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,11 +1,18 @@
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, {})
t.deepEqual(events, [{ t.deepEqual(events, [{
$type: "m.room.message",
body: "> ↪️ @papiophidian: used `/stats`",
format: "org.matrix.custom.html",
formatted_body: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
"m.mentions": {},
msgtype: "m.text",
}, {
$type: "m.room.message", $type: "m.room.message",
"m.mentions": {}, "m.mentions": {},
msgtype: "m.notice", msgtype: "m.notice",
@ -60,7 +67,7 @@ test("message2event embeds: image embed and attachment", async t => {
msgtype: "m.image", msgtype: "m.image",
url: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR", url: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR",
body: "Screenshot_20231001_034036.jpg", body: "Screenshot_20231001_034036.jpg",
external_url: "https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg?ex=651a1faa&is=6518ce2a&hm=eb5ca80a3fa7add8765bf404aea2028a28a2341e4a62435986bcdcf058da82f3&", external_url: "https://bridge.example.org/download/discordcdn/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg",
filename: "Screenshot_20231001_034036.jpg", filename: "Screenshot_20231001_034036.jpg",
info: { info: {
h: 1170, h: 1170,
@ -143,6 +150,13 @@ test("message2event embeds: crazy html is all escaped", async t => {
test("message2event embeds: title without url", async t => { test("message2event embeds: title without url", async t => {
const events = await messageToEvent(data.message_with_embeds.title_without_url, data.guild.general) const events = await messageToEvent(data.message_with_embeds.title_without_url, data.guild.general)
t.deepEqual(events, [{ t.deepEqual(events, [{
$type: "m.room.message",
body: "> ↪️ @papiophidian: used `/stats`",
format: "org.matrix.custom.html",
formatted_body: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
"m.mentions": {},
msgtype: "m.text",
}, {
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.notice", msgtype: "m.notice",
body: "| ## Hi, I'm Amanda!\n| \n| I condone pirating music!", body: "| ## Hi, I'm Amanda!\n| \n| I condone pirating music!",
@ -155,6 +169,13 @@ test("message2event embeds: title without url", async t => {
test("message2event embeds: url without title", async t => { test("message2event embeds: url without title", async t => {
const events = await messageToEvent(data.message_with_embeds.url_without_title, data.guild.general) const events = await messageToEvent(data.message_with_embeds.url_without_title, data.guild.general)
t.deepEqual(events, [{ t.deepEqual(events, [{
$type: "m.room.message",
body: "> ↪️ @papiophidian: used `/stats`",
format: "org.matrix.custom.html",
formatted_body: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
"m.mentions": {},
msgtype: "m.text",
}, {
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.notice", msgtype: "m.notice",
body: "| I condone pirating music!", body: "| I condone pirating music!",
@ -167,6 +188,13 @@ test("message2event embeds: url without title", async t => {
test("message2event embeds: author without url", async t => { test("message2event embeds: author without url", async t => {
const events = await messageToEvent(data.message_with_embeds.author_without_url, data.guild.general) const events = await messageToEvent(data.message_with_embeds.author_without_url, data.guild.general)
t.deepEqual(events, [{ t.deepEqual(events, [{
$type: "m.room.message",
body: "> ↪️ @papiophidian: used `/stats`",
format: "org.matrix.custom.html",
formatted_body: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
"m.mentions": {},
msgtype: "m.text",
}, {
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.notice", msgtype: "m.notice",
body: "| ## Amanda\n| \n| I condone pirating music!", body: "| ## Amanda\n| \n| I condone pirating music!",
@ -179,6 +207,13 @@ test("message2event embeds: author without url", async t => {
test("message2event embeds: author url without name", async t => { test("message2event embeds: author url without name", async t => {
const events = await messageToEvent(data.message_with_embeds.author_url_without_name, data.guild.general) const events = await messageToEvent(data.message_with_embeds.author_url_without_name, data.guild.general)
t.deepEqual(events, [{ t.deepEqual(events, [{
$type: "m.room.message",
body: "> ↪️ @papiophidian: used `/stats`",
format: "org.matrix.custom.html",
formatted_body: "<blockquote>↪️ <a href=\"https://matrix.to/#/@_ooye_papiophidian:cadence.moe\">@papiophidian</a> used <code>/stats</code></blockquote>",
"m.mentions": {},
msgtype: "m.text",
}, {
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.notice", msgtype: "m.notice",
body: "| I condone pirating music!", body: "| I condone pirating music!",
@ -316,3 +351,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

@ -18,7 +18,7 @@ const lottie = sync.require("../actions/lottie")
const mxUtils = sync.require("../../m2d/converters/utils") const mxUtils = sync.require("../../m2d/converters/utils")
/** @type {import("../../discord/utils")} */ /** @type {import("../../discord/utils")} */
const dUtils = sync.require("../../discord/utils") const dUtils = sync.require("../../discord/utils")
const reg = require("../../matrix/read-registration") const {reg} = require("../../matrix/read-registration")
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
@ -32,7 +32,10 @@ function getDiscordParseCallbacks(message, guild, useHTML) {
/** @param {{id: string, type: "discordUser"}} node */ /** @param {{id: string, type: "discordUser"}} node */
user: node => { user: node => {
const mxid = select("sim", "mxid", {user_id: node.id}).pluck().get() const mxid = select("sim", "mxid", {user_id: node.id}).pluck().get()
const username = message.mentions.find(ment => ment.id === node.id)?.username || node.id const interaction = message.interaction_metadata || message.interaction
const username = message.mentions.find(ment => ment.id === node.id)?.username
|| (interaction?.user.id === node.id ? interaction.user.username : null)
|| node.id
if (mxid && useHTML) { if (mxid && useHTML) {
return `<a href="https://matrix.to/#/${mxid}">@${username}</a>` return `<a href="https://matrix.to/#/${mxid}">@${username}</a>`
} else { } else {
@ -58,7 +61,7 @@ function getDiscordParseCallbacks(message, guild, useHTML) {
emoji: node => { emoji: node => {
if (useHTML) { if (useHTML) {
const mxc = select("emoji", "mxc_url", {emoji_id: node.id}).pluck().get() const mxc = select("emoji", "mxc_url", {emoji_id: node.id}).pluck().get()
assert(mxc) // All emojis should have been added ahead of time in the messageToEvent function. assert(mxc, `Emoji consistency assertion failed for ${node.name}:${node.id}`) // All emojis should have been added ahead of time in the messageToEvent function.
return `<img data-mx-emoticon height="32" src="${mxc}" title=":${node.name}:" alt=":${node.name}:">` return `<img data-mx-emoticon height="32" src="${mxc}" title=":${node.name}:" alt=":${node.name}:">`
} else { } else {
return `:${node.name}:` return `:${node.name}:`
@ -100,6 +103,7 @@ const embedTitleParser = markdown.markdownEngine.parserFor({
* @param {DiscordTypes.APIAttachment} attachment * @param {DiscordTypes.APIAttachment} attachment
*/ */
async function attachmentToEvent(mentions, attachment) { async function attachmentToEvent(mentions, attachment) {
const external_url = dUtils.getPublicUrlForCdn(attachment.url)
const emoji = const emoji =
attachment.content_type?.startsWith("image/jp") ? "📸" attachment.content_type?.startsWith("image/jp") ? "📸"
: attachment.content_type?.startsWith("image/") ? "🖼️" : attachment.content_type?.startsWith("image/") ? "🖼️"
@ -113,9 +117,9 @@ async function attachmentToEvent(mentions, attachment) {
$type: "m.room.message", $type: "m.room.message",
"m.mentions": mentions, "m.mentions": mentions,
msgtype: "m.text", msgtype: "m.text",
body: `${emoji} Uploaded SPOILER file: ${attachment.url} (${pb(attachment.size)})`, body: `${emoji} Uploaded SPOILER file: ${external_url} (${pb(attachment.size)})`,
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `<blockquote>${emoji} Uploaded SPOILER file: <a href="${attachment.url}">${attachment.url}</a> (${pb(attachment.size)})</blockquote>` formatted_body: `<blockquote>${emoji} Uploaded SPOILER file: <a href="${external_url}">${external_url}</a> (${pb(attachment.size)})</blockquote>`
} }
} }
// for large files, always link them instead of uploading so I don't use up all the space in the content repo // for large files, always link them instead of uploading so I don't use up all the space in the content repo
@ -124,9 +128,9 @@ async function attachmentToEvent(mentions, attachment) {
$type: "m.room.message", $type: "m.room.message",
"m.mentions": mentions, "m.mentions": mentions,
msgtype: "m.text", msgtype: "m.text",
body: `${emoji} Uploaded file: ${attachment.url} (${pb(attachment.size)})`, body: `${emoji} Uploaded file: ${external_url} (${pb(attachment.size)})`,
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `${emoji} Uploaded file: <a href="${attachment.url}">${attachment.filename}</a> (${pb(attachment.size)})` formatted_body: `${emoji} Uploaded file: <a href="${external_url}">${attachment.filename}</a> (${pb(attachment.size)})`
} }
} else if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) { } else if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) {
return { return {
@ -134,7 +138,7 @@ async function attachmentToEvent(mentions, attachment) {
"m.mentions": mentions, "m.mentions": mentions,
msgtype: "m.image", msgtype: "m.image",
url: await file.uploadDiscordFileToMxc(attachment.url), url: await file.uploadDiscordFileToMxc(attachment.url),
external_url: attachment.url, external_url,
body: attachment.description || attachment.filename, body: attachment.description || attachment.filename,
filename: attachment.filename, filename: attachment.filename,
info: { info: {
@ -150,7 +154,7 @@ async function attachmentToEvent(mentions, attachment) {
"m.mentions": mentions, "m.mentions": mentions,
msgtype: "m.video", msgtype: "m.video",
url: await file.uploadDiscordFileToMxc(attachment.url), url: await file.uploadDiscordFileToMxc(attachment.url),
external_url: attachment.url, external_url,
body: attachment.description || attachment.filename, body: attachment.description || attachment.filename,
filename: attachment.filename, filename: attachment.filename,
info: { info: {
@ -166,13 +170,13 @@ async function attachmentToEvent(mentions, attachment) {
"m.mentions": mentions, "m.mentions": mentions,
msgtype: "m.audio", msgtype: "m.audio",
url: await file.uploadDiscordFileToMxc(attachment.url), url: await file.uploadDiscordFileToMxc(attachment.url),
external_url: attachment.url, external_url,
body: attachment.description || attachment.filename, body: attachment.description || attachment.filename,
filename: attachment.filename, filename: attachment.filename,
info: { info: {
mimetype: attachment.content_type, mimetype: attachment.content_type,
size: attachment.size, size: attachment.size,
duration: attachment.duration_secs ? attachment.duration_secs * 1000 : undefined duration: attachment.duration_secs ? Math.round(attachment.duration_secs * 1000) : undefined
} }
} }
} else { } else {
@ -181,7 +185,7 @@ async function attachmentToEvent(mentions, attachment) {
"m.mentions": mentions, "m.mentions": mentions,
msgtype: "m.file", msgtype: "m.file",
url: await file.uploadDiscordFileToMxc(attachment.url), url: await file.uploadDiscordFileToMxc(attachment.url),
external_url: attachment.url, external_url,
body: attachment.description || attachment.filename, body: attachment.description || attachment.filename,
filename: attachment.filename, filename: attachment.filename,
info: { info: {
@ -193,11 +197,13 @@ async function attachmentToEvent(mentions, attachment) {
} }
/** /**
* @param {import("discord-api-types/v10").APIMessage} message * @param {DiscordTypes.APIMessage} message
* @param {import("discord-api-types/v10").APIGuild} guild * @param {DiscordTypes.APIGuild} guild
* @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: 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.
* - 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) {
@ -229,6 +235,16 @@ async function messageToEvent(message, guild, options = {}, di) {
}] }]
} }
const interaction = message.interaction_metadata || message.interaction
if (message.type === DiscordTypes.MessageType.ChatInputCommand && interaction && "name" in interaction) {
// Commands are sent by the responding bot. Need to attach the metadata of the person using the command at the top.
let content = message.content
if (content) content = `\n${content}`
else if ((message.flags || 0) & DiscordTypes.MessageFlags.Loading) content = " — interaction loading..."
content = `> ↪️ <@${interaction.user.id}> used \`/${interaction.name}\`${content}`
message = {...message, content} // editToChanges reuses the object so we can't mutate it. have to clone it
}
/** /**
@type {{room?: boolean, user_ids?: string[]}} @type {{room?: boolean, user_ids?: string[]}}
We should consider the following scenarios for mentions: We should consider the following scenarios for mentions:
@ -338,8 +354,13 @@ async function messageToEvent(message, guild, options = {}, di) {
result = `https://matrix.to/#/${roomID}/${eventID}?${via}` result = `https://matrix.to/#/${roomID}/${eventID}?${via}`
} else { } else {
const ts = dUtils.snowflakeToTimestampExact(messageID) const ts = dUtils.snowflakeToTimestampExact(messageID)
try {
const {event_id} = await di.api.getEventForTimestamp(roomID, ts) const {event_id} = await di.api.getEventForTimestamp(roomID, ts)
result = `https://matrix.to/#/${roomID}/${event_id}?${via}` result = `https://matrix.to/#/${roomID}/${event_id}?${via}`
} catch (e) {
// M_NOT_FOUND: Unable to find event from <ts> in direction Direction.FORWARDS
result = `[unknown event, timestamp resolution failed, in room: https://matrix.to/#/${roomID}?${via}]`
}
} }
} else { } else {
result = `${match[0]} [event is from another server]` result = `${match[0]} [event is from another server]`
@ -351,6 +372,13 @@ async function messageToEvent(message, guild, options = {}, di) {
return content return content
} }
/**
* Translate Discord attachment links into links that go via the bridge, so they last forever.
*/
function transformAttachmentLinks(content) {
return content.replace(/https:\/\/(cdn|media)\.discordapp\.(?:com|net)\/attachments\/([0-9]+)\/([0-9]+)\/([-A-Za-z0-9_.,]+)/g, url => dUtils.getPublicUrlForCdn(url))
}
/** /**
* Translate links and emojis and mentions and stuff. Give back the text and HTML so they can be combined into bigger events. * Translate links and emojis and mentions and stuff. Give back the text and HTML so they can be combined into bigger events.
* @param {string} content Partial or complete Discord message content * @param {string} content Partial or complete Discord message content
@ -359,11 +387,12 @@ async function messageToEvent(message, guild, options = {}, di) {
* @param {any} customHtmlOutput * @param {any} customHtmlOutput
*/ */
async function transformContent(content, customOptions = {}, customParser = null, customHtmlOutput = null) { async function transformContent(content, customOptions = {}, customParser = null, customHtmlOutput = null) {
content = transformAttachmentLinks(content)
content = await transformContentMessageLinks(content) content = await transformContentMessageLinks(content)
// Handling emojis that we don't know about. The emoji has to be present in the DB for it to be picked up in the emoji markdown converter. // Handling emojis that we don't know about. The emoji has to be present in the DB for it to be picked up in the emoji markdown converter.
// So we scan the message ahead of time for all its emojis and ensure they are in the DB. // So we scan the message ahead of time for all its emojis and ensure they are in the DB.
const emojiMatches = [...content.matchAll(/<(a?):([^:>]{2,64}):([0-9]+)>/g)] const emojiMatches = [...content.matchAll(/<(a?):([^:>]{1,64}):([0-9]+)>/g)]
await Promise.all(emojiMatches.map(match => { await Promise.all(emojiMatches.map(match => {
const id = match[3] const id = match[3]
const name = match[2] const name = match[2]
@ -401,8 +430,13 @@ async function messageToEvent(message, guild, options = {}, di) {
return {body, html} return {body, html}
} }
// FIXME: What was the scanMentions parameter supposed to activate? It's unused. /**
async function addTextEvent(body, html, msgtype, {scanMentions}) { * After converting Discord content to Matrix plaintext and HTML content, post-process the bodies and push the resulting text event
* @param {string} body matrix event plaintext body
* @param {string} html matrix event HTML body
* @param {string} msgtype matrix event msgtype (maybe m.text or m.notice)
*/
async function addTextEvent(body, html, msgtype) {
// Star * prefix for fallback edits // Star * prefix for fallback edits
if (options.includeEditFallbackStar) { if (options.includeEditFallbackStar) {
body = "* " + body body = "* " + body
@ -410,7 +444,7 @@ async function messageToEvent(message, guild, options = {}, di) {
} }
const flags = message.flags || 0 const flags = message.flags || 0
if (flags & 2) { if (flags & DiscordTypes.MessageFlags.IsCrosspost) {
body = `[🔀 ${message.author.username}]\n` + body body = `[🔀 ${message.author.username}]\n` + body
html = `🔀 <strong>${message.author.username}</strong><br>` + html html = `🔀 <strong>${message.author.username}</strong><br>` + html
} }
@ -430,10 +464,12 @@ async function messageToEvent(message, guild, options = {}, di) {
repliedToUserHtml = repliedToDisplayName repliedToUserHtml = repliedToDisplayName
} }
let repliedToContent = message.referenced_message?.content let repliedToContent = message.referenced_message?.content
if (repliedToContent?.startsWith("> <:L1:")) { if (repliedToContent?.match(/^(-# )?> (-# )?<:L1:/)) {
// If the Discord user is replying to a Matrix user's reply, the fallback is going to contain the emojis and stuff from the bridged rep of the Matrix user's reply quote. // If the Discord user is replying to a Matrix user's reply, the fallback is going to contain the emojis and stuff from the bridged rep of the Matrix user's reply quote.
// Need to remove that previous reply rep from this fallback body. The fallbody body should only contain the Matrix user's actual message. // Need to remove that previous reply rep from this fallback body. The fallbody body should only contain the Matrix user's actual message.
repliedToContent = repliedToContent.split("\n").slice(2).join("\n") // ┌──────A─────┐ A reply rep starting with >quote or -#smalltext >quote. Match until the end of the line.
// ┆ ┆┌─B─┐ There may be up to 2 reply rep lines in a row if it was created in the old format. Match all lines.
repliedToContent = repliedToContent.replace(/^((-# )?> .*\n){1,2}/, "")
} }
if (repliedToContent == "") repliedToContent = "[Media]" if (repliedToContent == "") repliedToContent = "[Media]"
else if (!repliedToContent) repliedToContent = "[Replied-to message content wasn't provided by Discord]" else if (!repliedToContent) repliedToContent = "[Replied-to message content wasn't provided by Discord]"
@ -462,7 +498,7 @@ async function messageToEvent(message, guild, options = {}, di) {
const isPlaintext = body === html const isPlaintext = body === html
if (!isPlaintext) { if (!isPlaintext || options.alwaysReturnFormattedBody) {
Object.assign(newTextMessageEvent, { Object.assign(newTextMessageEvent, {
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: html formatted_body: html
@ -480,11 +516,62 @@ async function messageToEvent(message, guild, options = {}, di) {
message.content = "changed the channel name to **" + message.content + "**" message.content = "changed the channel name to **" + message.content + "**"
} }
// Forwarded content appears first
if (message.message_reference?.type === DiscordTypes.MessageReferenceType.Forward && message.message_snapshots?.length) {
// Forwarded notice
const eventID = select("event_message", "event_id", {message_id: message.message_reference.message_id}).pluck().get()
const room = select("channel_room", ["room_id", "name", "nick"], {channel_id: message.message_reference.channel_id}).get()
const forwardedNotice = new mxUtils.MatrixStringBuilder()
if (room) {
const roomName = room && (room.nick || room.name)
const via = await getViaServersMemo(room.room_id)
if (eventID) {
forwardedNotice.addLine(
`[🔀 Forwarded from #${roomName}]`,
tag`🔀 <em>Forwarded from <a href="https://matrix.to/#/${room.room_id}/${eventID}?${via}">${roomName}</a></em>`
)
} else {
forwardedNotice.addLine(
`[🔀 Forwarded from #${roomName}]`,
tag`🔀 <em>Forwarded from <a href="https://matrix.to/#/${room.room_id}?${via}">${roomName}</a></em>`
)
}
} else {
forwardedNotice.addLine(
`[🔀 Forwarded message]`,
tag`🔀 <em>Forwarded message</em>`
)
}
// Forwarded content
// @ts-ignore
const forwardedEvents = await messageToEvent(message.message_snapshots[0].message, guild, {includeReplyFallback: false, includeEditFallbackStar: false, alwaysReturnFormattedBody: true, scanTextForMentions: false}, di)
// Indent
for (const event of forwardedEvents) {
if (["m.text", "m.notice"].includes(event.msgtype)) {
event.msgtype = "m.notice"
event.body = event.body.split("\n").map(l => "» " + l).join("\n")
event.formatted_body = `<blockquote>${event.formatted_body}</blockquote>`
}
}
// Try to merge the forwarded content with the forwarded notice
let {body, formatted_body} = forwardedNotice.get()
if (forwardedEvents.length >= 1 && ["m.text", "m.notice"].includes(forwardedEvents[0].msgtype)) { // Try to merge the forwarded content and the forwarded notice
forwardedEvents[0].body = body + "\n" + forwardedEvents[0].body
forwardedEvents[0].formatted_body = formatted_body + "<br>" + forwardedEvents[0].formatted_body
} else {
await addTextEvent(body, formatted_body, "m.notice")
}
events.push(...forwardedEvents)
}
// Then text content
if (message.content) { if (message.content) {
// Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention. // Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention.
const matches = [...message.content.matchAll(/@ ?([a-z0-9._]+)\b/gi)] const matches = [...message.content.matchAll(/@ ?([a-z0-9._]+)\b/gi)]
if (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)
@ -499,9 +586,8 @@ async function messageToEvent(message, guild, options = {}, di) {
} }
} }
// Text content appears first
const {body, html} = await transformContent(message.content) const {body, html} = await transformContent(message.content)
await addTextEvent(body, html, msgtype, {scanMentions: true}) await addTextEvent(body, html, msgtype)
} }
// Then attachments // Then attachments
@ -511,7 +597,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.
} }
@ -573,9 +664,9 @@ async function messageToEvent(message, guild, options = {}, di) {
let chosenImage = embed.image?.url let chosenImage = embed.image?.url
// the thumbnail seems to be used for "article" type but displayed big at the bottom by discord // the thumbnail seems to be used for "article" type but displayed big at the bottom by discord
if (embed.type === "article" && embed.thumbnail?.url && !chosenImage) chosenImage = embed.thumbnail.url if (embed.type === "article" && embed.thumbnail?.url && !chosenImage) chosenImage = embed.thumbnail.url
if (chosenImage) rep.addParagraph(`📸 ${chosenImage}`) if (chosenImage) rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`)
if (embed.video?.url) rep.addParagraph(`🎞️ ${embed.video.url}`) if (embed.video?.url) rep.addParagraph(`🎞️ ${dUtils.getPublicUrlForCdn(embed.video.url)}`)
if (embed.footer?.text) rep.addLine(`${embed.footer.text}`, tag`${embed.footer.text}`) if (embed.footer?.text) rep.addLine(`${embed.footer.text}`, tag`${embed.footer.text}`)
let {body, formatted_body: html} = rep.get() let {body, formatted_body: html} = rep.get()
@ -583,7 +674,7 @@ async function messageToEvent(message, guild, options = {}, di) {
html = `<blockquote>${html}</blockquote>` html = `<blockquote>${html}</blockquote>`
// Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person // Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person
await addTextEvent(body, html, "m.notice", {scanMentions: false}) await addTextEvent(body, html, "m.notice")
} }
// Then stickers // Then stickers

View file

@ -1,6 +1,6 @@
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 Ty = require("../../types")
/** /**

View file

@ -1,6 +1,7 @@
const {test} = require("supertape") const {test} = require("supertape")
const {messageToEvent} = require("./message-to-event") const {messageToEvent} = require("./message-to-event")
const data = require("../../test/data") const {MatrixServerError} = require("../../matrix/mreq")
const data = require("../../../test/data")
const Ty = require("../../types") const Ty = require("../../types")
/** /**
@ -64,6 +65,44 @@ test("message2event: simple user mention", async t => {
test("message2event: simple room mention", async t => { test("message2event: simple room mention", async t => {
let called = 0 let called = 0
const events = await messageToEvent(data.message.simple_room_mention, data.guild.general, {}, { const events = await messageToEvent(data.message.simple_room_mention, data.guild.general, {}, {
api: {
async getStateEvent(roomID, type, key) {
called++
t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {
users: {
"@_ooye_bot:cadence.moe": 100
}
}
},
async getJoinedMembers(roomID) {
called++
t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe")
return {
joined: {
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
"@user:matrix.org": {display_name: null, avatar_url: null}
}
}
}
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "#worm-farm",
format: "org.matrix.custom.html",
formatted_body: '<a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe?via=cadence.moe&via=matrix.org">#worm-farm</a>'
}])
t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each")
})
test("message2event: nicked room mention", async t => {
let called = 0
const events = await messageToEvent(data.message.nicked_room_mention, data.guild.general, {}, {
api: { api: {
async getStateEvent(roomID, type, key) { async getStateEvent(roomID, type, key) {
called++ called++
@ -229,6 +268,54 @@ test("message2event: message link that OOYE doesn't know about", async t => {
t.equal(called, 3, "getEventForTimestamp, getStateEvent, and getJoinedMembers should be called once each") t.equal(called, 3, "getEventForTimestamp, getStateEvent, and getJoinedMembers should be called once each")
}) })
test("message2event: message timestamp failed to fetch", async t => {
let called = 0
const events = await messageToEvent(data.message.message_link_to_before_ooye, data.guild.general, {}, {
api: {
async getEventForTimestamp(roomID, ts) {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
throw new MatrixServerError({
errcode: "M_NOT_FOUND",
error: "Unable to find event from 1726762095974 in direction Direction.FORWARDS"
}, {})
},
async getStateEvent(roomID, type, key) { // for ?via calculation
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {
users: {
"@_ooye_bot:cadence.moe": 100
}
}
},
async getJoinedMembers(roomID) { // for ?via calculation
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
return {
joined: {
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
"@user:matrix.org": {display_name: null, avatar_url: null}
}
}
}
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "Me: I'll scroll up to find a certain message I'll send\n_scrolls up and clicks message links for god knows how long_\n_completely forgets what they were looking for and simply begins scrolling up to find some fun moments_\n_stumbles upon:_ "
+ "[unknown event, timestamp resolution failed, in room: https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&via=matrix.org]",
format: "org.matrix.custom.html",
formatted_body: "Me: I'll scroll up to find a certain message I'll send<br><em>scrolls up and clicks message links for god knows how long</em><br><em>completely forgets what they were looking for and simply begins scrolling up to find some fun moments</em><br><em>stumbles upon:</em> "
+ '[unknown event, timestamp resolution failed, in room: <a href="https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&amp;via=matrix.org">https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe?via=cadence.moe&amp;via=matrix.org</a>]'
}])
t.equal(called, 3, "getEventForTimestamp, getStateEvent, and getJoinedMembers should be called once each")
})
test("message2event: message link from another server", async t => { test("message2event: message link from another server", async t => {
const events = await messageToEvent(data.message.message_link_from_another_server, data.guild.general) const events = await messageToEvent(data.message.message_link_from_another_server, data.guild.general)
t.deepEqual(events, [{ t.deepEqual(events, [{
@ -250,7 +337,7 @@ test("message2event: attachment with no content", async t => {
msgtype: "m.image", msgtype: "m.image",
url: "mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM", url: "mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM",
body: "image.png", body: "image.png",
external_url: "https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png", external_url: "https://bridge.example.org/download/discordcdn/497161332244742154/1124628646431297546/image.png",
filename: "image.png", filename: "image.png",
info: { info: {
mimetype: "image/png", mimetype: "image/png",
@ -267,9 +354,9 @@ test("message2event: spoiler attachment", async t => {
$type: "m.room.message", $type: "m.room.message",
"m.mentions": {}, "m.mentions": {},
msgtype: "m.text", msgtype: "m.text",
body: "📄 Uploaded SPOILER file: https://cdn.discordapp.com/attachments/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)", body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: "<blockquote>📄 Uploaded SPOILER file: <a href=\"https://cdn.discordapp.com/attachments/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci\">https://cdn.discordapp.com/attachments/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci</a> (74 KB)</blockquote>" formatted_body: "<blockquote>📄 Uploaded SPOILER file: <a href=\"https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci\">https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci</a> (74 KB)</blockquote>"
}]) }])
}) })
@ -286,7 +373,7 @@ test("message2event: stickers", async t => {
msgtype: "m.image", msgtype: "m.image",
url: "mxc://cadence.moe/ZDCNYnkPszxGKgObUIFmvjus", url: "mxc://cadence.moe/ZDCNYnkPszxGKgObUIFmvjus",
body: "image.png", body: "image.png",
external_url: "https://cdn.discordapp.com/attachments/122155380120748034/1106366167486038016/image.png", external_url: "https://bridge.example.org/download/discordcdn/122155380120748034/1106366167486038016/image.png",
filename: "image.png", filename: "image.png",
info: { info: {
mimetype: "image/png", mimetype: "image/png",
@ -340,7 +427,7 @@ test("message2event: skull webp attachment with content", async t => {
mimetype: "image/webp", mimetype: "image/webp",
size: 74290 size: 74290
}, },
external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1128084747910918195/skull.webp", external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1128084747910918195/skull.webp",
filename: "skull.webp", filename: "skull.webp",
url: "mxc://cadence.moe/sDxWmDErBhYBxtDcJQgBETes" url: "mxc://cadence.moe/sDxWmDErBhYBxtDcJQgBETes"
}]) }])
@ -374,7 +461,7 @@ test("message2event: reply to skull webp attachment with content", async t => {
mimetype: "image/jpeg", mimetype: "image/jpeg",
size: 85906 size: 85906
}, },
external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1128084851023675515/RDT_20230704_0936184915846675925224905.jpg", external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1128084851023675515/RDT_20230704_0936184915846675925224905.jpg",
filename: "RDT_20230704_0936184915846675925224905.jpg", filename: "RDT_20230704_0936184915846675925224905.jpg",
url: "mxc://cadence.moe/WlAbFSiNRIHPDEwKdyPeGywa" url: "mxc://cadence.moe/WlAbFSiNRIHPDEwKdyPeGywa"
}]) }])
@ -464,7 +551,7 @@ test("message2event: reply with a video", async t => {
body: "Ins_1960637570.mp4", body: "Ins_1960637570.mp4",
filename: "Ins_1960637570.mp4", filename: "Ins_1960637570.mp4",
url: "mxc://cadence.moe/kMqLycqMURhVpwleWkmASpnU", url: "mxc://cadence.moe/kMqLycqMURhVpwleWkmASpnU",
external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1197621094786531358/Ins_1960637570.mp4?ex=65bbee8f&is=65a9798f&hm=ae14f7824c3d526c5e11c162e012e1ee405fd5776e1e9302ed80ccd86503cfda&", external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1197621094786531358/Ins_1960637570.mp4",
info: { info: {
h: 854, h: 854,
mimetype: "video/mp4", mimetype: "video/mp4",
@ -485,10 +572,10 @@ test("message2event: voice message", async t => {
t.deepEqual(events, [{ t.deepEqual(events, [{
$type: "m.room.message", $type: "m.room.message",
body: "voice-message.ogg", body: "voice-message.ogg",
external_url: "https://cdn.discordapp.com/attachments/1099031887500034088/1112476845502365786/voice-message.ogg?ex=65c92d4c&is=65b6b84c&hm=0654bab5027474cbe23875954fa117cf44d8914c144cd151879590fa1baf8b1c&", 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,
}, },
@ -508,7 +595,7 @@ test("message2event: misc file", async t => {
}, { }, {
$type: "m.room.message", $type: "m.room.message",
body: "the.yml", body: "the.yml",
external_url: "https://cdn.discordapp.com/attachments/122155380120748034/1174514575220158545/the.yml?ex=65cd6270&is=65baed70&hm=8c5f1b571784e3c7f99628492298815884e351ae0dc7c2ae40dd22d97caf27d9&", external_url: "https://bridge.example.org/download/discordcdn/122155380120748034/1174514575220158545/the.yml",
filename: "the.yml", filename: "the.yml",
info: { info: {
mimetype: "text/plain; charset=utf-8", mimetype: "text/plain; charset=utf-8",
@ -559,6 +646,84 @@ test("message2event: simple reply in thread to a matrix user's reply", async t =
}]) }])
}) })
test("message2event: infinidoge's reply to ami's matrix smalltext reply to infinidoge", async t => {
const events = await messageToEvent(data.message.infinidoge_reply_to_ami_matrix_smalltext_reply_to_infinidoge, data.guild.general, {}, {
api: {
getEvent: mockGetEvent(t, "!BnKuBPCvyfOkhcUjEu:cadence.moe", "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4", {
type: "m.room.message",
sender: "@ami:the-apothecary.club",
content: {
msgtype: "m.text",
body: `> <@_ooye_infinidoge:cadence.moe> Neat that they thought of that\n\nlet me guess they got a lot of bug reports like "empty chest with no loot?"`,
format: "org.matrix.custom.html",
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$baby?via=cadence.moe">In reply to</a> <a href="https://matrix.to/#/@_ooye_infinidoge:cadence.moe">@_ooye_infinidoge:cadence.moe</a><br>Neat that they thought of that</blockquote></mx-reply>let me guess they got a lot of bug reports like "empty chest with no loot?"`,
"m.relates_to": {
"m.in_reply_to": {
event_id: "$baby"
}
}
},
event_id: "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4",
room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe"
})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4"
}
},
"m.mentions": {
user_ids: ["@ami:the-apothecary.club"]
},
msgtype: "m.text",
body: `> Ami (she/her): let me guess they got a lot of bug reports like "empty chest with no loot?"\n\nMost likely`,
format: "org.matrix.custom.html",
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4">In reply to</a> <a href="https://matrix.to/#/@ami:the-apothecary.club">Ami (she/her)</a><br>let me guess they got a lot of bug reports like "empty chest with no loot?"</blockquote></mx-reply>Most likely`,
}])
})
test("message2event: infinidoge's reply to ami's matrix smalltext singleline reply to infinidoge", async t => {
const events = await messageToEvent(data.message.infinidoge_reply_to_ami_matrix_smalltext_singleline_reply_to_infinidoge, data.guild.general, {}, {
api: {
getEvent: mockGetEvent(t, "!BnKuBPCvyfOkhcUjEu:cadence.moe", "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4", {
type: "m.room.message",
sender: "@ami:the-apothecary.club",
content: {
msgtype: "m.text",
body: `> <@_ooye_infinidoge:cadence.moe> Neat that they thought of that\n\nlet me guess they got a lot of bug reports like "empty chest with no loot?"`,
format: "org.matrix.custom.html",
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$baby?via=cadence.moe">In reply to</a> <a href="https://matrix.to/#/@_ooye_infinidoge:cadence.moe">@_ooye_infinidoge:cadence.moe</a><br>Neat that they thought of that</blockquote></mx-reply>let me guess they got a lot of bug reports like "empty chest with no loot?"`,
"m.relates_to": {
"m.in_reply_to": {
event_id: "$baby"
}
}
},
event_id: "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4",
room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe"
})
}
})
t.deepEqual(events, [{
$type: "m.room.message",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4"
}
},
"m.mentions": {
user_ids: ["@ami:the-apothecary.club"]
},
msgtype: "m.text",
body: `> Ami (she/her): let me guess they got a lot of bug reports like "empty chest with no loot?"\n\nMost likely`,
format: "org.matrix.custom.html",
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4">In reply to</a> <a href="https://matrix.to/#/@ami:the-apothecary.club">Ami (she/her)</a><br>let me guess they got a lot of bug reports like "empty chest with no loot?"</blockquote></mx-reply>Most likely`,
}])
})
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: {
@ -672,7 +837,7 @@ test("message2event: very large attachment is linked instead of being uploaded",
content: "hey", content: "hey",
attachments: [{ attachments: [{
filename: "hey.jpg", filename: "hey.jpg",
url: "https://discord.com/404/hey.jpg", url: "https://cdn.discordapp.com/attachments/123/456/789.mega",
content_type: "application/i-made-it-up", content_type: "application/i-made-it-up",
size: 100e6 size: 100e6
}] }]
@ -686,9 +851,9 @@ test("message2event: very large attachment is linked instead of being uploaded",
$type: "m.room.message", $type: "m.room.message",
"m.mentions": {}, "m.mentions": {},
msgtype: "m.text", msgtype: "m.text",
body: "📄 Uploaded file: https://discord.com/404/hey.jpg (100 MB)", body: "📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: '📄 Uploaded file: <a href="https://discord.com/404/hey.jpg">hey.jpg</a> (100 MB)' formatted_body: '📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)'
}]) }])
}) })
@ -849,3 +1014,140 @@ test("message2event: @everyone within a link", async t => {
"m.mentions": {} "m.mentions": {}
}]) }])
}) })
test("message2event: forwarded image", async t => {
const events = await messageToEvent(data.message.forwarded_image)
t.deepEqual(events, [
{
$type: "m.room.message",
body: "[🔀 Forwarded message]",
format: "org.matrix.custom.html",
formatted_body: "🔀 <em>Forwarded message</em>",
"m.mentions": {},
msgtype: "m.notice",
},
{
$type: "m.room.message",
body: "100km.gif",
external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1296237494987133070/100km.gif",
filename: "100km.gif",
info: {
h: 300,
mimetype: "image/gif",
size: 2965649,
w: 300,
},
"m.mentions": {},
msgtype: "m.image",
url: "mxc://cadence.moe/qDAotmebTfEIfsAIVCEZptLh",
},
])
})
test("message2event: constructed forwarded message", async t => {
const events = await messageToEvent(data.message.constructed_forwarded_message, {}, {}, {
api: {
async getJoinedMembers() {
return {
joined: {
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
"@user:matrix.org": {display_name: null, avatar_url: null}
}
}
}
}
})
t.deepEqual(events, [
{
$type: "m.room.message",
body: "[🔀 Forwarded from #wonderland]"
+ "\n» What's cooking, good looking? :hipposcope:",
format: "org.matrix.custom.html",
formatted_body: `🔀 <em>Forwarded from <a href="https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$tBIT8mO7XTTCgIINyiAIy6M2MSoPAdJenRl_RLyYuaE?via=cadence.moe&amp;via=matrix.org">wonderland</a></em>`
+ `<br><blockquote>What's cooking, good looking? <img data-mx-emoticon height="32" src="mxc://cadence.moe/WbYqNlACRuicynBfdnPYtmvc" title=":hipposcope:" alt=":hipposcope:"></blockquote>`,
"m.mentions": {},
msgtype: "m.notice",
},
{
$type: "m.room.message",
body: "100km.gif",
external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1296237494987133070/100km.gif",
filename: "100km.gif",
info: {
h: 300,
mimetype: "image/gif",
size: 2965649,
w: 300,
},
"m.mentions": {},
msgtype: "m.image",
url: "mxc://cadence.moe/qDAotmebTfEIfsAIVCEZptLh",
},
{
$type: "m.room.message",
body: "» | ## This man"
+ "\n» | "
+ "\n» | ## This man is 100 km away from your house"
+ "\n» | "
+ "\n» | ### Distance away"
+ "\n» | 99 km"
+ "\n» | "
+ "\n» | ### Distance away"
+ "\n» | 98 km",
format: "org.matrix.custom.html",
formatted_body: "<blockquote><blockquote><p><strong>This man</strong></p><p><strong>This man is 100 km away from your house</strong></p><p><strong>Distance away</strong><br>99 km</p><p><strong>Distance away</strong><br>98 km</p></blockquote></blockquote>",
"m.mentions": {},
msgtype: "m.notice"
}
])
})
test("message2event: constructed forwarded text", async t => {
const events = await messageToEvent(data.message.constructed_forwarded_text, {}, {}, {
api: {
async getJoinedMembers() {
return {
joined: {
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
"@user:matrix.org": {display_name: null, avatar_url: null}
}
}
}
}
})
t.deepEqual(events, [
{
$type: "m.room.message",
body: "[🔀 Forwarded from #amanda-spam]"
+ "\n» What's cooking, good looking?",
format: "org.matrix.custom.html",
formatted_body: `🔀 <em>Forwarded from <a href="https://matrix.to/#/!CzvdIdUQXgUjDVKxeU:cadence.moe?via=cadence.moe&amp;via=matrix.org">amanda-spam</a></em>`
+ `<br><blockquote>What's cooking, good looking?</blockquote>`,
"m.mentions": {},
msgtype: "m.notice",
},
{
$type: "m.room.message",
body: "What's cooking everybody ‼️",
"m.mentions": {},
msgtype: "m.text",
}
])
})
test("message2event: don't scan forwarded messages for mentions", async t => {
const events = await messageToEvent(data.message.forwarded_dont_scan_for_mentions, {}, {}, {})
t.deepEqual(events, [
{
$type: "m.room.message",
body: "[🔀 Forwarded message]"
+ "\n» If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile https://social.luca.run/@luca/113950834185678114",
format: "org.matrix.custom.html",
formatted_body: `🔀 <em>Forwarded message</em>`
+ `<br><blockquote>If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile <a href="https://social.luca.run/@luca/113950834185678114">https://social.luca.run/@luca/113950834185678114</a></blockquote>`,
"m.mentions": {},
msgtype: "m.notice"
}
])
})

View file

@ -1,5 +1,5 @@
const {test} = require("supertape") const {test} = require("supertape")
const data = require("../../test/data") const data = require("../../../test/data")
const {pinsToList} = require("./pins-to-list") const {pinsToList} = require("./pins-to-list")
test("pins2list: converts known IDs, ignores unknown IDs", t => { test("pins2list: converts known IDs, ignores unknown IDs", t => {

View file

@ -10,7 +10,9 @@ function fakeSpecificReactionRemoval(userID, emoji, emojiID) {
channel_id: "THE_CHANNEL", channel_id: "THE_CHANNEL",
message_id: "THE_MESSAGE", message_id: "THE_MESSAGE",
user_id: userID, user_id: userID,
emoji: {id: emojiID, name: emoji} emoji: {id: emojiID, name: emoji},
burst: false,
type: 0
} }
} }

File diff suppressed because it is too large Load diff

Binary file not shown.

View file

@ -4,10 +4,9 @@ const assert = require("assert").strict
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {discord, sync, db, select} = passthrough const {discord, sync, db, select} = passthrough
/** @type {import("../../matrix/read-registration")} */
const reg = sync.require("../../matrix/read-registration.js")
/** @type {import("../../m2d/converters/utils")} */ /** @type {import("../../m2d/converters/utils")} */
const mxUtils = sync.require("../../m2d/converters/utils") const mxUtils = sync.require("../../m2d/converters/utils")
const {reg} = require("../../matrix/read-registration.js")
const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))

View file

@ -1,6 +1,6 @@
const {test} = require("supertape") const {test} = require("supertape")
const {threadToAnnouncement} = require("./thread-to-announcement") const {threadToAnnouncement} = require("./thread-to-announcement")
const data = require("../../test/data") const data = require("../../../test/data")
const Ty = require("../../types") const Ty = require("../../types")
/** /**

View file

@ -1,7 +1,7 @@
// @ts-check // @ts-check
const assert = require("assert") const assert = require("assert")
const registration = require("../../matrix/read-registration") const {reg} = require("../../matrix/read-registration")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {select} = passthrough const {select} = passthrough
@ -26,7 +26,7 @@ function downcaseUsername(user) {
// remove leading and trailing dashes and underscores... // remove leading and trailing dashes and underscores...
.replace(/(?:^[_-]*|[_-]*$)/g, "") .replace(/(?:^[_-]*|[_-]*$)/g, "")
// If requested, also make the Discord user ID part of the username // If requested, also make the Discord user ID part of the username
if (registration.ooye.include_user_id_in_mxid) { if (reg.ooye.include_user_id_in_mxid) {
downcased = user.id + "_" + downcased downcased = user.id + "_" + downcased
} }
// The new length must be at least 2 characters (in other words, it should have some content) // The new length must be at least 2 characters (in other words, it should have some content)
@ -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,7 +1,7 @@
const {test} = require("supertape") const {test} = require("supertape")
const tryToCatch = require("try-to-catch") const tryToCatch = require("try-to-catch")
const assert = require("assert") const assert = require("assert")
const data = require("../../test/data") const data = require("../../../test/data")
const {userToSimName} = require("./user-to-mxid") const {userToSimName} = require("./user-to-mxid")
test("user2name: cannot create user for a webhook", async t => { test("user2name: cannot create user for a webhook", async t => {
@ -46,7 +46,7 @@ test("user2name: works on special user", t => {
}) })
test("user2name: includes ID if requested in config", t => { test("user2name: includes ID if requested in config", t => {
const reg = require("../../matrix/read-registration") const {reg} = require("../../matrix/read-registration")
reg.ooye.include_user_id_in_mxid = true reg.ooye.include_user_id_in_mxid = true
t.equal(userToSimName({username: "Harry Styles!", discriminator: "0001", id: "123456"}), "123456_harry_styles") t.equal(userToSimName({username: "Harry Styles!", discriminator: "0001", id: "123456"}), "123456_harry_styles")
t.equal(userToSimName({username: "f***", discriminator: "0001", id: "123456"}), "123456_f") t.equal(userToSimName({username: "f***", discriminator: "0001", id: "123456"}), "123456_f")

View file

@ -1,12 +1,16 @@
// @ts-check // @ts-check
const { SnowTransfer } = require("snowtransfer") const {Endpoints, SnowTransfer} = require("snowtransfer")
const { Client: CloudStorm } = require("cloudstorm") const {reg} = require("../matrix/read-registration")
const {Client: CloudStorm} = require("cloudstorm")
// @ts-ignore
Endpoints.BASE_HOST = reg.ooye.discord_origin || "https://discord.com"; Endpoints.CDN_URL = reg.ooye.discord_cdn_origin || "https://cdn.discordapp.com"
const passthrough = require("../passthrough") const passthrough = require("../passthrough")
const { sync } = passthrough const {sync} = passthrough
/** @type {typeof import("./discord-packets")} */ /** @type {import("./discord-packets")} */
const discordPackets = sync.require("./discord-packets") const discordPackets = sync.require("./discord-packets")
class DiscordClient { class DiscordClient {
@ -24,7 +28,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,
@ -57,6 +61,9 @@ class DiscordClient {
addEventLogger("error", "Error") addEventLogger("error", "Error")
addEventLogger("disconnected", "Disconnected") addEventLogger("disconnected", "Disconnected")
addEventLogger("ready", "Ready") addEventLogger("ready", "Ready")
this.snow.requestHandler.on("requestError", (requestID, error) => {
console.error("request error:", error)
})
} }
} }

View file

@ -4,7 +4,11 @@
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../passthrough") const passthrough = require("../passthrough")
const { sync } = passthrough const {sync, db} = passthrough
function populateGuildID(guildID, channelID) {
db.prepare("UPDATE channel_room SET guild_id = ? WHERE channel_id = ?").run(guildID, channelID)
}
const utils = { const utils = {
/** /**
@ -16,6 +20,8 @@ const utils = {
// requiring this later so that the client is already constructed by the time event-dispatcher is loaded // requiring this later so that the client is already constructed by the time event-dispatcher is loaded
/** @type {typeof import("./event-dispatcher")} */ /** @type {typeof import("./event-dispatcher")} */
const eventDispatcher = sync.require("./event-dispatcher") const eventDispatcher = sync.require("./event-dispatcher")
/** @type {import("../discord/register-interactions")} */
const interactions = sync.require("../discord/register-interactions")
// Client internals, keep track of the state we need // Client internals, keep track of the state we need
if (message.t === "READY") { if (message.t === "READY") {
@ -34,17 +40,25 @@ const utils = {
channel.guild_id = message.d.id channel.guild_id = message.d.id
arr.push(channel.id) arr.push(channel.id)
client.channels.set(channel.id, channel) client.channels.set(channel.id, channel)
populateGuildID(message.d.id, channel.id)
} }
for (const thread of message.d.threads || []) { for (const thread of message.d.threads || []) {
// @ts-ignore // @ts-ignore
thread.guild_id = message.d.id thread.guild_id = message.d.id
arr.push(thread.id) arr.push(thread.id)
client.channels.set(thread.id, thread) client.channels.set(thread.id, thread)
populateGuildID(message.d.id, thread.id)
} }
if (listen === "full") { if (listen === "full") {
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") {
@ -89,7 +103,11 @@ const utils = {
} else if (message.t === "THREAD_CREATE") { } else if (message.t === "THREAD_CREATE") {
client.channels.set(message.d.id, message.d) client.channels.set(message.d.id, message.d)
if (message.d["guild_id"]) {
populateGuildID(message.d["guild_id"], message.d.id)
const channels = client.guildChannelMap.get(message.d["guild_id"])
if (channels && !channels.includes(message.d.id)) channels.push(message.d.id)
}
} else if (message.t === "CHANNEL_UPDATE" || message.t === "THREAD_UPDATE") { } else if (message.t === "CHANNEL_UPDATE" || message.t === "THREAD_UPDATE") {
client.channels.set(message.d.id, message.d) client.channels.set(message.d.id, message.d)
@ -111,14 +129,15 @@ const utils = {
client.guildChannelMap.delete(message.d.id) client.guildChannelMap.delete(message.d.id)
} else if (message.t === "CHANNEL_CREATE" || message.t === "CHANNEL_DELETE") { } else if (message.t === "CHANNEL_CREATE") {
if (message.t === "CHANNEL_CREATE") {
client.channels.set(message.d.id, message.d) client.channels.set(message.d.id, message.d)
if (message.d["guild_id"]) { // obj[prop] notation can be used to access a property without typescript complaining that it doesn't exist on all values something can have if (message.d["guild_id"]) { // obj[prop] notation can be used to access a property without typescript complaining that it doesn't exist on all values something can have
populateGuildID(message.d["guild_id"], message.d.id)
const channels = client.guildChannelMap.get(message.d["guild_id"]) const channels = client.guildChannelMap.get(message.d["guild_id"])
if (channels && !channels.includes(message.d.id)) channels.push(message.d.id) if (channels && !channels.includes(message.d.id)) channels.push(message.d.id)
} }
} else {
} else if (message.t === "CHANNEL_DELETE") {
client.channels.delete(message.d.id) client.channels.delete(message.d.id)
if (message.d["guild_id"]) { if (message.d["guild_id"]) {
const channels = client.guildChannelMap.get(message.d["guild_id"]) const channels = client.guildChannelMap.get(message.d["guild_id"])
@ -128,7 +147,6 @@ const utils = {
} }
} }
} }
}
// Event dispatcher for OOYE bridge operations // Event dispatcher for OOYE bridge operations
if (listen === "full") { if (listen === "full") {
@ -145,6 +163,9 @@ const utils = {
} else if (message.t === "CHANNEL_PINS_UPDATE") { } else if (message.t === "CHANNEL_PINS_UPDATE") {
await eventDispatcher.onChannelPinsUpdate(client, message.d) await eventDispatcher.onChannelPinsUpdate(client, message.d)
} else if (message.t === "CHANNEL_DELETE") {
await eventDispatcher.onChannelDelete(client, message.d)
} else if (message.t === "THREAD_CREATE") { } else if (message.t === "THREAD_CREATE") {
// @ts-ignore // @ts-ignore
await eventDispatcher.onThreadCreate(client, message.d) await eventDispatcher.onThreadCreate(client, message.d)
@ -172,10 +193,17 @@ const utils = {
} else if (message.t === "MESSAGE_REACTION_REMOVE" || message.t === "MESSAGE_REACTION_REMOVE_EMOJI" || message.t === "MESSAGE_REACTION_REMOVE_ALL") { } else if (message.t === "MESSAGE_REACTION_REMOVE" || message.t === "MESSAGE_REACTION_REMOVE_EMOJI" || message.t === "MESSAGE_REACTION_REMOVE_ALL") {
await eventDispatcher.onSomeReactionsRemoved(client, message.d) await eventDispatcher.onSomeReactionsRemoved(client, message.d)
} else if (message.t === "INTERACTION_CREATE") {
await interactions.dispatchInteraction(message.d)
} else if (message.t === "PRESENCE_UPDATE") {
eventDispatcher.onPresenceUpdate(client, message.d)
} }
} catch (e) { } catch (e) {
// Let OOYE try to handle errors too // Let OOYE try to handle errors too
eventDispatcher.onError(client, e, message) await eventDispatcher.onError(client, e, message)
} }
} }
} }

View file

@ -27,14 +27,14 @@ const updatePins = sync.require("./actions/update-pins")
const api = sync.require("../matrix/api") const api = sync.require("../matrix/api")
/** @type {import("../discord/utils")} */ /** @type {import("../discord/utils")} */
const dUtils = sync.require("../discord/utils") const dUtils = sync.require("../discord/utils")
/** @type {import("../discord/discord-command-handler")}) */
const discordCommandHandler = sync.require("../discord/discord-command-handler")
/** @type {import("../m2d/converters/utils")} */ /** @type {import("../m2d/converters/utils")} */
const mxUtils = require("../m2d/converters/utils") const mxUtils = require("../m2d/converters/utils")
/** @type {import("./actions/speedbump")} */ /** @type {import("./actions/speedbump")} */
const speedbump = sync.require("./actions/speedbump") const speedbump = sync.require("./actions/speedbump")
/** @type {import("./actions/retrigger")} */ /** @type {import("./actions/retrigger")} */
const retrigger = sync.require("./actions/retrigger") const retrigger = sync.require("./actions/retrigger")
/** @type {import("./actions/set-presence")} */
const setPresence = sync.require("./actions/set-presence")
/** @type {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")
@ -50,7 +50,7 @@ module.exports = {
* @param {Error} e * @param {Error} e
* @param {import("cloudstorm").IGatewayMessage} gatewayMessage * @param {import("cloudstorm").IGatewayMessage} gatewayMessage
*/ */
onError(client, e, gatewayMessage) { async onError(client, e, gatewayMessage) {
console.error("hit event-dispatcher's error handler with this exception:") console.error("hit event-dispatcher's error handler with this exception:")
console.error(e) // TODO: also log errors into a file or into the database, maybe use a library for this? or just wing it? definitely need to be able to store the formatted event body to load back in later console.error(e) // TODO: also log errors into a file or into the database, maybe use a library for this? or just wing it? definitely need to be able to store the formatted event body to load back in later
console.error(`while handling this ${gatewayMessage.t} gateway event:`) console.error(`while handling this ${gatewayMessage.t} gateway event:`)
@ -83,7 +83,7 @@ module.exports = {
builder.addLine(`Error trace:\n${stackLines.join("\n")}`, `<details><summary>Error trace</summary><pre>${stackLines.join("\n")}</pre></details>`) builder.addLine(`Error trace:\n${stackLines.join("\n")}`, `<details><summary>Error trace</summary><pre>${stackLines.join("\n")}</pre></details>`)
} }
builder.addLine("", `<details><summary>Original payload</summary><pre>${util.inspect(gatewayMessage.d, false, 4, false)}</pre></details>`) builder.addLine("", `<details><summary>Original payload</summary><pre>${util.inspect(gatewayMessage.d, false, 4, false)}</pre></details>`)
api.sendEvent(roomID, "m.room.message", { await api.sendEvent(roomID, "m.room.message", {
...builder.get(), ...builder.get(),
"moe.cadence.ooye.error": { "moe.cadence.ooye.error": {
source: "discord", source: "discord",
@ -105,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
@ -133,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.
@ -181,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)
}, },
/** /**
@ -193,7 +202,7 @@ module.exports = {
async onThreadCreate(client, thread) { async onThreadCreate(client, thread) {
const channelID = thread.parent_id || undefined const channelID = thread.parent_id || undefined
const parentRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get() const parentRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
if (!parentRoomID) return // Not interested in a thread if we aren't interested in its wider channel if (!parentRoomID) return // Not interested in a thread if we aren't interested in its wider channel (won't autocreate)
const threadRoomID = await createRoom.syncRoom(thread.id) // Create room (will share the same inflight as the initial message to the thread) const threadRoomID = await createRoom.syncRoom(thread.id) // Create room (will share the same inflight as the initial message to the thread)
await announceThread.announceThread(parentRoomID, threadRoomID, thread) await announceThread.announceThread(parentRoomID, threadRoomID, thread)
}, },
@ -230,6 +239,19 @@ module.exports = {
await updatePins.updatePins(data.channel_id, roomID, convertedTimestamp) await updatePins.updatePins(data.channel_id, roomID, convertedTimestamp)
}, },
/**
* @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayChannelDeleteDispatchData} channel
*/
async onChannelDelete(client, channel) {
const guildID = channel["guild_id"]
if (!guildID) return // channel must have been a DM channel or something
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
if (!roomID) return // channel wasn't being bridged in the first place
// @ts-ignore
await createRoom.unbridgeDeletedChannel(channel, guildID)
},
/** /**
* @param {import("./discord-client")} client * @param {import("./discord-client")} client
* @param {DiscordTypes.GatewayMessageCreateDispatchData} message * @param {DiscordTypes.GatewayMessageCreateDispatchData} message
@ -238,6 +260,7 @@ module.exports = {
if (message.author.username === "Deleted User") return // Nothing we can do for deleted users. if (message.author.username === "Deleted User") return // Nothing we can do for deleted users.
const channel = client.channels.get(message.channel_id) const channel = client.channels.get(message.channel_id)
if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages. if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages.
const guild = client.guilds.get(channel.guild_id) const guild = client.guilds.get(channel.guild_id)
assert(guild) assert(guild)
@ -248,12 +271,13 @@ module.exports = {
if (dUtils.isEphemeralMessage(message)) return // Ephemeral messages are for the eyes of the receiver only! if (dUtils.isEphemeralMessage(message)) return // Ephemeral messages are for the eyes of the receiver only!
if (!createRoom.existsOrAutocreatable(channel, guild.id)) return // Check that the sending-to room exists or is autocreatable
const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id) const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id)
if (affected) return if (affected) return
// @ts-ignore // @ts-ignore
await sendMessage.sendMessage(message, channel, guild, row), await sendMessage.sendMessage(message, channel, guild, row)
await discordCommandHandler.execute(message, channel, guild)
retrigger.messageFinishedBridging(message.id) retrigger.messageFinishedBridging(message.id)
}, },
@ -268,9 +292,6 @@ module.exports = {
// Otherwise, if there are embeds, then the system generated URL preview embeds. // Otherwise, if there are embeds, then the system generated URL preview embeds.
if (!(typeof data.content === "string" || "embeds" in data)) return if (!(typeof data.content === "string" || "embeds" in data)) return
// Deal with Eventual Consistency(TM)
if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.onMessageUpdate, client, data)) return
if (data.webhook_id) { if (data.webhook_id) {
const row = select("webhook", "webhook_id", {webhook_id: data.webhook_id}).pluck().get() const row = select("webhook", "webhook_id", {webhook_id: data.webhook_id}).pluck().get()
if (row) return // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it. if (row) return // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it.
@ -282,16 +303,19 @@ module.exports = {
const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id) const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id)
if (affected) return if (affected) return
// Check that the sending-to room exists, and deal with Eventual Consistency(TM)
if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.onMessageUpdate, client, data)) return
/** @type {DiscordTypes.GatewayMessageCreateDispatchData} */ /** @type {DiscordTypes.GatewayMessageCreateDispatchData} */
// @ts-ignore // @ts-ignore
const message = data const message = data
const channel = client.channels.get(message.channel_id) const channel = client.channels.get(message.channel_id)
if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages. if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages.
const guild = client.guilds.get(channel.guild_id) const guild = client.guilds.get(channel.guild_id)
assert(guild) assert(guild)
// @ts-ignore // @ts-ignore
await editMessage.editMessage(message, guild, row) await retrigger.pauseChanges(message.id, editMessage.editMessage(message, guild, row))
}, },
/** /**
@ -300,7 +324,6 @@ module.exports = {
*/ */
async onReactionAdd(client, data) { async onReactionAdd(client, data) {
if (data.user_id === client.user.id) return // m2d reactions are added by the discord bot user - do not reflect them back to matrix. if (data.user_id === client.user.id) return // m2d reactions are added by the discord bot user - do not reflect them back to matrix.
discordCommandHandler.onReactionAdd(data)
await addReaction.addReaction(data) await addReaction.addReaction(data)
}, },
@ -351,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

@ -3,6 +3,7 @@ module.exports = async function(db) {
const contents = db.prepare("SELECT distinct hashed_profile_content FROM sim_member WHERE hashed_profile_content IS NOT NULL").pluck().all() const contents = db.prepare("SELECT distinct hashed_profile_content FROM sim_member WHERE hashed_profile_content IS NOT NULL").pluck().all()
const stmt = db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE hashed_profile_content = ?") const stmt = db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE hashed_profile_content = ?")
db.transaction(() => { db.transaction(() => {
/* c8 ignore next 6 */
for (let s of contents) { for (let s of contents) {
let b = Buffer.isBuffer(s) ? Uint8Array.from(s) : Uint8Array.from(Buffer.from(s)) let b = Buffer.isBuffer(s) ? Uint8Array.from(s) : Uint8Array.from(Buffer.from(s))
const unsignedHash = hasher.h64Raw(b) const unsignedHash = hasher.h64Raw(b)

View file

@ -10,7 +10,6 @@
*/ */
module.exports = async function(db) { module.exports = async function(db) {
const config = require("../../config") const id = require("../../../addbot").id
const id = Buffer.from(config.discordToken.split(".")[0], "base64").toString()
db.prepare("UPDATE OR REPLACE sim SET user_id = ? WHERE user_id = '0'").run(id) db.prepare("UPDATE OR REPLACE sim SET user_id = ? WHERE user_id = '0'").run(id)
} }

View file

@ -0,0 +1,14 @@
BEGIN TRANSACTION;
-- the power we want them to have
CREATE TABLE IF NOT EXISTS member_power (
mxid TEXT NOT NULL,
room_id TEXT NOT NULL,
power_level INTEGER NOT NULL,
PRIMARY KEY(mxid, room_id)
) WITHOUT ROWID;
-- the power they have
ALTER TABLE member_cache ADD COLUMN power_level INTEGER NOT NULL DEFAULT 0;
COMMIT;

View file

@ -0,0 +1,8 @@
BEGIN TRANSACTION;
CREATE TABLE "media_proxy" (
"permitted_hash" INTEGER NOT NULL,
PRIMARY KEY("permitted_hash")
) WITHOUT ROWID;
COMMIT;

View file

@ -0,0 +1,11 @@
BEGIN TRANSACTION;
CREATE TABLE "guild_active" (
"guild_id" TEXT NOT NULL,
"autocreate" INTEGER NOT NULL,
PRIMARY KEY("guild_id")
) WITHOUT ROWID;
INSERT INTO guild_active (guild_id, autocreate) SELECT guild_id, 1 FROM guild_space;
COMMIT;

View file

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

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;

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,21 @@ 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_id: string
autocreate: 0 | 1
}
invite: {
mxid: string
room_id: string
type: string | null
name: string | null
avatar: string | null
} }
lottie: { lottie: {
@ -42,7 +59,14 @@ export type Models = {
room_id: string room_id: string
mxid: string mxid: string
displayname: string | null displayname: string | null
avatar_url: string | null avatar_url: string | null,
power_level: number
}
member_power: {
mxid: string
room_id: string
power_level: number
} }
message_channel: { message_channel: {
@ -93,6 +117,10 @@ export type Models = {
emoji_id: string emoji_id: string
guild_id: string guild_id: string
} }
media_proxy: {
permitted_hash: number
}
} }
export type Prepared<Row> = { export type Prepared<Row> = {
@ -107,3 +135,5 @@ export type AllKeys<U> = U extends any ? keyof U : never
export type PickTypeOf<T, K extends AllKeys<T>> = T extends { [k in K]?: any } ? T[K] : never export type PickTypeOf<T, K extends AllKeys<T>> = T extends { [k in K]?: any } ? T[K] : never
export type Merge<U> = {[x in AllKeys<U>]: PickTypeOf<U, x>} export type Merge<U> = {[x in AllKeys<U>]: PickTypeOf<U, x>}
export type Nullable<T> = {[k in keyof T]: T[k] | null} export type Nullable<T> = {[k in keyof T]: T[k] | null}
export type Numberish<T> = {[k in keyof T]: T[k] extends number ? (number | bigint) : T[k]}
export type ValueOrArray<T> = {[k in keyof T]: T[k][] | T[k]}

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